Skip to content

Add global keyboard shortcuts and one roving tab index to the interop module - #2500

Merged
pournasserian merged 3 commits into
v2from
2434-interop-shortcuts
Oct 18, 2026
Merged

pournasserian merged 3 commits into
v2from
2434-interop-shortcuts

Conversation

@pournasserian

Copy link
Copy Markdown
Member

Refs #2434

What

admin.js becomes the one interop module #2434 asks for. It handles focus trapping, the roving tab index and global keyboard shortcuts, alongside the overlays, the theme and the reconnect display it already ran. It stays a set of delegated document listeners that read the page when an event arrives, so it works over an interactive-server circuit, while the circuit is down and after a reconnect, with no per-component interop.

  • Global keyboard shortcuts. An element declares its keys in aria-keyshortcuts, ARIA's own attribute, so assistive technology announces them too. For example, aria-keyshortcuts="Control+K Meta+K" means either chord.
    • On a matching key the module clicks a button or link and focuses anything else.
    • Letters and digits also match by their physical key, so a shortcut works on a Persian or Arabic layout and with Option on a Mac.
    • A shortcut without Control, Alt or Meta waits while someone types in a field or a menu.
    • While a modal dialog is open, only the shortcuts inside it apply.
    • Disabled, hidden and inert elements are skipped.
    • The page is read at the time of the key, so shortcuts on content Blazor renders later, or renders again after a reconnect, work with no registration.
  • One roving tab index. rove and roveIndex replace the separate code in menus and tabs: one item is in the tab order, the arrow keys, Home and End move focus, and horizontal groups follow the direction. Command keeps focus in its input and moves aria-activedescendant with the same wrap-around.
  • Focus trapping for every control. Each keydown handler now reports whether it used the key, and a key none of them used reaches the trap. Before this, Tab on a Command input or a menu trigger inside a dialog skipped the trap and left the modal. Tabs already had this fix from Add the Tabs, Sheet, Popover, Tooltip and Toast components #2493.
  • Shortcuts on /ui. Ctrl+K or ⌘K focuses the command palette. Alt+T focuses the toasts, as Sonner does; the Toaster region now has tabindex="-1" and aria-keyshortcuts="Alt+T".
  • CONTRIBUTING.md: how to declare a shortcut, and that admin.js is the only script and no component calls into it.

Boxes on #2434

To tick when this merges: "One small JavaScript interop module for what the platform does not cover: focus trapping, roving tab index and global keyboard shortcuts. It is the only JavaScript in the admin."

Why

  • aria-keyshortcuts rather than a registry. A registry, in C# or in JavaScript, would have to be rebuilt after every render and after a reconnect, and someone would have to keep it in step with the markup. An attribute on the element is in the page whenever the element is, carries its own accessibility, and lets the module stay stateless.
  • Click or focus. A shortcut stands in for the control it is declared on, so it does what activating that control does. A field or a region gets focus, which is what the palette and the toast region need.
  • Typing wins over single keys. The desk will want single-key shortcuts. They must never fire while someone types a value or uses a menu's typeahead, so they only apply outside fields and menus.

No new dependency.

Tests

bUnit cannot run the module, so its keyboard behaviour was checked by hand; the tests pin what the module relies on:

  • InteropModuleTests:
    • admin.js is the only script in wwwroot.
    • No component in the admin injects IJSRuntime.
    • Only App.razor loads scripts.
    • The showcase declares its shortcuts with aria-keyshortcuts.
  • ToasterTests: the region is focusable from script only and declares Alt+T.

Checked by hand on /ui over a live circuit:

  • Ctrl+K from a button moves focus to the command palette.
  • With the circuit paused (Blazor.pauseCircuit(), reconnect card showing), Alt+T moves focus to the toast region and the dialog still opens.
  • With the dialog open, Ctrl+K leaves focus inside it.
  • After Blazor.resumeCircuit(), Ctrl+K works again.
  • The menu, tabs and command keys behave as before.

dotnet build -warnaserror, the full suite and markdownlint pass locally.

Checklist

  • Linked to an issue a maintainer has confirmed
  • Targets v2
  • Tests or docs updated where behaviour changed
  • No tool-generated co-author trailers or footers

