Skip to content

fix(a11y): stop blade loading from unmounting the control that has focus - #301

Open
maksimzinchuk wants to merge 1 commit into
mainfrom
fix/VCST-5670-loading-focus
Open

fix(a11y): stop blade loading from unmounting the control that has focus#301
maksimzinchuk wants to merge 1 commit into
mainfrom
fix/VCST-5670-loading-focus

Conversation

@maksimzinchuk

Copy link
Copy Markdown
Collaborator

Addresses VCST-5670. Two unrelated failures behind one symptom — focus dropped to <body>, so the next Tab restarted from the top of the document (WCAG 2.4.3 Focus Order).

1. Save

Not the Save button disabling itself. ToolbarBaseButton never sets native disabled — only a class and a guard in handleClick. Verified: after a save the button is disabledClass: true, nativelyDisabled: false.

The real cause: loading is provided blade-wide from vc-blade.vue, and every control swaps itself for a skeleton — toolbar buttons in BladeToolbar.vue:11, and every field through vc-input.vue:4's bladeLoading branch. A save raises that flag, so the focused element is unmounted. That also takes the field out from under a user who was typing, which is why Ctrl/Cmd+S loses focus by the same route.

A skeleton stands in for content that has not rendered yet. Once the blade has shown real content, loading means something is in flight, and replacing the controls then is wrong on its own terms — design systems keep the control mounted and show its pending state in place (LoadingButton in MUI, loading on Ant Design's Button, spinner-in-button in Bootstrap and Carbon). Skeletons are for initial load.

So useBladeSkeleton makes the skeleton available exactly once, before the first render of real content. After that, loading is reported through aria-busy and each control's own pending state — ToolbarBaseButton already has isWaiting.

No module changes. Consumers keep passing a single loading prop.

2. Sign-in

The route watcher in vc-app.vue could never catch it: /login and the shell are sibling routes, so the watcher is created after that navigation has finished, and the workspace mounts later still — when the app reports ready — without a further route change. The workspace element appearing is the signal, so watch that. The route watcher stays for route changes within a mounted shell.

Measured, not assumed

Local vendor-portal against the real API, reading document.activeElement at each transition:

Transition before after
sign-in <body> main.vc-app__workspace
open workspace menu button unchanged
open details blade table row in blade unchanged
Maximize [aria-label="Restore"] unchanged
Save (POST 200) <body> button.vc-blade-toolbar-base-button

My first Save measurement was void and discarded — the button was still --disabled, so the click hit the early return and nothing saved. The form has to be dirtied first for the measurement to mean anything.

Verification

  • 4 new tests for useBladeSkeleton, 2 for the sign-in watcher (including one that pins it not stealing focus when something already holds it) — all written first and observed failing
  • vitest run ui/components/organisms/vc-app ui/components/organisms/vc-blade — 399 passed / 42 files. The BladeToolbar tests that assert skeleton replacement and mobile removal still pass: they exercise the initial load, which is unchanged.
  • Full suite: 4411 passed, 2 failed — useConnectionStatus and useNotificationStore, both pre-existing 5s timeouts under parallel load that pass in isolation and fail identically without this change.
  • yarn typecheck exit 0; eslint, prettier, check:locales, check:layers pass

Not covered

Mobile. useToolbarRegistration filters disabled items out of the toolbar on mobile only, so a Save that disables itself is removed from the DOM by a route the skeleton latch does not touch. Showing disabled items on mobile is a product change rather than an a11y repair, so it is left alone — flagging it rather than burying it. The mobile measurement itself is also unverified: the mobile toolbar hides items behind an expandable menu and needs a different driver.

Saving in a blade dropped focus to <body>, so the next Tab restarted from the
top of the document (WCAG 2.4.3 Focus Order).

The cause is not the Save button disabling itself -- ToolbarBaseButton never
sets native `disabled`, only a class and a guard. It is that `loading` is
provided blade-wide (vc-blade.vue) and every control swaps itself for a
skeleton: the toolbar buttons in BladeToolbar, and every field through
vc-input's `bladeLoading` branch. A save raises that flag, so the focused
element is unmounted. It takes the field out from under a user who was typing
too, which is why Ctrl/Cmd+S loses focus the same way.

A skeleton stands in for content that has not rendered yet. Once the blade has
shown real content, `loading` means something is in flight, and replacing the
controls then is wrong on its own terms -- design systems keep the control
mounted and show its pending state in place. So the skeleton is now available
exactly once, before the first render, and `loading` after that is reported
through aria-busy plus each control's own pending state (ToolbarBaseButton
already has one).

This needs no module changes: consumers keep passing a single `loading`.

Sign-in was a second, unrelated failure. The route watcher in vc-app could
never see it: `/login` and the shell are sibling routes, so the watcher is
created after that navigation has finished, and the workspace mounts later
still -- when the app reports ready -- without a further route change. The
workspace element appearing is the signal, so watch that instead. The route
watcher stays for route changes within a mounted shell.

Measured on a local vendor-portal against a real API, reading
document.activeElement:

  after sign-in   <body>  ->  main.vc-app__workspace
  after Save      <body>  ->  button.vc-blade-toolbar-base-button   (POST 200)

Blade open and Maximize/Restore already held focus and still do.

Not covered: mobile. `useToolbarRegistration` filters disabled items out of the
toolbar on mobile only, so a Save that disables itself is removed from the DOM
by a different route than the skeleton. Left alone because showing disabled
items on mobile is a product change, not an a11y repair.

Relates to VCST-5670
@github-actions

Copy link
Copy Markdown

📦 Preview published for commit 6c1e2de

Install the preview with dist-tag:

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

Or pin to the exact commit:

npm install @vc-shell/framework@2.4.0-pr301.6c1e2de

Published packages (dist-tag pr-301, version 2.4.0-pr301.6c1e2de):

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