Skip to content

Commit 7e154af

Browse files
committed
✨ [feat][frontend] Better timezone combobox
1 parent 1daea5b commit 7e154af

2 files changed

Lines changed: 134 additions & 37 deletions

File tree

frontend/src/components/create-account-fab.tsx

Lines changed: 12 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,24 @@
11
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
2-
import { ChevronDown, Globe } from 'lucide-react'
2+
import { ChevronDown } from 'lucide-react'
33
import { useState } from 'react'
44
import { toast } from 'sonner'
55

66
import { FabSheet } from '@/components/fab-sheet'
7+
import { TimezoneCombobox } from '@/components/timezone-combobox'
78
import { Button } from '@/components/ui/button'
8-
import {
9-
Combobox,
10-
ComboboxContent,
11-
ComboboxEmpty,
12-
ComboboxInput,
13-
ComboboxItem,
14-
ComboboxList,
15-
useComboboxAnchor
16-
} from '@/components/ui/combobox'
179
import {
1810
DropdownMenu,
1911
DropdownMenuContent,
2012
DropdownMenuItem,
2113
DropdownMenuTrigger
2214
} from '@/components/ui/dropdown-menu'
23-
import { Field, FieldGroup, FieldLabel } from '@/components/ui/field'
15+
import {
16+
Field,
17+
FieldDescription,
18+
FieldGroup,
19+
FieldLabel
20+
} from '@/components/ui/field'
2421
import { Input } from '@/components/ui/input'
25-
import { InputGroupAddon } from '@/components/ui/input-group'
2622
import { SheetFooter } from '@/components/ui/sheet'
2723
import {
2824
type AccountCreate,
@@ -32,8 +28,6 @@ import {
3228
} from '@/lib/client'
3329
import type { Client } from '@/lib/client/client'
3430

35-
const TIMEZONES = Intl.supportedValuesOf('timeZone')
36-
3731
interface CreateAccountFabProps {
3832
client: Client
3933
}
@@ -46,7 +40,6 @@ export function CreateAccountFab({ client }: CreateAccountFabProps) {
4640
const [timezone, setTimezone] = useState(
4741
() => Intl.DateTimeFormat().resolvedOptions().timeZone
4842
)
49-
const timezoneAnchor = useComboboxAnchor()
5043

5144
const { data: currencies } = useQuery({
5245
queryKey: ['currencies'],
@@ -165,30 +158,12 @@ export function CreateAccountFab({ client }: CreateAccountFabProps) {
165158

166159
<Field>
167160
<FieldLabel htmlFor="account-timezone">Timezone</FieldLabel>
168-
<Combobox
161+
<TimezoneCombobox
169162
id="account-timezone"
170-
items={TIMEZONES}
171163
value={timezone}
172-
onValueChange={(value) => setTimezone(value ?? '')}
173-
>
174-
<div ref={timezoneAnchor}>
175-
<ComboboxInput placeholder="Select timezone">
176-
<InputGroupAddon>
177-
<Globe className="size-4" />
178-
</InputGroupAddon>
179-
</ComboboxInput>
180-
</div>
181-
<ComboboxContent anchor={timezoneAnchor} className="min-w-0">
182-
<ComboboxEmpty>No timezone found.</ComboboxEmpty>
183-
<ComboboxList>
184-
{(tz: string) => (
185-
<ComboboxItem key={tz} value={tz}>
186-
{tz}
187-
</ComboboxItem>
188-
)}
189-
</ComboboxList>
190-
</ComboboxContent>
191-
</Combobox>
164+
onValueChange={setTimezone}
165+
/>
166+
<FieldDescription>Will be stored as {timezone}</FieldDescription>
192167
</Field>
193168
</FieldGroup>
194169

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
import { Globe } from 'lucide-react'
2+
3+
import {
4+
Combobox,
5+
ComboboxCollection,
6+
ComboboxContent,
7+
ComboboxEmpty,
8+
ComboboxGroup,
9+
ComboboxInput,
10+
ComboboxItem,
11+
ComboboxLabel,
12+
ComboboxList,
13+
ComboboxSeparator,
14+
useComboboxAnchor
15+
} from '@/components/ui/combobox'
16+
import { InputGroupAddon } from '@/components/ui/input-group'
17+
18+
interface TimezoneOption {
19+
value: string
20+
label: string
21+
}
22+
23+
interface TimezoneGroup {
24+
value: string
25+
items: TimezoneOption[]
26+
}
27+
28+
// "GMT-5", "GMT+5:30", or "GMT" for the zone's current offset.
29+
function gmtLabel(timeZone: string): string {
30+
const name = new Intl.DateTimeFormat('en-US', {
31+
timeZone,
32+
timeZoneName: 'shortOffset'
33+
})
34+
.formatToParts(new Date())
35+
.find((part) => part.type === 'timeZoneName')?.value
36+
return name ?? 'GMT'
37+
}
38+
39+
function offsetMinutes(gmt: string): number {
40+
const match = gmt.match(/GMT([+-])(\d{1,2})(?::(\d{2}))?/)
41+
if (!match) return 0
42+
const sign = match[1] === '-' ? -1 : 1
43+
return sign * (Number(match[2]) * 60 + Number(match[3] ?? 0))
44+
}
45+
46+
// Grouped by IANA area ("America", "Europe", ...), each option labelled
47+
// e.g. "America/New_York" -> "(GMT-5) New York" under the "America" group.
48+
const TIMEZONE_GROUPS: TimezoneGroup[] = (() => {
49+
const byArea = new Map<string, (TimezoneOption & { offset: number })[]>()
50+
for (const tz of Intl.supportedValuesOf('timeZone')) {
51+
const area = tz.split('/')[0]
52+
const gmt = gmtLabel(tz)
53+
const city = tz.split('/').pop()?.replace(/_/g, ' ') ?? tz
54+
const items = byArea.get(area) ?? []
55+
items.push({
56+
value: tz,
57+
label: `(${gmt}) ${city}`,
58+
offset: offsetMinutes(gmt)
59+
})
60+
byArea.set(area, items)
61+
}
62+
return [...byArea.entries()]
63+
.sort(([a], [b]) => a.localeCompare(b))
64+
.map(([area, items]) => ({
65+
value: area,
66+
items: items
67+
.sort((a, b) => a.offset - b.offset || a.label.localeCompare(b.label))
68+
.map(({ value, label }) => ({ value, label }))
69+
}))
70+
})()
71+
72+
const TIMEZONE_OPTIONS = TIMEZONE_GROUPS.flatMap((group) => group.items)
73+
74+
interface TimezoneComboboxProps {
75+
id?: string
76+
value: string
77+
onValueChange: (value: string) => void
78+
}
79+
80+
export function TimezoneCombobox({
81+
id,
82+
value,
83+
onValueChange
84+
}: TimezoneComboboxProps) {
85+
const anchor = useComboboxAnchor()
86+
const selected = TIMEZONE_OPTIONS.find((tz) => tz.value === value) ?? null
87+
88+
return (
89+
<Combobox
90+
id={id}
91+
items={TIMEZONE_GROUPS}
92+
value={selected}
93+
onValueChange={(option) => onValueChange(option?.value ?? '')}
94+
>
95+
<div ref={anchor}>
96+
<ComboboxInput placeholder="Select timezone">
97+
<InputGroupAddon>
98+
<Globe className="size-4" />
99+
</InputGroupAddon>
100+
</ComboboxInput>
101+
</div>
102+
<ComboboxContent anchor={anchor} className="min-w-0">
103+
<ComboboxEmpty>No timezone found.</ComboboxEmpty>
104+
<ComboboxList>
105+
{(group: TimezoneGroup, index: number) => (
106+
<ComboboxGroup key={group.value} items={group.items}>
107+
<ComboboxLabel>{group.value}</ComboboxLabel>
108+
<ComboboxCollection>
109+
{(tz: TimezoneOption) => (
110+
<ComboboxItem key={tz.value} value={tz}>
111+
{tz.label}
112+
</ComboboxItem>
113+
)}
114+
</ComboboxCollection>
115+
{index < TIMEZONE_GROUPS.length - 1 && <ComboboxSeparator />}
116+
</ComboboxGroup>
117+
)}
118+
</ComboboxList>
119+
</ComboboxContent>
120+
</Combobox>
121+
)
122+
}

0 commit comments

Comments
 (0)