Add in-app confirmation dialog replacing window.confirm

This commit is contained in:
2026-06-05 02:28:37 +03:00
parent edadc2a3fa
commit 7d7034ef1a
12 changed files with 554 additions and 16 deletions
+87 -6
View File
@@ -420,9 +420,10 @@ mapping in the app. There is no `name === 'datasets' && <DatasetsModal/>` chain.
### Focus trap
A small hook saves the previously focused element, focuses the first focusable
child on open, wraps `Tab`/`Shift+Tab` within the modal, and restores focus on
close.
A small hook saves the previously focused element, focuses a focusable child on
open, wraps `Tab`/`Shift+Tab` within the modal, and restores focus on close. The
optional `initialSelector` picks _which_ child takes focus (e.g. Cancel for a
destructive confirm); it falls back to the first focusable child.
```ts
// src/app/hooks/useFocusTrap.ts
@@ -432,7 +433,10 @@ const FOCUSABLE =
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), ' +
'textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
export function useFocusTrap<T extends HTMLElement = HTMLDivElement>(active: boolean) {
export function useFocusTrap<T extends HTMLElement = HTMLDivElement>(
active: boolean,
initialSelector?: string,
) {
const ref = useRef<T>(null);
const returnTo = useRef<Element | null>(null);
@@ -441,7 +445,10 @@ export function useFocusTrap<T extends HTMLElement = HTMLDivElement>(active: boo
if (!active || !el) return;
returnTo.current = document.activeElement;
el.querySelector<HTMLElement>(FOCUSABLE)?.focus();
const initial =
(initialSelector ? el.querySelector<HTMLElement>(initialSelector) : null) ??
el.querySelector<HTMLElement>(FOCUSABLE);
initial?.focus();
const onKey = (e: KeyboardEvent) => {
if (e.key !== 'Tab') return;
@@ -463,7 +470,7 @@ export function useFocusTrap<T extends HTMLElement = HTMLDivElement>(active: boo
el.removeEventListener('keydown', onKey);
(returnTo.current as HTMLElement | null)?.focus(); // restore focus on close
};
}, [active]);
}, [active, initialSelector]);
return ref;
}
@@ -493,6 +500,80 @@ export function useFocusTrap<T extends HTMLElement = HTMLDivElement>(active: boo
---
## Confirmation & alert dialogs
The registry/coordinator/shell above governs the **named feature modals** — a
fixed, registered, URL-navigable set with "at most one open at a time". A
destructive **confirmation** ("Delete _Name_? This cannot be undone.") is a
different animal and gets a **separate, lighter layer** rather than a `ModalName`
entry. Three properties force the split:
- **Ephemeral & content-on-call.** A confirm isn't a fixed surface with a stored
component; its title/message/labels are supplied at the call site. There's
nothing to register.
- **Stacks _above_ a feature modal.** The discard-changes prompt must appear over
an already-open Datasets/Settings modal — which directly violates the feature
layer's "at most one open" rule. So confirmations live on a higher z-layer
(`z-index: 1000`, above the future modal shell).
- **Not navigable.** A confirmation is never a URL destination or a reload-restore
target; it only exists for the duration of one decision.
### The primitive
A promise-based store + one globally-mounted renderer. `confirm(opts)` returns
`Promise<boolean>` and is callable from anywhere — React components and non-React
code alike:
```ts
// src/app/stores/ConfirmStore.ts
import { confirm } from '../stores/ConfirmStore';
const ok = await confirm({
title: 'Delete snippet',
message: `Delete "${name}"? This cannot be undone.`,
confirmLabel: 'Delete',
danger: true, // Carbon "danger" styling + Cancel-defaulted focus
});
if (ok) removeSnippet(id);
```
| Piece | Responsibility | Lives in |
| ------------------------------- | --------------------------------------------------------------- | -------------------------------------- |
| `useConfirmStore` / `confirm()` | Hold the open request; resolve the awaiting promise | `src/app/stores/ConfirmStore.ts` |
| `ConfirmDialog` | Render the active request; backdrop, focus trap, Escape, danger | `src/app/components/ConfirmDialog.tsx` |
| `useFocusTrap` | Shared overlay focus trap (this dialog now, the shell later) | `src/app/hooks/useFocusTrap.ts` |
`ConfirmDialog` is mounted once at the app root. Only one confirmation shows at a
time; opening a second resolves the first `false` so no awaiter hangs.
### Dismissal — Carbon's transactional rule
Confirmations follow Carbon's **transactional / danger modal** behavior, not the
passive-modal behavior the feature shell uses:
- **Escape** and **Cancel** resolve `false`.
- A **backdrop click does _not_ dismiss** — the user must pick an action, so a
destructive choice is never made by an accidental outside click. (Contrast the
feature shell, where backdrop-click dismiss is correct for passive modals.)
- For `danger` requests, initial focus goes to **Cancel**, so a stray Enter can't
destroy anything; non-danger confirms focus the primary action.
- `role="alertdialog"` (not `dialog`) with `aria-describedby` on the message.
### The coordinator seam
The feature-modal coordinator exposes `setConfirm(fn)` for its unsaved-change
prompt. Once the feature-modal system lands, wire it to this same primitive:
```ts
setConfirm((message) => confirm({ title: 'Discard changes?', message, danger: true }));
```
That keeps every destructive/lossy decision — deletes, revert, reset, and
discard-on-close — flowing through one consistent dialog. Per spec §10, all
destructive actions confirm; per §01, _non_-blocking outcomes (success, info) are
**toasts**, not dialogs — don't reach for a confirm where a toast is the right
tool.
## URL & Keyboard Integration
The coordinator is the join point for navigation: