Skip to content

fix(a11y): reset the popup close lifecycle on reopen and isolate secondary roots - #306

Merged
maksimzinchuk merged 2 commits into
mainfrom
fix/VCST-5632-popup-reopen-and-inert
Aug 19, 2026
Merged

fix(a11y): reset the popup close lifecycle on reopen and isolate secondary roots#306
maksimzinchuk merged 2 commits into
mainfrom
fix/VCST-5632-popup-reopen-and-inert

Conversation

@maksimzinchuk

Copy link
Copy Markdown
Collaborator

Closes VCST-5632 remainder. Two parts of the ticket were still open after #284/#286.

1. Reopening a popup left it in a closing state

closing stayed true and the fallback timer from the previous close was still armed. Opening now cancels that timer and clears the flag, and finalize only removes an instance that is actually closing — so a leave transition from an earlier close cannot unmount a popup that has been reopened in the meantime.

The original opener is kept on reopen, and focus restore still runs from the single removeInstance path.

Note the mechanism differs from the original theory in the ticket: the popup does not get unmounted by the stale timer. It stays mounted but stuck in a closing state. The fix addresses what actually happens.

2. inert missed two secondary roots

Isolation reached the blade stack but not:

  • the AI panel, which is a sibling of the stack
  • the mobile slide-out menu, which lives outside the inert top-bar root

Both now receive the state explicitly rather than relying on Transition/Teleport attribute fallthrough, which does not reach the real root element.

Deliberately not added

A focus trap for a maximized blade. It is not a modal, and trapping Tab in a non-modal region cuts the user off from the browser chrome. inert on everything behind it is what 2.4.3 / 4.1.2 ask for. This was argued in VCST-5670's description and is recorded here so the omission is explicit rather than silent.

Verification

  • Live: with a blade maximized, both the AI panel and the primary navigation report inert, and the panel's own controls are correctly unfocusable in that state
  • 403 files / 3799 tests green on this branch alone

…ndary roots

Closes VCST-5632 remainder.

Two parts of the ticket were still open after #284/#286.

Reopening the same popup instance left it in a closing state: `closing`
stayed true and the pending fallback timer from the previous close was
still armed. Opening now cancels that timer and clears the flag, and
`finalize` only removes an instance that is actually closing, so a leave
transition from an earlier close cannot unmount a popup that has been
reopened in the meantime. The original opener is kept on reopen, and
focus restore still runs from the single `removeInstance` path.

`inert` behind a maximized blade reached the blade stack but missed two
secondary roots: the AI panel is a sibling of the stack, and on mobile
the slide-out menu lives outside the inert top-bar root. Both now receive
the state explicitly rather than relying on Transition/Teleport attribute
fallthrough, which does not reach the real root element.

Verified live: with a blade maximized, both the panel and the primary
navigation report `inert`, and the panel's own controls are correctly
unfocusable in that state.

Deliberately not added: a focus trap for a maximized blade. It is not a
modal, and trapping Tab in a non-modal region cuts the user off from the
browser chrome. `inert` on everything behind it is what 2.4.3/4.1.2 ask
for.
@github-actions

github-actions Bot commented Aug 18, 2026

Copy link
Copy Markdown

📦 Preview published for commit cc97338

Install the preview with dist-tag:

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

Or pin to the exact commit:

npm install @vc-shell/framework@2.4.0-pr306.cc97338

Published packages (dist-tag pr-306, version 2.4.0-pr306.cc97338):

  • @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

@maksimzinchuk
maksimzinchuk merged commit 3cd2761 into main Aug 19, 2026
10 checks passed
@maksimzinchuk
maksimzinchuk deleted the fix/VCST-5632-popup-reopen-and-inert branch August 19, 2026 07:20
maksimzinchuk added a commit that referenced this pull request Aug 19, 2026
…310)

Closes VCST-5746.

Replaces #307, which GitHub auto-closed when its base branch
(`fix/VCST-5632-popup-reopen-and-inert`) was deleted on merge of #306.
The branch has since been rebased onto `main`, so it now carries only
its own commit.

## Problem

The panel consumed `mod+\` whenever it was open, so while the assistant
was up there was **no keyboard route to maximize a blade at all** — even
with focus demonstrably inside one. That contradicts the targeting model
the shell settled on in VCST-5680: a blade shortcut acts on the blade
holding focus.

## Change

The chord is claimed only when `document.activeElement` is inside the
panel; otherwise the event is left alone and the blade dispatcher
handles it. Escape is untouched — consuming it while the panel is open
is what keeps the blade behind from closing.

## Verified live

| Focus location | Result |
| --- | --- |
| Control in the middle blade, panel open | that blade gains
`vc-blade--maximized`; panel stays `362px` |
| Panel's own Maximize control | panel `362px` → `500px`, `--expanded`;
no blade maximized |
| Host, panel open | Escape closes the panel, all 3 blades intact —
unchanged |

## One caveat about the tests

Focus *inside the chat iframe* never reaches the host at all — the
keystroke goes to the iframe's own document, the same barrier as
VCST-5673. So the branch covering the iframe element is unreachable in a
browser; it passes in jsdom only because there is no cross-origin
boundary there. Do not read that test as proof of browser behaviour.

Expanding the panel from the keyboard while the chat has focus needs the
relay filed as VirtoCommerce/virto-oz#17.

## Verification

`407 files / 3830 tests` green on this branch, rebased onto current
`main`.
maksimzinchuk added a commit that referenced this pull request Sep 1, 2026
### Problem

The app hub and the notification panel both teleport out of the `<nav>`
that carries the covering state, so they land as siblings of it rather
than descendants — and `inert` does not cross a `Teleport`. Maximizing a
blade left both panels in the tab order and in the accessibility tree,
reachable behind content that was supposed to be covering them.

`VcSidebar` and `VcAiAgentPanel` already got this treatment in #306;
these two were missed because their teleport hides the relationship.

### Fix

- `VcPopover` gets an `inert` prop rendered on its teleported root. It
has to be a prop: a fallthrough attribute never reaches the real root
through the `Teleport`.
- `AppHubPopover` forwards it to the `VcPopover` it wraps.
- `DesktopLayout` passes the covering state to both panels explicitly.

`MobileLayout` renders neither panel, so it needs nothing.

### Tests

- `vc-popover.test.ts` — the panel carries `inert` when covered; the
attribute is absent otherwise (`inert="false"` is still inert in HTML,
so absence is the assertion).
- `DesktopLayout.test.ts` — the layout tells both panels they are
covered. This is the assertion that matches the defect: the panels were
fine, the layout never told them.

Removing either binding fails exactly these tests.

### Verification

`vue-tsc` clean · `vitest run` 4090 passed, exit 0 · `lint:check` clean
· prettier, stylelint, madge, layer checks, `docs:lint` clean.

Closes VCST-5815
maksimzinchuk added a commit that referenced this pull request Sep 4, 2026
…ding it (#353)

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
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