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:
co-authored by
Claude Opus 4.8
parent
46d7fcc2d2
commit
d6f6a5ae6f
@@ -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();
|
||||||
|
|||||||
@@ -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) */}
|
||||||
|
|||||||
Reference in New Issue
Block a user