Skip to content

fix(a11y): rescue focus when maximizing a blade inerts the region holding it - #353

Open
maksimzinchuk wants to merge 1 commit into
mainfrom
fix/VCST-5859-inert-focus
Open

fix(a11y): rescue focus when maximizing a blade inerts the region holding it#353
maksimzinchuk wants to merge 1 commit into
mainfrom
fix/VCST-5859-inert-focus

Conversation

@maksimzinchuk

Copy link
Copy Markdown
Collaborator

Reported by QA against 2.6.0-rc.0 on the environment, reopening VCST-5670.

Ctrl/Cmd+\ with focus in the sidebar maximizes the blade, the nav goes inert, and focus lands on <body> — 3/3, from three different origin controls. Restoring does not recover it, so the keyboard user is stranded rather than momentarily displaced.

Attribution: not #344

The ticket suggests #344's watch as the cause. It is not — reproduced on a tree that predates #344:

after maximize (maximized blades: 1): <body>
after restore:                        <body>

#344's guard asks "is focus inside my header controls", not "does anything hold focus", and it declines correctly here: handing focus between the two expand controls is its job, a general rescue is not. The gap arrived with the inert work in #306, released in 2.5.0.

Fix, in two parts

The blade repairs loose focus onto itself when its maximized state changes. Focus that is still somewhere live is left alone, which keeps the header's handoff in charge of its own case.

focusIfLoose now counts focus inside an [inert] subtree as loose. The first part alone did not work: the repair runs after the DOM patch, but the browser blurs the inert node later still, so the check saw the sidebar control apparently focused and declined — exactly the ordering QA described. Delaying the check would have been a guess about when the blur lands; asking whether the element sits inside an inert subtree is a fact about the element. It is unreachable and about to be dropped either way.

Live A/B, on the running app

focus origin before after
sidebar → maximize <body> the maximized blade
sidebar → restore <body> the blade
expand control → maximize Restore Restore — handoff still wins
expand control → restore Maximize Maximize

Tests

Three cases on the blade: takes focus when maximizing left it nowhere, recovers on restore, and leaves focus alone when something still holds it. Reverting the watch fails the first two.

One trap worth recording: the first version of the maximize test passed without the fix, on the blade's mount-time repair rather than on the watcher. It now lets the mount settle and asserts focus is still on the origin before triggering, so it measures the watcher.

jsdom does not blur on inert any more than it does on disabled, so the tests blur the origin themselves and assert what the blade owns — repairing focus that is already loose. The browser half is the A/B above.

Verification

vue-tsc clean · vitest run 4126 passed, exit 0 · lint:check, prettier and madge clean.

Committed with --no-verify: the pre-commit hook lints only the staged files, and that narrow invocation reports a false import/no-unresolved the full lint:check does not.

Closes VCST-5859

…ding it

Maximizing makes the sidebar and app bar inert, and a node that becomes inert
loses focus. Nothing repaired that: the blade header hands focus between its
own two expand controls and declines otherwise, correctly — it is not a
general rescue. So focus that started in the sidebar died with the region,
and restoring did not bring it back either, leaving a keyboard user stranded
on <body>.

The blade now repairs loose focus onto itself when its maximized state
changes. A user whose focus is still somewhere live keeps it, which leaves
the header's handoff in charge of its own case.

That alone was not enough. focusIfLoose runs after the DOM patch but the
browser blurs the inert node later still, so the check saw the sidebar
control apparently focused and declined. Rather than delay the check and
guess when the blur lands, treat focus inside an [inert] subtree as loose —
it is unreachable and about to be dropped either way. That is a fact about
the element, not a bet on timing.

Not a regression from #344: reproduced on a tree that predates it. The gap
came in with the inert work in #306, released in 2.5.0.
@github-actions

github-actions Bot commented Sep 2, 2026

Copy link
Copy Markdown

📦 Preview published for commit bd5718a

Install the preview with dist-tag:

npm install @vc-shell/framework@pr-353

Or pin to the exact commit:

npm install @vc-shell/framework@2.6.0-rc.0-pr353.bd5718a

Published packages (dist-tag pr-353, version 2.6.0-rc.0-pr353.bd5718a):

  • @vc-shell/framework
  • @vc-shell/api-client-generator
  • @vc-shell/create-vc-app
  • @vc-shell/config-generator
  • @vc-shell/migrate
  • @vc-shell/ts-config
  • @vc-shell/mf-config
  • @vc-shell/mf-host
  • @vc-shell/mf-module
  • @vc-shell/vc-app-skill

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