Files
redlight/src/components/Layout.jsx
T
MichelleandClaude Fable 5 762d6c54c8 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>
2026-07-17 22:31:43 +02:00

118 lines
4.3 KiB
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';
import { useLanguage } from '../contexts/LanguageContext';
import { AlertTriangle, RefreshCw } from 'lucide-react';
import api from '../services/api';
import toast from 'react-hot-toast';
export default function Layout() {
const [sidebarOpen, setSidebarOpen] = useState(false);
const { user } = useAuth();
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(() => {
if (resendCooldown <= 0) return;
const timer = setTimeout(() => setResendCooldown(c => c - 1), 1000);
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);
try {
await api.post('/auth/resend-verification', { email: user.email });
toast.success(t('auth.emailVerificationResendSuccess'));
setResendCooldown(60);
} catch (err) {
const wait = err.response?.data?.waitSeconds;
if (wait) {
setResendCooldown(wait);
}
toast.error(err.response?.data?.error || t('auth.emailVerificationResendFailed'));
} finally {
setResending(false);
}
};
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)} sidebarOpen={sidebarOpen} />
{/* Email verification banner */}
{user && user.email_verified === 0 && (
<div className="bg-amber-500/15 border-b border-amber-500/30 px-4 py-2.5 flex items-center justify-center gap-3 text-sm">
<AlertTriangle size={15} className="text-amber-400 shrink-0" />
<span className="text-amber-200">{t('auth.emailVerificationBanner')}</span>
<button
onClick={handleResendVerification}
disabled={resendCooldown > 0 || resending}
className="flex items-center gap-1.5 text-amber-400 hover:text-amber-300 underline underline-offset-2 transition-colors disabled:opacity-60 disabled:no-underline disabled:cursor-not-allowed"
>
<RefreshCw size={13} className={resending ? 'animate-spin' : ''} />
{resendCooldown > 0
? t('auth.emailVerificationResendCooldown').replace('{seconds}', resendCooldown)
: t('auth.emailVerificationResend')}
</button>
</div>
)}
<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>
{/* Mobile overlay */}
{sidebarOpen && (
<div
className="fixed inset-0 bg-black/50 z-30 lg:hidden"
onClick={() => setSidebarOpen(false)}
aria-hidden="true"
/>
)}
</div>
);
}