mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-06 08:07:49 +00:00
feat(ui): search in the agent model dropdown (#352)
Select gains an optional search box that filters its options; the agent model pickers use it, so long model lists can be filtered instead of scrolled. Keyboard navigation and type-ahead work on the filtered list, and the popup keeps its width while filtering. Co-authored-by: dimassfeb-09 <62858781+dimassfeb-09@users.noreply.github.com>
This commit is contained in:
@@ -44,6 +44,7 @@ export function ConfigPickers({
|
||||
: undefined,
|
||||
detail: v.description ?? undefined,
|
||||
}))}
|
||||
searchable={o.category === "model"}
|
||||
onChange={(v) => onChange(o.id, v)}
|
||||
// Effort and other levels say what they are: "Effort Medium".
|
||||
renderValue={
|
||||
|
||||
@@ -3431,6 +3431,7 @@
|
||||
"Chart Settings": "图表设置",
|
||||
"No numeric values to plot": "没有可绘制的数值",
|
||||
"Search": "搜索",
|
||||
"Search…": "搜索…",
|
||||
"Statements": "语句",
|
||||
"No statements": "没有语句",
|
||||
"Statement": "语句",
|
||||
|
||||
@@ -123,3 +123,16 @@
|
||||
border: 1px solid ButtonText;
|
||||
}
|
||||
}
|
||||
|
||||
.searchWrap {
|
||||
padding: 4px 6px 6px;
|
||||
}
|
||||
.search {
|
||||
width: 100%;
|
||||
height: var(--h-field, 28px);
|
||||
padding: 0 8px;
|
||||
border-radius: var(--radius-control, 4px);
|
||||
background: var(--bg-field, var(--bg-button));
|
||||
box-shadow: inset 0 0 0 1px var(--border-button);
|
||||
color: var(--fg-strong);
|
||||
}
|
||||
|
||||
+30
-4
@@ -4,6 +4,7 @@ import { Icon, type IconName } from "./icons";
|
||||
import { Popup } from "./Popup";
|
||||
import { List, listNavigate } from "./List";
|
||||
import { useTx } from "@/platform/i18n";
|
||||
import { SearchField } from "./Input";
|
||||
import styles from "./Select.module.css";
|
||||
|
||||
export interface SelectOption<V extends string = string> {
|
||||
@@ -26,6 +27,8 @@ export interface SelectProps<V extends string = string> {
|
||||
"aria-label"?: string;
|
||||
/** Custom trigger content. */
|
||||
renderValue?: (option: SelectOption<V> | undefined) => ReactNode;
|
||||
/** Show a search box in the popup (useful for long option lists). */
|
||||
searchable?: boolean;
|
||||
}
|
||||
|
||||
const TYPE_AHEAD_MS = 800;
|
||||
@@ -40,6 +43,7 @@ export function Select<V extends string = string>({
|
||||
variant = "framed",
|
||||
className,
|
||||
renderValue,
|
||||
searchable = false,
|
||||
...aria
|
||||
}: SelectProps<V>) {
|
||||
const tx = useTx();
|
||||
@@ -47,10 +51,16 @@ export function Select<V extends string = string>({
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
const current = options.find((o) => o.value === value);
|
||||
const [active, setActive] = useState(0);
|
||||
const [query, setQuery] = useState("");
|
||||
const typeAhead = useRef({ text: "", at: -Infinity });
|
||||
const isSection = (o: SelectOption<V>) => !!o.disabled;
|
||||
const filtered =
|
||||
searchable && query.trim()
|
||||
? options.filter((o) => !o.disabled && tx(o.label).toLowerCase().includes(query.trim().toLowerCase()))
|
||||
: options;
|
||||
|
||||
const openPopup = () => {
|
||||
setQuery("");
|
||||
setActive(
|
||||
Math.max(
|
||||
0,
|
||||
@@ -69,7 +79,7 @@ export function Select<V extends string = string>({
|
||||
const onPopupKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
choose(options[active]);
|
||||
choose(filtered[active]);
|
||||
return;
|
||||
}
|
||||
const next = listNavigate(e.key, { items: options, active, isSection, wrap: true });
|
||||
@@ -85,10 +95,10 @@ export function Select<V extends string = string>({
|
||||
ahead.at = e.timeStamp;
|
||||
const repeat = ahead.text.length > 1 && [...ahead.text].every((c) => c === ahead.text[0]);
|
||||
const prefix = repeat ? ahead.text[0]! : ahead.text;
|
||||
const n = options.length;
|
||||
const n = filtered.length;
|
||||
for (let s = prefix.length > 1 ? 0 : 1; s <= n; s++) {
|
||||
const i = (active + s) % n;
|
||||
if (!options[i]!.disabled && tx(options[i]!.label).toLowerCase().startsWith(prefix)) {
|
||||
if (!filtered[i]!.disabled && tx(filtered[i]!.label).toLowerCase().startsWith(prefix)) {
|
||||
setActive(i);
|
||||
break;
|
||||
}
|
||||
@@ -135,6 +145,22 @@ export function Select<V extends string = string>({
|
||||
className={styles.popup}
|
||||
>
|
||||
<div tabIndex={-1} data-autofocus onKeyDown={onPopupKey} className={styles.popupInner}>
|
||||
{searchable && (
|
||||
<div className={styles.searchWrap}>
|
||||
<SearchField
|
||||
value={query}
|
||||
autoFocus
|
||||
placeholder={tx("Search…")}
|
||||
onChange={(v) => {
|
||||
setQuery(v);
|
||||
setActive(0);
|
||||
}}
|
||||
size="normal"
|
||||
variant="framed"
|
||||
className={styles.search}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* Virtualised rows are absolutely positioned; this sizes the popup to the widest label. */}
|
||||
<div aria-hidden className={styles.sizer}>
|
||||
{options.map((o) => (
|
||||
@@ -147,7 +173,7 @@ export function Select<V extends string = string>({
|
||||
))}
|
||||
</div>
|
||||
<List
|
||||
items={options}
|
||||
items={filtered}
|
||||
getKey={(o) => o.value}
|
||||
isSection={isSection}
|
||||
renderSection={(o) => <span className={styles.disabled}>{tx(o.label)}</span>}
|
||||
|
||||
Reference in New Issue
Block a user