Skip to content

Commit 98c7b1c

Browse files
Update region-select component to match 360training style:
- Add search functionality - Remove check icon from trigger - Improve dropdown styling - Add react-stately dependency Co-authored-by: Jake Ruesink <jake@lambdacurry.dev>
1 parent 16c07e8 commit 98c7b1c

3 files changed

Lines changed: 792 additions & 36 deletions

File tree

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,8 @@
2929
"@remix-run/react": "^2.17.0",
3030
"react-dom": "^19.1.1",
3131
"react-phone-number-input": "^3.4.12",
32-
"react-router-dom": "^7.6.2"
32+
"react-router-dom": "^7.6.2",
33+
"react-stately": "^3.40.0"
3334
},
3435
"packageManager": "yarn@4.9.1"
3536
}
Lines changed: 112 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,12 @@
11
import * as React from 'react';
22
import { Check, ChevronDown } from 'lucide-react';
33
import { cn } from './utils';
4+
import { useOverlayTriggerState } from 'react-stately';
5+
import { Popover } from '@radix-ui/react-popover';
46
import {
5-
Select,
6-
SelectContent,
7-
SelectItem,
8-
SelectTrigger,
9-
SelectValue,
10-
} from './select';
7+
PopoverContent,
8+
PopoverTrigger,
9+
} from './popover';
1110

1211
export interface RegionOption {
1312
label: string;
@@ -35,39 +34,118 @@ export function RegionSelect({
3534
contentClassName,
3635
itemClassName,
3736
}: 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+
3862
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}
4478
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',
4680
contentClassName
4781
)}
82+
style={{ width: menuWidth ? `${menuWidth}px` : undefined }}
4883
>
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>
71149
);
72150
}
73151

0 commit comments

Comments
 (0)