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:
@@ -14,6 +14,19 @@ export default function Sidebar({ open, onClose }) {
|
||||
const { imprintUrl, privacyUrl } = useBranding();
|
||||
const [themeOpen, setThemeOpen] = useState(false);
|
||||
const [federationCount, setFederationCount] = useState(0);
|
||||
// On mobile the sidebar is an off-canvas drawer: while closed it must be
|
||||
// inert so its links aren't reachable via Tab or screen readers. On
|
||||
// desktop (lg+) it is always visible and never inert.
|
||||
const [isDesktop, setIsDesktop] = useState(
|
||||
() => window.matchMedia('(min-width: 1024px)').matches
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const mq = window.matchMedia('(min-width: 1024px)');
|
||||
const handleChange = (e) => setIsDesktop(e.matches);
|
||||
mq.addEventListener('change', handleChange);
|
||||
return () => mq.removeEventListener('change', handleChange);
|
||||
}, []);
|
||||
|
||||
// Fetch pending federation invitation count
|
||||
useEffect(() => {
|
||||
@@ -50,6 +63,8 @@ export default function Sidebar({ open, onClose }) {
|
||||
return (
|
||||
<>
|
||||
<aside
|
||||
id="app-sidebar"
|
||||
inert={!open && !isDesktop}
|
||||
className={`fixed top-0 left-0 z-40 h-full w-64 bg-th-side border-r border-th-border
|
||||
transition-transform duration-300 ease-in-out
|
||||
${open ? 'translate-x-0' : '-translate-x-full'} lg:translate-x-0`}
|
||||
@@ -68,7 +83,7 @@ export default function Sidebar({ open, onClose }) {
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 px-3 py-4 space-y-1 overflow-y-auto">
|
||||
<nav aria-label={t('nav.navigation')} className="flex-1 px-3 py-4 space-y-1 overflow-y-auto">
|
||||
<p className="px-3 mb-2 text-xs font-semibold text-th-text-s uppercase tracking-wider">
|
||||
{t('nav.navigation')}
|
||||
</p>
|
||||
@@ -95,6 +110,7 @@ export default function Sidebar({ open, onClose }) {
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setThemeOpen(!themeOpen)}
|
||||
aria-haspopup="dialog"
|
||||
className="w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium text-th-text-s hover:text-th-text hover:bg-th-hover transition-all duration-200"
|
||||
>
|
||||
<Palette size={18} />
|
||||
|
||||
Reference in New Issue
Block a user