| 1 | import { Children, Fragment, isValidElement, useEffect, useId, useMemo, useRef, useState } from "react"; |
| 2 | import type { ButtonHTMLAttributes, KeyboardEvent, ReactNode } from "react"; |
| 3 | import { Check, ChevronDown } from "lucide-react"; |
| 4 | import { AnchoredPopover } from "./AnchoredPopover"; |
| 5 | |
| 6 | if (import.meta.env?.MODE) void import("./SettingsSelect.css"); |
| 7 | |
| 8 | export type SettingsSelectOption = { |
| 9 | value: string; |
| 10 | label: string; |
| 11 | hint?: string; |
| 12 | group?: string; |
| 13 | groupLabel?: string; |
| 14 | searchText?: string; |
| 15 | disabled?: boolean; |
| 16 | }; |
| 17 | |
| 18 | function optionChildren(children: ReactNode, group?: string): SettingsSelectOption[] { |
| 19 | return Children.toArray(children).flatMap(child => { |
| 20 | if (!isValidElement<{ children?: ReactNode; value?: string | number; label?: string; disabled?: boolean }>(child)) return []; |
| 21 | if (child.type === Fragment) return optionChildren(child.props.children, group); |
| 22 | if (child.type === "optgroup") return optionChildren(child.props.children, child.props.label).map(option => ({ ...option, disabled: child.props.disabled || option.disabled })); |
| 23 | if (child.type !== "option") return []; |
| 24 | const label = Children.toArray(child.props.children).join(""); |
| 25 | return [{ value: String(child.props.value ?? label), label, group, disabled: child.props.disabled }]; |
| 26 | }); |
| 27 | } |
| 28 | |
| 29 | type Props = Omit<ButtonHTMLAttributes<HTMLButtonElement>, "value" | "onChange" | "children" | "onClick"> & { |
| 30 | value: string | number; |
| 31 | onValueChange: (value: string) => void; |
| 32 | children?: ReactNode; |
| 33 | options?: SettingsSelectOption[]; |
| 34 | searchPlaceholder?: string; |
| 35 | emptyLabel?: string; |
| 36 | selectedLabel?: string; |
| 37 | }; |
| 38 | |
| 39 | /** One compact selection surface for settings, including searchable model catalogs. */ |
| 40 | export function SettingsSelect({ value, onValueChange, children, options, searchPlaceholder, emptyLabel, |
| 41 | selectedLabel, className = "", disabled, name, onKeyDown, ...buttonProps }: Props) { |
| 42 | const id = useId(); |
| 43 | const triggerRef = useRef<HTMLButtonElement>(null); |
| 44 | const [open, setOpen] = useState(false); |
| 45 | const [query, setQuery] = useState(""); |
| 46 | const [active, setActive] = useState<string | null>(null); |
| 47 | useEffect(() => { if (disabled) setOpen(false); }, [disabled]); |
| 48 | const items = useMemo(() => options ?? optionChildren(children), [options, children]); |
| 49 | const selected = items.find(option => option.value === String(value)); |
| 50 | const visible = useMemo(() => { |
| 51 | const q = query.trim().toLocaleLowerCase(); |
| 52 | return items.filter(option => !q || [option.label, option.hint, option.groupLabel, option.group, option.searchText].join(" ").toLocaleLowerCase().includes(q)); |
| 53 | }, [items, query]); |
| 54 | const enabled = visible.filter(option => !option.disabled); |
| 55 | const activeValue = enabled.some(option => option.value === active) ? active : enabled[0]?.value; |
| 56 | const activeIndex = visible.findIndex(option => option.value === activeValue); |
| 57 | const activeID = activeIndex < 0 ? undefined : `${id}-option-${activeIndex}`; |
| 58 | const show = () => { |
| 59 | setQuery(""); |
| 60 | setActive(selected && !selected.disabled ? selected.value : null); |
| 61 | setOpen(true); |
| 62 | }; |
| 63 | const pick = (next: string) => { |
| 64 | setOpen(false); |
| 65 | triggerRef.current?.focus(); |
| 66 | if (next !== String(value)) onValueChange(next); |
| 67 | }; |
| 68 | const navigate = (event: KeyboardEvent<HTMLElement>) => { |
| 69 | // Candidate navigation/confirmation belongs to the IME, including WebKit's 229 fallback. |
| 70 | if (event.nativeEvent.isComposing || event.nativeEvent.keyCode === 229) { |
| 71 | event.stopPropagation(); |
| 72 | return; |
| 73 | } |
| 74 | if (event.key === "Escape") { |
| 75 | event.preventDefault(); |
| 76 | setOpen(false); |
| 77 | triggerRef.current?.focus(); |
| 78 | } else if (event.key === "Tab") { |
| 79 | setOpen(false); |
| 80 | triggerRef.current?.focus(); |
| 81 | } else if (["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) { |
| 82 | // Home/End retain text-editing behavior in the search input. |
| 83 | if (event.currentTarget.tagName === "INPUT" && ["Home", "End"].includes(event.key)) return; |
| 84 | event.preventDefault(); |
| 85 | if (!enabled.length) return; |
| 86 | const current = enabled.findIndex(option => option.value === activeValue); |
| 87 | const next = event.key === "Home" ? 0 : event.key === "End" ? enabled.length - 1 |
| 88 | : (current + (event.key === "ArrowDown" ? 1 : -1) + enabled.length) % enabled.length; |
| 89 | const option = enabled[next]; |
| 90 | setActive(option.value); |
| 91 | document.getElementById(`${id}-option-${visible.indexOf(option)}`)?.scrollIntoView({ block: "nearest" }); |
| 92 | } else if (event.key === "Enter" || (event.key === " " && !searchPlaceholder)) { |
| 93 | event.preventDefault(); |
| 94 | if (activeValue != null) pick(activeValue); |
| 95 | } else if (!searchPlaceholder && event.key.length === 1 && !event.metaKey && !event.ctrlKey && !event.altKey) { |
| 96 | const match = enabled.find(option => option.label.toLocaleLowerCase().startsWith(event.key.toLocaleLowerCase())); |
| 97 | if (match) setActive(match.value); |
| 98 | } |
| 99 | }; |
| 100 | |
| 101 | return <> |
| 102 | {name && <input type="hidden" name={name} value={value} disabled={disabled} />} |
| 103 | <button {...buttonProps} ref={triggerRef} type="button" value={value} disabled={disabled} |
| 104 | id={buttonProps.id ?? `${id}-trigger`} className={`settings-select ${className}`} aria-haspopup="listbox" aria-expanded={open && !disabled} |
| 105 | aria-controls={open ? `${id}-list` : undefined} onClick={() => open ? setOpen(false) : show()} |
| 106 | onKeyDown={event => { |
| 107 | onKeyDown?.(event); |
| 108 | if (!event.defaultPrevented && ["ArrowDown", "ArrowUp"].includes(event.key)) { event.preventDefault(); show(); } |
| 109 | }}> |
| 110 | <span className="settings-select__value" title={selected?.hint}>{selectedLabel ?? selected?.label ?? String(value)}</span> |
| 111 | <ChevronDown size={14} aria-hidden="true" /> |
| 112 | </button> |
| 113 | <AnchoredPopover open={open && !disabled} anchorRef={triggerRef} onClose={() => setOpen(false)} |
| 114 | className="settings-select-menu" placement="bottom" offset={4} |
| 115 | style={{ width: triggerRef.current?.getBoundingClientRect().width }}> |
| 116 | {searchPlaceholder && <div className="settings-select-menu__search"><input autoFocus value={query} |
| 117 | role="combobox" aria-expanded="true" aria-controls={`${id}-list`} aria-activedescendant={activeID} |
| 118 | aria-autocomplete="list" aria-label={searchPlaceholder} placeholder={searchPlaceholder} |
| 119 | onChange={event => { setQuery(event.target.value); setActive(null); }} onKeyDown={navigate} /></div>} |
| 120 | <div id={`${id}-list`} role="listbox" aria-label={buttonProps["aria-label"]} |
| 121 | aria-labelledby={buttonProps["aria-label"] ? undefined : (buttonProps["aria-labelledby"] ?? buttonProps.id ?? `${id}-trigger`)} tabIndex={searchPlaceholder ? -1 : 0} |
| 122 | aria-activedescendant={searchPlaceholder ? undefined : activeID} onKeyDown={navigate} |
| 123 | ref={node => { if (node && !searchPlaceholder && open) node.focus(); }} |
| 124 | className="settings-select-menu__list"> |
| 125 | {visible.map((option, index) => <Fragment key={option.value}> |
| 126 | {option.group && option.group !== visible[index - 1]?.group && <div className="settings-select-menu__group">{option.groupLabel ?? option.group}</div>} |
| 127 | <div id={`${id}-option-${index}`} role="option" data-value={option.value} aria-selected={option.value === String(value)} |
| 128 | aria-disabled={option.disabled || undefined} title={option.hint || option.label} |
| 129 | className={`settings-select-menu__option${option.value === activeValue ? " is-active" : ""}`} |
| 130 | onMouseDown={event => event.preventDefault()} |
| 131 | onMouseMove={() => !option.disabled && setActive(option.value)} |
| 132 | onClick={() => !option.disabled && pick(option.value)}> |
| 133 | <span>{option.label}</span>{option.value === String(value) && <Check size={14} aria-hidden="true" />} |
| 134 | </div> |
| 135 | </Fragment>)} |
| 136 | {!visible.length && <div className="settings-select-menu__empty" role="status">{emptyLabel}</div>} |
| 137 | </div> |
| 138 | </AnchoredPopover> |
| 139 | </>; |
| 140 | } |
| 141 |