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) <noreply@anthropic.com>
This commit is contained in:
Lucas Berger
2026-06-15 08:36:49 -04:00
co-authored by Claude Opus 4.8
parent 46d7fcc2d2
commit d6f6a5ae6f
2 changed files with 187 additions and 46 deletions
+32 -8
View File
@@ -4,9 +4,10 @@
* Verifies the admin Timezone section with the real 18-02 API endpoints. * Verifies the admin Timezone section with the real 18-02 API endpoints.
* Runs on desktop profile only (admin UI is desktop-focused). * Runs on desktop profile only (admin UI is desktop-focused).
* *
* NOTE: the IANA picker is a native <select> grouped by region, which has the * NOTE: the IANA picker is a searchable combobox — a text input (role=combobox)
* ARIA combobox role. Selecting a zone uses selectOption (not fill), and the * that opens a role=listbox of role=option items on focus. Selecting a zone means
* option's value is the full IANA id even though its visible label is shortened. * focusing the input, typing to filter, then clicking the option (not selectOption).
* Option accessible names are the full IANA id (e.g. "America/Chicago").
*/ */
import { test, expect } from '@playwright/test'; import { test, expect } from '@playwright/test';
@@ -24,14 +25,16 @@ test.describe('Admin Timezone section — 18-04 round-trip', () => {
}); });
test('Timezone picker (combobox) is visible and pre-filled', async ({ page }) => { test('Timezone picker (combobox) is visible and pre-filled', async ({ page }) => {
// Native <select> has the combobox role // The searchable text input exposes role=combobox
const input = page.getByRole('combobox', { name: 'Household timezone' }); const input = page.getByRole('combobox', { name: 'Household timezone' });
await expect(input).toBeVisible(); await expect(input).toBeVisible();
const val = await input.inputValue(); const val = await input.inputValue();
expect(val.length, 'Picker should have a non-empty timezone').toBeGreaterThan(0); 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 // 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 // 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 // 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 }) => { test('Changing the selection enables Save', async ({ page }) => {
const tzSection = page.getByRole('region', { name: 'Timezone' }); const tzSection = page.getByRole('region', { name: 'Timezone' });
const input = page.getByRole('combobox', { name: 'Household 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/ }); const saveBtn = tzSection.getByRole('button', { name: /Save/ });
await expect(saveBtn).toBeEnabled(); 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 }) => { test('Save persists timezone across reload', async ({ page }) => {
const input = page.getByRole('combobox', { name: 'Household timezone' }); const input = page.getByRole('combobox', { name: 'Household timezone' });
const tzSection = page.getByRole('region', { name: 'Timezone' }); const tzSection = page.getByRole('region', { name: 'Timezone' });
// Set to a known value // Set to a known value via the searchable combobox
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$/ }); const saveBtn = tzSection.getByRole('button', { name: /^Save$/ });
await expect(saveBtn).toBeEnabled(); await expect(saveBtn).toBeEnabled();
await saveBtn.click(); await saveBtn.click();
+153 -36
View File
@@ -64,6 +64,13 @@ export function AdminPage() {
// Timezone picker state // Timezone picker state
const [timezoneInput, setTimezoneInput] = useState<string | null>(null); const [timezoneInput, setTimezoneInput] = useState<string | null>(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<string | null>(null);
const [tzActiveIndex, setTzActiveIndex] = useState(0);
const tzBlurTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
// Members query // Members query
const membersQuery = useQuery({ const membersQuery = useQuery({
@@ -129,25 +136,28 @@ export function AdminPage() {
// IANA zones list (Intl.supportedValuesOf may not be present in all runtimes) // IANA zones list (Intl.supportedValuesOf may not be present in all runtimes)
const ianaZones: string[] = 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') ? (Intl as { supportedValuesOf: (key: string) => string[] }).supportedValuesOf('timeZone')
: []; : [];
// Group zones by region (the part before the first '/') for an <optgroup>-based // Searchable combobox: filter zones by the live search text (case-insensitive,
// native <select>. A native select shows the full list on tap with no typing — // ignoring underscores so "new york" matches "America/New_York"). When the search
// and renders as the native wheel picker on iOS — unlike a datalist, which hides // is empty the full list shows — so tapping the field reveals every zone with no
// the list behind whatever text is already in the field. // typing required.
const zonesByRegion = ianaZones.reduce<Record<string, string[]>>((acc, tz) => { const tzNorm = (s: string) => s.toLowerCase().replace(/_/g, ' ');
const region = tz.includes('/') ? tz.slice(0, tz.indexOf('/')) : 'Other'; const tzQuery = tzOpen ? tzNorm(tzSearch ?? '') : '';
(acc[region] ??= []).push(tz); const filteredZones = tzQuery
return acc; ? ianaZones.filter((tz) => tzNorm(tz).includes(tzQuery))
}, {}); : ianaZones;
const regionOrder = Object.keys(zonesByRegion).sort((a, b) =>
a === 'Other' ? 1 : b === 'Other' ? -1 : a.localeCompare(b), // Commit a zone selection from the list, then close.
); function selectTimezone(tz: string) {
// Defensive: a stored/validated zone could (rarely) be absent from supportedValuesOf. if (tzBlurTimer.current) clearTimeout(tzBlurTimer.current);
const currentZoneMissing = setTimezoneInput(tz);
!!effectiveTimezoneInput && !ianaZones.includes(effectiveTimezoneInput); setTzSearch(null);
setTzOpen(false);
}
// Save shared calendar mutation // Save shared calendar mutation
const sharedCalMutation = useMutation({ const sharedCalMutation = useMutation({
@@ -398,14 +408,64 @@ export function AdminPage() {
</div> </div>
)} )}
{/* IANA picker — native <select> grouped by region. Shows the full {/* IANA picker — searchable combobox. Focusing shows the full list
list on tap (no typing/erasing) and uses the native wheel picker (no typing/erasing needed); typing filters it case-insensitively
on iOS. */} (underscores ignored, so "new york" matches America/New_York). */}
<div style={{ marginBottom: 'var(--space-3, 12px)' }}> <div style={{ position: 'relative', marginBottom: 'var(--space-3, 12px)' }}>
<select <input
value={effectiveTimezoneInput} type="text"
onChange={(e) => setTimezoneInput(e.target.value)} role="combobox"
aria-label="Household timezone" aria-label="Household timezone"
aria-expanded={tzOpen}
aria-controls="tz-listbox"
aria-autocomplete="list"
aria-activedescendant={
tzOpen && filteredZones.length ? `tz-opt-${tzActiveIndex}` : undefined
}
autoComplete="off"
value={tzOpen ? (tzSearch ?? '') : effectiveTimezoneInput}
placeholder={
tzOpen ? effectiveTimezoneInput || 'Search timezones…' : 'Search timezones…'
}
onFocus={() => {
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={{ style={{
width: '100%', width: '100%',
boxSizing: 'border-box', boxSizing: 'border-box',
@@ -417,22 +477,79 @@ export function AdminPage() {
color: 'var(--color-text-primary)', color: 'var(--color-text-primary)',
background: 'var(--color-surface, #ffffff)', background: 'var(--color-surface, #ffffff)',
minHeight: '44px', minHeight: '44px',
cursor: 'pointer', }}
/>
{tzOpen && (
<ul
id="tz-listbox"
role="listbox"
aria-label="Timezones"
style={{
position: 'absolute',
zIndex: 10,
top: 'calc(100% + 4px)',
left: 0,
right: 0,
margin: 0,
padding: 'var(--space-1, 4px)',
listStyle: 'none',
maxHeight: '260px',
overflowY: 'auto',
background: 'var(--color-surface, #ffffff)',
border: '1px solid var(--color-border)',
borderRadius: 'var(--space-1, 4px)',
boxShadow: '0 6px 20px rgba(0,0,0,0.12)',
}} }}
> >
{currentZoneMissing && ( {filteredZones.length === 0 && (
<option value={effectiveTimezoneInput}>{effectiveTimezoneInput}</option> <li
style={{
padding: 'var(--space-2, 8px) var(--space-3, 12px)',
color: 'var(--color-text-muted)',
fontSize: 'var(--text-body-size, 15px)',
}}
>
No matching timezones
</li>
)}
{filteredZones.map((tz, i) => {
const active = i === tzActiveIndex;
return (
<li
key={tz}
id={`tz-opt-${i}`}
role="option"
aria-selected={tz === effectiveTimezoneInput}
ref={
active
? (el) => {
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}
</li>
);
})}
</ul>
)} )}
{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>
))}
</select>
</div> </div>
{/* Use detected zone affordance (D-02) */} {/* Use detected zone affordance (D-02) */}