Phase 17: UI Optimization & Polish #24

Merged
luckberg merged 61 commits from gsd/phase-17-ui-optimization-polish into main 2026-06-18 16:24:54 -04:00
Showing only changes of commit f601c0c408 - Show all commits
+32 -20
View File
@@ -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<string | null>(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') {
next = order[(idx + 1) % order.length];
} else if (e.key === 'ArrowLeft') {
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();
const next = current === 'members' ? 'settings' : 'members';
setActiveTab(next);
(
e.currentTarget.parentElement?.querySelector(
`[id="admin-tab-${next}"]`,
) as HTMLElement | null
)?.focus();
} 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();
}
}
// 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).
<div
key={toast.id}
role="status"
aria-live="polite"
aria-atomic="true"
@@ -1039,7 +1050,7 @@ export function AdminPage() {
: 'var(--space-6, 24px)',
left: '50%',
transform: 'translateX(-50%)',
zIndex: 300,
zIndex: 400,
background: 'var(--color-surface-raised, #ffffff)',
border: '1px solid var(--color-border)',
borderRadius: 'var(--space-2, 8px)',
@@ -1053,7 +1064,8 @@ export function AdminPage() {
lineHeight: 'var(--text-label-line-height, 1.4)',
fontFamily: 'var(--font-family-base)',
color: 'var(--color-text-primary)',
whiteSpace: 'nowrap' as React.CSSProperties['whiteSpace'],
// WR-04: let the toast wrap instead of overflowing 90vw (nowrap + maxWidth
// overflows and trips the layout suite's no-horizontal-overflow rule).
maxWidth: '90vw',
}}
>
@@ -1062,7 +1074,7 @@ export function AdminPage() {
aria-hidden="true"
style={{ color: 'var(--color-member-0)', flexShrink: 0 }}
/>
<span>{toast}</span>
<span>{toast.msg}</span>
</div>
)}
@@ -1089,7 +1101,7 @@ export function AdminPage() {
resetTriggerRef.current.focus();
}
}}
onSuccess={() => setToast('Password reset.')}
onSuccess={() => showToast('Password reset.')}
member={resetTargetMember}
/>
)}