From 39dfb386ac718555e0d4c0d7c40c6e3097056743 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 11:15:29 +0000 Subject: [PATCH] Dialog: Esc nur fuer den obersten Dialog, Schliessen ohne Ereignis, Fokus zurueck auch nach autoFocus Co-Authored-By: Claude Opus 5.5 --- src/components/ui/Dialog.jsx | 16 +++++++++++++--- src/hooks/useDialogFocus.js | 10 +++++++--- 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/src/components/ui/Dialog.jsx b/src/components/ui/Dialog.jsx index 8cd8cb0..a159d26 100644 --- a/src/components/ui/Dialog.jsx +++ b/src/components/ui/Dialog.jsx @@ -2,10 +2,14 @@ import { useEffect, useId, useRef } from 'react' import { useDialogFocus } from '../../hooks/useDialogFocus' import { shouldCloseOnKey } from '../../lib/dialogKeys' +// Alle gerade offenen Dialoge, der zuletzt geöffnete steht hinten: nur er reagiert auf Esc (sonst schlösse ein Esc auch die darunter liegenden) +const openDialogs = [] + /** * Dialog: weiße Box auf abgedunkeltem Hintergrund, auf dem Handy als Bottom-Sheet (Regeln in global.css, .overlay/.dialog). * Schließen geht nur über × und Esc, ein Klick auf den Hintergrund tut nichts (lange Eingaben sollen nicht verloren gehen). - * Solange `busy` gilt, wirken × und Esc nicht. Esc greift nur, wenn kein innerer Baustein die Taste schon behandelt hat + * Solange `busy` gilt, wirken × und Esc nicht. Liegen mehrere Dialoge übereinander, schließt Esc nur den obersten. + * Esc greift nur, wenn kein innerer Baustein die Taste schon behandelt hat * (z. B. schließt eine offene Vorschlagsliste zuerst sich selbst): React 18 verteilt seine Ereignisse am Wurzelelement, also vor * dem Listener auf `document`, und ein dortiges preventDefault() ist hier als `defaultPrevented` sichtbar. * Mit `as="form"` ist die Box ein
; die native Pflichtfeld-Prüfung bleibt aktiv (kein noValidate). @@ -23,12 +27,18 @@ export default function Dialog({ title, onClose, busy = false, wide = false, as latest.current = { busy, onClose } }) useEffect(() => { + openDialogs.push(latest) const onKeyDown = (e) => { + if (openDialogs[openDialogs.length - 1] !== latest) return const { busy: isBusy, onClose: close } = latest.current if (shouldCloseOnKey(e, isBusy)) close() } document.addEventListener('keydown', onKeyDown) - return () => document.removeEventListener('keydown', onKeyDown) + return () => { + document.removeEventListener('keydown', onKeyDown) + const index = openDialogs.indexOf(latest) + if (index !== -1) openDialogs.splice(index, 1) + } }, []) return ( @@ -44,7 +54,7 @@ export default function Dialog({ title, onClose, busy = false, wide = false, as >

{title}

-
diff --git a/src/hooks/useDialogFocus.js b/src/hooks/useDialogFocus.js index e398a6d..dbb50f5 100644 --- a/src/hooks/useDialogFocus.js +++ b/src/hooks/useDialogFocus.js @@ -1,17 +1,21 @@ -import { useCallback, useEffect } from 'react' +import { useCallback, useEffect, useRef } from 'react' const FOCUSABLE = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])' /** * Fokus für ein Dialog-Overlay: beim Öffnen springt der Fokus in den Dialog (`dialogRef`), beim Schließen zurück zum - * Element, das ihn vorher hatte (falls es noch da ist). Der zurückgegebene onKeyDown gehört an das Overlay: Tab und + * Element, das ihn vorher hatte (falls es noch da ist). Das Element wird beim ersten Render festgehalten, noch bevor + * ein autoFocus im Dialog den Fokus nimmt. Der zurückgegebene onKeyDown gehört an das Overlay: Tab und * Umschalt+Tab bleiben im Dialog (sonst landet man hinter dem Overlay in der Seite). */ export function useDialogFocus(dialogRef) { + const openerRef = useRef(undefined) + if (openerRef.current === undefined) openerRef.current = document.activeElement + useEffect(() => { - const opener = document.activeElement dialogRef.current?.focus() return () => { + const opener = openerRef.current if (opener && opener.isConnected && typeof opener.focus === 'function') opener.focus() } }, [dialogRef])