From d6f6a5ae6f13499f3e40ccac4d572bb84c063b87 Mon Sep 17 00:00:00 2001 From: Lucas Berger Date: Mon, 15 Jun 2026 08:36:49 -0400 Subject: [PATCH] fix(18): searchable timezone combobox with type-to-search Replace the picker with an accessible combobox (role=combobox + role=listbox): focusing shows the full zone list (no typing/erasing needed), typing filters it case-insensitively (underscores ignored, so "york" matches America/New_York), with arrow-key navigation, Enter/click to select, and Escape to close. Fixes the datalist limitation where a pre-filled value collapsed the dropdown to one match. e2e updated to type+click options and a type-to-search case added. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/pwa/e2e/timezone-verify.spec.ts | 40 ++++-- apps/pwa/src/routes/AdminPage.tsx | 193 +++++++++++++++++++++------ 2 files changed, 187 insertions(+), 46 deletions(-) diff --git a/apps/pwa/e2e/timezone-verify.spec.ts b/apps/pwa/e2e/timezone-verify.spec.ts index ff38615..31e2df2 100644 --- a/apps/pwa/e2e/timezone-verify.spec.ts +++ b/apps/pwa/e2e/timezone-verify.spec.ts @@ -4,9 +4,10 @@ * Verifies the admin Timezone section with the real 18-02 API endpoints. * Runs on desktop profile only (admin UI is desktop-focused). * - * NOTE: the IANA picker is a native has the combobox role + // The searchable text input exposes role=combobox const input = page.getByRole('combobox', { name: 'Household timezone' }); await expect(input).toBeVisible(); const val = await input.inputValue(); expect(val.length, 'Picker should have a non-empty timezone').toBeGreaterThan(0); }); - test('Save is enabled on first run when timezone is not yet explicit (WR-01)', async ({ page }) => { + test('Save is enabled on first run when timezone is not yet explicit (WR-01)', async ({ + page, + }) => { // On first run the GET returns isExplicitlySet:false with the detected zone // pre-filled. Saving that value to make the choice explicit is a meaningful // action, so Save must be ENABLED even though the input matches the displayed @@ -49,17 +52,38 @@ test.describe('Admin Timezone section — 18-04 round-trip', () => { test('Changing the selection enables Save', async ({ page }) => { const tzSection = page.getByRole('region', { name: 'Timezone' }); const input = page.getByRole('combobox', { name: 'Household timezone' }); - await input.selectOption('America/Chicago'); + await input.click(); + await input.fill('America/Chicago'); + await page.getByRole('option', { name: 'America/Chicago' }).click(); + await expect(input).toHaveValue('America/Chicago'); const saveBtn = tzSection.getByRole('button', { name: /Save/ }); await expect(saveBtn).toBeEnabled(); }); + test('Typing filters the list (type-to-search)', async ({ page }) => { + const input = page.getByRole('combobox', { name: 'Household timezone' }); + const listbox = page.getByRole('listbox', { name: 'Timezones' }); + + // Focus opens the full list with no typing required. + await input.click(); + await expect(listbox).toBeVisible(); + await expect(listbox.getByRole('option').first()).toBeVisible(); + + // Human-friendly partial query (case-insensitive, underscores ignored) filters. + await input.fill('york'); + await expect(page.getByRole('option', { name: 'America/New_York' })).toBeVisible(); + await expect(page.getByRole('option', { name: 'Europe/Paris' })).toHaveCount(0); + }); + test('Save persists timezone across reload', async ({ page }) => { const input = page.getByRole('combobox', { name: 'Household timezone' }); const tzSection = page.getByRole('region', { name: 'Timezone' }); - // Set to a known value - await input.selectOption('America/Chicago'); + // Set to a known value via the searchable combobox + await input.click(); + await input.fill('America/Chicago'); + await page.getByRole('option', { name: 'America/Chicago' }).click(); + await expect(input).toHaveValue('America/Chicago'); const saveBtn = tzSection.getByRole('button', { name: /^Save$/ }); await expect(saveBtn).toBeEnabled(); await saveBtn.click(); diff --git a/apps/pwa/src/routes/AdminPage.tsx b/apps/pwa/src/routes/AdminPage.tsx index fa186ad..8bcf21a 100644 --- a/apps/pwa/src/routes/AdminPage.tsx +++ b/apps/pwa/src/routes/AdminPage.tsx @@ -64,6 +64,13 @@ export function AdminPage() { // Timezone picker state const [timezoneInput, setTimezoneInput] = useState(null); + // Searchable combobox state: tzSearch is the live filter text while the list is + // open (null = closed, input shows the selected zone). tzActiveIndex tracks the + // keyboard-highlighted option. + const [tzOpen, setTzOpen] = useState(false); + const [tzSearch, setTzSearch] = useState(null); + const [tzActiveIndex, setTzActiveIndex] = useState(0); + const tzBlurTimer = useRef | null>(null); // Members query const membersQuery = useQuery({ @@ -129,25 +136,28 @@ export function AdminPage() { // IANA zones list (Intl.supportedValuesOf may not be present in all runtimes) const ianaZones: string[] = - typeof (Intl as { supportedValuesOf?: (key: string) => string[] }).supportedValuesOf === 'function' + 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 -based - // native grouped by region. Shows the full - list on tap (no typing/erasing) and uses the native wheel picker - on iOS. */} -
- { + if (tzBlurTimer.current) clearTimeout(tzBlurTimer.current); + setTzOpen(true); + setTzSearch(''); + setTzActiveIndex(0); + }} + onChange={(e) => { + setTzSearch(e.target.value); + setTzOpen(true); + setTzActiveIndex(0); + }} + onKeyDown={(e) => { + if (e.key === 'ArrowDown') { + e.preventDefault(); + if (!tzOpen) { + setTzOpen(true); + setTzSearch(''); + } + setTzActiveIndex((i) => Math.min(i + 1, filteredZones.length - 1)); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + setTzActiveIndex((i) => Math.max(i - 1, 0)); + } else if (e.key === 'Enter') { + if (tzOpen && filteredZones[tzActiveIndex]) { + e.preventDefault(); + selectTimezone(filteredZones[tzActiveIndex]); + } + } else if (e.key === 'Escape') { + setTzOpen(false); + setTzSearch(null); + } + }} + onBlur={() => { + // Delay so an option's onClick fires before the list unmounts. + tzBlurTimer.current = setTimeout(() => { + setTzOpen(false); + setTzSearch(null); + }, 120); + }} style={{ width: '100%', boxSizing: 'border-box', @@ -417,22 +477,79 @@ export function AdminPage() { color: 'var(--color-text-primary)', background: 'var(--color-surface, #ffffff)', minHeight: '44px', - cursor: 'pointer', }} - > - {currentZoneMissing && ( - - )} - {regionOrder.map((region) => ( - - {zonesByRegion[region].map((tz) => ( - - ))} - - ))} - + /> + {tzOpen && ( +
    + {filteredZones.length === 0 && ( +
  • + No matching timezones +
  • + )} + {filteredZones.map((tz, i) => { + const active = i === tzActiveIndex; + return ( +
  • { + el?.scrollIntoView({ block: 'nearest' }); + } + : undefined + } + onMouseDown={(e) => e.preventDefault()} + onMouseEnter={() => setTzActiveIndex(i)} + onClick={() => selectTimezone(tz)} + style={{ + padding: 'var(--space-2, 8px) var(--space-3, 12px)', + fontSize: 'var(--text-body-size, 15px)', + fontFamily: 'var(--font-family-base)', + color: 'var(--color-text-primary)', + borderRadius: 'var(--space-1, 4px)', + cursor: 'pointer', + background: active ? 'var(--color-member-0, #4A90D9)' : 'transparent', + ...(active ? { color: '#ffffff' } : null), + minHeight: '44px', + display: 'flex', + alignItems: 'center', + }} + > + {tz} +
  • + ); + })} +
+ )}
{/* Use detected zone affordance (D-02) */}