diff --git a/apps/pwa/src/routes/AdminPage.tsx b/apps/pwa/src/routes/AdminPage.tsx index 8fa86de..383e781 100644 --- a/apps/pwa/src/routes/AdminPage.tsx +++ b/apps/pwa/src/routes/AdminPage.tsx @@ -60,10 +60,15 @@ export function AdminPage() { // Phone detection for toast bottom offset (WR-05: resize-aware) const phone = useIsPhone(); - // Success toast state (D-08) - const [toast, setToast] = useState(null); + // Success toast state (D-08). + // WR-03: store a unique id per toast so an identical repeated message still + // re-announces (aria-live re-fires on remount) and the 3s timer resets. + const [toast, setToast] = useState<{ id: number; msg: string } | null>(null); + const showToast = (msg: string) => setToast({ id: Date.now(), msg }); - // Auto-dismiss toast after 3000ms — mirrors SyncStateToast lines 72-79 + // Auto-dismiss toast after 3000ms — mirrors SyncStateToast lines 72-79. + // `toast` is a fresh object per showToast() call, so a repeated identical + // message produces a new reference here → the timer restarts (WR-03). useEffect(() => { if (!toast) return; const timer = setTimeout(() => setToast(null), 3000); @@ -203,27 +208,30 @@ export function AdminPage() { }, }); - // Roving tabindex keyboard handler for the two-tab strip (D-10) + // Roving tabindex keyboard handler for the two-tab strip (D-10). + // WR-02: full WAI-ARIA tabs pattern — ArrowLeft/Right wrap around the ends, + // Home/End jump to the first/last tab. function handleTabKeyDown(e: React.KeyboardEvent, current: 'members' | 'settings') { + const order = ['members', 'settings'] as const; + const idx = order.indexOf(current); + let next: (typeof order)[number] | null = null; if (e.key === 'ArrowRight') { - e.preventDefault(); - const next = current === 'members' ? 'settings' : 'members'; - setActiveTab(next); - ( - e.currentTarget.parentElement?.querySelector( - `[id="admin-tab-${next}"]`, - ) as HTMLElement | null - )?.focus(); + next = order[(idx + 1) % order.length]; } else if (e.key === 'ArrowLeft') { - e.preventDefault(); - const prev = current === 'settings' ? 'members' : 'settings'; - setActiveTab(prev); - ( - e.currentTarget.parentElement?.querySelector( - `[id="admin-tab-${prev}"]`, - ) as HTMLElement | null - )?.focus(); + next = order[(idx - 1 + order.length) % order.length]; + } else if (e.key === 'Home') { + next = order[0]; + } else if (e.key === 'End') { + next = order[order.length - 1]; } + if (!next) return; + e.preventDefault(); + setActiveTab(next); + ( + e.currentTarget.parentElement?.querySelector( + `[id="admin-tab-${next}"]`, + ) as HTMLElement | null + )?.focus(); } // Open credential sheet for a member @@ -260,7 +268,7 @@ export function AdminPage() { setCreateError(null); void queryClient.invalidateQueries({ queryKey: ['admin', 'members'] }); void queryClient.invalidateQueries({ queryKey: ['me'] }); - setToast('Member added.'); + showToast('Member added.'); }, onError: (err) => { const msg = err instanceof Error ? err.message : 'server'; @@ -1028,7 +1036,10 @@ export function AdminPage() { {/* ── Success toast (D-08) ──────────────────────────────────────────────── */} {toast && ( + // WR-03: key on toast.id so an identical repeated message remounts and + // aria-live re-announces it (and the dismiss timer restarts).
@@ -1062,7 +1074,7 @@ export function AdminPage() { aria-hidden="true" style={{ color: 'var(--color-member-0)', flexShrink: 0 }} /> - {toast} + {toast.msg}
)} @@ -1089,7 +1101,7 @@ export function AdminPage() { resetTriggerRef.current.focus(); } }} - onSuccess={() => setToast('Password reset.')} + onSuccess={() => showToast('Password reset.')} member={resetTargetMember} /> )}