| | | 1 | | import { useEffect, useId, useRef, useState } from "react"; |
| | | 2 | | |
| | | 3 | | export type TuiComboboxOption = |
| | | 4 | | | { kind: "group"; label: string } |
| | | 5 | | | { kind: "option"; label: string; value: number }; |
| | | 6 | | |
| | 0 | 7 | | export function TuiCombobox({ name, value, options, onChange, disabled, ariaLabel }: { |
| | | 8 | | name: string; |
| | | 9 | | value: number | null; |
| | | 10 | | options: TuiComboboxOption[]; |
| | | 11 | | onChange: (v: number) => void; |
| | | 12 | | disabled?: boolean; |
| | | 13 | | ariaLabel?: string; |
| | | 14 | | }) { |
| | 0 | 15 | | const id = useId(); |
| | 0 | 16 | | const [open, setOpen] = useState(false); |
| | 0 | 17 | | const [highlightedValue, setHighlightedValue] = useState<number | null>(value); |
| | 0 | 18 | | const rootRef = useRef<HTMLDivElement>(null); |
| | 0 | 19 | | const listRef = useRef<HTMLUListElement>(null); |
| | | 20 | | |
| | 0 | 21 | | const flatOptions = options.filter((o): o is Extract<TuiComboboxOption, { kind: "option" }> => o.kind === "option"); |
| | 0 | 22 | | const current = flatOptions.find(o => o.value === value); |
| | | 23 | | |
| | 0 | 24 | | useEffect(() => { |
| | 0 | 25 | | if (!open) return; |
| | 0 | 26 | | function onDocPointer(e: MouseEvent) { |
| | 0 | 27 | | if (!rootRef.current?.contains(e.target as Node)) setOpen(false); |
| | | 28 | | } |
| | 0 | 29 | | document.addEventListener("mousedown", onDocPointer); |
| | 0 | 30 | | return () => document.removeEventListener("mousedown", onDocPointer); |
| | | 31 | | }, [open]); |
| | | 32 | | |
| | 0 | 33 | | useEffect(() => { |
| | 0 | 34 | | if (!open || highlightedValue == null) return; |
| | 0 | 35 | | const el = listRef.current?.querySelector<HTMLElement>(`[data-value="${highlightedValue}"]`); |
| | 0 | 36 | | el?.scrollIntoView({ block: "nearest" }); |
| | | 37 | | }, [open, highlightedValue]); |
| | | 38 | | |
| | 0 | 39 | | function openDropdown() { |
| | 0 | 40 | | setHighlightedValue(value ?? flatOptions[0]?.value ?? null); |
| | 0 | 41 | | setOpen(true); |
| | | 42 | | } |
| | | 43 | | |
| | 0 | 44 | | function moveHighlight(delta: number) { |
| | 0 | 45 | | if (flatOptions.length === 0) return; |
| | 0 | 46 | | const idx = flatOptions.findIndex(o => o.value === highlightedValue); |
| | 0 | 47 | | const nextIdx = idx < 0 |
| | | 48 | | ? (delta > 0 ? 0 : flatOptions.length - 1) |
| | | 49 | | : (idx + delta + flatOptions.length) % flatOptions.length; |
| | 0 | 50 | | setHighlightedValue(flatOptions[nextIdx]!.value); |
| | | 51 | | } |
| | | 52 | | |
| | 0 | 53 | | function onKeyDown(e: React.KeyboardEvent) { |
| | 0 | 54 | | if (disabled) return; |
| | | 55 | | |
| | 0 | 56 | | if (!open) { |
| | 0 | 57 | | if (e.key === "Enter" || e.key === " " || e.key === "ArrowDown") { |
| | 0 | 58 | | e.preventDefault(); |
| | 0 | 59 | | e.stopPropagation(); |
| | 0 | 60 | | openDropdown(); |
| | | 61 | | } |
| | 0 | 62 | | return; |
| | | 63 | | } |
| | | 64 | | |
| | | 65 | | // Open: trap navigation keys so global shortcuts don't fire |
| | 0 | 66 | | if (e.key === "ArrowDown" || e.key === "j") { e.preventDefault(); e.stopPropagation(); moveHighlight(1); return; } |
| | 0 | 67 | | if (e.key === "ArrowUp" || e.key === "k") { e.preventDefault(); e.stopPropagation(); moveHighlight(-1); return; } |
| | 0 | 68 | | if (e.key === "Home") { e.preventDefault(); e.stopPropagation(); setHighlightedValue(flatOptions[0]?.value ?? nul |
| | 0 | 69 | | if (e.key === "End") { e.preventDefault(); e.stopPropagation(); setHighlightedValue(flatOptions[flatOptions.leng |
| | 0 | 70 | | if (e.key === "Enter") { |
| | 0 | 71 | | e.preventDefault(); |
| | 0 | 72 | | e.stopPropagation(); |
| | 0 | 73 | | if (highlightedValue != null) onChange(highlightedValue); |
| | 0 | 74 | | setOpen(false); |
| | 0 | 75 | | return; |
| | | 76 | | } |
| | 0 | 77 | | if (e.key === "Escape") { |
| | 0 | 78 | | e.preventDefault(); |
| | 0 | 79 | | e.stopPropagation(); |
| | 0 | 80 | | setOpen(false); |
| | 0 | 81 | | return; |
| | | 82 | | } |
| | 0 | 83 | | if (e.key === "Tab") { |
| | | 84 | | // Let tab move focus naturally, but close the list first |
| | 0 | 85 | | setOpen(false); |
| | 0 | 86 | | return; |
| | | 87 | | } |
| | | 88 | | } |
| | | 89 | | |
| | 0 | 90 | | return ( |
| | | 91 | | <div className="tui-combobox" ref={rootRef} data-open={open ? "true" : undefined}> |
| | | 92 | | <button |
| | | 93 | | type="button" |
| | | 94 | | id={id} |
| | | 95 | | aria-haspopup="listbox" |
| | | 96 | | aria-expanded={open} |
| | | 97 | | aria-label={ariaLabel} |
| | | 98 | | disabled={disabled} |
| | | 99 | | className="tui-combobox-trigger" |
| | 0 | 100 | | onClick={() => { |
| | 0 | 101 | | if (disabled) return; |
| | 0 | 102 | | if (open) setOpen(false); else openDropdown(); |
| | | 103 | | }} |
| | | 104 | | onKeyDown={onKeyDown} |
| | | 105 | | > |
| | | 106 | | <span className="tui-edit-bracket">[</span> |
| | | 107 | | <span className="tui-combobox-arrow">{open ? "▲" : "▼"}</span> |
| | | 108 | | <span className="tui-combobox-value">{current?.label ?? "—"}</span> |
| | | 109 | | <span className="tui-edit-bracket">]</span> |
| | | 110 | | </button> |
| | | 111 | | |
| | | 112 | | {open && ( |
| | | 113 | | <ul |
| | | 114 | | role="listbox" |
| | | 115 | | aria-labelledby={id} |
| | | 116 | | className="tui-combobox-list" |
| | | 117 | | ref={listRef} |
| | | 118 | | > |
| | 0 | 119 | | {options.map((opt, i) => { |
| | 0 | 120 | | if (opt.kind === "group") { |
| | 0 | 121 | | return ( |
| | | 122 | | <li key={`g-${i}`} className="tui-combobox-group">── {opt.label} ──</li> |
| | | 123 | | ); |
| | | 124 | | } |
| | 0 | 125 | | const sel = opt.value === value; |
| | 0 | 126 | | const hi = opt.value === highlightedValue; |
| | 0 | 127 | | return ( |
| | | 128 | | <li |
| | | 129 | | key={`o-${opt.value}`} |
| | | 130 | | role="option" |
| | | 131 | | aria-selected={sel} |
| | | 132 | | data-value={opt.value} |
| | | 133 | | data-highlighted={hi ? "true" : undefined} |
| | | 134 | | data-selected={sel ? "true" : undefined} |
| | | 135 | | className="tui-combobox-option" |
| | 0 | 136 | | onMouseEnter={() => setHighlightedValue(opt.value)} |
| | 0 | 137 | | onMouseDown={(e) => { |
| | | 138 | | // mousedown so focus blur doesn't close before selection |
| | 0 | 139 | | e.preventDefault(); |
| | 0 | 140 | | onChange(opt.value); |
| | 0 | 141 | | setOpen(false); |
| | | 142 | | }} |
| | | 143 | | > |
| | | 144 | | <span className="tui-combobox-arrow-cell">{sel ? "▶" : " "}</span> |
| | | 145 | | <span>{opt.label}</span> |
| | | 146 | | </li> |
| | | 147 | | ); |
| | | 148 | | })} |
| | | 149 | | </ul> |
| | | 150 | | )} |
| | | 151 | | |
| | | 152 | | <input type="hidden" name={name} value={value ?? ""} /> |
| | | 153 | | </div> |
| | | 154 | | ); |
| | | 155 | | } |