pour-engine 1.1.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.
Files changed (86) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +99 -0
  3. package/config/project.config.js +11 -0
  4. package/engine/COVERAGE.md +115 -0
  5. package/engine/README.md +115 -0
  6. package/engine/index.js +258 -0
  7. package/engine/lib/accessible-name.js +126 -0
  8. package/engine/lib/contrast.js +628 -0
  9. package/engine/lib/dom.js +116 -0
  10. package/engine/lib/roles.js +125 -0
  11. package/engine/rules/best-practice/accesskeys.js +29 -0
  12. package/engine/rules/best-practice/aria-label-misuse.js +22 -0
  13. package/engine/rules/best-practice/button-type.js +15 -0
  14. package/engine/rules/best-practice/empty-heading.js +19 -0
  15. package/engine/rules/best-practice/fieldset-legend.js +35 -0
  16. package/engine/rules/best-practice/heading-order.js +30 -0
  17. package/engine/rules/best-practice/landmark-one-main.js +22 -0
  18. package/engine/rules/best-practice/landmark-top-level.js +46 -0
  19. package/engine/rules/best-practice/landmark-unique.js +84 -0
  20. package/engine/rules/best-practice/multiple-labels.js +23 -0
  21. package/engine/rules/best-practice/new-window-link.js +21 -0
  22. package/engine/rules/best-practice/no-autofocus.js +16 -0
  23. package/engine/rules/best-practice/page-heading-one.js +17 -0
  24. package/engine/rules/best-practice/positive-tabindex.js +17 -0
  25. package/engine/rules/best-practice/redundant-alt-phrase.js +22 -0
  26. package/engine/rules/best-practice/redundant-aria-label.js +18 -0
  27. package/engine/rules/best-practice/redundant-aria.js +32 -0
  28. package/engine/rules/best-practice/redundant-image-alt.js +20 -0
  29. package/engine/rules/best-practice/redundant-role.js +62 -0
  30. package/engine/rules/best-practice/redundant-tabindex.js +15 -0
  31. package/engine/rules/best-practice/region.js +73 -0
  32. package/engine/rules/index.js +179 -0
  33. package/engine/rules/wcag/1.1.1-area-alt.js +18 -0
  34. package/engine/rules/wcag/1.1.1-image-alt.js +48 -0
  35. package/engine/rules/wcag/1.1.1-input-image-alt.js +17 -0
  36. package/engine/rules/wcag/1.1.1-object-alt.js +17 -0
  37. package/engine/rules/wcag/1.1.1-svg-img-alt.js +34 -0
  38. package/engine/rules/wcag/1.2.2-media-captions.js +19 -0
  39. package/engine/rules/wcag/1.3.1-aria-required-children.js +66 -0
  40. package/engine/rules/wcag/1.3.1-aria-required-parent.js +49 -0
  41. package/engine/rules/wcag/1.3.1-definition-list.js +21 -0
  42. package/engine/rules/wcag/1.3.1-dlitem-parent.js +17 -0
  43. package/engine/rules/wcag/1.3.1-form-label.js +81 -0
  44. package/engine/rules/wcag/1.3.1-list-structure.js +59 -0
  45. package/engine/rules/wcag/1.3.1-listitem-parent.js +33 -0
  46. package/engine/rules/wcag/1.3.1-p-as-heading.js +25 -0
  47. package/engine/rules/wcag/1.3.1-table-headers.js +61 -0
  48. package/engine/rules/wcag/1.3.5-autocomplete-valid.js +38 -0
  49. package/engine/rules/wcag/1.4.1-link-in-text-block.js +74 -0
  50. package/engine/rules/wcag/1.4.10-reflow.js +25 -0
  51. package/engine/rules/wcag/1.4.11-non-text-contrast.js +109 -0
  52. package/engine/rules/wcag/1.4.12-text-spacing.js +71 -0
  53. package/engine/rules/wcag/1.4.2-audio-control.js +40 -0
  54. package/engine/rules/wcag/1.4.3-color-contrast.js +462 -0
  55. package/engine/rules/wcag/1.4.3-control-contrast.js +72 -0
  56. package/engine/rules/wcag/1.4.4-meta-viewport.js +22 -0
  57. package/engine/rules/wcag/1.4.6-contrast-enhanced.js +11 -0
  58. package/engine/rules/wcag/2.1.1-scrollable-region-focusable.js +48 -0
  59. package/engine/rules/wcag/2.2.1-meta-refresh.js +20 -0
  60. package/engine/rules/wcag/2.2.2-pause-stop-hide.js +25 -0
  61. package/engine/rules/wcag/2.4.1-bypass-blocks.js +36 -0
  62. package/engine/rules/wcag/2.4.11-focus-not-obscured.js +80 -0
  63. package/engine/rules/wcag/2.4.2-document-title.js +18 -0
  64. package/engine/rules/wcag/2.4.4-link-name.js +19 -0
  65. package/engine/rules/wcag/2.4.7-focus-visible.js +46 -0
  66. package/engine/rules/wcag/2.5.3-label-in-name.js +88 -0
  67. package/engine/rules/wcag/2.5.5-target-size-enhanced.js +14 -0
  68. package/engine/rules/wcag/2.5.8-target-size.js +159 -0
  69. package/engine/rules/wcag/3.1.1-html-lang.js +31 -0
  70. package/engine/rules/wcag/3.1.2-valid-lang-parts.js +20 -0
  71. package/engine/rules/wcag/3.3.8-auth-field-obstruction.js +37 -0
  72. package/engine/rules/wcag/4.1.2-aria-allowed-attr.js +44 -0
  73. package/engine/rules/wcag/4.1.2-aria-attr-valid.js +50 -0
  74. package/engine/rules/wcag/4.1.2-aria-field-name.js +54 -0
  75. package/engine/rules/wcag/4.1.2-aria-hidden-focus.js +48 -0
  76. package/engine/rules/wcag/4.1.2-aria-valid-refs.js +61 -0
  77. package/engine/rules/wcag/4.1.2-button-name.js +17 -0
  78. package/engine/rules/wcag/4.1.2-dialog-name.js +35 -0
  79. package/engine/rules/wcag/4.1.2-frame-title.js +17 -0
  80. package/engine/rules/wcag/4.1.2-label-for-valid.js +25 -0
  81. package/engine/rules/wcag/4.1.2-nested-interactive.js +30 -0
  82. package/engine/rules/wcag/4.1.2-role-required-aria.js +36 -0
  83. package/engine/rules/wcag/4.1.2-valid-role.js +52 -0
  84. package/engine/wcag22.js +116 -0
  85. package/engine/wcag30-draft.js +149 -0
  86. package/package.json +34 -0
@@ -0,0 +1,116 @@
1
+ // DOM helpers shared by all rules.
2
+
3
+ /**
4
+ * The audit context plus every OPEN shadow root beneath it, hosts before
5
+ * their shadow trees. Collected once per audit so each rule can query every
6
+ * root without re-walking the page. Closed shadow roots are unreachable by
7
+ * design — like every DOM tool, we can only see what the page exposes.
8
+ */
9
+ export function collectRoots(context) {
10
+ const roots = [context];
11
+ for (let i = 0; i < roots.length; i++) {
12
+ if (!roots[i].querySelectorAll) continue;
13
+ for (const el of roots[i].querySelectorAll('*')) {
14
+ if (el.shadowRoot) roots.push(el.shadowRoot);
15
+ }
16
+ }
17
+ return roots;
18
+ }
19
+
20
+ /** Nearest ancestor in the FLAT tree: parent element, or the shadow host
21
+ * when the walk reaches the top of a shadow tree. */
22
+ function flatTreeParent(node) {
23
+ return node.parentElement ?? node.getRootNode()?.host ?? null;
24
+ }
25
+
26
+ /**
27
+ * Rendered on screen — ignores aria-hidden. For purely visual rules
28
+ * (contrast, target size): sighted users see aria-hidden content too.
29
+ */
30
+ export function isRendered(element) {
31
+ if (typeof element.checkVisibility === 'function') {
32
+ return element.checkVisibility({ visibilityProperty: true });
33
+ }
34
+ const style = getComputedStyle(element);
35
+ return style.display !== 'none' && style.visibility !== 'hidden';
36
+ }
37
+
38
+ /** Rendered AND exposed to assistive technology (default rule filter).
39
+ * aria-hidden is checked up the flat tree, so aria-hidden on a shadow host
40
+ * hides its shadow content too. */
41
+ export function isVisible(element) {
42
+ for (let node = element; node; node = flatTreeParent(node)) {
43
+ if (node.getAttribute?.('aria-hidden') === 'true') return false;
44
+ }
45
+ return isRendered(element);
46
+ }
47
+
48
+ /** Selector for an element within its own root (document or shadow root). */
49
+ function cssPathInRoot(element) {
50
+ if (element.id) return `#${CSS.escape(element.id)}`;
51
+ const parts = [];
52
+ let current = element;
53
+ while (current && current.nodeType === Node.ELEMENT_NODE && current !== document.documentElement) {
54
+ let part = current.tagName.toLowerCase();
55
+ if (current.parentElement) {
56
+ // Sibling walk without materializing children arrays — this runs for
57
+ // every reported element at every ancestor level.
58
+ let index = 1;
59
+ for (let sib = current.previousElementSibling; sib; sib = sib.previousElementSibling) {
60
+ if (sib.tagName === current.tagName) index += 1;
61
+ }
62
+ let repeated = index > 1;
63
+ for (let sib = current.nextElementSibling; !repeated && sib; sib = sib.nextElementSibling) {
64
+ if (sib.tagName === current.tagName) repeated = true;
65
+ }
66
+ if (repeated) part += `:nth-of-type(${index})`;
67
+ }
68
+ parts.unshift(part);
69
+ if (current.parentElement?.id) {
70
+ parts.unshift(`#${CSS.escape(current.parentElement.id)}`);
71
+ break;
72
+ }
73
+ current = current.parentElement;
74
+ }
75
+ return parts.join(' > ') || element.tagName.toLowerCase();
76
+ }
77
+
78
+ /**
79
+ * A short, stable-ish CSS selector for an element. Elements inside shadow
80
+ * roots get a `host-path >>> inner-path` chain (one `>>>` per boundary);
81
+ * resolve by querying each segment and descending into `.shadowRoot`.
82
+ */
83
+ export function cssPath(element) {
84
+ let path = cssPathInRoot(element);
85
+ let root = element.getRootNode();
86
+ while (root && root.host) {
87
+ path = `${cssPathInRoot(root.host)} >>> ${path}`;
88
+ root = root.host.getRootNode();
89
+ }
90
+ return path;
91
+ }
92
+
93
+ /** Element's opening markup (attributes) plus a snippet of its own text —
94
+ * enough to recognise it in a table. Built WITHOUT element.outerHTML, which
95
+ * serializes the whole subtree and turns audits quadratic on huge pages
96
+ * (container divs would each stringify most of the document). */
97
+ export function htmlSnippet(element, maxLength = 300) {
98
+ let html = `<${element.tagName.toLowerCase()}`;
99
+ for (const { name, value } of element.attributes) {
100
+ if (html.length >= maxLength) break;
101
+ html += ` ${name}="${value}"`;
102
+ }
103
+ html += '>';
104
+ const text = ownText(element);
105
+ if (text && html.length < maxLength) html += text.slice(0, maxLength - html.length);
106
+ return html.length > maxLength ? `${html.slice(0, maxLength)}…` : html;
107
+ }
108
+
109
+ /** The element's own (not descendant-element) text, trimmed. */
110
+ export function ownText(element) {
111
+ return [...element.childNodes]
112
+ .filter((node) => node.nodeType === Node.TEXT_NODE)
113
+ .map((node) => node.textContent)
114
+ .join('')
115
+ .trim();
116
+ }
@@ -0,0 +1,125 @@
1
+ // ARIA 1.2 role model: implicit roles for HTML elements, and which aria-*
2
+ // attributes each role supports (flattened with inheritance). Attribute
3
+ // names are stored without the "aria-" prefix.
4
+
5
+ /** Every attribute name in the ARIA 1.2 vocabulary (without the prefix).
6
+ * aria-attr-valid judges membership; aria-allowed-attr judges role fit —
7
+ * each unknown attribute is reported once, by aria-attr-valid alone. */
8
+ export const KNOWN_ARIA = new Set([
9
+ 'activedescendant', 'atomic', 'autocomplete', 'braillelabel', 'brailleroledescription',
10
+ 'busy', 'checked', 'colcount', 'colindex', 'colindextext', 'colspan', 'controls',
11
+ 'current', 'describedby', 'description', 'details', 'disabled', 'dropeffect',
12
+ 'errormessage', 'expanded', 'flowto', 'grabbed', 'haspopup', 'hidden', 'invalid',
13
+ 'keyshortcuts', 'label', 'labelledby', 'level', 'live', 'modal', 'multiline',
14
+ 'multiselectable', 'orientation', 'owns', 'placeholder', 'posinset', 'pressed',
15
+ 'readonly', 'relevant', 'required', 'roledescription', 'rowcount', 'rowindex',
16
+ 'rowindextext', 'rowspan', 'selected', 'setsize', 'sort', 'valuemax', 'valuemin',
17
+ 'valuenow', 'valuetext',
18
+ ]);
19
+
20
+ /** Global attributes, usable with any role (ARIA 1.2 §6.4). */
21
+ export const GLOBAL_ARIA = new Set([
22
+ 'atomic', 'busy', 'controls', 'current', 'describedby', 'description', 'details',
23
+ 'dropeffect', 'flowto', 'grabbed', 'hidden', 'keyshortcuts', 'label', 'labelledby',
24
+ 'live', 'owns', 'relevant', 'roledescription', 'braillelabel', 'brailleroledescription',
25
+ ]);
26
+
27
+ /** Attributes each role supports IN ADDITION to the globals. */
28
+ export const ROLE_ARIA = {
29
+ link: ['disabled', 'expanded', 'haspopup'],
30
+ button: ['disabled', 'expanded', 'haspopup', 'pressed'],
31
+ checkbox: ['checked', 'disabled', 'errormessage', 'invalid', 'readonly', 'required'],
32
+ switch: ['checked', 'disabled', 'errormessage', 'invalid', 'readonly', 'required'],
33
+ radio: ['checked', 'disabled', 'posinset', 'setsize'],
34
+ option: ['selected', 'checked', 'disabled', 'posinset', 'setsize'],
35
+ tab: ['selected', 'disabled', 'expanded', 'haspopup', 'posinset', 'setsize'],
36
+ menuitem: ['disabled', 'expanded', 'haspopup', 'posinset', 'setsize'],
37
+ menuitemcheckbox: ['checked', 'disabled', 'expanded', 'haspopup', 'posinset', 'setsize'],
38
+ menuitemradio: ['checked', 'disabled', 'expanded', 'haspopup', 'posinset', 'setsize'],
39
+ textbox: ['activedescendant', 'autocomplete', 'disabled', 'errormessage', 'haspopup', 'invalid', 'multiline', 'placeholder', 'readonly', 'required'],
40
+ searchbox: ['activedescendant', 'autocomplete', 'disabled', 'errormessage', 'haspopup', 'invalid', 'multiline', 'placeholder', 'readonly', 'required'],
41
+ combobox: ['activedescendant', 'autocomplete', 'disabled', 'errormessage', 'expanded', 'haspopup', 'invalid', 'readonly', 'required'],
42
+ listbox: ['activedescendant', 'disabled', 'errormessage', 'expanded', 'invalid', 'multiselectable', 'orientation', 'readonly', 'required'],
43
+ slider: ['disabled', 'errormessage', 'haspopup', 'invalid', 'orientation', 'readonly', 'valuemax', 'valuemin', 'valuenow', 'valuetext'],
44
+ spinbutton: ['activedescendant', 'disabled', 'errormessage', 'invalid', 'readonly', 'required', 'valuemax', 'valuemin', 'valuenow', 'valuetext'],
45
+ progressbar: ['valuemax', 'valuemin', 'valuenow', 'valuetext'],
46
+ meter: ['valuemax', 'valuemin', 'valuenow', 'valuetext'],
47
+ scrollbar: ['disabled', 'orientation', 'valuemax', 'valuemin', 'valuenow', 'valuetext'],
48
+ heading: ['level'],
49
+ list: [],
50
+ listitem: ['level', 'posinset', 'setsize'],
51
+ row: ['colindex', 'disabled', 'expanded', 'level', 'posinset', 'rowindex', 'selected', 'setsize'],
52
+ rowgroup: [],
53
+ cell: ['colindex', 'colspan', 'rowindex', 'rowspan'],
54
+ gridcell: ['colindex', 'colspan', 'rowindex', 'rowspan', 'disabled', 'errormessage', 'expanded', 'haspopup', 'invalid', 'readonly', 'required', 'selected'],
55
+ columnheader: ['colindex', 'colspan', 'rowindex', 'rowspan', 'disabled', 'errormessage', 'expanded', 'haspopup', 'invalid', 'readonly', 'required', 'selected', 'sort'],
56
+ rowheader: ['colindex', 'colspan', 'rowindex', 'rowspan', 'disabled', 'errormessage', 'expanded', 'haspopup', 'invalid', 'readonly', 'required', 'selected', 'sort'],
57
+ table: ['colcount', 'rowcount'],
58
+ grid: ['activedescendant', 'colcount', 'disabled', 'multiselectable', 'readonly', 'rowcount'],
59
+ treegrid: ['activedescendant', 'colcount', 'disabled', 'errormessage', 'invalid', 'multiselectable', 'orientation', 'readonly', 'required', 'rowcount'],
60
+ tablist: ['activedescendant', 'disabled', 'multiselectable', 'orientation'],
61
+ menu: ['activedescendant', 'disabled', 'orientation'],
62
+ menubar: ['activedescendant', 'disabled', 'orientation'],
63
+ tree: ['activedescendant', 'disabled', 'errormessage', 'invalid', 'multiselectable', 'orientation', 'required'],
64
+ treeitem: ['checked', 'disabled', 'expanded', 'haspopup', 'level', 'posinset', 'selected', 'setsize'],
65
+ radiogroup: ['activedescendant', 'disabled', 'errormessage', 'invalid', 'orientation', 'readonly', 'required'],
66
+ group: ['activedescendant', 'disabled'],
67
+ separator: ['disabled', 'orientation', 'valuemax', 'valuemin', 'valuenow', 'valuetext'],
68
+ toolbar: ['activedescendant', 'disabled', 'orientation'],
69
+ dialog: ['modal'],
70
+ alertdialog: ['modal'],
71
+ application: ['activedescendant', 'disabled', 'expanded', 'haspopup'],
72
+ article: ['posinset', 'setsize'],
73
+ img: [], figure: [], document: [], feed: [], math: [], note: [], presentation: [], none: [],
74
+ banner: [], complementary: [], contentinfo: [], form: [], main: [], navigation: [], region: [], search: [],
75
+ alert: [], log: [], marquee: [], status: [], timer: [], tabpanel: [], tooltip: [],
76
+ definition: [], term: [], paragraph: [], generic: [], blockquote: [], caption: [], code: [],
77
+ emphasis: [], strong: [], time: [], deletion: [], insertion: [], subscript: [], superscript: [],
78
+ };
79
+
80
+ const INPUT_ROLES = {
81
+ checkbox: 'checkbox', radio: 'radio', range: 'slider', number: 'spinbutton',
82
+ search: 'searchbox', email: 'textbox', tel: 'textbox', text: 'textbox', url: 'textbox',
83
+ button: 'button', submit: 'button', reset: 'button', image: 'button',
84
+ };
85
+
86
+ const TAG_ROLES = {
87
+ button: 'button', textarea: 'textbox', img: 'img', article: 'article',
88
+ aside: 'complementary', nav: 'navigation', main: 'main',
89
+ h1: 'heading', h2: 'heading', h3: 'heading', h4: 'heading', h5: 'heading', h6: 'heading',
90
+ ul: 'list', ol: 'list', menu: 'list', li: 'listitem',
91
+ table: 'table', thead: 'rowgroup', tbody: 'rowgroup', tfoot: 'rowgroup',
92
+ tr: 'row', td: 'cell', th: 'columnheader',
93
+ form: 'form', fieldset: 'group', details: 'group', dialog: 'dialog', hr: 'separator',
94
+ progress: 'progressbar', meter: 'meter', output: 'status', option: 'option',
95
+ datalist: 'listbox', dt: 'term', dd: 'definition', p: 'paragraph',
96
+ div: 'generic', span: 'generic', blockquote: 'blockquote', figure: 'figure',
97
+ time: 'time', code: 'code', em: 'emphasis', strong: 'strong',
98
+ };
99
+
100
+ /**
101
+ * The element's implicit ARIA role, or null when it has none / we don't
102
+ * model it (callers should treat null as "don't judge").
103
+ */
104
+ export function implicitRole(element) {
105
+ const tag = element.tagName.toLowerCase();
106
+ if (tag === 'a' || tag === 'area') return element.hasAttribute('href') ? 'link' : 'generic';
107
+ if (tag === 'input') return INPUT_ROLES[element.type] ?? null;
108
+ if (tag === 'select') return element.multiple || element.size > 1 ? 'listbox' : 'combobox';
109
+ if (tag === 'img') return element.getAttribute('alt') === '' ? 'presentation' : 'img';
110
+ if (tag === 'header') return element.closest('article, aside, main, nav, section') ? 'generic' : 'banner';
111
+ if (tag === 'footer') return element.closest('article, aside, main, nav, section') ? 'generic' : 'contentinfo';
112
+ if (tag === 'section') {
113
+ return element.hasAttribute('aria-label') || element.hasAttribute('aria-labelledby') ? 'region' : 'generic';
114
+ }
115
+ return TAG_ROLES[tag] ?? null;
116
+ }
117
+
118
+ /** The element's effective role: explicit (first modelled token) or implicit. */
119
+ export function effectiveRole(element) {
120
+ const explicit = element.getAttribute('role')?.trim().split(/\s+/) ?? [];
121
+ for (const token of explicit) {
122
+ if (ROLE_ARIA[token.toLowerCase()]) return token.toLowerCase();
123
+ }
124
+ return explicit.length ? null : implicitRole(element); // unknown explicit role: don't judge
125
+ }
@@ -0,0 +1,29 @@
1
+ export default {
2
+ id: 'accesskeys',
3
+ impact: 'serious',
4
+ tags: ['best-practice'],
5
+ help: 'The same accesskey must not be assigned twice',
6
+ helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/character-key-shortcuts.html',
7
+ selector: '[accesskey]',
8
+ visibleOnly: false,
9
+ // Judged as a set: duplicates are the problem.
10
+ evaluateAll(elements) {
11
+ const byKey = {};
12
+ elements.forEach((element, index) => {
13
+ const key = element.getAttribute('accesskey').trim().toLowerCase();
14
+ (byKey[key] ??= []).push(index);
15
+ });
16
+ const outcomes = elements.map(() => ({ status: 'pass' }));
17
+ for (const [key, indexes] of Object.entries(byKey)) {
18
+ if (indexes.length < 2) continue;
19
+ for (const index of indexes) {
20
+ outcomes[index] = {
21
+ status: 'fail',
22
+ message: `accesskey="${key}" is used ${indexes.length} times — the browser can only honour one, so the others silently do nothing.`,
23
+ fix: 'Give each accesskey a unique value, or remove them (they conflict with AT shortcuts anyway).',
24
+ };
25
+ }
26
+ }
27
+ return outcomes;
28
+ },
29
+ };
@@ -0,0 +1,22 @@
1
+ // aria-label on a generic element (plain div/span/p) is PROHIBITED by ARIA
2
+ // 1.2 — the role doesn't support naming, so conforming assistive technology
3
+ // ignores the label. That makes it an authoring defect worth surfacing, but
4
+ // NOT a WCAG failure: a static text container is not a "user interface
5
+ // component", so no success criterion (4.1.2 included) actually fails.
6
+ // Best-practice scope is the honest home; demoted from WCAG scope after an
7
+ // independent audit found it dominating WCAG results on news/commerce pages.
8
+ export default {
9
+ id: 'aria-label-misuse',
10
+ impact: 'moderate',
11
+ tags: ['best-practice'],
12
+ help: 'aria-label on generic elements is ignored by assistive technology',
13
+ helpUrl: 'https://www.w3.org/TR/using-aria/#practical-support-aria-label-aria-labelledby-and-aria-describedby',
14
+ selector: 'div[aria-label]:not([role]):not([tabindex]), span[aria-label]:not([role]):not([tabindex]), p[aria-label]:not([role]):not([tabindex])',
15
+ evaluate(element) {
16
+ return {
17
+ status: 'fail',
18
+ message: `aria-label="${element.getAttribute('aria-label')}" on a plain <${element.tagName.toLowerCase()}> does nothing — screen readers only announce labels on interactive elements or landmarks.`,
19
+ fix: 'Give the element a suitable role (or use a landmark/heading), or remove the aria-label.',
20
+ };
21
+ },
22
+ };
@@ -0,0 +1,15 @@
1
+ export default {
2
+ id: 'button-type',
3
+ impact: 'minor',
4
+ tags: ['best-practice'],
5
+ help: 'Buttons inside forms should declare their type',
6
+ helpUrl: 'https://html.spec.whatwg.org/multipage/form-elements.html#attr-button-type',
7
+ selector: 'form button:not([type]), form button[type=""]',
8
+ evaluate() {
9
+ return {
10
+ status: 'fail',
11
+ message: 'A <button> without type inside a form defaults to type="submit" — pressing Enter (or activating it with AT) can submit the form by accident.',
12
+ fix: 'Add type="button" (or type="submit" if submission is intended).',
13
+ };
14
+ },
15
+ };
@@ -0,0 +1,19 @@
1
+ export default {
2
+ id: 'empty-heading',
3
+ impact: 'minor',
4
+ tags: ['best-practice'],
5
+ help: 'Headings must contain text',
6
+ helpUrl: 'https://www.w3.org/WAI/tutorials/page-structure/headings/',
7
+ selector: 'h1, h2, h3, h4, h5, h6, [role="heading"]',
8
+ evaluate(element, { accessibleName }) {
9
+ // A role override demotes the element — it isn't a heading any more.
10
+ const role = element.getAttribute('role');
11
+ if (role && role !== 'heading') return { status: 'pass' };
12
+ if (accessibleName(element)) return { status: 'pass' };
13
+ return {
14
+ status: 'fail',
15
+ message: 'This heading is empty — screen-reader users navigating by headings land on nothing.',
16
+ fix: 'Add text to the heading, or remove the element if it is only used for spacing.',
17
+ };
18
+ },
19
+ };
@@ -0,0 +1,35 @@
1
+ // WAI forms tutorial: radio/checkbox groups need a group label — each
2
+ // input's own label isn't enough to convey what the group asks.
3
+ export default {
4
+ id: 'fieldset-legend',
5
+ impact: 'moderate',
6
+ tags: ['best-practice'],
7
+ help: 'Radio and checkbox groups should be grouped with a label',
8
+ helpUrl: 'https://www.w3.org/WAI/tutorials/forms/grouping/',
9
+ selector: 'input[type="radio"][name], input[type="checkbox"][name]',
10
+ // Judged as a set: only same-name groups of 2+ need grouping, and one
11
+ // finding per group is enough.
12
+ evaluateAll(elements) {
13
+ const groups = {};
14
+ elements.forEach((element, index) => {
15
+ const key = `${element.type}::${element.form?.id ?? ''}::${element.name}`;
16
+ (groups[key] ??= []).push(index);
17
+ });
18
+ const outcomes = elements.map(() => ({ status: 'pass' }));
19
+ for (const indexes of Object.values(groups)) {
20
+ if (indexes.length < 2) continue;
21
+ const first = elements[indexes[0]];
22
+ const fieldset = first.closest('fieldset');
23
+ const grouped =
24
+ (fieldset && fieldset.querySelector('legend')?.textContent.trim()) ||
25
+ first.closest('[role="group"][aria-label], [role="group"][aria-labelledby], [role="radiogroup"][aria-label], [role="radiogroup"][aria-labelledby]');
26
+ if (grouped) continue;
27
+ outcomes[indexes[0]] = {
28
+ status: 'fail',
29
+ message: `This group of ${indexes.length} ${first.type} inputs (name="${first.name}") has no group label — screen-reader users hear each option without knowing what question it answers.`,
30
+ fix: 'Wrap the group in <fieldset> with a <legend>, or role="radiogroup"/"group" with an aria-label.',
31
+ };
32
+ }
33
+ return outcomes;
34
+ },
35
+ };
@@ -0,0 +1,30 @@
1
+ const level = (el) =>
2
+ el.hasAttribute('aria-level')
3
+ ? parseInt(el.getAttribute('aria-level'), 10)
4
+ : parseInt(el.tagName[1], 10) || 2; // role="heading" without aria-level defaults to 2
5
+
6
+ export default {
7
+ id: 'heading-order',
8
+ impact: 'moderate',
9
+ tags: ['best-practice'],
10
+ help: 'Headings should step down one level at a time',
11
+ helpUrl: 'https://www.w3.org/WAI/tutorials/page-structure/headings/',
12
+ selector: 'h1, h2, h3, h4, h5, h6, [role="heading"]',
13
+ // Needs the whole page: each heading is judged against the previous one.
14
+ evaluateAll(elements) {
15
+ let previous = 0;
16
+ return elements.map((element) => {
17
+ const current = level(element);
18
+ const skipped = previous > 0 && current > previous + 1;
19
+ const outcome = skipped
20
+ ? {
21
+ status: 'fail',
22
+ message: `Heading level jumps from h${previous} to h${current}, which breaks the page outline for screen-reader navigation.`,
23
+ fix: `Use <h${previous + 1}> here, or restructure so levels increase one at a time.`,
24
+ }
25
+ : { status: 'pass' };
26
+ previous = current;
27
+ return outcome;
28
+ });
29
+ },
30
+ };
@@ -0,0 +1,22 @@
1
+ export default {
2
+ id: 'landmark-one-main',
3
+ impact: 'moderate',
4
+ tags: ['best-practice'],
5
+ help: 'The page should have exactly one main landmark',
6
+ helpUrl: 'https://www.w3.org/WAI/ARIA/apg/practices/landmark-regions/',
7
+ selector: 'html',
8
+ visibleOnly: false,
9
+ evaluate(element, { isVisible }) {
10
+ // Hidden mains (responsive/SPA double-renders kept display:none) are
11
+ // not exposed and must not count as duplicates.
12
+ const count = [...element.ownerDocument.querySelectorAll('main, [role="main"]')].filter(isVisible).length;
13
+ if (count === 1) return { status: 'pass' };
14
+ return {
15
+ status: 'fail',
16
+ message: count === 0
17
+ ? 'No <main> landmark — screen-reader users have no shortcut to the primary content.'
18
+ : `${count} main landmarks — "skip to main" becomes ambiguous.`,
19
+ fix: count === 0 ? 'Wrap the primary content in a single <main> element.' : 'Keep one <main>; demote the others to <section> or <div>.',
20
+ };
21
+ },
22
+ };
@@ -0,0 +1,46 @@
1
+ // ARIA APG landmark pattern: banner, main, and contentinfo are page-level
2
+ // regions — nesting them inside other landmarks breaks the page outline.
3
+ import { implicitRole } from '../../lib/roles.js';
4
+
5
+ const LANDMARK_ROLES = new Set([
6
+ 'main', 'navigation', 'banner', 'contentinfo', 'complementary', 'region', 'search', 'form',
7
+ ]);
8
+
9
+ /** The nearest ancestor that is a landmark in the accessibility tree —
10
+ * explicit role OR implicit (nav, labelled section/form, top-level
11
+ * header/footer). A selector can't express the implicit cases; a
12
+ * usa.gov-style div[role=banner] inside section[aria-label] was invisible
13
+ * to the old explicit-only check. */
14
+ function landmarkAncestor(element) {
15
+ for (let parent = element.parentElement; parent; parent = parent.parentElement) {
16
+ const explicit = parent.getAttribute('role')?.trim().split(/\s+/)[0]?.toLowerCase();
17
+ const role = explicit ?? implicitRole(parent);
18
+ if (role && LANDMARK_ROLES.has(role)) return { element: parent, role };
19
+ }
20
+ return null;
21
+ }
22
+
23
+ export default {
24
+ id: 'landmark-top-level',
25
+ impact: 'moderate',
26
+ tags: ['best-practice'],
27
+ help: 'banner, main and contentinfo landmarks must be top level',
28
+ helpUrl: 'https://www.w3.org/WAI/ARIA/apg/practices/landmark-regions/',
29
+ selector: 'header, footer, main, [role="banner"], [role="contentinfo"], [role="main"]',
30
+ evaluate(element) {
31
+ const tag = element.tagName.toLowerCase();
32
+ // <header>/<footer> inside sectioning content are not landmarks at all.
33
+ if ((tag === 'header' || tag === 'footer') && !element.hasAttribute('role') &&
34
+ element.closest('article, aside, main, nav, section')) {
35
+ return { status: 'pass' };
36
+ }
37
+ const container = landmarkAncestor(element);
38
+ if (!container) return { status: 'pass' };
39
+ const role = element.getAttribute('role') ?? { header: 'banner', footer: 'contentinfo', main: 'main' }[tag];
40
+ return {
41
+ status: 'fail',
42
+ message: `This ${role} landmark is nested inside a ${container.role} landmark (<${container.element.tagName.toLowerCase()}>) — page-level regions lose their meaning when nested.`,
43
+ fix: 'Move it to be a direct child of <body>, or remove the landmark role.',
44
+ };
45
+ },
46
+ };
@@ -0,0 +1,84 @@
1
+ // ARIA APG landmark pattern: same-type landmarks need distinguishing
2
+ // labels, and banner / contentinfo / main must be unique per page outright
3
+ // (two "banners" are ambiguous however well they're labelled).
4
+ import { implicitRole } from '../../lib/roles.js';
5
+
6
+ const SINGLETON = new Set(['banner', 'contentinfo', 'main']);
7
+
8
+ /** Landmark role of the element in the accessibility tree — explicit role
9
+ * first, else the implicit role (which handles header/footer demotion
10
+ * inside sectioning content and unlabelled sections being generic). */
11
+ const landmarkRole = (element) =>
12
+ element.getAttribute('role')?.trim().split(/\s+/)[0]?.toLowerCase() ?? implicitRole(element);
13
+
14
+ // Landmarks are not name-from-content roles: only author-provided labels
15
+ // distinguish them (the text inside a nav is its content, not its name).
16
+ const landmarkName = (element) => {
17
+ const labelledby = element.getAttribute('aria-labelledby');
18
+ if (labelledby) {
19
+ const text = labelledby
20
+ .split(/\s+/)
21
+ .map((id) => element.ownerDocument.getElementById(id)?.textContent ?? '')
22
+ .join(' ')
23
+ .trim();
24
+ if (text) return text;
25
+ }
26
+ return element.getAttribute('aria-label')?.trim() || element.getAttribute('title')?.trim() || '';
27
+ };
28
+
29
+ export default {
30
+ id: 'landmark-unique',
31
+ impact: 'moderate',
32
+ tags: ['best-practice'],
33
+ help: 'Landmarks of the same type need distinguishing labels',
34
+ helpUrl: 'https://www.w3.org/WAI/ARIA/apg/practices/landmark-regions/',
35
+ // Implicit landmarks (header, footer, labelled section/form) join the
36
+ // grouping — a page's <header> and a div[role="banner"] ARE duplicates.
37
+ selector:
38
+ 'nav, aside, header, footer, section[aria-label], section[aria-labelledby], ' +
39
+ 'form[aria-label], form[aria-labelledby], [role="navigation"], [role="complementary"], ' +
40
+ '[role="banner"], [role="contentinfo"], [role="region"], [role="search"], [role="form"], [role="main"], main',
41
+ // Judged as a set: two unlabelled navs are the problem, not either alone.
42
+ evaluateAll(elements) {
43
+ const roles = elements.map(landmarkRole);
44
+ const outcomes = elements.map(() => ({ status: 'pass' }));
45
+ const landmarkIndexes = elements
46
+ .map((element, index) => ({ element, index, role: roles[index] }))
47
+ .filter(({ role }) => role && role !== 'generic' && role !== 'presentation' && role !== 'none');
48
+
49
+ // Singleton roles: more than one banner/contentinfo/main is a failure
50
+ // regardless of labels.
51
+ for (const singleton of SINGLETON) {
52
+ const dupes = landmarkIndexes.filter(({ role }) => role === singleton);
53
+ if (dupes.length < 2) continue;
54
+ for (const { index } of dupes) {
55
+ outcomes[index] = {
56
+ status: 'fail',
57
+ message: `${dupes.length} ${singleton} landmarks on one page — ${singleton} must be unique; screen-reader region navigation becomes ambiguous.`,
58
+ fix: `Keep one ${singleton}; demote the others (remove the role, or use a non-landmark element).`,
59
+ };
60
+ }
61
+ }
62
+
63
+ const groups = {};
64
+ for (const { element, index, role } of landmarkIndexes) {
65
+ if (SINGLETON.has(role)) continue; // already judged above
66
+ const key = `${role}::${landmarkName(element).toLowerCase()}`;
67
+ (groups[key] ??= []).push(index);
68
+ }
69
+ for (const [key, indexes] of Object.entries(groups)) {
70
+ if (indexes.length < 2) continue;
71
+ const [role, name] = key.split('::');
72
+ for (const index of indexes) {
73
+ outcomes[index] = {
74
+ status: 'fail',
75
+ message: name
76
+ ? `${indexes.length} ${role} landmarks share the label "${name}" — screen-reader users can't tell them apart.`
77
+ : `${indexes.length} unlabelled ${role} landmarks — screen-reader users hear "${role}" twice with no way to tell them apart.`,
78
+ fix: 'Give each one a distinct aria-label, e.g. aria-label="Primary" / aria-label="Footer".',
79
+ };
80
+ }
81
+ }
82
+ return outcomes;
83
+ },
84
+ };
@@ -0,0 +1,23 @@
1
+ // Multiple <label>s per field are VALID HTML and accname concatenates them,
2
+ // so nothing in WCAG 3.3.2 prohibits the pattern — demoted from WCAG scope
3
+ // after a spec sweep. It stays worth surfacing: screen readers differ in
4
+ // which label they announce, and the visible-label + sr-only-supplement
5
+ // pair usually reads better as label + aria-describedby.
6
+ export default {
7
+ id: 'multiple-labels',
8
+ impact: 'minor',
9
+ tags: ['best-practice'],
10
+ help: 'Form fields should not have multiple labels',
11
+ helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/labels-or-instructions.html',
12
+ selector: 'input, select, textarea',
13
+ evaluate(element, { isVisible }) {
14
+ // Hidden labels don't compete for announcement.
15
+ const visibleLabels = [...(element.labels ?? [])].filter(isVisible);
16
+ if (visibleLabels.length <= 1) return { status: 'pass' };
17
+ return {
18
+ status: 'fail',
19
+ message: `${visibleLabels.length} visible labels point at this field — screen readers differ in which they announce.`,
20
+ fix: 'Keep one label; move extra guidance into aria-describedby text.',
21
+ };
22
+ },
23
+ };
@@ -0,0 +1,21 @@
1
+ // WCAG technique G200/G201: opening a new window without warning disorients
2
+ // users who can't see the window change. Heuristic is English-only for now.
3
+ const MENTIONS_NEW_WINDOW = /new (window|tab)|opens? in/i;
4
+
5
+ export default {
6
+ id: 'new-window-link',
7
+ impact: 'minor',
8
+ tags: ['best-practice'],
9
+ help: 'Links opening a new window should say so',
10
+ helpUrl: 'https://www.w3.org/WAI/WCAG22/Techniques/general/G201',
11
+ selector: 'a[target="_blank"], a[target="blank"]',
12
+ evaluate(element, { accessibleName }) {
13
+ const name = `${accessibleName(element)} ${element.getAttribute('title') ?? ''}`;
14
+ if (MENTIONS_NEW_WINDOW.test(name)) return { status: 'pass' };
15
+ return {
16
+ status: 'fail',
17
+ message: 'This link opens a new window/tab without saying so — the back button stops working and screen-reader users get no warning about the context change.',
18
+ fix: 'Append visually-hidden text like <span class="sr-only">(opens in new window)</span>, or drop target="_blank".',
19
+ };
20
+ },
21
+ };
@@ -0,0 +1,16 @@
1
+ export default {
2
+ id: 'no-autofocus',
3
+ impact: 'minor',
4
+ tags: ['best-practice'],
5
+ help: 'autofocus disorients assistive-technology users',
6
+ helpUrl: 'https://html.spec.whatwg.org/multipage/interaction.html#the-autofocus-attribute',
7
+ selector: '[autofocus]',
8
+ visibleOnly: false,
9
+ evaluate(element) {
10
+ return {
11
+ status: 'fail',
12
+ message: 'autofocus jumps focus past the page start — screen-reader and magnifier users lose all the context before this element.',
13
+ fix: `Remove autofocus from the <${element.tagName.toLowerCase()}>; let users reach it in document order.`,
14
+ };
15
+ },
16
+ };
@@ -0,0 +1,17 @@
1
+ export default {
2
+ id: 'page-heading-one',
3
+ impact: 'moderate',
4
+ tags: ['best-practice'],
5
+ help: 'Every page should start its outline with an <h1>',
6
+ helpUrl: 'https://www.w3.org/WAI/tutorials/page-structure/headings/',
7
+ selector: 'html',
8
+ visibleOnly: false,
9
+ evaluate(element) {
10
+ if (element.ownerDocument.querySelector('h1, [role="heading"][aria-level="1"]')) return { status: 'pass' };
11
+ return {
12
+ status: 'fail',
13
+ message: 'No <h1> — the page outline has no starting point for screen-reader navigation.',
14
+ fix: 'Add one <h1> naming what the page is about.',
15
+ };
16
+ },
17
+ };