Skip to content

Keep terminal-context detail-dialog buttons focusable while their action is in flight - #574

Merged
nedtwigg merged 3 commits into
mainfrom
fix/context-dialog-focus-busy
Sep 6, 2026
Merged

Keep terminal-context detail-dialog buttons focusable while their action is in flight#574
nedtwigg merged 3 commits into
mainfrom
fix/context-dialog-focus-busy

Conversation

@dormouse-bot

@dormouse-bot dormouse-bot commented Sep 6, 2026

Copy link
Copy Markdown
Collaborator

In the terminal context's detail dialogs, Promote, Save default and Discard and reset passed busy straight to native disabled. A browser blurs an element the moment it becomes disabled, so activating one of them moved focus to <body>; on the failure path submit skips setDetail(null), so the dialog stays open and focus never comes back. Escape and the Tab trap both live on the <section>'s onKeyDown and need a focused descendant to bubble from, so a rejected edit left the aria-modal dialog with neither. They now use the busy prop #573 added for the launch button, which marks the button aria-disabled and drops its click while keeping it in the tab order.

Verified with a regression test that holds onModify pending, rejects it, and asserts focus stays on Save default across the whole flight, that Tab still moves focus and keeps it inside the dialog, and that Escape closes the dialog rather than the context. A second test pins the styling contract. Reverting any half of the fix turns one of them red. lib typecheck and all 2413 tests pass on 77f635e34a0210e516f32e9e885448d82da58518.

Route, and two follow-ons folded in

Open Modify, paste a multi-line command, Tab to Save default, press Enter. The host rejects it (docs/specs/terminal-context.md assigns command validation to the host and requires the rejected edit to show its error in Modify), the error renders in the dialog, and from there Escape no longer closes it and Tab walks into the page behind it. Promote has the same shape via Could not split the source pane, which lath.store.addLeaf returns for a pane too small to split — recoverable by mouse, but the keyboard path dead-ends on the error banner.

Both other sites keyed on the same busy/disabled conflation, so they are corrected here too:

  • disabled:opacity-40 was behind a busy ? '' : ternary. Now that busy never feeds disabled, the ternary could only fire when both were true — rendering a genuinely disabled button undimmed. It is unconditional again.
  • The subtle-action hover rules are built on enabled: variants, and :enabled no longer excludes a button that is dropping clicks, so an aria-disabled button took hover styling. The gate now lives in the constants themselves as enabled:not-aria-disabled:hover:*, rather than at the call site. Withholding the whole SUBTLE_ACTION_INTERACTION_CLASS while busy — the first shape of this fix — was wrong in both directions: it also dropped the ungated focus-visible:outline half from the very button this PR keeps focused, and it missed SUBTLE_ACTION_COLOR_CLASS's enabled:hover:text-link, which is applied unconditionally through color. Tailwind 4.3.3 compiles the variant to :enabled:not([aria-disabled="true"]):hover, and OnOffSwitch — the only other consumer of either constant — never sets aria-disabled.

No Chromatic movement expected: every Prototypes/Terminal context story resolves its handlers immediately, so no snapshot holds a button in flight, and the hover change is pointer-only.

This landed on main in #573 — I raised it on that thread about a minute before the merge, so it likely went unseen rather than being waved through.

The one open choice is the in-flight dim. Passing busy gives up disabled:opacity-40 on these three, which this PR accepts — an in-flight Save default keeps its focus ring and its aria-busy/aria-disabled announcement, but no dim. Restoring it needs an aria-disabled:opacity-40 variant on ContextAction, which would also dim the Opening… button and move the approved visual baseline — say the word and I'll push that shape instead.

Promote, Save default and Discard and reset passed `busy` to native
`disabled`. The browser blurs a disabled element, so activating one moved
focus to `<body>`; on the failure path the dialog stays open and focus
never returns. The context's Escape and Tab handling both live on the
`<section>`'s `onKeyDown` and need a focused descendant to bubble from, so
a rejected edit left the `aria-modal` dialog with no Escape and no Tab
trap.

They now use the `busy` prop introduced in ca86651 for the launch button,
which marks the button `aria-disabled` and drops its click while leaving it
in the tab order.

Two follow-ons from the same mechanism: `disabled:opacity-40` is
unconditional again, since `busy` no longer feeds `disabled` and the
ternary could only fire on a genuinely disabled button; and the
`enabled:hover:*` interaction classes are withheld while busy, because
`:enabled` no longer excludes a button that is dropping clicks.
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Sep 6, 2026

Copy link
Copy Markdown

Deploying mouseterm with  Cloudflare Pages  Cloudflare Pages

