Skip to content

Commit 9ee574b

Browse files
author
Dylan Huang
committed
Add keyboard navigation and highlighting to SearchableSelect component
1 parent 0052d2b commit 9ee574b

1 file changed

Lines changed: 68 additions & 12 deletions

File tree

vite-app/src/components/SearchableSelect.tsx

Lines changed: 68 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ const SearchableSelect = React.forwardRef<
3333
const [dropdownPosition, setDropdownPosition] = useState<"left" | "right">(
3434
"left"
3535
);
36+
const [highlightedIndex, setHighlightedIndex] = useState(-1);
3637
const containerRef = useRef<HTMLDivElement>(null);
3738
const inputRef = useRef<HTMLInputElement>(null);
3839

@@ -43,6 +44,7 @@ const SearchableSelect = React.forwardRef<
4344
option.value.toLowerCase().includes(searchTerm.toLowerCase())
4445
);
4546
setFilteredOptions(filtered);
47+
setHighlightedIndex(-1); // Reset highlighted index when options change
4648
}, [searchTerm, options]);
4749

4850
useEffect(() => {
@@ -65,6 +67,37 @@ const SearchableSelect = React.forwardRef<
6567
onChange(optionValue);
6668
setIsOpen(false);
6769
setSearchTerm("");
70+
setHighlightedIndex(-1);
71+
};
72+
73+
const handleKeyDown = (e: React.KeyboardEvent) => {
74+
if (!isOpen) return;
75+
76+
switch (e.key) {
77+
case "ArrowDown":
78+
e.preventDefault();
79+
setHighlightedIndex((prev) =>
80+
prev < filteredOptions.length - 1 ? prev + 1 : 0
81+
);
82+
break;
83+
case "ArrowUp":
84+
e.preventDefault();
85+
setHighlightedIndex((prev) =>
86+
prev > 0 ? prev - 1 : filteredOptions.length - 1
87+
);
88+
break;
89+
case "Enter":
90+
e.preventDefault();
91+
if (highlightedIndex >= 0 && filteredOptions[highlightedIndex]) {
92+
handleSelect(filteredOptions[highlightedIndex].value);
93+
}
94+
break;
95+
case "Escape":
96+
setIsOpen(false);
97+
setSearchTerm("");
98+
setHighlightedIndex(-1);
99+
break;
100+
}
68101
};
69102

70103
const calculateDropdownPosition = () => {
@@ -100,6 +133,16 @@ const SearchableSelect = React.forwardRef<
100133
<div
101134
ref={ref}
102135
onClick={handleToggle}
136+
onKeyDown={(e) => {
137+
if (e.key === "Enter" || e.key === " ") {
138+
e.preventDefault();
139+
handleToggle();
140+
}
141+
}}
142+
tabIndex={0}
143+
role="combobox"
144+
aria-expanded={isOpen}
145+
aria-haspopup="listbox"
103146
className={`
104147
${commonStyles.input.base}
105148
${commonStyles.input.size[size]}
@@ -130,45 +173,58 @@ const SearchableSelect = React.forwardRef<
130173

131174
{isOpen && (
132175
<div
133-
className={`absolute z-50 w-max min-w-full mt-1 bg-white border border-gray-200 rounded-md max-h-60 overflow-auto ${
176+
className={`absolute z-50 w-max min-w-full mt-1 ${
177+
commonStyles.input.base
178+
} rounded-md max-h-60 overflow-auto ${
134179
dropdownPosition === "right" ? "right-0" : "left-0"
135180
}`}
136181
style={{
137182
maxWidth: `min(calc(100vw - 2rem), 500px)`,
138183
right: dropdownPosition === "right" ? "0" : undefined,
139184
left: dropdownPosition === "left" ? "0" : undefined,
185+
boxShadow: commonStyles.input.shadow,
140186
}}
141187
>
142-
<div className="p-2 border-b border-gray-200">
188+
<div
189+
className={`p-2 border-b border-gray-200 rounded-t-md`}
190+
style={{ boxShadow: commonStyles.input.shadow }}
191+
>
143192
<input
144193
ref={inputRef}
145194
type="text"
146195
value={searchTerm}
147196
onChange={(e) => setSearchTerm(e.target.value)}
197+
onKeyDown={handleKeyDown}
148198
placeholder="Search..."
149199
className={`${commonStyles.input.base} ${commonStyles.input.size.sm} w-full min-w-48`}
150200
style={{ boxShadow: commonStyles.input.shadow }}
151-
onKeyDown={(e) => {
152-
if (e.key === "Escape") {
153-
setIsOpen(false);
154-
setSearchTerm("");
155-
}
156-
}}
201+
role="searchbox"
202+
aria-label="Search options"
157203
/>
158204
</div>
159-
<div className="max-h-48 overflow-auto">
205+
<div
206+
className="max-h-48 overflow-auto"
207+
role="listbox"
208+
aria-label="Options"
209+
>
160210
{filteredOptions.length > 0 ? (
161-
filteredOptions.map((option) => (
211+
filteredOptions.map((option, index) => (
162212
<div
163213
key={option.value}
164214
onClick={() => handleSelect(option.value)}
165-
className="px-3 py-2 text-sm cursor-pointer hover:bg-gray-50 text-gray-700 border-b border-gray-100 last:border-b-0"
215+
onMouseEnter={() => setHighlightedIndex(index)}
216+
className={`px-3 py-2 text-xs font-medium cursor-pointer hover:bg-gray-100 text-gray-700 border-b border-gray-100 last:border-b-0 ${
217+
highlightedIndex === index ? "bg-gray-100" : ""
218+
}`}
219+
role="option"
220+
aria-selected={highlightedIndex === index}
221+
tabIndex={-1}
166222
>
167223
{option.label}
168224
</div>
169225
))
170226
) : (
171-
<div className="px-3 py-2 text-sm text-gray-500">
227+
<div className="px-3 py-2 text-xs font-medium text-gray-500">
172228
No options found
173229
</div>
174230
)}

0 commit comments

Comments
 (0)