feat: improve accessibility across the app
- add useDialogA11y hook: focus trap, Escape handling, and focus restore for all custom modal dialogs (Modal, ThemeSelector, Admin modals) - add skip link, per-route document titles, and focus shift to main on SPA route changes - make notification list items real buttons (keyboard operable) and show hover-only controls on keyboard focus - close sidebar, dropdowns, and context menus with Escape, returning focus to their triggers; mark closed mobile sidebar inert - associate missing form labels (2FA code, DateTimePicker), add missing aria-labels on icon-only buttons, aria-expanded/controls on toggles, aria-pressed on theme tiles, scope=col on table headers - announce loading states via role=status - respect prefers-reduced-motion - new i18n keys: common.skipToContent/previous/next, admin.userActions Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Outlet, useLocation } from 'react-router-dom';
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import Navbar from './Navbar';
|
||||
import Sidebar from './Sidebar';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
@@ -14,6 +14,19 @@ export default function Layout() {
|
||||
const { t } = useLanguage();
|
||||
const [resendCooldown, setResendCooldown] = useState(0);
|
||||
const [resending, setResending] = useState(false);
|
||||
const location = useLocation();
|
||||
const isFirstRoute = useRef(true);
|
||||
|
||||
// On SPA route changes, move focus to the main region so keyboard and
|
||||
// screen-reader users land on the new page content instead of staying
|
||||
// on the link they clicked in the sidebar.
|
||||
useEffect(() => {
|
||||
if (isFirstRoute.current) {
|
||||
isFirstRoute.current = false;
|
||||
return;
|
||||
}
|
||||
document.getElementById('main-content')?.focus();
|
||||
}, [location.pathname]);
|
||||
|
||||
// Countdown timer for resend cooldown
|
||||
useEffect(() => {
|
||||
@@ -22,6 +35,16 @@ export default function Layout() {
|
||||
return () => clearTimeout(timer);
|
||||
}, [resendCooldown]);
|
||||
|
||||
// Close the mobile sidebar drawer with Escape
|
||||
useEffect(() => {
|
||||
if (!sidebarOpen) return;
|
||||
const handleKey = (e) => {
|
||||
if (e.key === 'Escape') setSidebarOpen(false);
|
||||
};
|
||||
document.addEventListener('keydown', handleKey);
|
||||
return () => document.removeEventListener('keydown', handleKey);
|
||||
}, [sidebarOpen]);
|
||||
|
||||
const handleResendVerification = async () => {
|
||||
if (resendCooldown > 0 || resending) return;
|
||||
setResending(true);
|
||||
@@ -42,12 +65,20 @@ export default function Layout() {
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-th-bg flex">
|
||||
{/* Skip link — first tab stop, jumps past sidebar and navbar */}
|
||||
<a
|
||||
href="#main-content"
|
||||
className="sr-only focus:not-sr-only focus:fixed focus:top-2 focus:left-2 focus:z-50 focus:px-4 focus:py-2 focus:rounded-lg focus:bg-th-accent focus:text-th-accent-t focus:shadow-lg"
|
||||
>
|
||||
{t('common.skipToContent')}
|
||||
</a>
|
||||
|
||||
{/* Sidebar */}
|
||||
<Sidebar open={sidebarOpen} onClose={() => setSidebarOpen(false)} />
|
||||
|
||||
{/* Main content */}
|
||||
<div className="flex-1 flex flex-col min-h-screen lg:ml-64">
|
||||
<Navbar onMenuClick={() => setSidebarOpen(true)} />
|
||||
<Navbar onMenuClick={() => setSidebarOpen(true)} sidebarOpen={sidebarOpen} />
|
||||
|
||||
{/* Email verification banner */}
|
||||
{user && user.email_verified === 0 && (
|
||||
@@ -67,7 +98,7 @@ export default function Layout() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<main className="flex-1 p-4 md:p-6 lg:p-8 max-w-7xl w-full mx-auto">
|
||||
<main id="main-content" tabIndex={-1} className="flex-1 p-4 md:p-6 lg:p-8 max-w-7xl w-full mx-auto focus:outline-hidden">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
@@ -77,6 +108,7 @@ export default function Layout() {
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 z-30 lg:hidden"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user