// Adapted from Kibo UI (MIT), with owned React/CSS APIs. See THIRD_PARTY_NOTICES.md. "use client"; import { useEffect, useId, useRef, useState, type ComponentProps } from "react"; export interface KiboComboboxOption { value: string; label: string; disabled?: boolean; } export type KiboComboboxProps = Omit< ComponentProps<"div">, "defaultValue" | "onChange" > & { label: string; options: readonly KiboComboboxOption[]; value?: string; defaultValue?: string; onValueChange?: (value: string) => void; name?: string; disabled?: boolean; placeholder?: string; }; export function KiboCombobox({ label, options, value, defaultValue = "", onValueChange, name, disabled, placeholder = "Search…", className = "", ...props }: KiboComboboxProps) { const id = useId(); const [internal, setInternal] = useState(defaultValue); const selected = value ?? internal; const selectedLabel = options.find((o) => o.value === selected)?.label ?? ""; const [query, setQuery] = useState(selectedLabel); const [open, setOpen] = useState(false); const [active, setActive] = useState(-1); const ref = useRef(null); const filtered = options.filter((o) => o.label.toLowerCase().includes(query.toLowerCase()), ); useEffect(() => setQuery(selectedLabel), [selectedLabel]); const close = () => { setOpen(false); setQuery(selectedLabel); setActive(-1); }; const choose = (option: KiboComboboxOption) => { if (option.disabled) return; if (value === undefined) setInternal(option.value); onValueChange?.(option.value); setQuery(value === undefined ? option.label : selectedLabel); setOpen(false); setActive(-1); }; return (
{ if (!event.currentTarget.contains(event.relatedTarget)) close(); }} {...props} > = 0 ? `${id}-${active}` : undefined } disabled={disabled} value={query} placeholder={placeholder} autoComplete="off" onFocus={() => { setQuery(""); setOpen(true); setActive(-1); }} onChange={(event) => { setQuery(event.target.value); setOpen(true); setActive(-1); }} onKeyDown={(event) => { if (event.nativeEvent.isComposing) return; if (event.key === "Escape") { event.preventDefault(); close(); } if (["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) { event.preventDefault(); setOpen(true); const indexes = filtered.flatMap((o, i) => (o.disabled ? [] : [i])); if (!indexes.length) return; const pos = indexes.indexOf(active); const next = event.key === "Home" ? indexes[0] : event.key === "End" ? indexes.at(-1)! : pos === -1 ? event.key === "ArrowUp" ? indexes.at(-1)! : indexes[0] : indexes[ (pos + (event.key === "ArrowDown" ? 1 : -1) + indexes.length) % indexes.length ]; setActive(next); requestAnimationFrame(() => document .getElementById(`${id}-${next}`) ?.scrollIntoView({ block: "nearest" }), ); } if (event.key === "Enter" && open && active >= 0) { event.preventDefault(); const option = filtered[active]; if (option) choose(option); } }} /> {name && ( )}{" "} {open && !disabled && (
{filtered.map((option, i) => (
event.preventDefault()} onClick={() => choose(option)} > {option.label}
))} {!filtered.length &&

No options found.

}
)}
); }