pour-engine 1.36.0 → 1.37.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,6 +6,6 @@
6
6
  export default {
7
7
  engine: {
8
8
  name: 'pour engine',
9
- version: '1.36.0',
9
+ version: '1.37.0',
10
10
  },
11
11
  };
@@ -2,7 +2,12 @@
2
2
  // Composite roles are meaningless without their required child roles —
3
3
  // role="list" with no listitems announces as an empty list.
4
4
  import { implicitRole } from '../../lib/roles.js';
5
- const REQUIRED_CHILDREN = {
5
+ // Every row of ARIA 1.2's Required Owned Elements table; verify:aria-tables
6
+ // diffs this map against the spec (2026-09-01, after feed and row were
7
+ // found missing).
8
+ export const REQUIRED_CHILDREN = {
9
+ feed: ['article'],
10
+ row: ['cell', 'columnheader', 'gridcell', 'rowheader'],
6
11
  list: ['listitem'],
7
12
  listbox: ['option', 'group'],
8
13
  menu: ['menuitem', 'menuitemcheckbox', 'menuitemradio', 'group'],
@@ -13,7 +13,13 @@
13
13
  // > group > treeitem exactly as authored). For the roles whose context does
14
14
  // not list group (listitem, tab, row, cell), group stays transparent as
15
15
  // before.
16
- const REQUIRED_PARENT = {
16
+ // Every row of ARIA 1.2's Required Context Role table; verify:aria-tables
17
+ // diffs this map against the spec (2026-09-01, after caption, columnheader
18
+ // and rowheader were found missing).
19
+ export const REQUIRED_PARENT = {
20
+ caption: ['figure', 'grid', 'table', 'treegrid'],
21
+ columnheader: ['row'],
22
+ rowheader: ['row'],
17
23
  listitem: ['list'],
18
24
  option: ['listbox', 'group'],
19
25
  menuitem: ['menu', 'menubar', 'group'],
@@ -27,7 +33,7 @@ const REQUIRED_PARENT = {
27
33
  gridcell: ['row'],
28
34
  };
29
35
 
30
- const IMPLICIT_CONTAINER = { ul: 'list', ol: 'list', menu: 'list', table: 'table', tbody: 'rowgroup', thead: 'rowgroup', tfoot: 'rowgroup', tr: 'row' };
36
+ const IMPLICIT_CONTAINER = { ul: 'list', ol: 'list', menu: 'list', table: 'table', tbody: 'rowgroup', thead: 'rowgroup', tfoot: 'rowgroup', tr: 'row', figure: 'figure' };
31
37
 
32
38
  export default {
33
39
  id: 'aria-required-parent',
@@ -125,6 +125,23 @@ export default {
125
125
  : 'Put visible text inside the label.',
126
126
  };
127
127
  }
128
+ // A label points at this id, but the id is shared: for binds to the
129
+ // FIRST element with it, so this later twin has no label and no name
130
+ // even though a label sits beside it (hsbc.com download basket, two
131
+ // rows reusing one document id, 2026-09-01). The fix is a unique id,
132
+ // not another label.
133
+ if (element.id) {
134
+ const root = element.getRootNode();
135
+ const pointed = root.querySelector?.(`label[for="${CSS.escape(element.id)}"]`);
136
+ const first = root.getElementById?.(element.id);
137
+ if (pointed && first && first !== element) {
138
+ return {
139
+ status: 'fail',
140
+ message: `A <label for="${element.id}"> exists, but an earlier element on the page has the same id, so the label belongs to that one and this field has no label at all.`,
141
+ fix: `Give this field a unique id and point its label at that; ids must not repeat on a page.`,
142
+ };
143
+ }
144
+ }
128
145
  return {
129
146
  status: 'fail',
130
147
  message: 'This form field has no label — users cannot tell what to enter.',
@@ -84,10 +84,16 @@ export default {
84
84
  message: 'This table has no header cells and its cells hold block content (headings, paragraphs, lists or forms), which is a layout-table signature. If it really presents data, mark its header cells with <th>; if it is layout scaffolding, add role="presentation".',
85
85
  };
86
86
  }
87
+ // A two-column table is a list of label-and-value pairs; its first
88
+ // column is the row headers, and that is the whole fix (gov.uk
89
+ // inheritance tax guidance, 2026-09-01: five such tables, all td).
90
+ const twoColumns = Math.max(0, ...rows.map((row) => row.cells.length)) === 2;
87
91
  return {
88
92
  status: 'fail',
89
93
  message: 'This looks like a data table but has no header cells — screen reader users get the data with no way to tell what each row/column means.',
90
- fix: 'Mark header cells with <th> (add scope="col" or scope="row" when the table has both).',
94
+ fix: twoColumns
95
+ ? 'Mark the first cell of each row as <th scope="row">: in a two-column table the first column names what the second holds.'
96
+ : 'Mark header cells with <th> (add scope="col" or scope="row" when the table has both).',
91
97
  };
92
98
  }
93
99
  return { status: 'pass' };
@@ -40,6 +40,18 @@ const NAME_FROM_CONTENT = 'a[href], button, summary, h1, h2, h3, h4, h5, h6, th,
40
40
  + ' [role="checkbox"], [role="radio"], [role="switch"], [role="heading"],'
41
41
  + ' [role="cell"], [role="gridcell"], [role="columnheader"], [role="rowheader"], [role="tooltip"]';
42
42
 
43
+ // ARIA 1.2's ten roles that prohibit naming, and the HTML elements that
44
+ // carry them implicitly (HTML-AAM); verify:aria-tables diffs the set
45
+ // against the spec. The rule started with div, span and p alone
46
+ // (generic and paragraph); the other eight, explicit or as strong, em,
47
+ // code, del, s, ins, sub and sup, joined 2026-09-01.
48
+ export const NAME_PROHIBITED_ROLES = ['caption', 'code', 'deletion', 'emphasis', 'generic', 'insertion', 'paragraph', 'strong', 'subscript', 'superscript'];
49
+ const IMPLICITLY_PROHIBITED = ['div', 'span', 'p', 'strong', 'em', 'code', 'del', 's', 'ins', 'sub', 'sup'];
50
+ const SELECTOR = ['aria-label', 'aria-labelledby'].flatMap((attr) => [
51
+ ...IMPLICITLY_PROHIBITED.map((tag) => `${tag}[${attr}]:not([role]):not([tabindex])`),
52
+ ...NAME_PROHIBITED_ROLES.map((role) => `[role="${role}"][${attr}]:not([tabindex])`),
53
+ ]).join(', ');
54
+
43
55
  export default {
44
56
  id: 'aria-label-misuse',
45
57
  name: 'aria-label placement',
@@ -50,8 +62,7 @@ export default {
50
62
  // §5.2.8.6 prohibits BOTH naming attributes on generic — aria-labelledby
51
63
  // pointing at perfectly real text is the same authoring error as an inline
52
64
  // aria-label, and the more tempting one (a card labelled by its own title).
53
- selector: 'div[aria-label]:not([role]):not([tabindex]), span[aria-label]:not([role]):not([tabindex]), p[aria-label]:not([role]):not([tabindex]),'
54
- + ' div[aria-labelledby]:not([role]):not([tabindex]), span[aria-labelledby]:not([role]):not([tabindex]), p[aria-labelledby]:not([role]):not([tabindex])',
65
+ selector: SELECTOR,
55
66
  evaluate(element) {
56
67
  // Inside a name-from-content ancestor the label is NOT ignored: accname
57
68
  // applies the whole algorithm to each descendant while building the
@@ -72,7 +83,7 @@ export default {
72
83
  })();
73
84
  return {
74
85
  status: 'incomplete',
75
- message: `${attempt} on a plain <${element.tagName.toLowerCase()}> is prohibited by ARIA: this element's role does not support naming, so although many browser and screen reader pairings announce the label today, that support is not guaranteed anywhere and can differ between assistive technologies. Where it is announced it replaces the element's visible text; where it is not, users get the visible text alone. Check both presentations read correctly, and that nothing here relies on the label being heard. Prohibited markup is not by itself a WCAG failure: the criterion that policed validity, 4.1.1 Parsing, was removed in WCAG 2.2 (w3.org/WAI/WCAG22/Understanding/parsing.html), so what decides this is what users actually hear.`,
86
+ message: `${attempt} on ${element.hasAttribute('role') ? `role="${element.getAttribute('role').trim()}"` : `a plain <${element.tagName.toLowerCase()}>`} is prohibited by ARIA: this element's role does not support naming, so although many browser and screen reader pairings announce the label today, that support is not guaranteed anywhere and can differ between assistive technologies. Where it is announced it replaces the element's visible text; where it is not, users get the visible text alone. Check both presentations read correctly, and that nothing here relies on the label being heard. Prohibited markup is not by itself a WCAG failure: the criterion that policed validity, 4.1.1 Parsing, was removed in WCAG 2.2 (w3.org/WAI/WCAG22/Understanding/parsing.html), so what decides this is what users actually hear.`,
76
87
  fix: 'If this element must reliably announce something of its own, give it the role it is playing (a landmark, group, or widget role) so the name is legal and exposure is guaranteed — or move the spoken text into visible or visually-hidden real text. If the label is stray, remove it. Do not add a role solely to legalise the label.',
77
88
  };
78
89
  },
@@ -15,7 +15,7 @@
15
15
  // control yet), and without aria-selected on an option (selection is
16
16
  // often conveyed by focus alone); the spec lists both, the DOM cannot
17
17
  // show a user is blocked, so the finding asks rather than asserts.
18
- const REQUIRED = {
18
+ export const REQUIRED = {
19
19
  checkbox: ['aria-checked'],
20
20
  switch: ['aria-checked'],
21
21
  radio: ['aria-checked'],
@@ -29,7 +29,7 @@ const REQUIRED = {
29
29
  combobox: ['aria-expanded', 'aria-controls'],
30
30
  option: ['aria-selected'],
31
31
  };
32
- const HELD_AT_REVIEW = new Set(['aria-controls', 'aria-selected']);
32
+ export const HELD_AT_REVIEW = new Set(['aria-controls', 'aria-selected']);
33
33
 
34
34
  // What each missing property costs the user, in the message.
35
35
  const COST = {
@@ -1,7 +1,7 @@
1
1
  // WCAG SC 4.1.2 Name, Role, Value (Level A)
2
2
  import { attributesOf } from '../../lib/dom.js';
3
3
  // Concrete, usable roles — abstract roles are invalid in markup.
4
- const VALID_ROLES = new Set([
4
+ export const VALID_ROLES = new Set([
5
5
  'alert', 'alertdialog', 'application', 'article', 'banner', 'blockquote', 'button',
6
6
  'caption', 'cell', 'checkbox', 'code', 'columnheader', 'combobox', 'complementary',
7
7
  'contentinfo', 'definition', 'deletion', 'dialog', 'directory', 'document', 'emphasis',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pour-engine",
3
- "version": "1.36.0",
3
+ "version": "1.37.0",
4
4
  "description": "Accessibility (WCAG 2.2) audit engine written from the W3C specifications — runs entirely in the browser, no dependencies.",
5
5
  "type": "module",
6
6
  "main": "engine/index.js",