import { useEffect, useRef, useState, type KeyboardEvent, type ReactNode } from "react"; import clsx from "clsx"; 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 { value: V; label: string; icon?: IconName | (string & {}); detail?: string; /** Extra text (not shown) that the search should also match. */ keywords?: string; disabled?: boolean; /** A non-selectable group heading, independent of disabled options. */ section?: boolean; } export interface SelectProps { value: V | null; options: readonly SelectOption[]; onChange: (value: V) => void; placeholder?: string; disabled?: boolean; /** `ghost` renders as plain text + chevron (title bar widgets). */ variant?: "framed" | "ghost"; className?: string; "aria-label"?: string; /** Custom trigger content. */ renderValue?: (option: SelectOption | undefined) => ReactNode; /** Show a search box in the popup (useful for long option lists). */ searchable?: boolean; /** The option under the pointer or keyboard while the list is open; `null` once it closes. */ onPreview?: (value: V | null) => void; } const TYPE_AHEAD_MS = 800; /** Substring or subsequence (fuzzy) match. */ function containsOrFuzzy(haystack: string, needle: string): boolean { if (haystack.includes(needle)) return true; let i = 0; for (const ch of haystack) { if (ch === needle[i]) i++; if (i >= needle.length) return true; } return false; } /** Dropdown select with a listbox popup (↑↓ Enter Esc, type-ahead). */ export function Select({ value, options, onChange, placeholder = "Select…", disabled, variant = "framed", className, renderValue, searchable = false, onPreview, ...aria }: SelectProps) { const tx = useTx(); const [open, setOpen] = useState(false); const triggerRef = useRef(null); const current = options.find((o) => !o.section && o.value === value); const [active, setActive] = useState(0); const [query, setQuery] = useState(""); const typeAhead = useRef({ text: "", at: -Infinity }); const isSection = (o: SelectOption) => !!o.section; const unavailable = (o: SelectOption) => !!(o.section || o.disabled); const filtered = searchable && query.trim() ? (() => { const q = query.trim().toLowerCase(); const tokens = q.split(/\s+/).filter(Boolean); const matches = (o: SelectOption) => { const haystack = `${tx(o.label)} ${o.detail != null ? tx(o.detail) : ""} ${o.keywords ?? ""}`.toLowerCase(); return tokens.every((tok) => containsOrFuzzy(haystack, tok)); }; // Keep section headers visible as long as at least one of their // items matches, so the provider grouping survives filtering. const out: SelectOption[] = []; let section: SelectOption | null = null; let bucket: SelectOption[] = []; let sectionMatched = false; const flush = () => { if (section && sectionMatched) { out.push(section, ...bucket); } else if (!section && bucket.length) { out.push(...bucket); } section = null; bucket = []; sectionMatched = false; }; for (const o of options) { if (o.section) { flush(); section = o; } else if (matches(o)) { bucket.push(o); sectionMatched = true; } } flush(); return out; })() : options; const activeIndex = filtered[active] && !unavailable(filtered[active]!) ? active : filtered.findIndex((o) => !unavailable(o)); const previewed = open ? (filtered[activeIndex]?.value ?? null) : null; const lastPreview = useRef(null); useEffect(() => { if (previewed === lastPreview.current) return; lastPreview.current = previewed; onPreview?.(previewed); }, [previewed, onPreview]); const openPopup = () => { setQuery(""); setActive( Math.max( 0, options.findIndex((o) => !unavailable(o) && o.value === value), ), ); setOpen(true); }; const choose = (o: SelectOption | undefined) => { if (!o || unavailable(o)) return; onChange(o.value); setOpen(false); }; const onPopupKey = (e: KeyboardEvent) => { const searching = searchable && e.target instanceof HTMLInputElement; if (e.key === "Enter" || (e.key === " " && !searching)) { e.preventDefault(); choose(filtered[activeIndex]); return; } if (searching && !["ArrowDown", "ArrowUp"].includes(e.key)) return; const next = listNavigate(e.key, { items: filtered, active: activeIndex, isSection: unavailable, wrap: true }); if (next !== null) { e.preventDefault(); setActive(next); return; } if (e.key.length === 1 && !e.metaKey && !e.ctrlKey) { // Keys typed in quick succession extend the prefix ("me" → Menlo); a lone key cycles. const ahead = typeAhead.current; ahead.text = e.timeStamp - ahead.at < TYPE_AHEAD_MS ? ahead.text + e.key.toLowerCase() : e.key.toLowerCase(); 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 = filtered.length; for (let s = prefix.length > 1 ? 0 : 1; s <= n; s++) { const i = (activeIndex + s) % n; if (!unavailable(filtered[i]!) && tx(filtered[i]!.label).toLowerCase().startsWith(prefix)) { setActive(i); break; } } } }; return ( <> setOpen(false)} anchor={triggerRef} matchWidth role="presentation" className={styles.popup} >
{searchable && (
{ setQuery(v); setActive(-1); }} size="normal" variant="framed" className={styles.search} />
)} {/* Virtualised rows are absolutely positioned; this sizes the popup to the widest label. */}
{options.map((o) => (
{o.icon && } {tx(o.label)} {o.detail && {tx(o.detail)}}
))}
`${o.section ? "section" : "option"}:${o.value}`} isSection={isSection} renderSection={(o) => {tx(o.label)}} activeIndex={activeIndex} onActiveChange={(i) => { if (!unavailable(filtered[i]!)) setActive(i); }} onActivate={(o) => choose(o)} focusable={false} hoverSelects rowHeight={24} aria-label={tx(aria["aria-label"])} className={styles.list} renderItem={(o) => ( {o.value === value && } {o.icon && } {tx(o.label)} {o.detail && ( {tx(o.detail)} )} )} />
); }