diff --git a/src/components/ui/Dialog.jsx b/src/components/ui/Dialog.jsx new file mode 100644 index 0000000..8cd8cb0 --- /dev/null +++ b/src/components/ui/Dialog.jsx @@ -0,0 +1,56 @@ +import { useEffect, useId, useRef } from 'react' +import { useDialogFocus } from '../../hooks/useDialogFocus' +import { shouldCloseOnKey } from '../../lib/dialogKeys' + +/** + * 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 + * (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). + * Der Fokus springt beim Öffnen in die Box und beim Schließen zurück (useDialogFocus); Tab bleibt im Dialog. + */ +export default function Dialog({ title, onClose, busy = false, wide = false, as = 'div', onSubmit, footer, children }) { + const Box = as + const titleId = useId() + const dialogRef = useRef(null) + const trapKeyDown = useDialogFocus(dialogRef) + + // Der Listener bleibt über alle Renders registriert und liest immer den neuesten Stand + const latest = useRef({ busy, onClose }) + useEffect(() => { + latest.current = { busy, onClose } + }) + useEffect(() => { + const onKeyDown = (e) => { + const { busy: isBusy, onClose: close } = latest.current + if (shouldCloseOnKey(e, isBusy)) close() + } + document.addEventListener('keydown', onKeyDown) + return () => document.removeEventListener('keydown', onKeyDown) + }, []) + + return ( +
+ +
+

{title}

+ +
+
{children}
+ {footer &&
{footer}
} +
+
+ ) +} diff --git a/src/components/ui/index.js b/src/components/ui/index.js index f6da960..7a27be0 100644 --- a/src/components/ui/index.js +++ b/src/components/ui/index.js @@ -11,3 +11,4 @@ export { default as Kpi } from './Kpi' export { default as AutocompleteInput } from './AutocompleteInput' export { default as DateTimeInput } from './DateTimeInput' export { default as DurationSelect } from './DurationSelect' +export { default as Dialog } from './Dialog' diff --git a/src/lib/dialogKeys.js b/src/lib/dialogKeys.js new file mode 100644 index 0000000..e2f3af4 --- /dev/null +++ b/src/lib/dialogKeys.js @@ -0,0 +1,7 @@ +/** + * Soll diese Taste den Dialog schließen? Nur Esc, nur wenn gerade nichts gespeichert wird (`busy`) und nur, wenn + * niemand die Taste schon behandelt hat (`defaultPrevented`, z. B. schließt eine offene Vorschlagsliste zuerst sich selbst). + */ +export function shouldCloseOnKey({ key, defaultPrevented }, busy) { + return key === 'Escape' && !defaultPrevented && !busy +} diff --git a/src/styles/global.css b/src/styles/global.css index 2815fb8..f0b456a 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -396,6 +396,7 @@ a.list-row:hover .cell-title { color: var(--accent-ink); } .dialog-body { padding: 16px 24px 8px; overflow-y: auto; display: flex; flex-direction: column; gap: 16px; } .dialog-foot { display: flex; align-items: center; gap: 8px; padding: 16px 24px 20px; } .dialog-foot .grow { flex: 1; } +@media (prefers-reduced-motion: reduce) { .overlay, .dialog { animation: none; } } .field { display: flex; flex-direction: column; gap: 6px; } .field > label { font-weight: 500; } diff --git a/tests/unit/dialogKeys.test.mjs b/tests/unit/dialogKeys.test.mjs new file mode 100644 index 0000000..2efda50 --- /dev/null +++ b/tests/unit/dialogKeys.test.mjs @@ -0,0 +1,12 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { loadModule } from './load.mjs' + +const { shouldCloseOnKey } = await loadModule('src/lib/dialogKeys.js') + +test('Esc schliesst nur ohne Sperre und ohne vorherige Behandlung', () => { + assert.equal(shouldCloseOnKey({ key: 'Escape', defaultPrevented: false }, false), true) + assert.equal(shouldCloseOnKey({ key: 'Escape', defaultPrevented: true }, false), false) + assert.equal(shouldCloseOnKey({ key: 'Escape', defaultPrevented: false }, true), false) + assert.equal(shouldCloseOnKey({ key: 'Enter', defaultPrevented: false }, false), false) +})