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:
1 parent
769ec70847
commit
988934552f
5 files changed
+77
No files matched your search
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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'
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
Reference in new issue
Block a user