fix(18): timezone picker shows full list on tap (native select)

The IANA picker was an <input list=datalist>, which filters the dropdown by
whatever text is already in the field — so with the stored zone pre-filled a
user only saw a single option and had to erase the value (undiscoverable) to
browse. datalist is also unreliable in iOS Safari.

Replace it with a native <select> grouped by region (<optgroup>): tapping
shows the whole list with no typing/erasing, and it renders as the native
wheel picker on iOS. The "Use detected" one-tap shortcut still covers the
common case. Option labels are shortened (region stripped, underscores → spaces)
while values remain full IANA ids. e2e updated from fill() to selectOption().

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Lucas Berger
2026-06-15 08:19:42 -04:00
co-authored by Claude Opus 4.8
parent 745e806d89
commit a8d6142566
2 changed files with 44 additions and 19 deletions
+35 -11
View File
@@ -127,12 +127,28 @@ export function AdminPage() {
effectiveTimezoneInput === '' ||
(isExplicit && effectiveTimezoneInput === storedTimezone);
// IANA zones list for the datalist (Intl.supportedValuesOf may not be present in all runtimes)
// IANA zones list (Intl.supportedValuesOf may not be present in all runtimes)
const ianaZones: string[] =
typeof (Intl as { supportedValuesOf?: (key: string) => string[] }).supportedValuesOf === 'function'
? (Intl as { supportedValuesOf: (key: string) => string[] }).supportedValuesOf('timeZone')
: [];
// Group zones by region (the part before the first '/') for an <optgroup>-based
// native <select>. A native select shows the full list on tap with no typing —
// and renders as the native wheel picker on iOS — unlike a datalist, which hides
// the list behind whatever text is already in the field.
const zonesByRegion = ianaZones.reduce<Record<string, string[]>>((acc, tz) => {
const region = tz.includes('/') ? tz.slice(0, tz.indexOf('/')) : 'Other';
(acc[region] ??= []).push(tz);
return acc;
}, {});
const regionOrder = Object.keys(zonesByRegion).sort((a, b) =>
a === 'Other' ? 1 : b === 'Other' ? -1 : a.localeCompare(b),
);
// Defensive: a stored/validated zone could (rarely) be absent from supportedValuesOf.
const currentZoneMissing =
!!effectiveTimezoneInput && !ianaZones.includes(effectiveTimezoneInput);
// Save shared calendar mutation
const sharedCalMutation = useMutation({
mutationFn: (calId: number) => setSharedCalendar(calId),
@@ -382,14 +398,13 @@ export function AdminPage() {
</div>
)}
{/* Searchable IANA picker */}
{/* IANA picker — native <select> grouped by region. Shows the full
list on tap (no typing/erasing) and uses the native wheel picker
on iOS. */}
<div style={{ marginBottom: 'var(--space-3, 12px)' }}>
<input
type="text"
list="iana-zones"
<select
value={effectiveTimezoneInput}
onChange={(e) => setTimezoneInput(e.target.value)}
placeholder="e.g. America/Chicago"
aria-label="Household timezone"
style={{
width: '100%',
@@ -402,13 +417,22 @@ export function AdminPage() {
color: 'var(--color-text-primary)',
background: 'var(--color-surface, #ffffff)',
minHeight: '44px',
cursor: 'pointer',
}}
/>
<datalist id="iana-zones">
{ianaZones.map((tz) => (
<option key={tz} value={tz} />
>
{currentZoneMissing && (
<option value={effectiveTimezoneInput}>{effectiveTimezoneInput}</option>
)}
{regionOrder.map((region) => (
<optgroup key={region} label={region}>
{zonesByRegion[region].map((tz) => (
<option key={tz} value={tz}>
{tz.includes('/') ? tz.slice(tz.indexOf('/') + 1).replace(/_/g, ' ') : tz}
</option>
))}
</optgroup>
))}
</datalist>
</select>
</div>
{/* Use detected zone affordance (D-02) */}