refactor(ui): ConfirmDialog gains the keyboard contract, and two confirm() sites move onto it (#1924) - #2780
Conversation
Review —
|
b9a41bd to
40c5321
Compare
f70cc87 to
39bb7b1
Compare
/review — post-rebase (stacked on
|
40c5321 to
c371b8a
Compare
…y, and a mounted spec for the wiring (#1923) Merge-train review on #2778, twice: the shell's rules were tested, its wiring was not, and the docblocks justified that with a claim that was false on this head — the repo mounts components (per-file jsdom opt-in, @vue/test-utils, #2918). Two defects sat in exactly that untested wiring. 1. The scroll lock was global state written per instance: `immediate: true` ran the else-branch on every mount and `onBeforeUnmount` cleared unconditionally, so a nested dialog (the #2780 shape — a ConfirmDialog inside another modal's slot) unlocked the page the moment the outer opened, and any modal's unmount clobbered another component's lock. Now `utils/focusTrap.js::createScrollLock` is a ref count over one value (first holder saves and hides, last release restores) and every BaseModal shares `bodyScrollLock`, taking and releasing exactly its own count. 2. The overlay had no `tabindex="-1"`, so `overlay.focus()` was a no-op and Esc did nothing once focus left a control (a click on dialog text lands focus on the nearest focusable ancestor, which was <body>). `tests/unit/baseModal.spec.js` mounts the shell: initial focus on the safe control, Tab wraps, Esc emits close and focus returns, the overlay is focusable, a modal with no tabbable child is still dismissable, backdrop semantics, the lock is taken/restored, two nested modals share one lock, and unmount releases only what the instance held. Mutation-checked: gutting @keydown, the focus return, the tabindex or the lock each turns it red. Also: the false "node-only vitest" claims are rewritten in all three docblocks; the overlay's p-4 now IS the 16px phone gutter (the two adopted panels drop their mx-4, which had doubled it to 32); the design-system docs name BaseModal as the modal shell. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
39bb7b1 to
e824ed2
Compare
…urn (#1923) (#2778) * refactor(ui): a shared modal shell with Esc, focus trap and focus return (#1923) Six bespoke overlays each re-implemented the same markup and each omitted the same two behaviours: Esc did nothing, and Tab walked out behind the overlay. Verified still true on `dev` before starting — **zero** `Escape` references across all six files (the `@keydown` hits there are Enter-to-submit), no shell primitive, no focus trap anywhere. `BaseModal` owns exactly four things — overlay, Esc, focus, scroll lock — and nothing about content, so adopting it is deleting two wrapper divs rather than rewriting a dialog. It teleports to `<body>`: several of these modals are declared inside panels that establish a stacking context, where a `z-50` overlay nested in one renders BEHIND its siblings. **The decidable half is a separate pure module, and that is the point.** This repo's vitest runs `environment: 'node'` with no jsdom/happy-dom and no `@vue/test-utils` — I checked, because a focus trap written entirely inside an SFC would be a rule no unit test could reach, and source-text assertions would prove only that it had been TYPED. `utils/focusTrap.js` holds every decision as a function over plain data (tabbable filtering, the Tab wrap, the dismiss-key predicate, safe-action selection, backdrop identity) with 25 tests. What that leaves uncovered is the wiring itself — listener attachment, the focus() calls — which needs a browser and belongs to e2e. Said plainly rather than implied by a green suite. Three decisions worth naming: * initial focus goes to the SAFE action, and destructiveness is DECLARED (`data-destructive`) rather than guessed from label text, which would be wrong in every language but English. A dialog that opens with Delete focused turns a reflexive Enter into data loss. * backdrop dismissal compares identity against the overlay node, not a rectangle — a rectangle test mis-fires for a select popup or date picker rendered at the document root and closes the modal under the user. * a modified Escape (Ctrl/Cmd/Alt/Shift) does not dismiss; that is a browser or OS gesture, not an intent to close. Migrated in this commit: `SystemViewEditor.vue` and `NavBar.vue`'s build-info modal. The remaining four files are enumerated in the PR with their exact overlay bounds; they are 80-230 line tag surgeries and are deliberately left for a reviewed pass rather than done blind in one go. One acceptance-criteria item is stale: `views/Agents.vue` was deleted by ent#260 (the Agents page folded into the Dashboard list view), so its bulk-tag popover no longer exists. Related to #1923 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q19uRCksdn4DiRAJ55rfpZ * fix(ui): BaseModal — shared ref-counted scroll lock, focusable overlay, and a mounted spec for the wiring (#1923) Merge-train review on #2778, twice: the shell's rules were tested, its wiring was not, and the docblocks justified that with a claim that was false on this head — the repo mounts components (per-file jsdom opt-in, @vue/test-utils, #2918). Two defects sat in exactly that untested wiring. 1. The scroll lock was global state written per instance: `immediate: true` ran the else-branch on every mount and `onBeforeUnmount` cleared unconditionally, so a nested dialog (the #2780 shape — a ConfirmDialog inside another modal's slot) unlocked the page the moment the outer opened, and any modal's unmount clobbered another component's lock. Now `utils/focusTrap.js::createScrollLock` is a ref count over one value (first holder saves and hides, last release restores) and every BaseModal shares `bodyScrollLock`, taking and releasing exactly its own count. 2. The overlay had no `tabindex="-1"`, so `overlay.focus()` was a no-op and Esc did nothing once focus left a control (a click on dialog text lands focus on the nearest focusable ancestor, which was <body>). `tests/unit/baseModal.spec.js` mounts the shell: initial focus on the safe control, Tab wraps, Esc emits close and focus returns, the overlay is focusable, a modal with no tabbable child is still dismissable, backdrop semantics, the lock is taken/restored, two nested modals share one lock, and unmount releases only what the instance held. Mutation-checked: gutting @keydown, the focus return, the tabindex or the lock each turns it red. Also: the false "node-only vitest" claims are rewritten in all three docblocks; the overlay's p-4 now IS the 16px phone gutter (the two adopted panels drop their mx-4, which had doubled it to 32); the design-system docs name BaseModal as the modal shell. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…irm() sites move onto it (#1924) Stacked on #1923 — `ConfirmDialog` adopts the `BaseModal` shell built there. The issue reads as "replace confirm() calls", but the thread rules otherwise (webmixgamer, 2026-08-21), and I re-verified it still holds on dev: ConfirmDialog.vue has no focus trap, no initial focus on the safe action, no Esc handler, and its DOM order is confirm-first — so this issue's AC "Initial focus lands on the safe action" needs the primitive itself fixed, not just the call sites. Confirmed: zero Escape / @keydown / focus() / role="dialog" in the file, and `variant="danger"` at :58 ahead of `variant="secondary"` at :66. **The confirm-first DOM order is fixed WITHOUT reordering the buttons.** The confirm is marked `data-destructive`, which is what `focusTrap.initialFocusIndex` skips, so focus lands on Cancel while the DOM order and `sm:flex-row-reverse` stay exactly as they were and nothing moves on screen. Reordering was the obvious fix and the worse one: it would have changed the rendered layout of a dialog seven components already use. Adopting BaseModal gives all seven existing callers Esc-to-close, a focus trap and focus return at once — none of them change. Every `data-testid` is preserved. `confirm-dialog-backdrop` is the one that goes (BaseModal owns the overlay now); checked first — no test or component referenced it, and the two that ARE used (`confirm-dialog`, `confirm-dialog-confirm`) are untouched. Call sites migrated here: `SystemViewEditor` delete-view and `GitPanel` clear-PAT. Each names the verb ("Delete view", "Clear token"), restates the consequence, and offers a named safe action instead of "Cancel/OK". Remaining on the issue and NOT done here: Settings.vue (3) and SchedulesPanel.vue (3), plus MobileAdmin's bespoke overlay and the two unconfirmed single-click actions. Not touched, by the thread's ruling: `/m`'s approval submit step (#2370) is an inline p19-shaped flow by design and must not be re-modalised. **Baseline edited by hand, not regenerated.** ConfirmDialog IMPROVED (raw_gray 9 -> 7 — its bespoke backdrop and the gray-500/gray-900 overlay went away), and a stale ceiling fails the guard too. A wholesale `--baseline` run deleted the entire `refrozen` provenance block and absorbed canvas/CanvasDocument.vue and views/SharedCanvas.vue — two files that landed on dev after this branch was cut — so the edit is scoped to the two entries that actually moved, per the rule the 2638 note already states. Related to #1924 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q19uRCksdn4DiRAJ55rfpZ
e824ed2 to
26cfb9c
Compare
|
merge-train: rebased onto |
vybe
left a comment
There was a problem hiding this comment.
merge-train: validated (lane B), rebased onto dev, all checks green on the retargeted run.
Related to #1924
The thread changes what this issue is
The title reads "replace confirm() calls". The ruling (webmixgamer, 2026-08-21) — re-verified on
devbefore starting — says the primitive is the blocker:Confirmed: zero
Escape/@keydown/focus()/role="dialog"in the file, andvariant="danger"at:58ahead ofvariant="secondary"at:66.Also honoured from the thread:
/m's approval submit step (#2370) is inline p19-shaped by design and is NOT re-modalised; ent#523's Reset is not a caller (operator ruled it acts without confirmation).The confirm-first order is fixed without moving any button
ConfirmDialognow adoptsBaseModal(from #1923) and marks its confirmdata-destructive— which is exactly whatfocusTrap.initialFocusIndexskips.So focus lands on Cancel while the DOM order and
sm:flex-row-reversestay as they were and nothing moves on screen. Reordering the buttons was the obvious fix and the worse one: it would change the rendered layout of a dialog seven components already use.Those seven all gain Esc-to-close, a focus trap and focus return for free. None of them change.
Every
data-testidpreserved.confirm-dialog-backdropis the one that goes —BaseModalowns the overlay now. I checked before removing it: no test or component referenced it, and the two that are used (confirm-dialog,confirm-dialog-confirm) are untouched.Call sites migrated
SystemViewEditordelete-viewGitPanelclear-PATBoth previously ran through native
confirm()— no named verb, no consequence, unsafe action focused.What's left
views/Settings.vue— 3 nativeconfirm()components/SchedulesPanel.vue— 3 nativeconfirm()views/MobileAdmin.vue— the bespoke Emergency-Stop overlayGitConflictModal.vueForce Replace / Force Push,ExecutionsPanel.vueStop — single-click destructive, no confirmation stepA note on the baseline
ConfirmDialogimproved (raw_gray 9 → 7 — its bespoke backdrop and thegray-500/gray-900overlay went away), and a stale ceiling fails the ratchet too, so the baseline had to move.I edited it by hand rather than regenerating. A wholesale
scan-raw-colors --baselinerun:refrozenprovenance block — every named-increase note (_2718_note,_2616_note,ent554,_ent553_note,2638,named_increases…), i.e. the record of why each ceiling sits where it does;canvas/CanvasDocument.vueandviews/SharedCanvas.vue— two files that landed ondevafter this branch was cut and have nothing to do with it.Scoped to the two entries that actually moved, per the rule the
2638note already states. Verified after: 11refrozenkeys intact, neither canvas file present.Verification
npm run buildNot verified: the DOM behaviour itself — Esc firing, focus actually landing on Cancel, focus returning to the trigger. This repo has no jsdom/happy-dom and no
@vue/test-utils(see #2778), so the rules are unit-tested and the wiring needs a browser.e2e/settings-tabs.spec.jstouches ConfirmDialog and would be the place.🤖 Generated with Claude Code
https://claude.ai/code/session_01Q19uRCksdn4DiRAJ55rfpZ