Phase 18: Auto timezone detection and ability to change timezone #21
@@ -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 <select> grouped by region, which has the
|
||||
* 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.
|
||||
* NOTE: the IANA picker is a searchable combobox — a text input (role=combobox)
|
||||
* that opens a role=listbox of role=option items on focus. Selecting a zone means
|
||||
* 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';
|
||||
|
||||
@@ -24,14 +25,16 @@ test.describe('Admin Timezone section — 18-04 round-trip', () => {
|
||||
});
|
||||
|
||||
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' });
|
||||
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();
|
||||
|
||||
@@ -64,6 +64,13 @@ export function AdminPage() {
|
||||
|
||||
// Timezone picker state
|
||||
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
|
||||
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 <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);
|
||||
// Searchable combobox: filter zones by the live search text (case-insensitive,
|
||||
// ignoring underscores so "new york" matches "America/New_York"). When the search
|
||||
// is empty the full list shows — so tapping the field reveals every zone with no
|
||||
// typing required.
|
||||
const tzNorm = (s: string) => s.toLowerCase().replace(/_/g, ' ');
|
||||
const tzQuery = tzOpen ? tzNorm(tzSearch ?? '') : '';
|
||||
const filteredZones = tzQuery
|
||||
? ianaZones.filter((tz) => tzNorm(tz).includes(tzQuery))
|
||||
: ianaZones;
|
||||
|
||||
// Commit a zone selection from the list, then close.
|
||||
function selectTimezone(tz: string) {
|
||||
if (tzBlurTimer.current) clearTimeout(tzBlurTimer.current);
|
||||
setTimezoneInput(tz);
|
||||
setTzSearch(null);
|
||||
setTzOpen(false);
|
||||
}
|
||||
|
||||
// Save shared calendar mutation
|
||||
const sharedCalMutation = useMutation({
|
||||
@@ -398,14 +408,64 @@ export function AdminPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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)' }}>
|
||||
<select
|
||||
value={effectiveTimezoneInput}
|
||||
onChange={(e) => setTimezoneInput(e.target.value)}
|
||||
{/* IANA picker — searchable combobox. Focusing shows the full list
|
||||
(no typing/erasing needed); typing filters it case-insensitively
|
||||
(underscores ignored, so "new york" matches America/New_York). */}
|
||||
<div style={{ position: 'relative', marginBottom: 'var(--space-3, 12px)' }}>
|
||||
<input
|
||||
type="text"
|
||||
role="combobox"
|
||||
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={{
|
||||
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 && (
|
||||
<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>
|
||||
))}
|
||||
</select>
|
||||
/>
|
||||
{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)',
|
||||
}}
|
||||
>
|
||||
{filteredZones.length === 0 && (
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Use detected zone affordance (D-02) */}
|
||||
|
||||
Reference in New Issue
Block a user