Skip to content

FSHSP-106 fix(a11y): clear the mechanical axe violations (151 → 79) - #87

Open
LBU4SH wants to merge 15 commits into
mainfrom
fix/a11y-input-combobox
Open

FSHSP-106 fix(a11y): clear the mechanical axe violations (151 → 79)#87
LBU4SH wants to merge 15 commits into
mainfrom
fix/a11y-input-combobox

Conversation

@LBU4SH

@LBU4SH LBU4SH commented Aug 24, 2026

Copy link
Copy Markdown
Collaborator

Premier lot de FSHSP-106 : toutes les violations axe qui ne demandent aucune décision de design — donc rien qui touche aux couleurs, qui sont bloquées sur les variables Figma.

Mesure

Baseline re-mesurée sur main (605 stories) : 151 occurrences / 135 stories — pas les 136 du ticket, écrit avant que le datepicker gagne 18 stories.

Règle Avant Après
aria-allowed-attr 29 0
aria-input-field-name 14 0
scrollable-region-focusable 6 0
aria-prohibited-attr 4 0
aria-required-parent 2 0
empty-table-header 2 0
button-name 1 0
aria-valid-attr-value 13 2
aria-required-children 4 1
nested-interactive 11 11
color-contrast 65 65
Total 151 79

77 stories en échec au lieu de 135, aucune story devenue rouge, aucune erreur de chargement. Lint et les 154 tests unitaires passent.

Un commit par règle, relisible indépendamment.

Deux trouvailles qui ne sont pas des correctifs d'attribut

ui-button avalait son contenu projeté (af31015). hasProjectedContent est détecté depuis .ui-button-content, dont le rendu était conditionné à isIconOnly() — qui dérive de cette détection. Un ui-button avec une icône et du seul contenu projeté ne rendait donc jamais ce contenu, restait en mode icône seule et se retrouvait sans nom accessible. La détection ne pouvait pas aboutir : l'élément qu'elle inspecte n'existait pas. C'était l'origine réelle du button-name.

Le kit n'avait aucun utilitaire sr-only (fca6ba9), alors que ui-datepicker, ui-select, ui-file-upload et ui-read-only recopient le même bloc dans leur SCSS. Un aria-label sur un <th> vide ne satisfait pas empty-table-header — axe veut du texte réellement présent, mesuré et confirmé.

Point à trancher en revue

fca6ba9 est le seul commit qui élargit la surface CSS publiée (.sr-only dans les helpers, d'où le type feat et l'entrée en Added). Il s'annule seul, sans toucher aux sept autres, si vous préférez le sortir en ticket dédié.

Réserve

Le role="combobox" de ui-input (079c575) n'a pas été testé au lecteur d'écran. axe valide la légalité de l'attribut, pas l'expérience : reste à vérifier que NVDA/VoiceOver, en mode formulaire, ne captent pas les flèches dont la grille du datepicker a besoin. À faire avant de retirer continue-on-error du job a11y.

Reste sur le ticket

  • nested-interactive (11) + aria-required-children (1) — pattern chips-dans-combobox de ui-input-tags/ui-autocomplete, et ui-stepper vertical
  • aria-valid-attr-value (2) — aria-controls vers un panneau non monté, demande un registre des panneaux rendus
  • color-contrast (65) — passe designer, détail des 7 paires de couleurs en commentaire sur FSHSP-106

🤖 Generated with Claude Code

LBU4SH added 15 commits August 24, 2026 20:31
ui-input forwarded aria-expanded to the native input, but that attribute is
not allowed on the implicit textbox role: screen readers dropped it, so a
ui-datepicker never announced that a calendar could open, nor its state.

Promote the input to role="combobox" (the ARIA 1.2 role that accepts
aria-expanded) only when ariaExpanded is provided, leaving plain text fields
announced as before. ui-datepicker is the only consumer of these attributes
in the kit.

Clears the 25 aria-allowed-attr violations of the a11y job (136 -> 111
occurrences, 122 -> 97 failing stories), with no new violation and no
cascade on aria-required-children, verified by a full rebuild + axe pass.
`min`/`max` are declared with `numberAttribute`, which turns an unset input into
NaN rather than null. The spinbutton then rendered aria-valuemin="NaN" and
aria-valuemax="NaN" — an invalid ARIA value, so the bound was not just useless
but discarded by assistive tech.

Emit both attributes only for a finite bound: no bound, no attribute.

Clears 11 of the aria-valid-attr-value violations of the a11y job.
The tags listbox only got an accessible name when no visible `label` was set:
with a label, the name went to the inner <input> and the listbox announced
nothing. Fall back to `label` for the listbox name.

