11import { useState , useMemo } from "react" ;
2+ import { Check , ChevronsUpDown } from "lucide-react" ;
3+ import { cn } from "@/lib/utils" ;
4+ import { Button } from "@/components/ui/button" ;
25import {
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" ;
1118import { allTimezones , getUserTimezone } from "@/lib/time-tools" ;
1219
1320interface 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 “{ searchTerm } ”
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