/** * Combobox (Autocomplete) - a text input with a filterable dropdown of options. * * Like Select, but the trigger is a real text field: typing filters the option * list by label (case-insensitive substring), and picking an option (click, * Enter on the highlight) commits its value. Arrow keys move the highlight, * Enter selects, Escape closes (restoring the committed label), and a click * outside dismisses it. Roles: input role="combobox" + a role="listbox" of * role="option" rows with aria-activedescendant on the highlighted one. * * Controlled by `value` (the committed option value); the visible query text is * managed internally and re-synced to the committed label on close. * * All colours from the theme; sizes/radii from tokens; both themes. */ import { useEffect, useId, useMemo, useRef, useState } from "react"; import type { CSSProperties, KeyboardEvent } from "react"; import { FS, FW, R, SP } from "@rukin/tokens"; import { useTheme } from "./theme"; import { Icon } from "./Icon"; export type ComboboxOption = { value: string; label: string }; export type ComboboxProps = { value?: string; onChange?: (value: string) => void; options: ComboboxOption[]; placeholder?: string; disabled?: boolean; /** Text shown in the empty dropdown when nothing matches. */ emptyText?: string; width?: number | string; id?: string; style?: CSSProperties; }; export function Combobox({ value, onChange, options, placeholder = "Start typing…", disabled, emptyText = "Nothing found", width, id, style, }: ComboboxProps) { const { C } = useTheme(); const autoId = useId(); const baseId = id ?? autoId; const listId = `${baseId}-list`; const rootRef = useRef(null); const inputRef = useRef(null); const committedLabel = useMemo( () => options.find((o) => o.value === value)?.label ?? "", [options, value], ); const [query, setQuery] = useState(committedLabel); const [open, setOpen] = useState(false); const [highlight, setHighlight] = useState(0); // Keep the field text in sync when the committed value changes from outside. useEffect(() => { if (!open) setQuery(committedLabel); }, [committedLabel, open]); const filtered = useMemo(() => { const q = query.trim().toLowerCase(); if (!q) return options; return options.filter((o) => o.label.toLowerCase().includes(q)); }, [options, query]); // Close on outside click; restore the committed label. useEffect(() => { if (!open) return; const onDoc = (e: MouseEvent) => { if (rootRef.current && !rootRef.current.contains(e.target as Node)) { setOpen(false); setQuery(committedLabel); } }; document.addEventListener("mousedown", onDoc); return () => document.removeEventListener("mousedown", onDoc); }, [open, committedLabel]); const choose = (i: number) => { const o = filtered[i]; if (!o) return; onChange?.(o.value); setQuery(o.label); setOpen(false); }; const onKeyDown = (e: KeyboardEvent) => { if (disabled) return; if (e.key === "ArrowDown") { e.preventDefault(); if (!open) { setOpen(true); setHighlight(0); } else { setHighlight((h) => Math.min(filtered.length - 1, h + 1)); } } else if (e.key === "ArrowUp") { e.preventDefault(); setHighlight((h) => Math.max(0, h - 1)); } else if (e.key === "Enter") { if (open) { e.preventDefault(); choose(highlight); } } else if (e.key === "Escape") { if (open) { e.preventDefault(); setOpen(false); setQuery(committedLabel); } } }; const borderColor = disabled ? `${C.muted}55` : open ? C.accent : C.hairline; return (
{ setQuery(e.target.value); setOpen(true); setHighlight(0); }} onFocus={() => !disabled && setOpen(true)} onKeyDown={onKeyDown} style={{ flex: 1, minWidth: 0, border: "none", outline: "none", background: "transparent", padding: "12px 0", fontSize: FS.field, fontWeight: FW.medium, fontFamily: "inherit", color: disabled ? C.muted : C.ink, }} />
{open && (
    {filtered.length === 0 && (
  • {emptyText}
  • )} {filtered.map((o, i) => { const isSel = o.value === value; const isHi = i === highlight; return (
  • setHighlight(i)} onMouseDown={(e) => { // keep focus in the input; select before blur closes the list e.preventDefault(); choose(i); }} style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: SP[2], padding: "10px 12px", borderRadius: R.sm, fontSize: FS.base, fontWeight: isSel ? FW.semibold : FW.medium, color: isSel ? C.accent : C.ink, background: isHi ? C.canvas : "transparent", cursor: "pointer", }} > {o.label} {isSel && }
  • ); })}
)}
); }