Skip to content

Commit 7e42e15

Browse files
authored
feat(mosaic): Add shared useConfirmController that supports reverification for handles (#10201)
1 parent f806e4e commit 7e42e15

123 files changed

Lines changed: 3108 additions & 3084 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
---
2+
---
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
---
2+
---
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
---
2+
---
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
---
2+
---

‎packages/mosaic/AGENTS.md‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,9 +35,9 @@ Most problems a feature hits already have a shared answer. Find the row for your
3535
| An async action from a button or row: pending, an error, one lock | `usePendingAction` (`src/hooks/`) |
3636
| A form: values, validation, submit, field errors | `useForm` (`src/components/form/`), with `validateAsync` for a server-side check |
3737
| A model save whose Clerk errors should land on form fields | `save` in the model (`src/utils/errors.ts`), or reject with `FormSubmitError` |
38-
| A confirm step before an action | `Confirmation` + `useConfirmationController` (`src/blocks/confirmation/`) |
39-
| A delete that needs the user to type a phrase | `Destructive` + `useDestructiveController` (`src/blocks/destructive/`) |
40-
| An action that may need step-up verification | `useReverificationFlow` (`src/features/reverification/`), passed to `useDestructiveController` |
38+
| A confirm step before an action | `useConfirmController` (`src/blocks/confirm/`) with `Confirmation` (`src/blocks/confirmation/`) |
39+
| A delete that needs the user to type a phrase | `useConfirmController` (`src/blocks/confirm/`) with `Destructive` (`src/blocks/destructive/`) |
40+
| An action that may need step-up verification | `useReverificationFlow` (`src/features/reverification/`) in the wrapper, passed to `useConfirmController` |
4141
| Showing any error to the user | One of the owners above. They call `toLocalizableError` and resolve copy with `useErrorText` |
4242
| Moving focus after a list row is removed | `useListRemovalFocus` (`src/hooks/`) |
4343
| A debounced, abortable async check as the user types | `useDebouncedAsync` (`src/hooks/`) |

‎packages/mosaic/ARCHITECTURE.md‎

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -490,17 +490,17 @@ export function UserButtonView({ open, onOpenChange, pendingKey, onSignOutAll, .
490490

491491
A **block** is a view fragment that owns one piece of state nothing outside it can
492492
use, and takes the rest as props. `blocks/destructive` is the example: it holds the
493-
half-typed confirmation phrase and compares it, while `open`, `isDeleting`, and
494-
`errorMessage` come from the controller, because those are what decide whether the
495-
dialog closes or explains itself. `blocks/confirmation` is the same shape for a
496-
confirm step with no typed phrase.
497-
498-
Each block ships its own controller (`useDestructiveController`,
499-
`useConfirmationController`) that owns the pending and error state, so a feature
500-
passes it the effect and spreads the result onto the block rather than wiring that
501-
state by hand. Each also has a handle form (`DestructiveHandle`, and its
502-
`Confirmation` equivalent) for one dialog shared by several triggers, where the
503-
clicked row arrives as a payload.
493+
half-typed confirmation phrase and compares it, while open, pending and the error
494+
come from its controller, because those are what decide whether the dialog closes
495+
or explains itself. `blocks/confirmation` is the same shape for a confirm step
496+
with no typed phrase.
497+
498+
Both blocks are driven by one controller, `useConfirmController` (`blocks/confirm/`),
499+
created in the wrapper or controller layer and handed to the block as `controller`.
500+
It owns the dialog's lifecycle and the reverification step, and it carries a
501+
payload: a dialog shared by a list is opened from any row with `open(payload)`, the
502+
copy is derived from that payload, and the action receives it back. A dialog that
503+
sits next to its button takes the button as `trigger` instead.
504504

505505
### Testing a flow
506506

‎packages/mosaic/docs/controllers.md‎

Lines changed: 90 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -23,14 +23,14 @@ A controller is not a machine wrapper. It is the layer that owns the
2323
interaction, and it holds that state in whichever tool the interaction's
2424
complexity calls for:
2525

26-
| The interaction… | Hold it in |
27-
| ----------------------------------------------------------------------------- | ------------------------------------------------------------------------- |
28-
| Is a boolean or a controlled value, no async, nothing else depends on it | `useState` |
29-
| Has an async lifecycle, or two values that must change together | A machine, same file |
30-
| Has a coordinated async core plus some UI-only flags beside it | Both — machine for the core |
31-
| Is one async action from a button or a row, needing only pending and an error | `usePendingAction` |
32-
| Is a form | `useForm` |
33-
| Is a confirm step before an action | `useConfirmationController` / `useDestructiveController` with their block |
26+
| The interaction… | Hold it in |
27+
| ----------------------------------------------------------------------------- | ----------------------------------------------------------- |
28+
| Is a boolean or a controlled value, no async, nothing else depends on it | `useState` |
29+
| Has an async lifecycle, or two values that must change together | A machine, same file |
30+
| Has a coordinated async core plus some UI-only flags beside it | Both — machine for the core |
31+
| Is one async action from a button or a row, needing only pending and an error | `usePendingAction` |
32+
| Is a form | `useForm` |
33+
| Is a confirm step before an action | `useConfirmController` with `Confirmation` or `Destructive` |
3434

3535
Check `packages/mosaic/AGENTS.md` → "Reuse before you write" before holding any
3636
of this by hand. If nothing there fits, ask the user before adding a new pattern.
@@ -56,17 +56,43 @@ focus. To hold pending through an OAuth redirect, await a delay inside the
5656
action (`user-profile-connected-accounts-section.controller.ts`). It is not for:
5757

5858
- a form → `useForm`
59-
- a confirm step → `Confirmation` / `Destructive` and their controllers
59+
- a confirm step → `useConfirmController` with `Confirmation` / `Destructive`
6060
- an action whose result drives other state → a machine
6161
- a check that reruns as the user types → `useDebouncedAsync` (`hooks/`), or a
6262
form field's `validateAsync`
6363

64-
`useConfirmationController` earns a machine: the confirm is async, and a failure
65-
must land back on the confirming step with a reason. `useUserButtonController`
66-
earns one because `open` and `pendingKey` constrain each other and dismissing
67-
must not abandon an in-flight invoke. `useDestructiveController` is the middle
68-
ground: its states never overlap, so one `useState` holding a status union is
69-
enough.
64+
`useConfirmController` earns a machine: the confirm is async, a failure must land
65+
back on the confirming step with a reason, and a cancelled reverification must
66+
close without one. `useUserButtonController` earns one because `open` and
67+
`pendingKey` constrain each other and dismissing must not abandon an in-flight
68+
invoke.
69+
70+
`useConfirmController({ onConfirm, reverification, errorFallback })` owns one
71+
confirm dialog: open, pending, error, and the verify step a reverified action
72+
swaps in. It is generic over a payload. Create it in the wrapper, pass it to the
73+
view, and the view hands it to the block as `controller` and opens it from a row
74+
with `open(payload)`; the copy props read the payload and `onConfirm` receives it.
75+
A dialog next to its button has no payload and takes the button as `trigger`.
76+
77+
An action Clerk may interrupt for step-up verification goes through
78+
`useReverificationFlow` first. It reads Clerk hooks, so the wrapper calls it next
79+
to the model and hands the controller the wrapped action together with the
80+
`reverification` it returns; the controller passes both on, and the block shows
81+
the verify step inside the same dialog:
82+
83+
```ts
84+
// wrapper
85+
const [revoke, reverification] = useReverificationFlow((id: string) => model.revoke(id));
86+
const controller = useInvitationsController({ ...model, revoke, reverification });
87+
88+
// controller
89+
const removalFocus = useListRemovalFocus({ ids, onRemove: revoke, fallback });
90+
const remove = useConfirmController<Invitation>({
91+
onConfirm: invitation => removalFocus.remove(invitation.id),
92+
reverification,
93+
errorFallback: m.revokeError,
94+
});
95+
```
7096

7197
A single open boolean earns nothing. A two-state machine with one event is a
7298
boolean spelled long. `useUserProfileEditUsernameController` holds `isOpen` in
@@ -201,6 +227,53 @@ When introducing lazy loading or Suspense, consider a boundary around dialog
201227
content that leaves the trigger and parent available. Choose that boundary from
202228
the feature's loading behavior; it does not require restructuring every dialog.
203229

230+
## Removing a row takes focus with it
231+
232+
A dialog returns focus to its trigger on close. When the action removes the row
233+
that trigger sits on — signing a device out, removing an email — the trigger
234+
unmounts and focus falls to `<body>`. Nothing catches it: the menu item that
235+
opened the dialog unmounted with the menu, and a dialog mounted at the section
236+
rather than inside the menu has no floating-tree ancestor to walk back to. A
237+
keyboard user loses their place mid-list and a screen reader announces nothing.
238+
239+
Hand focus to a surviving element with `useListRemovalFocus` (`hooks/`). It sits
240+
in the controller or wrapper, e.g. between the confirm controller and the model call:
241+
`remove` records which row went, and `finalFocus` picks the row that took its
242+
place, the last row when it was the last, or `fallback()` once the list is empty.
243+
The view receives `registerTrigger` and `finalFocus` as props and passes
244+
`finalFocus` to the block, which resolves it when the dialog closes, after the
245+
row has gone:
246+
247+
```ts
248+
const sectionRef = useRef<HTMLDivElement>(null);
249+
const removalFocus = useListRemovalFocus({
250+
ids: methods.map(method => method.id),
251+
onRemove,
252+
fallback: () => sectionRef.current?.querySelector<HTMLButtonElement>('button:not([disabled])') ?? sectionRef.current,
253+
});
254+
const remove = useConfirmController<Method>({ onConfirm: method => removalFocus.remove(method.id) });
255+
256+
return { sectionRef, remove, registerTrigger: removalFocus.registerTrigger, finalFocus: removalFocus.finalFocus };
257+
```
258+
259+
```tsx
260+
// each row's trigger
261+
triggerRef={registerTrigger(method.id)}
262+
263+
// the dialog
264+
<Confirmation controller={remove} finalFocus={finalFocus} … />
265+
```
266+
267+
A cancelled or failed removal keeps its own trigger, so `finalFocus` returns
268+
`null` and the dialog's default (the trigger) applies.
269+
270+
Prefer the row that took the removed one's place, the last row when it was the
271+
last, and a control that outlives the list once it is empty.
272+
273+
Test the removal, not just the cancel: `toHaveFocus()` on the row that should
274+
have caught it. A suite that only asserts focus after cancelling passes while
275+
every successful removal drops focus on the floor.
276+
204277
## Rules
205278

206279
- **No Clerk imports.** If a controller needs a Clerk fact, the model supplies it
@@ -223,8 +296,8 @@ the feature's loading behavior; it does not require restructuring every dialog.
223296
One path, so every error a user sees goes through the `errors.*` catalog:
224297

225298
- **Owners map, nothing else does.** A failure is owned by one of
226-
`usePendingAction` (button and row actions), `useConfirmationController`,
227-
`useDestructiveController`, or `useForm` (forms). The first three call
299+
`usePendingAction` (button and row actions), `useConfirmController` (confirm
300+
dialogs), or `useForm` (forms). The first two call
228301
`toLocalizableError(cause)` from `utils/errors.ts`. Use one of them instead of
229302
catching in a feature. A controller with its own machine maps in the machine's
230303
`onError` (`user-profile-rename-passkey.controller.ts`).

‎packages/mosaic/docs/views.md‎

Lines changed: 28 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -67,29 +67,37 @@ use, and takes the rest as props. There are two:
6767
and compares it.
6868
- `blocks/confirmation` (`Confirmation`) is a confirm step with no typed phrase.
6969

70-
`open`, the pending flag and `errorMessage` come from the block's own controller
71-
(`useDestructiveController`, `useConfirmationController`), because those decide
72-
whether the dialog closes or explains itself. Spread the controller onto the
73-
block rather than wiring that state by hand:
70+
Both take a `controller` from `useConfirmController` (`blocks/confirm/`), which
71+
decides whether the dialog closes or explains itself. The hook runs in the
72+
wrapper or controller, never in the view; the view receives the controller as a
73+
prop. A dialog next to its button takes the button as `trigger`:
7474

7575
```tsx
76-
const destructive = useDestructiveController({ onDelete, reverification });
77-
7876
<Destructive
79-
{...destructive}
77+
controller={deleteAccount}
8078
trigger={<Button color='negative'>{m.actionLabel}</Button>}
8179
title={m.dialogTitle}
8280
confirmationValue={m.fieldPlaceholder}
83-
/>;
81+
/>
8482
```
8583

86-
When one dialog serves every row in a list, use the handle form: create a handle
87-
with `Confirmation.createHandle<Row>()` (or `Destructive.createHandle`), pass it
88-
to each row's trigger and to the block's `handle` prop, and the clicked row
89-
arrives as the payload of `onConfirm` / `onDelete`. In this form the block runs
90-
its own controller, so pass the effect and `errorFallback` straight to it.
91-
`user-profile-mfa-section.view.tsx` and `api-keys-table.view.tsx` are worked
92-
examples.
84+
When one dialog serves every row in a list, the controller carries the row as
85+
its payload. Mount the block once after the list, open it from a row with
86+
`controller.open(row)`, and derive the copy from the payload. `onConfirm` in the
87+
wrapper receives the same row. See "Removing a row takes focus with it" in
88+
`controllers.md` for the focus hand-off that comes with a removal:
89+
90+
```tsx
91+
<ActionMenu actions={[{ label: m.remove, onClick: () => remove.open(row) }]} />
92+
93+
<Confirmation
94+
controller={remove}
95+
title={member => fill(m.removeTitle, { name: member.name })}
96+
description={m.removeDescription}
97+
actionLabel={m.remove}
98+
finalFocus={finalFocus}
99+
/>
100+
```
93101

94102
## Pure derivation belongs beside the view, not in it
95103

@@ -99,43 +107,11 @@ rearranges a list, are decisions with no React in them. They live in
99107

100108
## Removing a row takes focus with it
101109

102-
A dialog returns focus to its trigger on close. When the action removes the row
103-
that trigger sits on — signing a device out, removing an email — the trigger
104-
unmounts and focus falls to `<body>`. Nothing catches it: the menu item that
105-
opened the dialog unmounted with the menu, and a dialog mounted at the section
106-
rather than inside the menu has no floating-tree ancestor to walk back to. A
107-
keyboard user loses their place mid-list and a screen reader announces nothing.
108-
109-
Hand focus to a surviving element with `useListRemovalFocus` (`hooks/`). It
110-
records which row was removed and, through `finalFocus`, picks the row that took
111-
its place, the last row when it was the last, or `fallback()` once the list is
112-
empty. `finalFocus` on `Dialog.Popup` and on the `Confirmation` block takes a
113-
function resolved when the dialog closes, after the row has gone:
114-
115-
```tsx
116-
const removalFocus = useListRemovalFocus({
117-
ids: methods.map(method => method.id),
118-
onRemove,
119-
fallback: () => section.current?.querySelector<HTMLButtonElement>('button:not([disabled])') ?? section.current,
120-
});
121-
122-
// each row's trigger
123-
triggerRef={removalFocus.registerTrigger(method.id)}
124-
125-
// the dialog
126-
finalFocus={removalFocus.finalFocus}
127-
onConfirm={method => removalFocus.remove(method.id)}
128-
```
129-
130-
A cancelled or failed removal keeps its own trigger, so `finalFocus` returns
131-
`null` and the dialog's default (the trigger) applies.
132-
133-
Prefer the row that took the removed one's place, the last row when it was the
134-
last, and a control that outlives the list once it is empty.
135-
136-
Test the removal, not just the cancel: `toHaveFocus()` on the row that should
137-
have caught it. A suite that only asserts focus after cancelling passes while
138-
every successful removal drops focus on the floor.
110+
When a confirmed action removes the row its trigger sits on, focus has to be
111+
handed to a surviving element. The bookkeeping lives in the controller, with
112+
`useListRemovalFocus`; see `controllers.md`. The view's part is to register each
113+
row's trigger with the `registerTrigger` it is given and pass `finalFocus` to the
114+
block.
139115

140116
## Testing
141117

‎packages/mosaic/src/__tests__/feature/fake-fapi.ts‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -870,6 +870,31 @@ export function takeUnsettledHolds(): string[] {
870870
return holds.map(hold => hold.name);
871871
}
872872

873+
/** Answers the next POST to `path` with a reverification demand, then lets later requests through. */
874+
export function demandReverificationOnce(path: string): void {
875+
let demanded = false;
876+
worker.use(
877+
http.post(fapiUrl(path), () => {
878+
if (demanded) {
879+
return undefined;
880+
}
881+
demanded = true;
882+
return HttpResponse.json(
883+
{
884+
errors: [
885+
{
886+
code: 'session_reverification_required',
887+
message: 'Verification required',
888+
long_message: 'Verification required',
889+
},
890+
],
891+
},
892+
{ status: 403 },
893+
);
894+
}),
895+
);
896+
}
897+
873898
export function holdRequests(method: 'get' | 'post', path: string): HeldRequests {
874899
const requests: Request[] = [];
875900
let resolveGate: (response: Response | undefined) => void = () => {};

0 commit comments

Comments
 (0)