Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
251 changes: 251 additions & 0 deletions docs/components/ConfirmDialog.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,251 @@
# ConfirmDialog

`ConfirmDialog` is the shared, accessible confirmation modal used by destructive and irreversible flows in TalentTrust (e.g. submitting a milestone for approval, releasing escrow funds, opening a dispute). It traps keyboard focus inside the dialog, dismisses on **Escape** or backdrop click, and surfaces a `role="alertdialog"` when `tone="destructive"`.

The component is intentionally minimal: it does **not** own focus restoration after close, do its own routing, or talk to the network. The owning component is responsible for opening, closing, and returning focus to its trigger once the dialog finishes.

## Location

`src/components/ConfirmDialog.tsx`

## Props

| Prop | Type | Required | Default | Description |
|------|------|----------|---------|-------------|
| `isOpen` | `boolean` | Yes | — | Whether the dialog is open. When `false`, the component renders nothing. |
| `title` | `string` | Yes | — | Dialog heading. Rendered as `<h2>` and linked via `aria-labelledby` (id generated by React `useId`). |
| `description` | `string` | Yes | — | Body copy explaining the consequence. Rendered as `<p>` and linked via `aria-describedby` (id generated by React `useId`). |
| `confirmLabel` | `string` | No | `"Confirm"` | Accessible name of the confirm button. |
| `cancelLabel` | `string` | No | `"Cancel"` | Accessible name of the cancel button. |
| `tone` | `'default' \| 'destructive'` | No | `'default'` | When `'destructive'`, the dialog uses `role="alertdialog"`. Otherwise it uses `role="dialog"`. |
| `onConfirm` | `() => void` | Yes | — | Callback fired when the user clicks the confirm button. |
| `onCancel` | `() => void` | Yes | — | Callback fired when the user clicks the cancel button, hits **Escape**, or clicks the backdrop. The caller decides what "cancel" means — typically closing the dialog and restoring focus. |

All props are required unless marked otherwise. The component never mutates any of them.

## Output contract

When `isOpen` is `true`, the rendered tree is:

```html
<div aria-hidden="true" /> <!-- backdrop, click closes -->
<div role="dialog"|"alertdialog" aria-modal="true" aria-labelledby="…" aria-describedby="…">
<h2 id="…">{title}</h2>
<p id="…">{description}</p>
<button type="button">{cancelLabel}</button> <!-- initial focus target -->
<button type="button">{confirmLabel}</button>
</div>
```

When `isOpen` is `false`, the component renders `null`. There is no fallback DOM, no portal target, and no ancestors left in the document.

`role` is determined by `tone`:

* `tone === 'destructive'` → `role="alertdialog"`
* `tone === 'default'` (the default) → `role="dialog"`

## Focus-trap contract

`ConfirmDialog` shares its keyboard-focus behavior with the rest of the modal system via `src/hooks/useDialogFocusTrap.ts`. When `isOpen` is `true`:

1. **Initial focus** moves to the **Cancel** button (`initialFocusRef` is wired to the cancel button).
2. **Tab** from the last focusable element wraps to the first.
3. **Shift + Tab** from the first focusable element wraps to the last.
4. **Escape** invokes `onCancel` and preventDefaults the browser's default Escape behavior.
5. **Backdrop click** invokes `onCancel`.
6. Background content (everything outside the dialog overlay) is hidden via `inert` + `aria-hidden="true"` while open. On close the previous `aria-hidden` value (if any) is restored and `inert` is removed.

### FOCUSABLE_SELECTORS

The trap considers an element focusable if and only if it matches the shared `FOCUSABLE_SELECTORS` selector from the hook:

```ts
'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
```

> Concretely: enabled `<button>`, any element with `href`, enabled `<input>`/`<select>`/`<textarea>`, and any element whose `tabindex` is anything other than `"-1"`.

### What happens when the dialog contains no focusable element

If `Array.from(dialog.querySelectorAll(FOCUSABLE_SELECTORS))` is empty, the hook returns early on `Tab`. In that state:

- The hook does **not** move focus anywhere on Tab. `event.preventDefault()` is **not** called, so the browser's normal Tab order takes over.
- The `Escape` handler is independent of the focusable-elements check and still fires — the user can always dismiss the dialog with the keyboard.

