TimezoneDropdown.tsx113 lines · main
1import { CheckIcon } from 'lucide-react'
2import { useMemo, useState } from 'react'
3import {
4 cn,
5 Command,
6 CommandEmpty,
7 CommandGroup,
8 CommandInput,
9 CommandItem,
10 CommandList,
11 DropdownMenuPortal,
12 DropdownMenuSub,
13 DropdownMenuSubContent,
14 DropdownMenuSubTrigger,
15 ScrollArea,
16} from 'ui'
17
18import { findTimezoneByIana, TIMEZONES_BY_IANA } from '@/lib/constants/timezones'
19import { useTimezone } from '@/lib/datetime'
20import { guessLocalTimezone } from '@/lib/dayjs'
21import { useTrack } from '@/lib/telemetry/track'
22
23const AUTO_OPTION_VALUE = '__auto__'
24
25export const TimezoneDropdown = () => {
26 const { timezone, storedTimezone, setTimezone, isAutoDetected } = useTimezone()
27 const track = useTrack()
28 const [open, setOpen] = useState(false)
29
30 // The "Auto detect" row always advertises the browser's own timezone, even
31 // when the user is currently overriding it with a manual pick.
32 const browserTimezone = useMemo(() => guessLocalTimezone(), [])
33
34 const triggerLabel = useMemo(() => {
35 return findTimezoneByIana(timezone)?.text ?? timezone
36 }, [timezone])
37
38 const handleSelect = (nextStored: string) => {
39 setTimezone(nextStored)
40 const resolvedNext = nextStored || guessLocalTimezone()
41 track('timezone_picker_clicked', {
42 previousTimezone: timezone,
43 nextTimezone: resolvedNext,
44 isAutoDetected: nextStored === '',
45 source: 'user_dropdown',
46 })
47 setOpen(false)
48 }
49
50 return (
51 <DropdownMenuSub open={open} onOpenChange={setOpen}>
52 <DropdownMenuSubTrigger className="flex gap-2 cursor-pointer">
53 <div className="flex flex-col min-w-0">
54 <span>Timezone</span>
55 <span className="text-xs text-foreground-lighter truncate" title={triggerLabel}>
56 {isAutoDetected ? `Auto (${timezone})` : triggerLabel}
57 </span>
58 </div>
59 </DropdownMenuSubTrigger>
60 <DropdownMenuPortal>
61 <DropdownMenuSubContent className="p-0 w-[320px]" sideOffset={4}>
62 <Command>
63 <CommandInput placeholder="Search timezone..." className="h-9" />
64 <CommandList>
65 <CommandEmpty>No timezones found</CommandEmpty>
66 <CommandGroup>
67 <ScrollArea className="h-72">
68 <CommandItem
69 key={AUTO_OPTION_VALUE}
70 value={`Auto detect ${browserTimezone}`}
71 onSelect={() => handleSelect('')}
72 >
73 <div className="flex flex-col">
74 <span>Auto detect</span>
75 <span className="text-xs text-foreground-lighter">{browserTimezone}</span>
76 </div>
77 <CheckIcon
78 className={cn(
79 'ml-auto h-4 w-4',
80 isAutoDetected ? 'opacity-100' : 'opacity-0'
81 )}
82 />
83 </CommandItem>
84 {TIMEZONES_BY_IANA.map((entry) => {
85 const ianaName = entry.utc[0]
86 const isSelected = !isAutoDetected && storedTimezone === ianaName
87 return (
88 <CommandItem
89 key={ianaName}
90 // CommandItem matches against the `value` prop for the input filter — include
91 // both the human label and the IANA name so search works for either.
92 value={`${entry.text} ${ianaName}`}
93 onSelect={() => handleSelect(ianaName)}
94 >
95 {entry.text}
96 <CheckIcon
97 className={cn(
98 'ml-auto h-4 w-4',
99 isSelected ? 'opacity-100' : 'opacity-0'
100 )}
101 />
102 </CommandItem>
103 )
104 })}
105 </ScrollArea>
106 </CommandGroup>
107 </CommandList>
108 </Command>
109 </DropdownMenuSubContent>
110 </DropdownMenuPortal>
111 </DropdownMenuSub>
112 )
113}