fix(17): WR-02 WR-03 WR-04 IN-06 admin tab Home/End+wrap; toast re-announce, wrap, z-index 400
This commit is contained in:
@@ -60,10 +60,15 @@ export function AdminPage() {
|
|||||||
// Phone detection for toast bottom offset (WR-05: resize-aware)
|
// Phone detection for toast bottom offset (WR-05: resize-aware)
|
||||||
const phone = useIsPhone();
|
const phone = useIsPhone();
|
||||||
|
|
||||||
// Success toast state (D-08)
|
// Success toast state (D-08).
|
||||||
const [toast, setToast] = useState<string | null>(null);
|
// 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(() => {
|
useEffect(() => {
|
||||||
if (!toast) return;
|
if (!toast) return;
|
||||||
const timer = setTimeout(() => setToast(null), 3000);
|
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') {
|
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') {
|
if (e.key === 'ArrowRight') {
|
||||||
e.preventDefault();
|
next = order[(idx + 1) % order.length];
|
||||||
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') {
|
} else if (e.key === 'ArrowLeft') {
|
||||||
e.preventDefault();
|
next = order[(idx - 1 + order.length) % order.length];
|
||||||
const prev = current === 'settings' ? 'members' : 'settings';
|
} else if (e.key === 'Home') {
|
||||||
setActiveTab(prev);
|
next = order[0];
|
||||||
(
|
} else if (e.key === 'End') {
|
||||||
e.currentTarget.parentElement?.querySelector(
|
next = order[order.length - 1];
|
||||||
`[id="admin-tab-${prev}"]`,
|
|
||||||
) as HTMLElement | null
|
|
||||||
)?.focus();
|
|
||||||
}
|
}
|
||||||
|
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
|
// Open credential sheet for a member
|
||||||
@@ -260,7 +268,7 @@ export function AdminPage() {
|
|||||||
setCreateError(null);
|
setCreateError(null);
|
||||||
void queryClient.invalidateQueries({ queryKey: ['admin', 'members'] });
|
void queryClient.invalidateQueries({ queryKey: ['admin', 'members'] });
|
||||||
void queryClient.invalidateQueries({ queryKey: ['me'] });
|
void queryClient.invalidateQueries({ queryKey: ['me'] });
|
||||||
setToast('Member added.');
|
showToast('Member added.');
|
||||||
},
|
},
|
||||||
onError: (err) => {
|
onError: (err) => {
|
||||||
const msg = err instanceof Error ? err.message : 'server';
|
const msg = err instanceof Error ? err.message : 'server';
|
||||||
@@ -1028,7 +1036,10 @@ export function AdminPage() {
|
|||||||
|
|
||||||
{/* ── Success toast (D-08) ──────────────────────────────────────────────── */}
|
{/* ── Success toast (D-08) ──────────────────────────────────────────────── */}
|
||||||
{toast && (
|
{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
|
<div
|
||||||
|
key={toast.id}
|
||||||
role="status"
|
role="status"
|
||||||
aria-live="polite"
|
aria-live="polite"
|
||||||
aria-atomic="true"
|
aria-atomic="true"
|
||||||
@@ -1039,7 +1050,7 @@ export function AdminPage() {
|
|||||||
: 'var(--space-6, 24px)',
|
: 'var(--space-6, 24px)',
|
||||||
left: '50%',
|
left: '50%',
|
||||||
transform: 'translateX(-50%)',
|
transform: 'translateX(-50%)',
|
||||||
zIndex: 300,
|
zIndex: 400,
|
||||||
background: 'var(--color-surface-raised, #ffffff)',
|
background: 'var(--color-surface-raised, #ffffff)',
|
||||||
border: '1px solid var(--color-border)',
|
border: '1px solid var(--color-border)',
|
||||||
borderRadius: 'var(--space-2, 8px)',
|
borderRadius: 'var(--space-2, 8px)',
|
||||||
@@ -1053,7 +1064,8 @@ export function AdminPage() {
|
|||||||
lineHeight: 'var(--text-label-line-height, 1.4)',
|
lineHeight: 'var(--text-label-line-height, 1.4)',
|
||||||
fontFamily: 'var(--font-family-base)',
|
fontFamily: 'var(--font-family-base)',
|
||||||
color: 'var(--color-text-primary)',
|
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',
|
maxWidth: '90vw',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -1062,7 +1074,7 @@ export function AdminPage() {
|
|||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
style={{ color: 'var(--color-member-0)', flexShrink: 0 }}
|
style={{ color: 'var(--color-member-0)', flexShrink: 0 }}
|
||||||
/>
|
/>
|
||||||
<span>{toast}</span>
|
<span>{toast.msg}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -1089,7 +1101,7 @@ export function AdminPage() {
|
|||||||
resetTriggerRef.current.focus();
|
resetTriggerRef.current.focus();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onSuccess={() => setToast('Password reset.')}
|
onSuccess={() => showToast('Password reset.')}
|
||||||
member={resetTargetMember}
|
member={resetTargetMember}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user