Skip to content

Commit da5c57d

Browse files
authored
fix(timezone): sticky search field and proper filtering in dropdown (#263)
Replace Radix UI Select with Popover+Command (cmdk) pattern to fix: - Search field now stays fixed at top while scrolling results - Typing filters results instead of jumping to matching items Fixes #110
1 parent ddde5d3 commit da5c57d

File tree

6 files changed

+316
-72
lines changed

6 files changed

+316
-72
lines changed
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
"use client";
2+
3+
import * as React from "react";
4+
import { Command as CommandPrimitive } from "cmdk";
5+
import { Search } from "lucide-react";
6+
7+
import { cn } from "@/lib/utils";
8+
9+
const Command = React.forwardRef<
10+
React.ElementRef<typeof CommandPrimitive>,
11+
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
12+
>(({ className, ...props }, ref) => (
13+
<CommandPrimitive
14+
ref={ref}
15+
className={cn(
16+
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
17+
className
18+
)}
19+
{...props}
20+
/>
21+
));
22+
Command.displayName = CommandPrimitive.displayName;
23+
24+
const CommandInput = React.forwardRef<
25+
React.ElementRef<typeof CommandPrimitive.Input>,
26+
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
27+
>(({ className, ...props }, ref) => (
28+
<div className="flex items-center border-b px-3" data-cmdk-input-wrapper="">
29+
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
30+
<CommandPrimitive.Input
31+
ref={ref}
32+
className={cn(
33+
"flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
34+
className
35+
)}
36+
{...props}
37+
/>
38+
</div>
39+
));
40+
41+
CommandInput.displayName = CommandPrimitive.Input.displayName;
42+
43+
const CommandList = React.forwardRef<
44+
React.ElementRef<typeof CommandPrimitive.List>,
45+
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
46+
>(({ className, ...props }, ref) => (
47+
<CommandPrimitive.List
48+
ref={ref}
49+
className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
50+
{...props}
51+
/>
52+
));
53+
54+
CommandList.displayName = CommandPrimitive.List.displayName;
55+
56+
const CommandEmpty = React.forwardRef<
57+
React.ElementRef<typeof CommandPrimitive.Empty>,
58+
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
59+
>((props, ref) => (
60+
<CommandPrimitive.Empty
61+
ref={ref}
62+
className="py-6 text-center text-sm"
63+
{...props}
64+
/>
65+
));
66+
67+
CommandEmpty.displayName = CommandPrimitive.Empty.displayName;
68+
69+
const CommandGroup = React.forwardRef<
70+
React.ElementRef<typeof CommandPrimitive.Group>,
71+
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
72+
>(({ className, ...props }, ref) => (
73+
<CommandPrimitive.Group
74+
ref={ref}
75+
className={cn(
76+
"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
77+
className
78+
)}
79+
{...props}
80+
/>
81+
));
82+
83+
CommandGroup.displayName = CommandPrimitive.Group.displayName;
84+
85+
const CommandItem = React.forwardRef<
86+
React.ElementRef<typeof CommandPrimitive.Item>,
87+
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
88+
>(({ className, ...props }, ref) => (
89+
<CommandPrimitive.Item
90+
ref={ref}
91+
className={cn(
92+
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50",
93+
className
94+
)}
95+
{...props}
96+
/>
97+
));
98+
99+
CommandItem.displayName = CommandPrimitive.Item.displayName;
100+
101+
export {
102+
Command,
103+
CommandInput,
104+
CommandList,
105+
CommandEmpty,
106+
CommandGroup,
107+
CommandItem,
108+
};
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
"use client";
2+
3+
import * as React from "react";
4+
import * as PopoverPrimitive from "@radix-ui/react-popover";
5+
6+
import { cn } from "@/lib/utils";
7+
8+
const Popover = PopoverPrimitive.Root;
9+
10+
const PopoverTrigger = PopoverPrimitive.Trigger;
11+
12+
const PopoverContent = React.forwardRef<
13+
React.ElementRef<typeof PopoverPrimitive.Content>,
14+
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
15+
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
16+
<PopoverPrimitive.Portal>
17+
<PopoverPrimitive.Content
18+
ref={ref}
19+
align={align}
20+
sideOffset={sideOffset}
21+
className={cn(
22+
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
23+
className
24+
)}
25+
{...props}
26+
/>
27+
</PopoverPrimitive.Portal>
28+
));
29+
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
30+
31+
export { Popover, PopoverTrigger, PopoverContent };
Lines changed: 110 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,20 @@
11
import { useState, useMemo } from "react";
2+
import { Check, ChevronsUpDown } from "lucide-react";
3+
import { cn } from "@/lib/utils";
4+
import { Button } from "@/components/ui/button";
25
import {
3-
Select,
4-
SelectContent,
5-
SelectItem,
6-
SelectTrigger,
7-
SelectValue,
8-
} from "@/components/ui/select";
9-
import { Input } from "@/components/ui/input";
10-
import { Search } from "lucide-react";
6+
Command,
7+
CommandEmpty,
8+
CommandGroup,
9+
CommandInput,
10+
CommandItem,
11+
CommandList,
12+
} from "@/components/ui/command";
13+
import {
14+
Popover,
15+
PopoverContent,
16+
PopoverTrigger,
17+
} from "@/components/ui/popover";
1118
import { allTimezones, getUserTimezone } from "@/lib/time-tools";
1219