The confirmation dialog ships with two always-enabled buttons (Cancel and Confirm) under the current implementation, so this fallback exists primarily as a safety net for future configurations (e.g. dialogs with read-only content).

### What the dialog does **not** do

`ConfirmDialog` deliberately does **not** restore focus to the element that opened it after close. The owning component owns focus restoration; the dialog only signals that the close happened (through `onConfirm` and `onCancel`).

This is intentional and is called out in the JSDoc on the component and verified by a dedicated test:

```ts
// src/components/__tests__/ConfirmDialog.test.tsx
it('does NOT restore focus itself — the caller owns focus restoration', async () => { … });
```

## Caller responsibilities — focus restoration

Because the dialog does not restore focus itself, every consumer must:

1. **Retain a ref to the trigger element** — the `<button>` (or other control) that opened the dialog.
2. **Restore focus to that ref** in every close handler — typically the caller's `onCancel` and a post-`onConfirm` settling path.

The most reliable pattern is to capture `event.currentTarget` from the trigger's own click handler at the moment the user opens the dialog. This avoids the "last rendered ref wins" bug that static `ref={triggerRef}` props suffer from when several dialogs (or several triggers that open the same dialog) are visible at once.

### Worked example: `src/components/ActionPanel.tsx`

`ActionPanel` opens `ConfirmDialog` for "Submit Milestone" and "Release Funds" on a single contract. Because both buttons are visible at once on the `Active` status, it uses the `event.currentTarget` capture pattern:

```tsx
// src/components/ActionPanel.tsx (excerpt)

/**
* Holds a reference to the button that opened the confirmation dialog or the
* dispute form. After closing, focus is restored here to satisfy WCAG 2.1
* SC 3.2.2 and the APG dialog pattern.
*/
const triggerElementRef = useRef<HTMLButtonElement | null>(null);

const handleOpenConfirm = (
action: Exclude<ConfirmAction, null>,
event: React.MouseEvent<HTMLButtonElement>,
) => {
// Capture the exact button that was clicked — NOT a static ref prop on
// each button, which would always point at the last one rendered.
triggerElementRef.current = event.currentTarget;
setConfirmAction(action);
};

const handleConfirm = () => {
if (confirmAction === 'submit') {
onSubmitMilestone?.();
showSuccess({ title: 'Milestone submitted' });
} else if (confirmAction === 'release') {
onReleaseFunds?.();
} else if (confirmAction === 'dispute') {
onDispute?.('Dispute opened from action panel.');
}
setConfirmAction(null);
// Caller-managed focus restoration — happens *after* the dialog unmounts.
triggerElementRef.current?.focus();
};

const handleCancel = () => {
setConfirmAction(null);
triggerElementRef.current?.focus();
};

// In the JSX:
<button onClick={(e) => handleOpenConfirm('submit', e)}>Submit Milestone</button>
<button onClick={(e) => handleOpenConfirm('release', e)}>Release Funds</button>

<ConfirmDialog
isOpen={confirmAction !== null}
title={…}
description={…}
confirmLabel={…}
cancelLabel="Cancel"
tone={confirmAction === 'release' || confirmAction === 'dispute' ? 'destructive' : 'default'}
onConfirm={handleConfirm}
onCancel={handleCancel}
/>
```

The behavior this satisfies:

| Action | Trap entry | Cancel/Escape/Backdrop | Confirm |
|---|---|---|---|
| Submit Milestone | focus → "Cancel" | `handleCancel` → focus returns to **Submit Milestone** button | `handleConfirm` → callback + toast + focus returns to **Submit Milestone** button |
| Release Funds | focus → "Cancel" | `handleCancel` → focus returns to **Release Funds** button | `handleConfirm` → callback + focus returns to **Release Funds** button |

`ActionPanel`'s dedicated test suite ([`src/components/__tests__/ActionPanel.test.tsx`](../../src/components/__tests__/ActionPanel.test.tsx), section "focus restoration after dialog close") verifies every cell in this table, including the `correctly distinguishes Release Funds from Dispute — each restores to its own button` regression guard.

### Recommended pattern for new call sites

