From 1eca7c334c0bc3b90777baac2f5087eb7ec8855d Mon Sep 17 00:00:00 2001 From: Claude Fable 5 Date: Tue, 21 Jul 2026 13:52:59 +0200 Subject: [PATCH] =?UTF-8?q?#162:=20Fokus-Management=20f=C3=BCr=20Dialoge?= =?UTF-8?q?=20und=20Such-Palette?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gemeinsamer useModalFocus-Hook: Initialfokus in den Dialog, Tab/Shift-Tab zyklisch gefangen, Fokus-Rückgabe an den Auslöser (bzw. returnFocusRef, wenn der öffnende Menüpunkt mit dem Menü unmountet). Dialoge tragen jetzt aria-labelledby auf ihre Überschrift und tabindex=-1 als Fokus-Fallback. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01AGM8jo3hwoV9wsCVGfy8iq --- apps/web/src/import/VaultImportSection.tsx | 18 +++++- apps/web/src/lib/use-modal-focus.ts | 69 ++++++++++++++++++++++ apps/web/src/pages/DeletePageDialog.tsx | 21 ++++++- apps/web/src/pages/MovePageDialog.tsx | 21 ++++++- apps/web/src/pages/PageActions.tsx | 8 ++- apps/web/src/search/SearchPalette.tsx | 11 +++- 6 files changed, 137 insertions(+), 11 deletions(-) create mode 100644 apps/web/src/lib/use-modal-focus.ts diff --git a/apps/web/src/import/VaultImportSection.tsx b/apps/web/src/import/VaultImportSection.tsx index acc87f2..78ed69f 100644 --- a/apps/web/src/import/VaultImportSection.tsx +++ b/apps/web/src/import/VaultImportSection.tsx @@ -9,7 +9,7 @@ import type { } from '@dorfteich/shared'; import { buildTree, labelDepth } from '@dorfteich/shared'; import { useQuery, useQueryClient } from '@tanstack/react-query'; -import { useRef, useState } from 'react'; +import { useId, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Link } from 'react-router-dom'; @@ -17,6 +17,7 @@ import { FormError } from '../components/forms'; import { labelsKey, usePondLabels } from '../labels/use-pond-labels'; import { ApiError, apiGet, apiPost, apiUploadFile } from '../lib/api'; import { useDismissable } from '../lib/use-dismissable'; +import { useModalFocus } from '../lib/use-modal-focus'; const POLL_INTERVAL_MS = 1000; // Vault jobs process many notes and files — budget well past a single @@ -83,6 +84,8 @@ function VaultImportDialog({ const [frontmatter, setFrontmatter] = useState('strip'); const running = phase.kind === 'running'; useDismissable(dialogRef, !running, onClose); + useModalFocus(dialogRef); + const titleId = useId(); const pond = useQuery({ queryKey: ['pond', pondSlug], @@ -182,8 +185,17 @@ function VaultImportDialog({ return (
-
-

{t('vault.title')}

+
+

+ {t('vault.title')} +

