Skip to content

fix(a11y): give every column header a scope, so screen readers can associate cells - #69

Merged
rubenvdlinde merged 1 commit into
developmentfrom
chore/table-headers-scope
Aug 8, 2026
Merged

fix(a11y): give every column header a scope, so screen readers can associate cells#69
rubenvdlinde merged 1 commit into
developmentfrom
chore/table-headers-scope

Conversation

@rubenvdlinde

Copy link
Copy Markdown
Contributor

What

Adds scope="col" to all 19 column headers across the five tables that had
none. WCAG 2.2 AA SC 1.3.1 (Info and Relationships) — without scope, nothing
programmatically ties a data cell to the header describing it.

src/modals/TemplateResyncModal.vue                       5
src/modals/AcknowledgementReportModal.vue                3
src/components/admin/AdminAnalytics.vue                  3
src/components/admin/ConditionalVisibilityOverview.vue   5
src/components/admin/DashboardBulkOperations.vue         3

All five are single-row, column-header tables — every <th> sits in the
<thead> row and labels the column beneath it — so scope="col" is correct for
all of them. No row headers and no spans, so no scope="row" and no
headers=/id= pairing is needed. Attribute-only: no template structure, no
bindings, no script, no styles.

Proof it can fail

gate-43 table-headers, whole tree, hydra-gates at .github@main (756fe89).
The two arms differ only by the attribute:

ARM A  without scope=   FAIL — 5 <table>(s) missing <th scope=>
ARM B  with scope=      PASS

Whole-tree failing gates 15 → 14; no other gate moved in either direction.

The negative control found something — about the gate

I removed one scope="col" from AdminAnalytics.vue and re-ran, expecting
gate-43 to report 1 finding. It reported PASS.

That is not a flake. gate-43 is per-table, at-least-one — its own header says
so: "every <table> in .vue templates must have at least one <th> with a
scope="col" / scope="row" attribute"
. So a table with one scoped <th> and
nine unscoped ones is green, and nine data columns stay unassociated with a gate
saying the file is fine.

Worth flagging on ConductionNL/.github: the gate counts tables, not header
cells, so its finding count understates the real remediation and it can be
satisfied by a one-attribute change per table. This PR does not take that
shortcut — all 19 headers are scoped, which is strictly more than the gate
requires. I have not touched the gate here; that belongs in its own change with
its own fixture.

Not fixed here, deliberately

The select-all checkbox in DashboardBulkOperations' first header cell has no
accessible name. That is gate-40 form-label-association's subject matter, and
gate-40 measured 58% false-positive fleet-wide with a remediation that can
override a visible label — so it wants its own change with its own evidence,
not a drive-by in an attribute-only PR.

…sociate cells

Five tables rendered `<thead><tr><th>` with no `scope`, so nothing tied a data
cell to the header describing it. Assistive technology can infer the association
for a trivial table, but inference is not the contract — WCAG 2.2 AA SC 1.3.1
(Info and Relationships) asks for the relationship to be programmatically
determinable, and gate-43 table-headers enforces it.

All five are single-row, column-header tables: every `<th>` sits in the `<thead>`
row and labels the column beneath it, so `scope="col"` is the correct value for
all 19 of them. No row headers, no spans, so no `scope="row"` or `headers=`/`id=`
pairing is needed.

  src/modals/TemplateResyncModal.vue                        5
  src/modals/AcknowledgementReportModal.vue                 3
  src/components/admin/AdminAnalytics.vue                   3
  src/components/admin/ConditionalVisibilityOverview.vue    5
  src/components/admin/DashboardBulkOperations.vue          3

Attribute-only change: no template structure, no bindings, no script, no styles.

gate-43 table-headers, whole tree, hydra-gates at .github@main 756fe89 — the two
arms differ only by the attribute:

  without scope=  FAIL — 5 <table>(s) missing <th scope=>
  with scope=     PASS

Whole-tree failing gates 15 -> 14, no other gate moved in either direction.

Noted, not fixed here: the select-all checkbox in DashboardBulkOperations' first
header cell has no accessible name. That is gate-40's subject matter, not
gate-43's, and gate-40 measured 58% false-positive fleet-wide, so it wants its
own change with its own evidence rather than a drive-by.
@rubenvdlinde

Copy link
Copy Markdown
Contributor Author

CI: one gate red, and it is inherited

[hydra-gates] Scope: diff vs origin/development — 5 changed file(s)
[hydra-gates] COVERAGE: 58 of 64 declared gates reported a result (58 of 58 applicable ran)
[gate-40] form-label-association: FAIL — 4 form input(s) without an associated label
[hydra-gates] 1 gate(s) failed

gate-43 — the gate this PR exists for — is green. gate-40 fires only
because touching these five files pulls them into the diff scope; it is already
in development's own failing set (10 findings full-tree).

Proven pre-existing by running the checker against the base tree with the five
files forced into scope
, not by comparing counts:

ARM 1  base = origin/development (b3ed9c5c)   4 findings
ARM 2  this PR head (ade03d4b)                4 findings
byte-identical

All four are in DashboardBulkOperations.vue, and all four are <input>
elements — the select-all checkbox, the per-row checkbox, the parent-uuid text
field and the publish-at datetime field. This PR adds scope="col" to <th>
elements and touches nothing else, so it cannot have created an unlabelled
input. Same finding text, same order, byte for byte.

Failure set is therefore a strict subset of development's, by name.

Quality Report is a pure aggregator of the jobs above it and is not counted as
debt.

Not fixed here

The four gate-40 findings are real enough to be worth their own change — the
select-all checkbox genuinely has no accessible name. But gate-40 measured 58%
false-positive fleet-wide and its remediation can override a visible label, so
it needs its own PR where each of the four is adjudicated individually against
what the component actually renders. Folding it into an attribute-only a11y fix
would mean shipping four unreviewed label decisions under a diff nobody would
read closely.

@rubenvdlinde
rubenvdlinde merged commit 4f8ad26 into development Aug 8, 2026
25 of 27 checks passed
@github-actions

github-actions Bot commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/launchpad @ 8dd385a

Check PHP Vue Security License Tests
lint
phpcs
phpmd
psalm
phpstan
phpmetrics
eslint
stylelint
build
check-manifest
composer ✅ 102/102
npm ✅ 548/548
PHPUnit
Newman
Playwright
Hydra gates

Quality workflow — 2026-08-08 11:02 UTC

Download the full PDF report from the workflow artifacts.

@rubenvdlinde
rubenvdlinde deleted the chore/table-headers-scope branch August 14, 2026 09:33
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