返回 DeepSeek-Reasonix
SettingsSelect.tsx
根目录 / desktop / frontend / src / components / SettingsSelect.tsx
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
141 lines Plain Text