Skip to content

refactor(ui): a shared modal shell with Esc, focus trap and focus return (#1923) - #2778

Draft
dolho wants to merge 1 commit into
devfrom
refactor/1923-modal-keyboard-contract
Draft

refactor(ui): a shared modal shell with Esc, focus trap and focus return (#1923)#2778
dolho wants to merge 1 commit into
devfrom
refactor/1923-modal-keyboard-contract

Conversation

@dolho

@dolho dolho commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

Related to #1923

Draft — deliberately partial. AC #1 (the primitive) is done and tested. AC #2 (all six adopt it) is 2 of 6. The rest are enumerated below with exact bounds. See Why partial.

Premise re-checked before starting

The issue is from 2026-07-31. Before claiming it I verified it is still true on dev:

claim status
No shared modal shell primitive truecomponents/base/ had Badge, Button, Card, Input, Select, Textarea, Toggle; no modal
Six modals lack Esc truezero Escape references across all six (the @keydown hits are Enter-to-submit)
No focus trap true
views/Agents.vue:143 bulk-tag popovers stale — that file was deleted by ent#260 (Agents page folded into the Dashboard list view)

What 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

  • Initial focus goes to the safe action, and destructiveness is declared (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.
  • Backdrop dismissal is identity against the overlay node, not a rectangle test. 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 does not dismiss (Ctrl/Cmd/Alt/Shift) — that is a browser or OS gesture.
  • Teleport to="body" is load-bearing: several of these modals sit inside panels that establish a stacking context, where a z-50 overlay 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:

file overlay close
GitConflictModal.vue :2 :116
GitConflictModal.vue (parallel-history) :120 :195
GitPanel.vue (initialize) :35 :171
GitPanel.vue (PAT) :366 :451
SchedulesPanel.vue (create/edit) :50 :284
TasksPanel.vue (execution log) :406 shape differs — needs reading

The migration is mechanical: replace the outer v-if overlay 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.vue nested <button>, InfoPanel.vue clickable <div>s, NavBar.vue user dropdown) — untouched here.

Verification

check result
New rule tests 25 passed
Full frontend suite 2870 / 2870
npm run build clean
Raw-color ratchet green — zero raw palette classes in BaseModal
Loading-gate ratchet green
portalLoadingTreatment (scanline allowlist) green
Step 10 hygiene staged by path; gitlinks clean; no markers

🤖 Generated with Claude Code

https://claude.ai/code/session_01Q19uRCksdn4DiRAJ55rfpZ

…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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant