import { 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 styles from "./Select.module.css"; export interface SelectOption { value: V; label: string; icon?: IconName | (string & {}); detail?: string; disabled?: 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; } /** Dropdown select with a listbox popup (↑↓ Enter Esc, type-ahead). */ export function Select({ value, options, onChange, placeholder = "Select…", disabled, variant = "framed", className, renderValue, ...aria }: SelectProps) { const tx = useTx(); const [open, setOpen] = useState(false); const triggerRef = useRef(null); const current = options.find((o) => o.value === value); const [active, setActive] = useState(0); const isSection = (o: SelectOption) => !!o.disabled; const openPopup = () => { setActive( Math.max( 0, options.findIndex((o) => o.value === value), ), ); setOpen(true); }; const choose = (o: SelectOption | undefined) => { if (!o || o.disabled) return; onChange(o.value); setOpen(false); }; const onPopupKey = (e: KeyboardEvent) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); choose(options[active]); return; } const next = listNavigate(e.key, { items: options, active, isSection, wrap: true }); if (next !== null) { e.preventDefault(); setActive(next); return; } if (e.key.length === 1 && !e.metaKey && !e.ctrlKey) { const ch = e.key.toLowerCase(); const n = options.length; for (let s = 1; s <= n; s++) { const i = (active + s) % n; if (!options[i]!.disabled && tx(options[i]!.label).toLowerCase().startsWith(ch)) { setActive(i); break; } } } }; return ( <> setOpen(false)} anchor={triggerRef} matchWidth role="presentation" className={styles.popup} >
{/* 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.value} isSection={isSection} renderSection={(o) => {tx(o.label)}} activeIndex={active} onActiveChange={setActive} 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)} )} )} />
); }