@pournasserian pournasserian added this to the M1 Governance core milestone Oct 18, 2026
@pournasserian pournasserian added v2 FluentCMS v2 (v2 branch) type:chore Repository, tooling or housekeeping area:infra labels Oct 18, 2026
@pournasserian
pournasserian merged commit c4ae95f into v2 Oct 18, 2026
2 checks passed
@pournasserian
pournasserian deleted the 2434-interop-shortcuts branch October 18, 2026 22:52
pournasserian added a commit that referenced this pull request Oct 19, 2026
…hecks (#2585)

Closes #2582

## What

The fourth slice of F-DSK (#2422): keyboard-first batch review (DSK-04,
UX-02) on the merged interop module (#2500). It also adds the Playwright
browser tests and the automated WCAG 2.1 AA checks (UX-04) that #2434
left for this epic.

- **`admin.js` roving lists**: a `[data-ui="rove"]` list of
`[data-ui="rove-item"]` elements uses the existing `rove` helper.
- J and K move to the next and previous item from anywhere outside a
field, a menu or another modal. From outside the list, J enters at the
item in the tab order and K at the last one.
  - The arrow keys, Home and End move while focus is in the list.
- The list is read on every key, so items DiffView adds in a later batch
are reached too.
- **Desk**:
- The rows rove, with only the first in the tab order, and Enter opens a
set.
  - Keys 1 to 6 switch queues through `aria-keyshortcuts`.
  - A set link carries its queue (`sets/{id}?queue=awaiting-me`).
- When the set view comes back to an empty queue, the desk says what was
done.
- **Set view**:
- The diff items rove: `DiffView` gets an opt-in `Roving` parameter,
used by the island. `/ui` and other uses keep every summary in the tab
order.
- Shortcuts: A approves, R opens the reject dialog, P publishes, N opens
the next set in the queue and B goes back. The keys show on the buttons
and are hidden from screen readers, which read `aria-keyshortcuts`.
- After an approve or reject from a queue, the page redirects to the
next set in that queue with "The previous set was approved", or back to
the queue once it is empty.
- **`ShortcutHelp`** (`Components/Review`): a dialog opened with `?`
that lists the page's keys with `Kbd`, on the desk and the set view.
- **`TableBody`** passes extra attributes through, for `data-ui="rove"`.
- **Accessibility fixes from the axe run on `/ui`**:
- `CommandSeparator` is `aria-hidden` instead of `role="separator"`,
which a listbox may not hold.
- `Kbd` text uses `text-foreground`, because `text-muted-foreground` on
`bg-muted` was 4.3:1 in the light theme.
- **CI**: caches `~/.cache/ms-playwright`, installs Chromium with
`playwright.ps1 install --with-deps chromium` after the build, and sets
`FLUENTCMS_TEST_BROWSER`. CONTRIBUTING says how to run the browser tests
locally.

## New test packages

- `Microsoft.Playwright` 1.63.0 (MIT), in `FluentCMS.IntegrationTests`
only.
- `Deque.AxeCore.Playwright` 4.14.0 (MIT), which runs axe-core in the
page, in `FluentCMS.IntegrationTests` only. It brings
`Deque.AxeCore.Commons` (MPL-2.0), which carries axe-core itself.

Neither ships with the product. MPL-2.0 is file-level copyleft and
applies only if the package's own files are changed and distributed,
which this repository does not do.

## Why

- **Browser tests in the integration project, behind a variable.** They
need the real host, `AdminHost` and the same data setup as the screen
tests. Skipping without `FLUENTCMS_TEST_BROWSER` matches the PostgreSQL
tests, so `dotnet test` still needs only the .NET SDK.
- **J and K as well as arrows.** Arrows only move inside a list, so they
never take over page scrolling. J and K reach the list from anywhere
with one hand, which is what makes a ten-item review fast.
- **Move on after a decision.** A batch is a queue of sets. Going
straight to the next set saves a round trip per set, and the decision is
already saved before the redirect.
- **The island says when it is interactive.** When the circuit attaches,
Blazor replaces the prerendered items, which drops focus from a summary
that J reached before then. `ChangeSetItems` marks itself
`data-interactive="true"`, and the keyboard test waits for it. A full
run under heavy local load lost focus that way before the marker was
added. On a reviewer's screen this window is the circuit's start,
usually well under a second.
- **Not in this PR**: per-item approval of a routing sample (RTE-04)
needs routing's sampling, so this slice approves whole sets. A revert
shortcut is left out because a single key should not open a revert by
accident.

## Tests

Browser (Playwright, headless Chromium, against the host):
- **UX-02**: with the keyboard alone, the reviewer signs in, presses J,
opens the ten-item set and steps through all ten items. Only the focused
summary is in the tab order, and K moves back. A approves the set and
lands on the next set in the queue with the done note, in well under two
minutes (asserted), and the set is approved.
- **Reject by keyboard**: 2 switches to awaiting anyone. J and Enter
open the set. R puts focus in the reason, and typing, Tab, Tab and Enter
reject it. The reviewer lands on the now empty queue with the note, and
the set is rejected.
- **Typing and the help**: letters typed in the reason field are not
shortcuts. `?` opens the help, and A does nothing while it is open. B
goes back to the queue.
- **WCAG 2.1 AA**: axe-core (`wcag2a`, `wcag2aa`, `wcag21a`, `wcag21aa`)
reports no violation on `/ui`, the desk, the stale facts queue and the
set view, in LTR and RTL, light and dark.
- A smoke test that `admin.js` is served by the test host.

bUnit:
- `DiffView` with `Roving` marks the list and summaries, and only the
first summary is tabbable. Without it, nothing changes.
- Desk rows rove, queues have keys 1 to 6, the help lists five keys, set
links carry the queue, and the done notes.
- Set view: A and R are declared, back and next links follow the queue
with B and N, the done note shows, and the help lists eight keys.
Without a queue, it goes back to the desk and has no next link.

Integration: approving from a queue redirects to the next set with
`done=approved`, and rejecting the last one redirects to the queue with
`done=rejected`, which the desk shows.

`dotnet build -warnaserror`, the full suite with the browser tests, and
markdownlint pass locally against a `postgres:17` container as well as
SQLite. One loaded full run had `EntityRelationsTests` (PostgreSQL) fail
once; it passed three reruns, and this PR does not touch it.

## Checklist

- [x] Linked to an issue a maintainer has confirmed
- [x] Targets `v2`
- [x] Tests or docs updated where behaviour changed
- [x] No tool-generated co-author trailers or footers
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

type:chore Repository, tooling or housekeeping v2 FluentCMS v2 (v2 branch)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant