#162: Fokus-Management für Dialoge und Such-Palette

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AGM8jo3hwoV9wsCVGfy8iq
This commit is contained in:
Claude Fable 5 2026-07-21 13:52:59 +02:00
parent db0e563f95
commit 1eca7c334c
6 changed files with 137 additions and 11 deletions

View File

@ -9,7 +9,7 @@ import type {
} from '@dorfteich/shared'; } from '@dorfteich/shared';
import { buildTree, labelDepth } from '@dorfteich/shared'; import { buildTree, labelDepth } from '@dorfteich/shared';
import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useRef, useState } from 'react'; import { useId, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
@ -17,6 +17,7 @@ import { FormError } from '../components/forms';
import { labelsKey, usePondLabels } from '../labels/use-pond-labels'; import { labelsKey, usePondLabels } from '../labels/use-pond-labels';
import { ApiError, apiGet, apiPost, apiUploadFile } from '../lib/api'; import { ApiError, apiGet, apiPost, apiUploadFile } from '../lib/api';
import { useDismissable } from '../lib/use-dismissable'; import { useDismissable } from '../lib/use-dismissable';
import { useModalFocus } from '../lib/use-modal-focus';
const POLL_INTERVAL_MS = 1000; const POLL_INTERVAL_MS = 1000;
// Vault jobs process many notes and files — budget well past a single // Vault jobs process many notes and files — budget well past a single
@ -83,6 +84,8 @@ function VaultImportDialog({
const [frontmatter, setFrontmatter] = useState<VaultFrontmatterMode>('strip'); const [frontmatter, setFrontmatter] = useState<VaultFrontmatterMode>('strip');
const running = phase.kind === 'running'; const running = phase.kind === 'running';
useDismissable(dialogRef, !running, onClose); useDismissable(dialogRef, !running, onClose);
useModalFocus(dialogRef);
const titleId = useId();
const pond = useQuery({ const pond = useQuery({
queryKey: ['pond', pondSlug], queryKey: ['pond', pondSlug],
@ -182,8 +185,17 @@ function VaultImportDialog({
return ( return (
<div className="modal-overlay"> <div className="modal-overlay">
<div className="modal vault-import-dialog" role="dialog" aria-modal="true" ref={dialogRef}> <div
<h2 className="modal__title">{t('vault.title')}</h2> className="modal vault-import-dialog"
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
tabIndex={-1}
ref={dialogRef}
>
<h2 className="modal__title" id={titleId}>
{t('vault.title')}
</h2>
<FormError error={error} /> <FormError error={error} />
{phase.kind === 'done' ? ( {phase.kind === 'done' ? (

View File

@ -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<HTMLElement>(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<HTMLElement | null>,
restoreRef?: RefObject<HTMLElement | null>,
): 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]);
}

View File

@ -1,9 +1,10 @@
import { useRef, useState } from 'react'; import { useId, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { FormError } from '../components/forms'; import { FormError } from '../components/forms';
import { apiDelete } from '../lib/api'; import { apiDelete } from '../lib/api';
import { useDismissable } from '../lib/use-dismissable'; 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): * The per-case delete decision for a page with subpages (issue #107/#109):
@ -17,18 +18,23 @@ export function DeletePageDialog({
childCount, childCount,
onDeleted, onDeleted,
onClose, onClose,
returnFocusRef,
}: { }: {
pageId: string; pageId: string;
title: string; title: string;
childCount: number; childCount: number;
onDeleted: () => void; onDeleted: () => void;
onClose: () => void; onClose: () => void;
/** Where focus goes after closing when the opening control unmounted. */
returnFocusRef?: React.RefObject<HTMLElement | null>;
}): React.JSX.Element { }): React.JSX.Element {
const { t } = useTranslation('editor'); const { t } = useTranslation('editor');
const [error, setError] = useState<unknown>(null); const [error, setError] = useState<unknown>(null);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const dialogRef = useRef<HTMLDivElement>(null); const dialogRef = useRef<HTMLDivElement>(null);
const titleId = useId();
useDismissable(dialogRef, true, onClose); useDismissable(dialogRef, true, onClose);
useModalFocus(dialogRef, returnFocusRef);
async function remove(mode: 'promote' | 'subtree'): Promise<void> { async function remove(mode: 'promote' | 'subtree'): Promise<void> {
setError(null); setError(null);
@ -44,8 +50,17 @@ export function DeletePageDialog({
return ( return (
<div className="modal-overlay"> <div className="modal-overlay">
<div className="modal delete-page-dialog" role="dialog" aria-modal="true" ref={dialogRef}> <div
<h2 className="modal__title">{t('page.deleteChildrenTitle')}</h2> className="modal delete-page-dialog"
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
tabIndex={-1}
ref={dialogRef}
>
<h2 className="modal__title" id={titleId}>
{t('page.deleteChildrenTitle')}
</h2>
<FormError error={error} /> <FormError error={error} />
<p>{t('page.deleteChildrenHint', { title, count: childCount })}</p> <p>{t('page.deleteChildrenHint', { title, count: childCount })}</p>
<div className="modal__actions modal__actions--stacked"> <div className="modal__actions modal__actions--stacked">

View File

@ -1,12 +1,13 @@
import type { PageListItemView, PondView, TreeNode } from '@dorfteich/shared'; import type { PageListItemView, PondView, TreeNode } from '@dorfteich/shared';
import { buildTree, collectSubtreeIds } from '@dorfteich/shared'; import { buildTree, collectSubtreeIds } from '@dorfteich/shared';
import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useRef, useState } from 'react'; import { useId, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { FormError } from '../components/forms'; import { FormError } from '../components/forms';
import { apiGet, apiPatch } from '../lib/api'; import { apiGet, apiPatch } from '../lib/api';
import { useDismissable } from '../lib/use-dismissable'; 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 * "Move to…" dialog (issue #109): an indented parent picker over the page
@ -19,17 +20,22 @@ export function MovePageDialog({
pageId, pageId,
pondSlug, pondSlug,
onClose, onClose,
returnFocusRef,
}: { }: {
pageId: string; pageId: string;
pondSlug: string; pondSlug: string;
onClose: () => void; onClose: () => void;
/** Where focus goes after closing when the opening control unmounted. */
returnFocusRef?: React.RefObject<HTMLElement | null>;
}): React.JSX.Element { }): React.JSX.Element {
const { t } = useTranslation('editor'); const { t } = useTranslation('editor');
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [error, setError] = useState<unknown>(null); const [error, setError] = useState<unknown>(null);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const dialogRef = useRef<HTMLDivElement>(null); const dialogRef = useRef<HTMLDivElement>(null);
const titleId = useId();
useDismissable(dialogRef, true, onClose); useDismissable(dialogRef, true, onClose);
useModalFocus(dialogRef, returnFocusRef);
const pond = useQuery({ const pond = useQuery({
queryKey: ['pond', pondSlug], queryKey: ['pond', pondSlug],
@ -81,8 +87,17 @@ export function MovePageDialog({
return ( return (
<div className="modal-overlay"> <div className="modal-overlay">
<div className="modal move-dialog" role="dialog" aria-modal="true" ref={dialogRef}> <div
<h2 className="modal__title">{t('page.moveTitle')}</h2> className="modal move-dialog"
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
tabIndex={-1}
ref={dialogRef}
>
<h2 className="modal__title" id={titleId}>
{t('page.moveTitle')}
</h2>
<FormError error={error} /> <FormError error={error} />
<ul className="move-dialog__options"> <ul className="move-dialog__options">
<li> <li>

View File

@ -284,13 +284,19 @@ function PageOverflowMenu({
</div> </div>
)} )}
{moving && ( {moving && (
<MovePageDialog pageId={pageId} pondSlug={pondSlug} onClose={() => setMoving(false)} /> <MovePageDialog
pageId={pageId}
pondSlug={pondSlug}
onClose={() => setMoving(false)}
returnFocusRef={menuRef}
/>
)} )}
{deleting && ( {deleting && (
<DeletePageDialog <DeletePageDialog
pageId={pageId} pageId={pageId}
title={deleting.title} title={deleting.title}
childCount={deleting.childCount} childCount={deleting.childCount}
returnFocusRef={menuRef}
onDeleted={() => { onDeleted={() => {
setDeleting(null); setDeleting(null);
void queryClient.invalidateQueries({ queryKey: ['pages'] }); void queryClient.invalidateQueries({ queryKey: ['pages'] });

View File

@ -8,6 +8,7 @@ import { LabelChips } from '../labels/LabelChips';
import { usePondLabels } from '../labels/use-pond-labels'; import { usePondLabels } from '../labels/use-pond-labels';
import { useCurrentPondRoute } from '../layout/use-pond-route'; import { useCurrentPondRoute } from '../layout/use-pond-route';
import { apiGet } from '../lib/api'; import { apiGet } from '../lib/api';
import { useModalFocus } from '../lib/use-modal-focus';
import { HighlightedSnippet } from './highlight'; import { HighlightedSnippet } from './highlight';
const RECENT_KEY = 'dorfteich.recentSearches'; const RECENT_KEY = 'dorfteich.recentSearches';
@ -45,6 +46,8 @@ export function SearchPalette({ onClose }: { onClose: () => void }): React.JSX.E
const navigate = useNavigate(); const navigate = useNavigate();
const { pondSlug } = useCurrentPondRoute(); const { pondSlug } = useCurrentPondRoute();
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const overlayRef = useRef<HTMLDivElement>(null);
useModalFocus(overlayRef);
const pond = useQuery({ const pond = useQuery({
queryKey: ['pond', pondSlug], queryKey: ['pond', pondSlug],
@ -111,7 +114,13 @@ export function SearchPalette({ onClose }: { onClose: () => void }): React.JSX.E
} }
return ( return (
<div className="search-overlay" role="dialog" aria-modal="true" aria-label={t('title')}> <div
className="search-overlay"
role="dialog"
aria-modal="true"
aria-label={t('title')}
ref={overlayRef}
>
<div className="search-backdrop" onClick={onClose} aria-hidden /> <div className="search-backdrop" onClick={onClose} aria-hidden />
<div className="search-palette" onKeyDown={onKeyDown}> <div className="search-palette" onKeyDown={onKeyDown}>
<input <input