```tsx
function MyDestructiveFlow() {
const [isOpen, setIsOpen] = useState(false);
const triggerRef = useRef<HTMLButtonElement | null>(null);

const open = (event: React.MouseEvent<HTMLButtonElement>) => {
triggerRef.current = event.currentTarget;
setIsOpen(true);
};

const handleConfirm = () => {
setIsOpen(false);
triggerRef.current?.focus(); // ← caller owns focus restoration
actuallyDoTheDestructiveThing();
};

const handleCancel = () => {
setIsOpen(false);
triggerRef.current?.focus(); // ← same trigger every time
};

return (
<>
<button ref={triggerRef} type="button" onClick={open}>
Delete account
</button>
<ConfirmDialog
isOpen={isOpen}
title="Delete account"
description="This permanently deletes your account and all associated data."
tone="destructive"
confirmLabel="Delete"
cancelLabel="Keep account"
onConfirm={handleConfirm}
onCancel={handleCancel}
/>
</>
);
}
```

Three non-negotiables:

1. **Capture the trigger ref via `event.currentTarget`**, not via static `ref={…}` props when multiple buttons share a dialog.
2. **Restore focus on every close path** — confirm, cancel, Escape, and any "dismiss externally" you add.
3. **Test it** — render the dialog, click cancel/confirm/Escape, then assert `expect(triggerButton).toHaveFocus()`.

## Accessibility

The component is screen-reader friendly and passes `jest-axe` in both tones:

- `role="dialog"` (default) or `role="alertdialog"` (destructive) so screen readers can announce urgency.
- `aria-modal="true"` signals modal exclusivity.
- `aria-labelledby` references the auto-generated `useId` for the heading.
- `aria-describedby` references the auto-generated `useId` for the description.
- Initial focus on Cancel (the "safe" action) — compliant with the WAI-ARIA APG dialog pattern.
- Background content is hidden from the accessibility tree while the dialog is open (`inert` + `aria-hidden="true"`), and is fully restored on close.

## Dependencies

- `src/hooks/useDialogFocusTrap.ts` — shared keyboard focus management for modals. The dialog does not implement its own focus trap.
- React 19 `useId` for SSR-safe, hydration-stable id generation on the title and description.

## Testing

Tested with Jest and React Testing Library in `src/components/__tests__/ConfirmDialog.test.tsx`. Coverage targets ≥ 95% on this module (Lines: 100%, Statements: ≥ 95%) and includes:

| Category | Cases |
|---|---|
| Open / close | Renders nothing when closed; full DOM when open. |
| Defaults | `confirmLabel = "Confirm"`, `cancelLabel = "Cancel"`, `tone = "default"` (`role="dialog"`, no `alertdialog`). |
| Button callbacks | Confirm button → `onConfirm`; Cancel button → `onCancel`; no handlers fire when closed. |
| Backdrop click | Clicking the backdrop fires `onCancel` (same as Escape/Cancel button). |
| Tone / role | `role="dialog"` ↔ `role="alertdialog"` swaps correctly. |
| ARIA wiring | `aria-labelledby` / `aria-describedby` point to the title and description elements; generated IDs are distinct across renders; `aria-modal="true"` always present. |
| Background hiding | Background elements get `aria-hidden="true"` and `inert` set on open, both removed on close. Pre-existing `aria-hidden` and `inert` values on siblings are **preserved** (not clobbered) by the cleanup. Repeated open/close cycles clean up correctly every iteration. |
| Focus trap | Initial focus lands on Cancel; Tab from last focusable wraps to first; Shift+Tab from first wraps to last. |
| Escape | Pressing Escape fires `onCancel` and does not throw. |
| No focusable elements | The shared hook safely no-ops Tab when the panel has nothing focusable; Escape still works. |
| Caller responsibility | A dedicated test exercises a parent that captures `event.currentTarget` and asserts focus returns to the trigger after Cancel/Confirm. A spy on `HTMLElement.prototype.focus` confirms `ConfirmDialog` does NOT call any focus method after `Escape` beyond the initial focus set on open. |
| jest-axe audits | Zero violations in both `tone="default"` and `tone="destructive"` states. |

## Differences from `MilestoneCreationForm`

`MilestoneCreationForm` (a richer dialog used for adding milestones to a contract) does **not** use `ConfirmDialog`. It uses `useDialogFocusTrap` directly with `restoreFocus: true`, so it manages focus restoration itself. `ConfirmDialog` deliberately does not auto-restore so it can be reused in any flow — each owner chooses its own restoration strategy (e.g. `ActionPanel`'s captured `event.currentTarget`).
Loading
Loading