From e20ca52c307faf137c1a28dd7dac7bb9e9d8fc61 Mon Sep 17 00:00:00 2001 From: Omitogun Ayobami Date: Thu, 20 Aug 2026 16:21:14 +0100 Subject: [PATCH] fix(a11y): trap dialog focus and restore opener --- src/app/components/accessibility/README.md | 10 +- src/components/CommandPalette.tsx | 40 +++++- src/components/ConflictResolver.tsx | 22 +++- src/components/CookieConsentBanner.tsx | 66 +--------- src/components/courses/VideoPreview.tsx | 36 +++++- src/components/editor/MediaEmbedder.tsx | 135 ++++++++++++--------- src/components/notificationcenter.tsx | 76 +++++++++--- src/components/ui/Modal.tsx | 21 ++-- src/components/ui/__tests__/Modal.test.tsx | 126 +++---------------- src/hooks/__tests__/useFocusTrap.test.tsx | 51 ++++++++ src/hooks/useAccessibility.tsx | 43 +------ src/hooks/useFocusTrap.ts | 94 ++++++++++++++ 12 files changed, 403 insertions(+), 317 deletions(-) create mode 100644 src/hooks/__tests__/useFocusTrap.test.tsx create mode 100644 src/hooks/useFocusTrap.ts diff --git a/src/app/components/accessibility/README.md b/src/app/components/accessibility/README.md index 6838498c..9864510b 100644 --- a/src/app/components/accessibility/README.md +++ b/src/app/components/accessibility/README.md @@ -145,12 +145,16 @@ return
{/* content */}
; ### useFocusTrap -Traps focus within a container (for modals/dialogs). +Traps focus within an active container, moves focus to an optional initial target, +and restores focus to the opener when the dialog closes. New dialogs should import +the shared hook directly. ```tsx -import { useFocusTrap } from '@/hooks/useAccessibility'; +import { useRef } from 'react'; +import { useFocusTrap } from '@/hooks/useFocusTrap'; -const containerRef = useFocusTrap(isModalOpen); +const initialFocusRef = useRef(null); +const containerRef = useFocusTrap(isModalOpen, { initialFocusRef }); return
{/* modal content */}
; ``` diff --git a/src/components/CommandPalette.tsx b/src/components/CommandPalette.tsx index 05bc28cc..885d4ef7 100644 --- a/src/components/CommandPalette.tsx +++ b/src/components/CommandPalette.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useMemo, useState } from 'react'; +import { useEffect, useId, useMemo, useRef, useState } from 'react'; import { PollCreationModal, type PollDraft } from '@/components/polls/PollCreationModal'; import { useSettingsStore } from '@/lib/settings/store'; import { useToast } from '@/context/ToastContext'; @@ -12,6 +12,7 @@ import { useKeyboardShortcuts, } from '@/hooks/useKeyboardShortcuts'; import { createLogger } from '@/lib/logging'; +import { useFocusTrap } from '@/hooks/useFocusTrap'; const logger = createLogger('CommandPalette'); function navigateTo(path: string): void { @@ -92,6 +93,13 @@ export function CommandPalette() { const [query, setQuery] = useState(''); const { theme, setTheme } = useTheme(); const [pollModalOpen, setPollModalOpen] = useState(false); + const commandInputRef = useRef(null); + const commandPaletteRef = useFocusTrap(open && !showHelp, { + initialFocusRef: commandInputRef, + }); + const shortcutHelpRef = useFocusTrap(showHelp); + const commandPaletteTitleId = useId(); + const shortcutHelpTitleId = useId(); const settings = useSettingsStore((s) => s.settings); const { info: toastInfo } = useToast(); @@ -179,6 +187,20 @@ export function CommandPalette() { ); }, [commands, query]); + useEffect(() => { + if (!open && !showHelp) return; + + const handleEscape = (event: KeyboardEvent) => { + if (event.key !== 'Escape') return; + event.preventDefault(); + if (showHelp) setShowHelp(false); + else setOpen(false); + }; + + document.addEventListener('keydown', handleEscape); + return () => document.removeEventListener('keydown', handleEscape); + }, [open, showHelp]); + return ( <> {open ? ( @@ -189,12 +211,15 @@ export function CommandPalette() { aria-hidden="true" />
setQuery(e.target.value)} @@ -204,6 +229,9 @@ export function CommandPalette() { placeholder="Type a command..." className="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 dark:border-gray-700 dark:bg-gray-950 dark:text-gray-100" /> +

+ Command palette +

{filtered.map((command) => { @@ -258,13 +286,17 @@ export function CommandPalette() { aria-hidden="true" />
-

+

Keyboard shortcuts