Skip to content

Commit abe666f

Browse files
committed
Dropdown
1 parent 81c071c commit abe666f

File tree

5 files changed

+562
-8
lines changed

5 files changed

+562
-8
lines changed

explorer/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
},
1313
"dependencies": {
1414
"@radix-ui/react-dialog": "^1.1.14",
15+
"@radix-ui/react-select": "^2.2.5",
1516
"@radix-ui/react-separator": "^1.1.7",
1617
"@radix-ui/react-slot": "^1.2.3",
1718
"@radix-ui/react-switch": "^1.2.5",

explorer/pnpm-lock.yaml

Lines changed: 90 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
import { ChevronDown } from 'lucide-react';
2+
import { PropsWithChildren, useEffect, useRef, useState } from 'react';
3+
import { Button } from './ui/button';
4+
5+
export interface DropdownSelectorProps<T> extends PropsWithChildren {
6+
loadedItems: T[];
7+
renderItem: (item: T) => React.ReactNode;
8+
onSelect: (item: T) => void;
9+
isDisabled?: (item: T) => boolean;
10+
width?: string;
11+
className?: string;
12+
}
13+
14+
export function DropdownSelector<T>({
15+
loadedItems,
16+
renderItem,
17+
onSelect,
18+
isDisabled,
19+
width = 'w-[300px]',
20+
className,
21+
children,
22+
}: DropdownSelectorProps<T>) {
23+
const [isOpen, setIsOpen] = useState(false);
24+
const [selectedIndex, setSelectedIndex] = useState(-1);
25+
const containerRef = useRef<HTMLDivElement>(null);
26+
const listRef = useRef<HTMLDivElement>(null);
27+
const optionsRef = useRef<(HTMLDivElement | null)[]>([]);
28+
29+
// Reset selected index when items change
30+
useEffect(() => {
31+
setSelectedIndex(-1);
32+
}, [loadedItems]);
33+
34+
// Handle click outside and escape key
35+
useEffect(() => {
36+
function handleClickOutside(event: MouseEvent) {
37+
if (
38+
containerRef.current &&
39+
!containerRef.current.contains(event.target as Node)
40+
) {
41+
setIsOpen(false);
42+
}
43+
}
44+
45+
function handleKeyDown(event: KeyboardEvent) {
46+
if (!isOpen) return;
47+
48+
switch (event.key) {
49+
case 'Escape':
50+
setIsOpen(false);
51+
break;
52+
case 'ArrowDown':
53+
event.preventDefault();
54+
setSelectedIndex((prev) =>
55+
prev < loadedItems.length - 1 ? prev + 1 : prev,
56+
);
57+
break;
58+
case 'ArrowUp':
59+
event.preventDefault();
60+
setSelectedIndex((prev) => (prev > 0 ? prev - 1 : prev));
61+
break;
62+
case 'Enter':
63+
event.preventDefault();
64+
if (selectedIndex >= 0 && selectedIndex < loadedItems.length) {
65+
// If an item is selected, use that
66+
const item = loadedItems[selectedIndex];
67+
if (!isDisabled?.(item)) {
68+
onSelect(item);
69+
setIsOpen(false);
70+
setSelectedIndex(-1);
71+
}
72+
} else {
73+
// If no item is selected, use the first non-disabled item
74+
const firstValidIndex = loadedItems.findIndex(
75+
(item) => !isDisabled?.(item),
76+
);
77+
if (firstValidIndex >= 0) {
78+
onSelect(loadedItems[firstValidIndex]);
79+
setIsOpen(false);
80+
setSelectedIndex(-1);
81+
}
82+
}
83+
break;
84+
}
85+
}
86+
87+
document.addEventListener('mousedown', handleClickOutside);
88+
document.addEventListener('keydown', handleKeyDown);
89+
90+
return () => {
91+
document.removeEventListener('mousedown', handleClickOutside);
92+
document.removeEventListener('keydown', handleKeyDown);
93+
};
94+
}, [isOpen, loadedItems, selectedIndex, onSelect, isDisabled]);
95+
96+
// Scroll selected item into view
97+
useEffect(() => {
98+
if (selectedIndex >= 0 && optionsRef.current[selectedIndex]) {
99+
optionsRef.current[selectedIndex]?.scrollIntoView({
100+
block: 'nearest',
101+
});
102+
}
103+
}, [selectedIndex]);
104+
105+
return (
106+
<div
107+
className='min-w-0 flex-grow relative'
108+
role='combobox'
109+
ref={containerRef}
110+
>
111+
<Button
112+
variant='outline'
113+
className={`w-full justify-start p-2 h-12 ${className ?? ''}`}
114+
onClick={() => setIsOpen(!isOpen)}
115+
aria-expanded={isOpen}
116+
aria-haspopup='listbox'
117+
>
118+
<div className='flex items-center gap-2 w-full justify-between min-w-0'>
119+
{children}
120+
<ChevronDown className='h-4 w-4 opacity-50 mr-2 flex-shrink-0' />
121+
</div>
122+
</Button>
123+
124+
{isOpen && (
125+
<div
126+
className={`absolute z-50 ${width} bg-background border rounded-md shadow-lg`}
127+
role='listbox'
128+
aria-label='Options'
129+
>
130+
<div
131+
className='max-h-[260px] overflow-y-auto'
132+
ref={listRef}
133+
tabIndex={0}
134+
role='listbox'
135+
>
136+
{loadedItems.length === 0 ? (
137+
<div className='p-4 text-center text-sm text-muted-foreground'>
138+
No items available
139+
</div>
140+
) : (
141+
loadedItems.map((item, i) => {
142+
const disabled = isDisabled?.(item) ?? false;
143+
return (
144+
<div
145+
// eslint-disable-next-line react/no-array-index-key
146+
key={i}
147+
ref={(el) => {
148+
optionsRef.current[i] = el;
149+
}}
150+
onClick={() => {
151+
if (!disabled) {
152+
onSelect(item);
153+
setIsOpen(false);
154+
}
155+
}}
156+
role='option'
157+
aria-selected={i === selectedIndex}
158+
aria-disabled={disabled}
159+
className={`px-2 py-1.5 text-sm rounded-sm cursor-pointer ${
160+
disabled
161+
? 'opacity-50 cursor-not-allowed'
162+
: i === selectedIndex
163+
? 'bg-accent'
164+
: 'hover:bg-accent'
165+
}`}
166+
>
167+
{renderItem(item)}
168+
</div>
169+
);
170+
})
171+
)}
172+
</div>
173+
</div>
174+
)}
175+
</div>
176+
);
177+
}

0 commit comments

Comments
 (0)