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:
co-authored by
Claude Opus 4.8
parent
745e806d89
commit
a8d6142566
@@ -4,8 +4,9 @@
|
|||||||
* 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 input is type="text" with list="iana-zones" which gives
|
* NOTE: the IANA picker is a native <select> grouped by region, which has the
|
||||||
* it the ARIA combobox role (not textbox) in Chromium.
|
* ARIA combobox role. Selecting a zone uses selectOption (not fill), and the
|
||||||
|
* option's value is the full IANA id even though its visible label is shortened.
|
||||||
*/
|
*/
|
||||||
import { test, expect } from '@playwright/test';
|
import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
@@ -22,12 +23,12 @@ test.describe('Admin Timezone section — 18-04 round-trip', () => {
|
|||||||
await expect(page.getByRole('region', { name: 'Timezone' })).toBeVisible();
|
await expect(page.getByRole('region', { name: 'Timezone' })).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Timezone input (combobox) is visible and pre-filled', async ({ page }) => {
|
test('Timezone picker (combobox) is visible and pre-filled', async ({ page }) => {
|
||||||
// ARIA role for <input type="text" list="iana-zones"> is combobox
|
// Native <select> has the combobox role
|
||||||
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, 'Input 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 }) => {
|
||||||
@@ -45,10 +46,10 @@ test.describe('Admin Timezone section — 18-04 round-trip', () => {
|
|||||||
await expect(saveBtn).toBeEnabled();
|
await expect(saveBtn).toBeEnabled();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Changing the input 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.fill('America/Chicago');
|
await input.selectOption('America/Chicago');
|
||||||
const saveBtn = tzSection.getByRole('button', { name: /Save/ });
|
const saveBtn = tzSection.getByRole('button', { name: /Save/ });
|
||||||
await expect(saveBtn).toBeEnabled();
|
await expect(saveBtn).toBeEnabled();
|
||||||
});
|
});
|
||||||
@@ -58,7 +59,7 @@ test.describe('Admin Timezone section — 18-04 round-trip', () => {
|
|||||||
const tzSection = page.getByRole('region', { name: 'Timezone' });
|
const tzSection = page.getByRole('region', { name: 'Timezone' });
|
||||||
|
|
||||||
// Set to a known value
|
// Set to a known value
|
||||||
await input.fill('America/Chicago');
|
await input.selectOption('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();
|
||||||
|
|||||||
@@ -127,12 +127,28 @@ export function AdminPage() {
|
|||||||
effectiveTimezoneInput === '' ||
|
effectiveTimezoneInput === '' ||
|
||||||
(isExplicit && effectiveTimezoneInput === storedTimezone);
|
(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[] =
|
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
|
||||||
|
// 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
|
// Save shared calendar mutation
|
||||||
const sharedCalMutation = useMutation({
|
const sharedCalMutation = useMutation({
|
||||||
mutationFn: (calId: number) => setSharedCalendar(calId),
|
mutationFn: (calId: number) => setSharedCalendar(calId),
|
||||||
@@ -382,14 +398,13 @@ export function AdminPage() {
|
|||||||
</div>
|
</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)' }}>
|
<div style={{ marginBottom: 'var(--space-3, 12px)' }}>
|
||||||
<input
|
<select
|
||||||
type="text"
|
|
||||||
list="iana-zones"
|
|
||||||
value={effectiveTimezoneInput}
|
value={effectiveTimezoneInput}
|
||||||
onChange={(e) => setTimezoneInput(e.target.value)}
|
onChange={(e) => setTimezoneInput(e.target.value)}
|
||||||
placeholder="e.g. America/Chicago"
|
|
||||||
aria-label="Household timezone"
|
aria-label="Household timezone"
|
||||||
style={{
|
style={{
|
||||||
width: '100%',
|
width: '100%',
|
||||||
@@ -402,13 +417,22 @@ 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',
|
||||||
}}
|
}}
|
||||||
/>
|
>
|
||||||
<datalist id="iana-zones">
|
{currentZoneMissing && (
|
||||||
{ianaZones.map((tz) => (
|
<option value={effectiveTimezoneInput}>{effectiveTimezoneInput}</option>
|
||||||
<option key={tz} value={tz} />
|
)}
|
||||||
|
{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>
|
</div>
|
||||||
|
|
||||||
{/* Use detected zone affordance (D-02) */}
|
{/* Use detected zone affordance (D-02) */}
|
||||||
|
|||||||
Reference in New Issue
Block a user