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
@@ -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