|
1 | | -import { Button as DSButton } from '@/ds/components/Button/Button'; |
2 | | -import { Popover, PopoverContent, PopoverTrigger } from '@/ds/components/Popover'; |
| 1 | +import { Combobox as BaseCombobox } from '@base-ui/react/combobox'; |
| 2 | +import { buttonVariants } from '@/ds/components/Button/Button'; |
3 | 3 | import { cn } from '@/lib/utils'; |
4 | 4 | import { Check, ChevronsUpDown, Search } from 'lucide-react'; |
5 | 5 | import * as React from 'react'; |
6 | | -import { type FormElementSize, formElementFocus } from '@/ds/primitives/form-element'; |
| 6 | +import { type FormElementSize } from '@/ds/primitives/form-element'; |
7 | 7 | import { transitions } from '@/ds/primitives/transitions'; |
8 | 8 |
|
9 | 9 | export type ComboboxOption = { |
@@ -36,173 +36,74 @@ export function Combobox({ |
36 | 36 | variant = 'default', |
37 | 37 | size = 'md', |
38 | 38 | }: ComboboxProps) { |
39 | | - const [open, setOpen] = React.useState(false); |
40 | | - const [search, setSearch] = React.useState(''); |
41 | | - const [highlightedIndex, setHighlightedIndex] = React.useState(0); |
42 | | - const triggerRef = React.useRef<HTMLButtonElement>(null); |
43 | | - const [triggerWidth, setTriggerWidth] = React.useState<number | undefined>(undefined); |
44 | | - const inputRef = React.useRef<HTMLInputElement>(null); |
45 | | - const listRef = React.useRef<HTMLDivElement>(null); |
| 39 | + const selectedOption = options.find(option => option.value === value) ?? null; |
46 | 40 |
|
47 | | - const selectedOption = options.find(option => option.value === value); |
48 | | - |
49 | | - const handleSelect = (optionValue: string) => { |
50 | | - onValueChange?.(optionValue); |
51 | | - setOpen(false); |
52 | | - setSearch(''); |
53 | | - setHighlightedIndex(0); |
54 | | - }; |
55 | | - |
56 | | - React.useEffect(() => { |
57 | | - if (triggerRef.current) { |
58 | | - setTriggerWidth(triggerRef.current.offsetWidth); |
59 | | - } |
60 | | - }, [open]); |
61 | | - |
62 | | - React.useEffect(() => { |
63 | | - if (open && inputRef.current) { |
64 | | - inputRef.current.focus(); |
65 | | - } |
66 | | - }, [open]); |
67 | | - |
68 | | - const filteredOptions = React.useMemo(() => { |
69 | | - if (!search) return options; |
70 | | - return options.filter(option => option.label.toLowerCase().includes(search.toLowerCase())); |
71 | | - }, [options, search]); |
72 | | - |
73 | | - React.useEffect(() => { |
74 | | - setHighlightedIndex(0); |
75 | | - }, [filteredOptions]); |
76 | | - |
77 | | - const handleKeyDown = (e: React.KeyboardEvent) => { |
78 | | - if (!open) return; |
79 | | - |
80 | | - switch (e.key) { |
81 | | - case 'ArrowDown': |
82 | | - e.preventDefault(); |
83 | | - setHighlightedIndex(prev => (prev < filteredOptions.length - 1 ? prev + 1 : prev)); |
84 | | - break; |
85 | | - case 'ArrowUp': |
86 | | - e.preventDefault(); |
87 | | - setHighlightedIndex(prev => (prev > 0 ? prev - 1 : prev)); |
88 | | - break; |
89 | | - case 'Enter': |
90 | | - e.preventDefault(); |
91 | | - if (filteredOptions[highlightedIndex]) { |
92 | | - handleSelect(filteredOptions[highlightedIndex].value); |
93 | | - } |
94 | | - break; |
95 | | - case 'Escape': |
96 | | - e.preventDefault(); |
97 | | - setOpen(false); |
98 | | - setSearch(''); |
99 | | - break; |
100 | | - case 'Home': |
101 | | - e.preventDefault(); |
102 | | - setHighlightedIndex(0); |
103 | | - break; |
104 | | - case 'End': |
105 | | - e.preventDefault(); |
106 | | - setHighlightedIndex(filteredOptions.length - 1); |
107 | | - break; |
| 41 | + const handleSelect = (item: ComboboxOption | null) => { |
| 42 | + if (item) { |
| 43 | + onValueChange?.(item.value); |
108 | 44 | } |
109 | 45 | }; |
110 | 46 |
|
111 | | - React.useEffect(() => { |
112 | | - if (listRef.current && highlightedIndex >= 0) { |
113 | | - const highlightedElement = listRef.current.children[highlightedIndex] as HTMLElement; |
114 | | - if (highlightedElement) { |
115 | | - highlightedElement.scrollIntoView({ block: 'nearest' }); |
116 | | - } |
117 | | - } |
118 | | - }, [highlightedIndex]); |
119 | | - |
120 | 47 | return ( |
121 | | - <Popover open={open} onOpenChange={setOpen}> |
122 | | - <PopoverTrigger asChild> |
123 | | - <DSButton |
124 | | - ref={triggerRef} |
125 | | - role="combobox" |
126 | | - aria-expanded={open} |
127 | | - variant={variant} |
128 | | - size={size} |
129 | | - className={cn('w-full justify-between', className)} |
130 | | - disabled={disabled} |
131 | | - > |
132 | | - <span className="truncate">{selectedOption ? selectedOption.label : placeholder}</span> |
133 | | - <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> |
134 | | - </DSButton> |
135 | | - </PopoverTrigger> |
136 | | - <PopoverContent className="p-0 w-fit" align="start"> |
137 | | - <div |
138 | | - className={cn( |
139 | | - 'flex h-full w-full flex-col overflow-hidden rounded-md bg-surface3 text-neutral5', |
140 | | - 'shadow-elevated', |
141 | | - 'animate-in fade-in-0 zoom-in-95 duration-150', |
142 | | - )} |
143 | | - > |
144 | | - <div className={cn('flex items-center border-b border-border1 px-3 py-2', transitions.colors)}> |
145 | | - <Search className={cn('mr-2 h-4 w-4 shrink-0 text-neutral3', transitions.colors)} /> |
146 | | - <input |
147 | | - ref={inputRef} |
148 | | - className={cn( |
149 | | - 'flex h-8 w-full rounded-md bg-transparent py-1 text-sm', |
150 | | - 'placeholder:text-neutral3 disabled:cursor-not-allowed disabled:opacity-50', |
151 | | - 'outline-none', |
152 | | - transitions.colors, |
153 | | - )} |
154 | | - placeholder={searchPlaceholder} |
155 | | - value={search} |
156 | | - onChange={e => setSearch(e.target.value)} |
157 | | - onKeyDown={handleKeyDown} |
158 | | - role="combobox" |
159 | | - aria-autocomplete="list" |
160 | | - aria-controls="combobox-options" |
161 | | - aria-expanded={open} |
162 | | - /> |
163 | | - </div> |
164 | | - <div |
165 | | - ref={listRef} |
166 | | - id="combobox-options" |
167 | | - role="listbox" |
168 | | - className="max-h-dropdown-max-height overflow-y-auto overflow-x-hidden p-1" |
169 | | - > |
170 | | - {filteredOptions.length === 0 ? ( |
171 | | - <div className="py-6 text-center text-sm text-neutral3">{emptyText}</div> |
172 | | - ) : ( |
173 | | - filteredOptions.map((option, index) => { |
174 | | - const isSelected = value === option.value; |
175 | | - const isHighlighted = index === highlightedIndex; |
176 | | - return ( |
177 | | - <div |
178 | | - key={option.value} |
179 | | - role="option" |
180 | | - aria-selected={isSelected} |
181 | | - className={cn( |
182 | | - 'relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm', |
183 | | - transitions.colors, |
184 | | - 'hover:bg-surface5 hover:text-neutral5', |
185 | | - isHighlighted && 'bg-surface5 text-neutral5', |
186 | | - isSelected && !isHighlighted && 'bg-accent1Dark text-accent1', |
187 | | - )} |
188 | | - onClick={() => handleSelect(option.value)} |
189 | | - onMouseEnter={() => setHighlightedIndex(index)} |
190 | | - > |
191 | | - <Check |
192 | | - className={cn( |
193 | | - 'mr-2 h-4 w-4', |
194 | | - transitions.opacity, |
195 | | - isSelected ? 'opacity-100 text-accent1' : 'opacity-0', |
196 | | - )} |
197 | | - /> |
198 | | - {option.label} |
199 | | - </div> |
200 | | - ); |
201 | | - }) |
| 48 | + <BaseCombobox.Root items={options} value={selectedOption} onValueChange={handleSelect} disabled={disabled}> |
| 49 | + <BaseCombobox.Trigger className={cn(buttonVariants({ variant, size }), 'w-full justify-between', className)}> |
| 50 | + <span className="truncate"> |
| 51 | + <BaseCombobox.Value placeholder={placeholder} /> |
| 52 | + </span> |
| 53 | + <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> |
| 54 | + </BaseCombobox.Trigger> |
| 55 | + |
| 56 | + <BaseCombobox.Portal> |
| 57 | + <BaseCombobox.Positioner align="start" sideOffset={4}> |
| 58 | + <BaseCombobox.Popup |
| 59 | + className={cn( |
| 60 | + 'min-w-[var(--anchor-width)] w-max rounded-md bg-surface3 text-neutral5', |
| 61 | + 'shadow-elevated', |
| 62 | + 'origin-[var(--transform-origin)]', |
| 63 | + 'transition-[transform,scale,opacity] duration-150 ease-out', |
| 64 | + 'data-[starting-style]:scale-95 data-[starting-style]:opacity-0', |
| 65 | + 'data-[ending-style]:scale-95 data-[ending-style]:opacity-0', |
202 | 66 | )} |
203 | | - </div> |
204 | | - </div> |
205 | | - </PopoverContent> |
206 | | - </Popover> |
| 67 | + > |
| 68 | + <div className={cn('flex items-center border-b border-border1 px-3 py-2', transitions.colors)}> |
| 69 | + <Search className={cn('mr-2 h-4 w-4 shrink-0 text-neutral3', transitions.colors)} /> |
| 70 | + <BaseCombobox.Input |
| 71 | + className={cn( |
| 72 | + 'flex h-8 w-full rounded-md bg-transparent py-1 text-sm', |
| 73 | + 'placeholder:text-neutral3 disabled:cursor-not-allowed disabled:opacity-50', |
| 74 | + 'outline-none', |
| 75 | + transitions.colors, |
| 76 | + )} |
| 77 | + placeholder={searchPlaceholder} |
| 78 | + /> |
| 79 | + </div> |
| 80 | + <BaseCombobox.Empty className="[&:not(:empty)]:block hidden py-6 text-center text-sm text-neutral3"> |
| 81 | + {emptyText} |
| 82 | + </BaseCombobox.Empty> |
| 83 | + <BaseCombobox.List className="max-h-dropdown-max-height overflow-y-auto overflow-x-hidden p-1"> |
| 84 | + {(option: ComboboxOption) => ( |
| 85 | + <BaseCombobox.Item |
| 86 | + key={option.value} |
| 87 | + value={option} |
| 88 | + className={cn( |
| 89 | + 'relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm', |
| 90 | + transitions.colors, |
| 91 | + 'data-[highlighted]:bg-surface5 data-[highlighted]:text-neutral5', |
| 92 | + 'data-[selected]:bg-accent1Dark data-[selected]:text-accent1', |
| 93 | + )} |
| 94 | + > |
| 95 | + <span className="mr-2 flex h-4 w-4 shrink-0 items-center justify-center"> |
| 96 | + <BaseCombobox.ItemIndicator> |
| 97 | + <Check className={cn('h-4 w-4 text-accent1', transitions.opacity)} /> |
| 98 | + </BaseCombobox.ItemIndicator> |
| 99 | + </span> |
| 100 | + <span className="whitespace-nowrap">{option.label}</span> |
| 101 | + </BaseCombobox.Item> |
| 102 | + )} |
| 103 | + </BaseCombobox.List> |
| 104 | + </BaseCombobox.Popup> |
| 105 | + </BaseCombobox.Positioner> |
| 106 | + </BaseCombobox.Portal> |
| 107 | + </BaseCombobox.Root> |
207 | 108 | ); |
208 | 109 | } |
0 commit comments