In ui-autocomplete the `role="listbox"` also spanned the whole box, including
the combobox <input> — a listbox may contain only options/groups. Wrap the
option tags alone, with `display: contents` so the layout is untouched (same
treatment ui-input-tags already had).

Clears 14 aria-input-field-name and 3 aria-required-children violations.
Both pickers rendered `role="gridcell"` buttons straight inside `role="grid"`,
with no `role="row"` in between — required by the grid pattern, and present on
the day grid. Screen readers got no row structure to walk in the month and year
views, and axe flagged the whole grid as invalid.

Chunk the cells into the rows the CSS actually renders (3 columns for months,
2 for years) and mark the wrappers `display: contents`, so the rendered grid is
byte-for-byte the same.

Clears 2 aria-required-parent (22 nodes) and 2 aria-required-children.
aria-label is prohibited on a role-less <span>: the name the consumer passed was
simply dropped, so these components had no accessible name at all.

Take role="img" when an aria-label is provided — and role="group" for a
removable chip, whose × button must not end up inside an img, which hides its
children from assistive tech. No label, no role: unlabelled usage is unchanged.

Clears the 4 aria-prohibited-attr violations (ui-avatar's badge included, it is
a ui-badge instance).
…hable

A scrolling region has to be focusable, or its overflowing content can only be
reached with a mouse.

ui-table: the viewport takes tabindex=0 whenever `scrollable` is on.

ui-modal: the body region is always `overflow-y: auto`, but an unconditional tab
stop inserts itself into the focus order of every dialog — a focus-trap unit
test caught exactly that. It therefore becomes focusable only once it actually
overflows AND holds nothing focusable itself; since the content is projected,
neither is derivable from the inputs, so both are measured (ResizeObserver).

Clears the 6 scrollable-region-focusable violations.
`hasProjectedContent` is detected from the `.ui-button-content` element, but that
element was only rendered `@if (label() || !isIconOnly())` — and `isIconOnly()`
derives from that very detection. A ui-button given an `icon` and nothing but
projected content therefore never rendered the content, stayed in icon-only mode,
and ended up with no accessible name at all. The detection could never succeed:
the element it inspects did not exist.

Always render the wrapper. The SCSS already carried
`.ui-button-content:empty { display: none; }` ("empty → removed, avoids phantom
gap") precisely for this, so a genuine icon-only button is unchanged.

Clears the button-name violation on the Projected Content story — which was not
a story oversight but this bug, rendering an empty button.
…ble headers

axe's empty-table-header wants text that is actually present: an aria-label on
the <th> does not satisfy it (measured — the violation survived that attempt).
The kit had no screen-reader-only utility, while four components hand-roll the
same block in their own SCSS (ui-datepicker, ui-select, ui-file-upload,
ui-read-only), so add it once to the published helpers sheet.

Use it to name the selection and row-expansion columns of the ui-table stories.
Verified: both stories now report zero violations, and the headers still measure
56px and 68px — the absolutely-positioned span adds nothing to the column.
… mounted

ui-tabs used as a navigation menu (a router-outlet renders the content, see
the TabMenu story) and ui-stepper used as a plain "steps only" progress
indicator (both explicitly documented usages) still had every tab/step point
aria-controls at an id that resolves to nothing — an invalid ARIA value.

Each container now tracks the panels actually mounted via contentChildren;
panelId() returns null when a step/tab has no matching panel, so the attribute
is omitted rather than dangled.

Clears 2 of the 3 remaining aria-valid-attr-value violations. Verified: both
stories now report zero violations for this rule (ui-tabs/Tab Menu's one
remaining violation is color-contrast, unrelated).

ui-stepper/Vertical's aria-required-children (tablist containing its own
tabpanel) is untouched — needs a design decision (tabs vs. accordion in
vertical mode) before a fix, tracked separately on FSHSP-106.
…ttern

Vertical interleaves each step's panel right under its own header inside the
same ui-step-item (see the Vertical story), so the container's
role="tablist" always contained a role="tabpanel" descendant — a tablist
containing its own tabpanel is invalid ARIA.

No DOM restructuring: vertical switches its role vocabulary to the accordion
pattern instead (matching ui-accordion-panel's own role+aria-labelledby
pairing) rather than moving panels out of the tablist.

- ui-stepper host: role="group" instead of "tablist" in vertical (keeps
  aria-label legal — a plain div with no role could not carry one, the same
  aria-prohibited-attr fixed earlier on ui-tag/ui-badge/ui-chip).
- ui-step header: no role + aria-expanded instead of role="tab" +
  aria-selected, in vertical only.
- ui-step-panel: role="region" instead of "tabpanel", in vertical only.