Latest commit: c2e01f3
Status: ✅  Deploy successful!
Preview URL: https://fefc1fbe.mouseterm.pages.dev
Branch Preview URL: https://fix-context-dialog-focus-bus.mouseterm.pages.dev

View logs

@dormouse-bot dormouse-bot left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two of the three changes land clean: busy no longer reaching native disabled, and disabled:opacity-40 going back to unconditional. The hover withdrawal has a problem in both directions, from one root cause — :enabled is now the wrong gate for "not accepting clicks", and withholding the whole SUBTLE_ACTION_INTERACTION_CLASS is too blunt one way and not enough the other.

The focus ring goes with the hover styling. SUBTLE_ACTION_INTERACTION_CLASS in lib/src/components/design.tsx is enabled:hover:bg-current/10 focus-visible:outline focus-visible:outline-focus-ring — only the hover half carries the enabled: gate. Putting !busy && in front of the whole string drops the outline from every in-flight ContextAction, which is exactly the button this PR keeps focused. On main the Opening… launch button #573 introduced keeps its ring for the whole flight; after this it loses it. Concretely: Tab to Save default, press Enter, and the focus ring pops off for the duration of the flight and returns when the host rejects. SUBTLE_ACTION_COLOR_CLASS's enabled:focus-visible:text-link still fires (:enabled is true on an aria-disabled button), so it degrades to colour-only rather than nothing — but stacked on giving up disabled:opacity-40 for these three, an in-flight Save default ends up with no visual state change of any kind, while aria-busy and aria-disabled tell a screen reader it is busy and unavailable. That widens the in-flight-dim question the description leaves open rather than leaving it where it was.

And the hover styling isn't actually withheld. SUBTLE_ACTION_COLOR_CLASS carries enabled:hover:text-link, gated the same way, and it is applied unconditionally through color on the line above. Hovering an in-flight button still tints it link-coloured, so the second follow-on holds for the background but not the text.

Both fall out if the gate moves into the constants and off the call site:

// design.tsx
export const SUBTLE_ACTION_COLOR_CLASS = `${SUBTLE_ACTION_REST_COLOR_CLASS} enabled:not-aria-disabled:hover:text-link enabled:focus-visible:text-link`;
export const SUBTLE_ACTION_INTERACTION_CLASS = 'enabled:not-aria-disabled:hover:bg-current/10 focus-visible:outline focus-visible:outline-focus-ring';

with line 40 reverting to ${windowFocused ? SUBTLE_ACTION_INTERACTION_CLASS : ''}. OnOffSwitch is the only other consumer of either constant and never sets aria-disabled, so it is unaffected. If not-aria-disabled: doesn't generate what you expect on Tailwind 4.3, the fallback is exporting the focus-visible: half separately and selecting it when busy — but that fixes the ring only and leaves the colour hover as is.

Comment thread lib/src/components/wall/TerminalContext.test.tsx
The previous commit withheld SUBTLE_ACTION_INTERACTION_CLASS entirely while
busy. That was wrong in both directions:

- The constant's `focus-visible:outline` half carries no `enabled:` gate, so
  dropping the whole string also dropped the focus ring from every in-flight
  ContextAction -- including the button this PR exists to keep focused, and the
  `Opening...` launch button, which keeps its ring on main.
- SUBTLE_ACTION_COLOR_CLASS carries `enabled:hover:text-link` and is applied
  unconditionally through `color`, so hovering an in-flight button still tinted
  it link-coloured. The hover withdrawal only ever covered the background.

Both fall out of moving the gate into the constants: `:enabled` is the wrong
test for "not accepting clicks" now that busy means `aria-disabled`, so the two
hover rules become `enabled:not-aria-disabled:hover:*` and the call site goes
back to keying only on window focus. Tailwind 4.3.3 compiles the variant to
`:enabled:not([aria-disabled="true"]):hover`. OnOffSwitch is the only other
consumer of either constant and never sets aria-disabled, so it is unaffected.

A regression test pins both halves: every hover class on an in-flight button
carries the aria-disabled gate, and the focus-ring classes survive the flight.
Reverting either half turns it red.

Also fixes the vacuous Tab assertion in the previous commit's test, which
asserted the dialog contains document.activeElement while the line above had
already pinned activeElement to a button inside it -- a no-op Tab handler
passed. It now asserts focus moved off the button and stayed inside. It does
not name the landing element: jsdom returns a comma-separated selector list
grouped by selector rather than in document order, so the trap's wrap target
under test differs from a real browser's.
@dormouse-bot

Copy link
Copy Markdown
Collaborator Author

Both halves confirmed and fixed as suggested in 77f635e — the gate moved into the constants and line 40 went back to keying only on window focus.