{phase.kind === 'done' ? ( diff --git a/apps/web/src/lib/use-modal-focus.ts b/apps/web/src/lib/use-modal-focus.ts new file mode 100644 index 0000000..5c7ac96 --- /dev/null +++ b/apps/web/src/lib/use-modal-focus.ts @@ -0,0 +1,69 @@ +import { useEffect, type RefObject } from 'react'; + +const FOCUSABLE = + 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), ' + + 'textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'; + +function visibleFocusables(container: HTMLElement): HTMLElement[] { + return [...container.querySelectorAll(FOCUSABLE)].filter( + (el) => el.getClientRects().length > 0, + ); +} + +/** + * Focus management for modal dialogs (issue #162, WCAG 2.4.3): moves focus + * into the dialog on mount (unless an autofocused child already holds it), + * keeps Tab/Shift+Tab cycling inside, and returns focus on unmount — to + * `restoreRef` if given (an element, or a container whose first focusable + * child is used; needed when the opening control unmounts with its menu), + * otherwise to the element focused when the dialog appeared. + */ +export function useModalFocus( + ref: RefObject, + restoreRef?: RefObject, +): void { + useEffect(() => { + const dialog = ref.current; + if (!dialog) return undefined; + const opener = document.activeElement instanceof HTMLElement ? document.activeElement : null; + + if (!dialog.contains(document.activeElement)) { + (visibleFocusables(dialog)[0] ?? dialog).focus(); + } + + function onKeyDown(event: KeyboardEvent): void { + if (event.key !== 'Tab') return; + const focusables = visibleFocusables(dialog!); + if (focusables.length === 0) { + event.preventDefault(); + return; + } + const first = focusables[0]!; + const last = focusables[focusables.length - 1]!; + const active = document.activeElement; + const inside = dialog!.contains(active); + if (event.shiftKey && (active === first || !inside)) { + event.preventDefault(); + last.focus(); + } else if (!event.shiftKey && (active === last || !inside)) { + event.preventDefault(); + first.focus(); + } + } + + document.addEventListener('keydown', onKeyDown, true); + return () => { + document.removeEventListener('keydown', onKeyDown, true); + const target = restoreRef?.current; + const restore = + target && target.isConnected + ? target.matches(FOCUSABLE) + ? target + : visibleFocusables(target)[0] + : opener && opener.isConnected + ? opener + : undefined; + restore?.focus(); + }; + }, [ref, restoreRef]); +} diff --git a/apps/web/src/pages/DeletePageDialog.tsx b/apps/web/src/pages/DeletePageDialog.tsx index 3e4d446..8da0ca9 100644 --- a/apps/web/src/pages/DeletePageDialog.tsx +++ b/apps/web/src/pages/DeletePageDialog.tsx @@ -1,9 +1,10 @@ -import { useRef, useState } from 'react'; +import { useId, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { FormError } from '../components/forms'; import { apiDelete } from '../lib/api'; import { useDismissable } from '../lib/use-dismissable'; +import { useModalFocus } from '../lib/use-modal-focus'; /** * The per-case delete decision for a page with subpages (issue #107/#109): @@ -17,18 +18,23 @@ export function DeletePageDialog({ childCount, onDeleted, onClose, + returnFocusRef, }: { pageId: string; title: string; childCount: number; onDeleted: () => void; onClose: () => void; + /** Where focus goes after closing when the opening control unmounted. */ + returnFocusRef?: React.RefObject; }): React.JSX.Element { const { t } = useTranslation('editor'); const [error, setError] = useState(null); const [busy, setBusy] = useState(false); const dialogRef = useRef(null); + const titleId = useId(); useDismissable(dialogRef, true, onClose); + useModalFocus(dialogRef, returnFocusRef); async function remove(mode: 'promote' | 'subtree'): Promise { setError(null); @@ -44,8 +50,17 @@ export function DeletePageDialog({ return (
-
-

{t('page.deleteChildrenTitle')}

+
+

+ {t('page.deleteChildrenTitle')} +

{t('page.deleteChildrenHint', { title, count: childCount })}

diff --git a/apps/web/src/pages/MovePageDialog.tsx b/apps/web/src/pages/MovePageDialog.tsx index 0d09ee6..88a85fa 100644 --- a/apps/web/src/pages/MovePageDialog.tsx +++ b/apps/web/src/pages/MovePageDialog.tsx @@ -1,12 +1,13 @@ import type { PageListItemView, PondView, TreeNode } from '@dorfteich/shared'; import { buildTree, collectSubtreeIds } from '@dorfteich/shared'; import { useQuery, useQueryClient } from '@tanstack/react-query'; -import { useRef, useState } from 'react'; +import { useId, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { FormError } from '../components/forms'; import { apiGet, apiPatch } from '../lib/api'; import { useDismissable } from '../lib/use-dismissable'; +import { useModalFocus } from '../lib/use-modal-focus'; /** * "Move to…" dialog (issue #109): an indented parent picker over the page @@ -19,17 +20,22 @@ export function MovePageDialog({ pageId, pondSlug, onClose, + returnFocusRef, }: { pageId: string; pondSlug: string; onClose: () => void; + /** Where focus goes after closing when the opening control unmounted. */ + returnFocusRef?: React.RefObject; }): React.JSX.Element { const { t } = useTranslation('editor'); const queryClient = useQueryClient(); const [error, setError] = useState(null); const [busy, setBusy] = useState(false); const dialogRef = useRef(null); + const titleId = useId(); useDismissable(dialogRef, true, onClose); + useModalFocus(dialogRef, returnFocusRef); const pond = useQuery({ queryKey: ['pond', pondSlug], @@ -81,8 +87,17 @@ export function MovePageDialog({ return (
-
-

{t('page.moveTitle')}

+
+

+ {t('page.moveTitle')} +

  • diff --git a/apps/web/src/pages/PageActions.tsx b/apps/web/src/pages/PageActions.tsx index d8ab604..20fc7e8 100644 --- a/apps/web/src/pages/PageActions.tsx +++ b/apps/web/src/pages/PageActions.tsx @@ -284,13 +284,19 @@ function PageOverflowMenu({
)} {moving && ( - setMoving(false)} /> + setMoving(false)} + returnFocusRef={menuRef} + /> )} {deleting && ( { setDeleting(null); void queryClient.invalidateQueries({ queryKey: ['pages'] }); diff --git a/apps/web/src/search/SearchPalette.tsx b/apps/web/src/search/SearchPalette.tsx index 6cbab8e..f5265c9 100644 --- a/apps/web/src/search/SearchPalette.tsx +++ b/apps/web/src/search/SearchPalette.tsx @@ -8,6 +8,7 @@ import { LabelChips } from '../labels/LabelChips'; import { usePondLabels } from '../labels/use-pond-labels'; import { useCurrentPondRoute } from '../layout/use-pond-route'; import { apiGet } from '../lib/api'; +import { useModalFocus } from '../lib/use-modal-focus'; import { HighlightedSnippet } from './highlight'; const RECENT_KEY = 'dorfteich.recentSearches'; @@ -45,6 +46,8 @@ export function SearchPalette({ onClose }: { onClose: () => void }): React.JSX.E const navigate = useNavigate(); const { pondSlug } = useCurrentPondRoute(); const inputRef = useRef(null); + const overlayRef = useRef(null); + useModalFocus(overlayRef); const pond = useQuery({ queryKey: ['pond', pondSlug], @@ -111,7 +114,13 @@ export function SearchPalette({ onClose }: { onClose: () => void }): React.JSX.E } return ( -
+