Horizontal (ui-step-list, a real tablist with no panel inside it) is
untouched. No CSS rule keys off aria-selected, so nothing visual moves.

Clears the last aria-required-children violation. Verified across Vertical,
StepsOnly, Linear, Horizontal and Basic: zero violations on all five.
… the tag pattern

Decision made with the team: accept the chip-in-listbox pattern as-is rather
than restructure it now. Waived via parameters.a11y.config.rules, read by
storybook/test-runner.ts.

Restructuring tracked in a follow-up ticket.
…hreshold

actions.low.content.default (slate-500 on grey-50, 4.44:1) and
navigation.highOutlined.content.active (primary-500 on grey-50, 4.20:1) both
missed the 4.5:1 AA threshold by a hair.

slate-500 -> slate-600 (5.45:1). Its hover state already sat one step above at
slate-600 — left alone, default would now equal hover and the state would lose
its visible feedback. hover moves to slate-700 to keep a step between them.

primary-500 -> primary-600 (6.00:1) for the active tab. hover shared the exact
same value already (also 4.20:1, real defect but not axe-measured since it's a
:hover pseudo-class) — moved to primary-600 too, staying in sync with active as
before rather than diverging.

Verified against the real build: ui-link/Default, ui-segment-control/Basic and
ui-tabs/Basic all report zero violations, no visible change beyond the two
target texts.

Source is Figma-exported (src/design-tokens/semantics.json, each value carries
its Figma variableId) — the same change is needed on the Figma variable, or a
future export silently reverts this.
c3824a2 added a second `contentChildren` on UiTabs (matching each UiTabPanel by
value, to know whether a panel exists) alongside the existing one for UiTab.
Two sibling content-children queries on this component break `<ng-content>`
projection for every UiTab but the first: labels 2..n rendered empty, leaving
tabs with no accessible name at all. The a11y run caught it as `button-name`,
2 stories / 14 nodes (Dynamic, Scrollable).

Bisected by restoring only this file to main's version — the defect vanished,
confirming the query, not the rest of the branch.

Use a single `contentChild` on the UiTabPanels wrapper instead: a presence
check is all `panelId()` needs, and it keeps the query count at one. Verified
on all seven tabs stories — labels are back, `aria-controls` still resolves to
a real panel where panels exist and is still omitted on TabMenu, which has
none. Zero violations across the whole ui-tabs suite.

Also pins both contracts in the spec (rendered label, aria-controls presence),
which nothing asserted before. They would NOT have caught this regression —
verified by reintroducing the faulty query, and they still pass: the defect
only appears in a real compiled build, not under TestBed/jsdom. Kept because
they are cheap and the contracts were untested either way.

No CHANGELOG entry: this repairs a defect introduced earlier in this same
unmerged branch, so the published behaviour is unchanged.
White on red-500 (#ef4444) is 3.76:1, under the 4.5 AA threshold. red-600
(#dc2626) is 4.83:1 and the 600 step was unused across the whole chain
(hover=700, focused=800, pressed=900), so retargeting default onto it needs no
new colour and shifts nothing else.

Retargets actions.error.surface.default and
informative.errorHigh.surface.default, light mode only — the CI job measures
nothing else, and dark is left untouched rather than changed unverified.

Verified on the real build: ui-alert/Error, ui-button/Error and ui-tag/Error
report zero violations. The visible difference is marginal.

success and warning are NOT covered here: their own 600 falls short (3.77 and
3.56), so both need a palette decision — either a new intermediate shade or
dark text on the bright surface, which would invert the hover ramp. Tracked on
FSHSP-106.
White on green-500 (#10b981) is 2.54:1 against a 4.5 threshold, and no light
green can pass with white text — green-600 only reaches 3.77 while green-700 is
already the hover state, so the ramp had no usable step left.

Add green-650 = #0b835c to all three brand modes: the lightest green clearing
4.5 with a real margin (4.76), chosen to keep the visual shift as small as the
threshold allows. Retarget actions.success.surface.default and
informative.successHigh.surface.default onto it, light mode only.

Designer's call: keep white text and darken the surface, rather than switch the
text to dark on the bright green. Dark text would have preserved green-500
exactly (6.47:1) but breaks every other state — the surface darkens on
hover/focused/pressed, where dark text drops to 2.99/2.14/1.08 — so it would
have meant inverting the ramp direction for this family alone.

The surface is noticeably darker, which is inherent to WCAG 2 on saturated
greens, not a tuning choice.

Verified on the real build: ui-alert/Success, ui-button/Success and
ui-tag/Success report zero violations.

green-650 has no Figma variableId yet — it must be created there, or the next
export drops it.
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