mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
Add in-app confirmation dialog replacing window.confirm
This commit is contained in:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user