1320
interface WorldClockCity {
@@ -31,7 +38,7 @@ export function TimezoneSelector({
3138
className,
3239
"data-testid": testId,
3340
}: TimezoneSelectorProps) {
34-
const [searchTerm, setSearchTerm] = useState("");
41+
const [open, setOpen] = useState(false);
3542

3643
// Get timezone offset for display
3744
const getTimezoneOffset = (timezone: string): string => {
@@ -50,72 +57,105 @@ export function TimezoneSelector({
5057
}
5158
};
5259

53-
// Filter and sort timezones
54-
const filteredTimezones = useMemo(() => {
55-
const filtered = allTimezones.filter(
56-
(city: WorldClockCity) =>
57-
city.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
58-
city.country.toLowerCase().includes(searchTerm.toLowerCase()) ||
59-
city.timezone.toLowerCase().includes(searchTerm.toLowerCase())
60-
);
60+
// Sort timezones by offset then by name
61+
const sortedTimezones = useMemo(
62+
() =>
63+
[...allTimezones].sort((a: WorldClockCity, b: WorldClockCity) => {
64+
const offsetA = getTimezoneOffset(a.timezone);
65+
const offsetB = getTimezoneOffset(b.timezone);
6166

62-
// Sort by timezone offset, then by city name
63-
return filtered.sort((a: WorldClockCity, b: WorldClockCity) => {
64-
const offsetA = getTimezoneOffset(a.timezone);
65-
const offsetB = getTimezoneOffset(b.timezone);
67+
// Extract numeric offset for proper sorting
68+
const numericOffsetA = parseFloat(offsetA.replace(/[^\d.-]/g, "")) || 0;
69+
const numericOffsetB = parseFloat(offsetB.replace(/[^\d.-]/g, "")) || 0;
6670

67-
// Extract numeric offset for proper sorting
68-
const numericOffsetA = parseFloat(offsetA.replace(/[^\d.-]/g, "")) || 0;
69-
const numericOffsetB = parseFloat(offsetB.replace(/[^\d.-]/g, "")) || 0;
71+
if (numericOffsetA !== numericOffsetB) {
72+
return numericOffsetA - numericOffsetB;
73+
}
7074

71-
if (numericOffsetA !== numericOffsetB) {
72-
return numericOffsetA - numericOffsetB;
73-
}
75+
return a.name.localeCompare(b.name);
76+
}),
77+
[]
78+
);
7479

75-
return a.name.localeCompare(b.name);
76-
});
77-
}, [searchTerm]);
80+
// Find the selected timezone display value
81+
const selectedTimezone = value
82+
? allTimezones.find((tz: WorldClockCity) => tz.timezone === value)
83+
: null;
7884

7985
return (
80-
<Select value={value} onValueChange={onValueChange} data-testid={testId}>
81-
<SelectTrigger className={className}>
82-
<SelectValue placeholder={placeholder} />
83-
</SelectTrigger>
84-
<SelectContent className="max-h-80">
85-
{/* Search Input */}
86-
<div className="flex items-center px-3 pb-2 border-b border-slate-200 dark:border-slate-700">
87-
<Search className="w-4 h-4 mr-2 text-slate-400" />
88-
<Input
89-
placeholder="Search timezones..."
90-
value={searchTerm}
91-
onChange={e => setSearchTerm(e.target.value)}
92-
className="border-0 px-0 focus-visible:ring-0"
93-
/>
94-
</div>
95-
96-
{/* Timezone Options */}
97-
{filteredTimezones.map((city: WorldClockCity) => {
98-
const offset = getTimezoneOffset(city.timezone);
99-
return (
100-
<SelectItem key={city.timezone} value={city.timezone}>
101-
<div className="flex justify-between items-center w-full min-w-0">
102-
<span className="font-medium truncate">
103-
{city.name}, {city.country}
104-
</span>
105-
<span className="text-xs text-slate-500 dark:text-slate-400 ml-2 flex-shrink-0">
106-
{offset}
107-
</span>
108-
</div>
109-
</SelectItem>
110-
);
111-
})}
112-
113-
{filteredTimezones.length === 0 && (
114-
<div className="px-3 py-2 text-sm text-slate-500 dark:text-slate-400">
115-
No timezones found matching &ldquo;{searchTerm}&rdquo;
116-
</div>
117-
)}
118-
</SelectContent>
119-
</Select>
86+
<Popover open={open} onOpenChange={setOpen}>
87+
<PopoverTrigger asChild>
88+
<Button
89+
variant="outline"
90+
role="combobox"
91+
aria-expanded={open}
92+
className={cn("w-full justify-between", className)}
93+
data-testid={testId}
94+
>
95+
{selectedTimezone ? (
96+
<span className="truncate">
97+
{selectedTimezone.name}, {selectedTimezone.country} (
98+
{getTimezoneOffset(selectedTimezone.timezone)})
99+
</span>
100+
) : (
101+
<span className="text-muted-foreground">{placeholder}</span>
102+
)}
103+
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
104+
</Button>
105+
</PopoverTrigger>
106+
<PopoverContent className="w-[350px] p-0" align="start">
107+
<Command
108+
filter={(value, search) => {
109+
// Custom filter that searches city name, country, and timezone
110+
const timezone = allTimezones.find(
111+
(tz: WorldClockCity) => tz.timezone === value
112+
);
113+
if (!timezone) return 0;
114+
const searchLower = search.toLowerCase();
115+
if (
116+
timezone.name.toLowerCase().includes(searchLower) ||
117+
timezone.country.toLowerCase().includes(searchLower) ||
118+
timezone.timezone.toLowerCase().includes(searchLower)
119+
) {
120+
return 1;
121+
}
122+
return 0;
123+
}}
124+
>
125+
<CommandInput placeholder="Search timezones..." />
126+
<CommandList>
127+
<CommandEmpty>No timezone found.</CommandEmpty>
128+
<CommandGroup>
129+
{sortedTimezones.map((city: WorldClockCity) => {
130+
const offset = getTimezoneOffset(city.timezone);
131+
return (
132+
<CommandItem
133+
key={city.timezone}
134+
value={city.timezone}
135+
onSelect={currentValue => {
136+
onValueChange(currentValue);
137+
setOpen(false);
138+
}}
139+
>
140+
<Check
141+
className={cn(
142+
"mr-2 h-4 w-4",
143+
value === city.timezone ? "opacity-100" : "opacity-0"
144+
)}
145+
/>
146+
<span className="flex-1 truncate">
147+
{city.name}, {city.country}
148+
</span>
149+
<span className="ml-2 text-xs text-muted-foreground">
150+
{offset}
151+
</span>
152+
</CommandItem>
153+
);
154+
})}
155+
</CommandGroup>
156+
</CommandList>
157+
</Command>
158+
</PopoverContent>
159+
</Popover>
120160
);
121161
}

0 commit comments

Comments
 (0)