Design: Dialog-Baustein (Esc, Sperre waehrend Speichern, Fokus, Bottom-Sheet)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-06 06:02:54 +00:00
1 parent 769ec70847
commit 988934552f
5 files changed
+77

No files matched your search

+56
View File
@@ -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 <form onSubmit>; 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 (
<div className="overlay" onKeyDown={trapKeyDown}>
<Box
className={wide ? 'dialog dialog-wide' : 'dialog'}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
ref={dialogRef}
tabIndex={-1}
onSubmit={as === 'form' ? onSubmit : undefined}
>
<div className="dialog-head">
<h3 id={titleId}>{title}</h3>
<button type="button" className="dialog-close" aria-label="Schließen" disabled={busy} onClick={onClose}>
×
</button>
</div>
<div className="dialog-body">{children}</div>
{footer && <div className="dialog-foot">{footer}</div>}
</Box>
</div>
)
}
+1
View File
@@ -11,3 +11,4 @@ export { default as Kpi } from './Kpi'
export { default as AutocompleteInput } from './AutocompleteInput' export { default as AutocompleteInput } from './AutocompleteInput'
export { default as DateTimeInput } from './DateTimeInput' export { default as DateTimeInput } from './DateTimeInput'
export { default as DurationSelect } from './DurationSelect' export { default as DurationSelect } from './DurationSelect'
export { default as Dialog } from './Dialog'
+7
View File
@@ -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
}
+1
View File
@@ -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-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 { display: flex; align-items: center; gap: 8px; padding: 16px 24px 20px; }
.dialog-foot .grow { flex: 1; } .dialog-foot .grow { flex: 1; }
@media (prefers-reduced-motion: reduce) { .overlay, .dialog { animation: none; } }
.field { display: flex; flex-direction: column; gap: 6px; } .field { display: flex; flex-direction: column; gap: 6px; }
.field > label { font-weight: 500; } .field > label { font-weight: 500; }
+12
View File
@@ -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)
})