Skip to content

fix(a11y): aria-modal, keyboard-operable ExpandingList, and focus rings [sc-217428] - #190

Open
mnutt wants to merge 3 commits into
mainfrom
mn/accessibility
Open

fix(a11y): aria-modal, keyboard-operable ExpandingList, and focus rings [sc-217428]#190
mnutt wants to merge 3 commits into
mainfrom
mn/accessibility

Conversation

@mnutt

@mnutt mnutt commented Aug 26, 2026

Copy link
Copy Markdown
Member

Upstream @movable/fluid work called for by canvas's packages/canvas/docs/accessibility-plan.md. The plan names one item explicitly; auditing fluid against its own findings surfaced three more in this repo.

Shortcut: [sc-217428]

FluidModal — WCAG 4.1.2 (A)

  • aria-modal="true", the plan's Phase 1 upstream follow-up. Covers the ~25 canvas modals that go through FluidModal in one change.
  • Fixed a live bug: aria-labelledby was emitted unconditionally, so a modal rendered without a title pointed at an element that never existed and had no accessible name. Now only emitted when a title or header block is present; titleless modals can pass aria-label through ...attributes.

ExpandingList — WCAG 2.1.1 + 4.1.2 (A)

Header and toggle were click-only role="button" elements with no tabindex and no key handler, so the disclosure could not be operated by keyboard at all. The toggle was also an unnamed SVG.

  • Toggle is now a real <button type="button"> with aria-expanded, disabled, an aria-hidden chevron, and a visually-hidden name via @label (falls back to "Toggle section").
  • Header drops role="button" and keeps its click handler as a mouse convenience. It deliberately does not become a button: every consumer nests the toggle inside the header, so that would nest a button in a button. There's a template-lint-disable recording this.
  • Added the UA button reset to .expanding-list-toggle — this is the only visual diff in the PR, so worth a look in Percy.

Consumed by hogwarts (4 files, 6 instances). Canvas has its own fork of this component carrying the identical bug — tracked separately, not fixed here.

Focus visibility — WCAG 2.4.7 (AA)

Six components dropped the focus outline unconditionally with no replacement indicator, at a specificity that beats a consuming app's :focus-visible baseline. This is the same failure canvas documented in finding F1, just sourced from fluid rather than @movable/tailwind-config.

Scoped each reset to :focus:not(:focus-visible) in classy-select, input-field textarea, on-off-switch, token-field, fluid-select__option and fluid-checkbox. Pointer focus stays clean; keyboard focus stays visible. Browsers without :focus-visible drop the whole rule and keep the default outline, so it fails safe.

Deliberately left alone: fluid-input, fluid-text-input, fluid-date-input and power-calendar-days also remove the outline, but each pairs it with a designed focus state (border-transparentborder-primary-dark plus a background change). That is a visible indicator, so it passes 2.4.7 — and :focus-visible always matches text-entry elements, so the rewrite would be a no-op there and would just stack a browser ring on the designed border on every click.

Also

Storybook story and docs page for ExpandingList, which had neither. Under Components → Fluid Lab → Expanding List.

Testing

  • yarn lint-hbs and yarn lint-js clean.
  • yarn test: 88 tests, 87 pass, 1 pre-existing skip. New assertions cover aria-modal, the absent aria-labelledby, external aria-label, aria-expanded flipping, and the toggle's accessible name.
  • Percy will diff the ExpandingList "renders all possible states" snapshot (button reset). Modal snapshots should be unchanged.
  • Manual: tab to an ExpandingList toggle in Storybook — it should focus, show a ring, respond to Enter and Space, and flip aria-expanded.

Notes for reviewers

Consuming apps only pick this up on the next release-it publish. Canvas's plan checkboxes shouldn't be ticked until that lands.

mnutt added 3 commits August 26, 2026 13:03
The dialog set aria-labelledby unconditionally, so a modal rendered without
a title pointed at an element that never existed and had no accessible name.
Only emit it when a title or header block is present; titleless modals can
pass their own aria-label through ...attributes.
Six components dropped the outline unconditionally with no replacement
indicator, at a specificity that beats a consuming app's :focus-visible
baseline. Scope each reset to :focus:not(:focus-visible) so pointer focus
stays clean and keyboard focus stays visible.

Left fluid-input and fluid-text-input alone: they pair the reset with a
designed focus state, and :focus-visible always matches text-entry elements
so the rewrite would be a no-op there.
The header and toggle were click-only role="button" elements with no
tabindex and no key handler, so the disclosure could not be operated by
keyboard at all. The toggle is now a real button carrying aria-expanded and
a visually-hidden label; the header drops role="button" and keeps its click
handler as a mouse convenience, since the toggle usually renders inside it
and making both interactive would nest a button in a button.

Adds a storybook story and docs page, which the component never had.
@mnutt mnutt changed the title fix(a11y): aria-modal, keyboard-operable ExpandingList, and focus rings fix(a11y): aria-modal, keyboard-operable ExpandingList, and focus rings [sc-217428] Aug 26, 2026
@mnutt
mnutt requested a review from nicksteffens September 1, 2026 16:35
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