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:
2026-07-17 22:31:43 +02:00
co-authored by Claude Fable 5
parent 6a312ba055
commit 762d6c54c8
21 changed files with 328 additions and 105 deletions
+17 -1
View File
@@ -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} />