|
1 | 1 | import * as React from 'react'; |
2 | 2 | import { Check, ChevronDown } from 'lucide-react'; |
3 | 3 | import { cn } from './utils'; |
| 4 | +import { useOverlayTriggerState } from 'react-stately'; |
| 5 | +import { Popover } from '@radix-ui/react-popover'; |
4 | 6 | import { |
5 | | - Select, |
6 | | - SelectContent, |
7 | | - SelectItem, |
8 | | - SelectTrigger, |
9 | | - SelectValue, |
10 | | -} from './select'; |
| 7 | + PopoverContent, |
| 8 | + PopoverTrigger, |
| 9 | +} from './popover'; |
11 | 10 |
|
12 | 11 | export interface RegionOption { |
13 | 12 | label: string; |
@@ -35,39 +34,118 @@ export function RegionSelect({ |
35 | 34 | contentClassName, |
36 | 35 | itemClassName, |
37 | 36 | }: RegionSelectProps) { |
| 37 | + const popoverState = useOverlayTriggerState({}); |
| 38 | + const [query, setQuery] = React.useState(''); |
| 39 | + const triggerRef = React.useRef<HTMLButtonElement>(null); |
| 40 | + const popoverRef = React.useRef<HTMLDivElement>(null); |
| 41 | + const [menuWidth, setMenuWidth] = React.useState<number | undefined>(undefined); |
| 42 | + |
| 43 | + React.useEffect(() => { |
| 44 | + if (triggerRef.current) setMenuWidth(triggerRef.current.offsetWidth); |
| 45 | + }, []); |
| 46 | + |
| 47 | + const selectedOption = options.find((o) => o.value === value); |
| 48 | + |
| 49 | + const filtered = React.useMemo( |
| 50 | + () => (query ? options.filter((o) => `${o.label}`.toLowerCase().includes(query.trim().toLowerCase())) : options), |
| 51 | + [options, query] |
| 52 | + ); |
| 53 | + |
| 54 | + // Candidate that would be chosen on Enter (exact match else first filtered) |
| 55 | + const enterCandidate = React.useMemo(() => { |
| 56 | + const q = query.trim().toLowerCase(); |
| 57 | + if (filtered.length === 0) return undefined; |
| 58 | + const exact = q ? filtered.find((o) => o.label.toLowerCase() === q) : undefined; |
| 59 | + return exact ?? filtered[0]; |
| 60 | + }, [filtered, query]); |
| 61 | + |
38 | 62 | return ( |
39 | | - <Select value={value} onValueChange={onValueChange} disabled={disabled}> |
40 | | - <SelectTrigger className={cn('w-full', className)}> |
41 | | - <SelectValue placeholder={placeholder} /> |
42 | | - </SelectTrigger> |
43 | | - <SelectContent |
| 63 | + <Popover open={popoverState.isOpen} onOpenChange={popoverState.setOpen}> |
| 64 | + <PopoverTrigger |
| 65 | + ref={triggerRef} |
| 66 | + disabled={disabled} |
| 67 | + className={cn( |
| 68 | + 'flex items-center justify-between w-full sm:text-base rounded-md border border-input bg-background px-3 py-2 h-10 text-sm ring-offset-background', |
| 69 | + 'placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50', |
| 70 | + className |
| 71 | + )} |
| 72 | + > |
| 73 | + {selectedOption?.label || placeholder} |
| 74 | + <ChevronDown className="w-4 h-4 opacity-50" /> |
| 75 | + </PopoverTrigger> |
| 76 | + <PopoverContent |
| 77 | + ref={popoverRef} |
44 | 78 | className={cn( |
45 | | - 'max-h-[200px] overflow-y-auto divide-y rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none', |
| 79 | + 'z-50 p-0', |
46 | 80 | contentClassName |
47 | 81 | )} |
| 82 | + style={{ width: menuWidth ? `${menuWidth}px` : undefined }} |
48 | 83 | > |
49 | | - {options.map((option) => ( |
50 | | - <SelectItem |
51 | | - key={option.value} |
52 | | - value={option.value} |
53 | | - className={cn( |
54 | | - 'cursor-pointer select-none py-3 px-3 transition-colors duration-150', |
55 | | - 'data-[highlighted]:bg-gray-100 data-[highlighted]:text-gray-900', |
56 | | - 'data-[selected]:bg-gray-200 data-[selected]:text-gray-900 data-[selected]:font-semibold', |
57 | | - 'hover:bg-gray-50', |
58 | | - itemClassName |
59 | | - )} |
60 | | - > |
61 | | - <div className="flex items-center"> |
62 | | - <Check className={cn('mr-2 h-4 w-4', value === option.value ? 'opacity-100' : 'opacity-0')} /> |
63 | | - <span className={cn('block truncate', value === option.value && 'font-semibold')}> |
64 | | - {option.label} |
65 | | - </span> |
66 | | - </div> |
67 | | - </SelectItem> |
68 | | - ))} |
69 | | - </SelectContent> |
70 | | - </Select> |
| 84 | + <div className="bg-white p-1.5 rounded-md shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm"> |
| 85 | + <div className="px-1.5 pb-1.5"> |
| 86 | + <input |
| 87 | + type="text" |
| 88 | + value={query} |
| 89 | + onChange={(e) => setQuery(e.target.value)} |
| 90 | + placeholder="Search..." |
| 91 | + // focus after mount for accessibility without using autoFocus |
| 92 | + ref={(el) => { |
| 93 | + if (el) queueMicrotask(() => el.focus()); |
| 94 | + }} |
| 95 | + onKeyDown={(e) => { |
| 96 | + if (e.key === 'Enter') { |
| 97 | + e.preventDefault(); |
| 98 | + const toSelect = enterCandidate; |
| 99 | + if (toSelect) { |
| 100 | + onValueChange?.(toSelect.value); |
| 101 | + setQuery(''); |
| 102 | + popoverState.close(); |
| 103 | + triggerRef.current?.focus(); |
| 104 | + } |
| 105 | + } else if (e.key === 'Escape') { |
| 106 | + e.preventDefault(); |
| 107 | + setQuery(''); |
| 108 | + popoverState.close(); |
| 109 | + triggerRef.current?.focus(); |
| 110 | + } |
| 111 | + }} |
| 112 | + className="w-full h-9 rounded-md bg-white px-2 text-sm leading-none focus:ring-0 focus:outline-none border border-input" |
| 113 | + /> |
| 114 | + </div> |
| 115 | + <ul className="max-h-[200px] overflow-y-auto rounded-md"> |
| 116 | + {filtered.length === 0 && <li className="px-3 py-2 text-sm text-gray-500">No results.</li>} |
| 117 | + {filtered.map((option) => { |
| 118 | + const isSelected = option.value === value; |
| 119 | + const isEnterCandidate = enterCandidate?.value === option.value && !isSelected; |
| 120 | + return ( |
| 121 | + <li key={option.value} className="list-none"> |
| 122 | + <button |
| 123 | + type="button" |
| 124 | + onClick={() => { |
| 125 | + onValueChange?.(option.value); |
| 126 | + setQuery(''); |
| 127 | + popoverState.close(); |
| 128 | + }} |
| 129 | + className={cn( |
| 130 | + 'w-full text-left cursor-pointer select-none py-3 px-3 transition-colors duration-150 flex items-center gap-2 rounded', |
| 131 | + 'text-gray-900 hover:bg-gray-50', |
| 132 | + isSelected && 'bg-gray-200', |
| 133 | + !isSelected && isEnterCandidate && 'bg-gray-50', |
| 134 | + itemClassName |
| 135 | + )} |
| 136 | + > |
| 137 | + {isSelected && <Check className="h-4 w-4 flex-shrink-0" />} |
| 138 | + <span className={cn('block truncate', !isSelected && 'ml-6', isSelected && 'font-semibold')}> |
| 139 | + {option.label} |
| 140 | + </span> |
| 141 | + </button> |
| 142 | + </li> |
| 143 | + ); |
| 144 | + })} |
| 145 | + </ul> |
| 146 | + </div> |
| 147 | + </PopoverContent> |
| 148 | + </Popover> |
71 | 149 | ); |
72 | 150 | } |
73 | 151 |
|
0 commit comments