export const SUBTLE_ACTION_COLOR_CLASS = `${SUBTLE_ACTION_REST_COLOR_CLASS} enabled:not-aria-disabled:hover:text-link enabled:focus-visible:text-link`;
export const SUBTLE_ACTION_INTERACTION_CLASS = 'enabled:not-aria-disabled:hover:bg-current/10 focus-visible:outline focus-visible:outline-focus-ring';

not-aria-disabled: generates what the fallback was hedging against — compiling those classes against the repo's Tailwind 4.3.3 yields :enabled:not([aria-disabled="true"]):hover, so no fallback needed. OnOffSwitch checks out as the only other consumer and never sets aria-disabled.

A regression test pins both directions: every hover class on an in-flight button carries the not-aria-disabled: gate, and focus-visible:outline, focus-visible:outline-focus-ring and enabled:focus-visible:text-link all survive the flight. Reverting either half — the gate in the constants, or the !busy && back on the call site — turns it red; I checked both. 2413 tests and lib typecheck pass on 77f635e34a0210e516f32e9e885448d82da58518.

On the widened dim question: the PR description now states the resulting in-flight state rather than leaving it implicit — focus ring plus aria-busy/aria-disabled, no dim. The aria-disabled:opacity-40 variant is still the alternative, and still moves the approved baseline for Opening…, so it stays a maintainer call rather than something I fold in.

@dormouse-bot dormouse-bot left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both prior findings hold on 77f635e, checked against the tools rather than the description: enabled:not-aria-disabled:hover:bg-current/10 compiles under tailwindcss 4.3.3 to :enabled:not([aria-disabled="true"]):hover (inside the usual @media (hover: hover)), and jsdom 29.1.1 really does return querySelectorAll('button:not(:disabled),input,select') grouped by selector — [Close details, Reset helper…, Save default, input] for this dialog's markup — so the Close details suggestion would have gone red. OnOffSwitch is the only other consumer of either constant and sets no aria-disabled.

One new thing. docs/specs/layout.md now understates the rule this PR changed:

Must suppress context action hover and focus highlights while the window is unfocused, including after opening a native explorer or system browser.

That rule owns context-action hover suppression, and there is now a second suppression condition it doesn't state: hover is withheld from any action that is dropping clicks, window focus aside. The neighbouring Must show "Opening…" rule carries preserving width and keyboard focus while blocking repeat clicks for the directory-explorer button alone, so nothing in the spec tells a reader the three detail-dialog buttons are held to the same thing, and nothing there stops an editor putting disabled={busy} back and breaking Escape from controls closes the innermost disclosure, then context. three rules up. Per AGENTS.md: "When code covered by a spec changes, change the spec." The two code comments cover the two modules; this one is for the spec's reader.

Appending to the unfocused-hover rule keeps it in the section that owns it:

**Must suppress context action hover and focus highlights while the window is unfocused**, including after opening a native explorer or system browser. **Must also withhold hover from an in-flight action, which stays focusable and `aria-disabled` rather than `disabled`** so the innermost disclosure keeps a focused descendant for Escape and Tab.

I applied that locally to check the budget: layout.md goes 7763 → 7791 words against its 7800, and node scripts/spec-lint.mjs stays green, so no --ratchet needed. (Reverted; nothing pushed.)

The rest reads clean. disabled:opacity-40 going unconditional restores the dim on the one button that could be disabled and busy at once, every Prototypes/Terminal context story resolves its handlers inline so Chromatic has nothing in flight to snapshot, and the in-flight dim is a maintainer call as you framed it.

Comment thread lib/src/components/wall/TerminalContext.test.tsx Outdated
The unfocused-hover rule in docs/specs/layout.md now also covers an in-flight
action, which stays focusable and aria-disabled rather than disabled.

The regression test's hover loop passed vacuously on an empty filter, so
deleting the hover classes outright stayed green; it now asserts the filter
is non-empty.
@dormouse-bot

Copy link
Copy Markdown
Collaborator Author

Spec finding taken as written — docs/specs/layout.md now carries the second suppression condition on the rule that owns context-action hover, appended in c2e01f3 exactly as suggested:

Must suppress context action hover and focus highlights while the window is unfocused, including after opening a native explorer or system browser. Must also withhold hover from an in-flight action, which stays focusable and aria-disabled rather than disabled so the innermost disclosure keeps a focused descendant for Escape and Tab.

node scripts/spec-lint.mjs is green on the pushed head (32 specs, 63 files), so no --ratchet — matching your local check.

The inline comment is answered on its own thread; both changes rode the one push.

@nedtwigg
nedtwigg merged commit 7113586 into main Sep 6, 2026
13 checks passed
@nedtwigg
nedtwigg deleted the fix/context-dialog-focus-busy branch September 6, 2026 05:22
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.

2 participants