refactor(ui): a shared modal shell with Esc, focus trap and focus return (#1923) - #2778
Draft
dolho wants to merge 1 commit into
Draft
refactor(ui): a shared modal shell with Esc, focus trap and focus return (#1923)#2778dolho wants to merge 1 commit into
dolho wants to merge 1 commit into
Conversation
…urn (#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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Related to #1923
Premise re-checked before starting
The issue is from 2026-07-31. Before claiming it I verified it is still true on
dev:components/base/had Badge, Button, Card, Input, Select, Textarea, Toggle; no modalEscapereferences across all six (the@keydownhits are Enter-to-submit)views/Agents.vue:143bulk-tag popoversWhat landed
utils/focusTrap.js— every decision as a pure function over plain data, with 25 tests.components/base/BaseModal.vue— the wiring: overlay, Esc, click-outside, focus trap, initial focus, focus return, scroll lock,role="dialog"/aria-modal.Migrated:
SystemViewEditor.vue,NavBar.vue(build-info modal).Why the rules are a separate module
This repo's vitest runs
environment: 'node'with no jsdom/happy-dom and no@vue/test-utils— I checked rather than assumed. A focus trap written inside an SFC would therefore be a rule no unit test could reach, and source-text assertions would prove only that it had been typed — the exact weak-coverage pattern the merge-train playbook names as its most common ejection.So the rules live where they can be tested, and the SFC is the dispatcher (the ent#392 precedent this codebase already uses).
What that still leaves uncovered, stated plainly: the wiring — that the listener is attached, that
focus()is actually called, that focus returns to the trigger. That needs a browser.e2e/exists and is the right home; this PR does not add one.Three decisions worth review
data-destructive), not guessed from label text — guessing would be wrong in every language but English. A dialog opening with Delete focused turns a reflexive Enter into data loss.<select>popup or date picker rendered at the document root and closes the modal under the user.Teleport to="body"is load-bearing: several of these modals sit inside panels that establish a stacking context, where az-50overlay nested in one renders behind its siblings.Why partial
Each remaining migration is an 80–230 line open/close tag surgery in a file I have not read end to end. Doing four of those blind in one pass is how a subtle markup break ships — the build catches structural errors but not a panel whose footprint quietly changed. Bounds computed by indent matching, ready for a reviewed pass:
GitConflictModal.vue:2:116GitConflictModal.vue(parallel-history):120:195GitPanel.vue(initialize):35:171GitPanel.vue(PAT):366:451SchedulesPanel.vue(create/edit):50:284TasksPanel.vue(execution log):406The migration is mechanical: replace the outer
v-ifoverlay div with<BaseModal :model-value="…" panel-class="…" @close="…">, replace the panel div with a bare<div>, close with</BaseModal>. Both completed migrations show it.Also outstanding from the ACs: the keyboard-reachability items (
SystemViewsSidebar.vuenested<button>,InfoPanel.vueclickable<div>s,NavBar.vueuser dropdown) — untouched here.Verification
npm run buildBaseModalportalLoadingTreatment(scanline allowlist)🤖 Generated with Claude Code
https://claude.ai/code/session_01Q19uRCksdn4DiRAJ55rfpZ