pour-engine 1.5.0 → 1.7.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.5.0',
9
+ version: '1.7.0',
10
10
  },
11
11
  };
package/engine/README.md CHANGED
@@ -108,7 +108,7 @@ Nothing in the spec is silently skipped — that claim is the product.
108
108
 
109
109
  ## Honest current state
110
110
 
111
- 72 rules, hardened on real sites and the `testbeds/` pages. Audits walk into
111
+ 74 rules, hardened on real sites and the `testbeds/` pages. Audits walk into
112
112
  open shadow roots (targets come back as `host >>> inner` selector chains);
113
113
  iframe traversal is the host's job, and the extension does it. Known gaps:
114
114
  the full accname spec (ours is a close approximation) and the language
@@ -2,19 +2,45 @@
2
2
  // the ARIA accname spec: aria-labelledby → aria-label → native labelling
3
3
  // (alt, <label>, text content, value) → title/placeholder fallbacks.
4
4
  //
5
- // Deliberately simpler than the full spec (no recursion into referenced
6
- // hidden subtrees, no CSS content). Good enough for name-presence rules;
7
- // grow it as rules need more.
5
+ // Deliberately simpler than the full spec. Good enough for name-presence
6
+ // rules; grow it as rules need more.
7
+
8
+ /** Input types whose `.value` is user-entered text rather than a state. */
9
+ const TEXT_INPUT = new Set(['text', 'search', 'url', 'tel', 'email', 'password', 'number', 'date',
10
+ 'datetime-local', 'month', 'time', 'week', '']);
8
11
 
9
12
  export function accessibleName(element) {
10
- const labelledby = element.getAttribute('aria-labelledby');
11
- if (labelledby) {
12
- const text = labelledby
13
- .split(/\s+/)
14
- .map((id) => element.getRootNode().getElementById?.(id)?.textContent ?? '')
15
- .join(' ')
16
- .trim();
17
- if (text) return text;
13
+ return computeName(element, false);
14
+ }
15
+
16
+ /** The name aria-labelledby contributes, resolved the way accname requires:
17
+ * each referenced element's NAME, not its raw text. A reference whose target
18
+ * is named by an img alt, an aria-label or a control value names the referrer
19
+ * too, and reading textContent instead reports those elements as nameless.
20
+ * Rules that only need "is it labelled?" call this directly. */
21
+ export function labelledByName(element) {
22
+ const refs = element.getAttribute?.('aria-labelledby');
23
+ if (!refs) return '';
24
+ const root = element.getRootNode();
25
+ return refs
26
+ .split(/\s+/)
27
+ .filter(Boolean)
28
+ .map((id) => {
29
+ const target = root.getElementById?.(id);
30
+ return target ? computeName(target, true) : '';
31
+ })
32
+ .join(' ')
33
+ .replace(/\s+/g, ' ')
34
+ .trim();
35
+ }
36
+
37
+ function computeName(element, inLabelledBy) {
38
+ // accname step 2B: a referenced element's own aria-labelledby is not
39
+ // followed a second time. That is also what stops <p id="a"
40
+ // aria-labelledby="a"> recursing forever.
41
+ if (!inLabelledBy) {
42
+ const fromLabelledBy = labelledByName(element);
43
+ if (fromLabelledBy) return fromLabelledBy;
18
44
  }
19
45
 
20
46
  const ariaLabel = element.getAttribute('aria-label')?.trim();
@@ -42,6 +68,14 @@ export function accessibleName(element) {
42
68
  const alt = element.getAttribute('alt')?.trim();
43
69
  if (alt) return alt;
44
70
  }
71
+ // accname step 2E: an embedded control contributes its value, so
72
+ // aria-labelledby pointing at a filled text box names the referrer with
73
+ // what the user typed. Text-like fields only: a checkbox's `.value` is
74
+ // the string "on", which is a state, not a name.
75
+ if (inLabelledBy && (tag === 'textarea' || TEXT_INPUT.has(element.type))) {
76
+ const value = (element.value ?? '').trim();
77
+ if (value) return value;
78
+ }
45
79
  // HTML-AAM: value-less submit/reset buttons get a UA-default name —
46
80
  // browsers announce "Submit"/"Reset button" without any author text.
47
81
  if (element.type === 'submit') return 'Submit';
@@ -51,18 +85,21 @@ export function accessibleName(element) {
51
85
  // Buttons, links, headings etc. — name from contents, including image
52
86
  // alts, but EXCLUDING hidden content: text inside aria-hidden or
53
87
  // display:none/visibility:hidden subtrees does not name an element
54
- // (per the accname spec).
55
- const fromContents = visibleContentText(element).replace(/\s+/g, ' ').trim();
88
+ // (per the accname spec). The one exception is a subtree reached THROUGH
89
+ // aria-labelledby, where accname ignores the hidden state.
90
+ const fromContents = visibleContentText(element, inLabelledBy).replace(/\s+/g, ' ').trim();
56
91
  if (fromContents) return fromContents;
57
92
 
58
93
  return (element.getAttribute('title') ?? element.getAttribute('placeholder') ?? '').trim();
59
94
  }
60
95
 
61
- function visibleContentText(element) {
96
+ function visibleContentText(element, includeHidden) {
62
97
  // A custom element with a shadow root renders its shadow content — name
63
98
  // from contents follows the FLAT tree (slots inside pull light DOM back).
64
99
  const nodes = element.shadowRoot ? element.shadowRoot.childNodes : element.childNodes;
65
- return generatedContent(element, '::before') + textFromNodes(nodes) + generatedContent(element, '::after');
100
+ return generatedContent(element, '::before')
101
+ + textFromNodes(nodes, includeHidden)
102
+ + generatedContent(element, '::after');
66
103
  }
67
104
 
68
105
  /** CSS generated content contributes to name-from-contents per the accname
@@ -75,7 +112,10 @@ function generatedContent(element, pseudo) {
75
112
  return match ? match[1].replace(/\\(.)/g, '$1') : '';
76
113
  }
77
114
 
78
- function textFromNodes(nodes) {
115
+ /** `includeHidden` is set while resolving an aria-labelledby target: accname
116
+ * ignores the hidden state of referenced content, which is what makes the
117
+ * visually-hidden label span (and the display:none one) work in browsers. */
118
+ function textFromNodes(nodes, includeHidden) {
79
119
  let text = '';
80
120
  for (const node of nodes) {
81
121
  if (node.nodeType === 3 /* TEXT_NODE */) {
@@ -89,15 +129,17 @@ function textFromNodes(nodes) {
89
129
  // on, and sites can override the UA's display:none (seen in the wild
90
130
  // with lazy-load <noscript> fallbacks leaking "<img …>" into names).
91
131
  if (tag === 'script' || tag === 'style' || tag === 'noscript' || tag === 'template') continue;
92
- if (node.getAttribute('aria-hidden') === 'true' || node.hasAttribute('hidden')) continue;
93
- const style = getComputedStyle(node);
94
- if (style.display === 'none' || style.visibility === 'hidden') continue;
132
+ if (!includeHidden) {
133
+ if (node.getAttribute('aria-hidden') === 'true' || node.hasAttribute('hidden')) continue;
134
+ const style = getComputedStyle(node);
135
+ if (style.display === 'none' || style.visibility === 'hidden') continue;
136
+ }
95
137
  // A slot renders its assigned light-DOM nodes (fallback children when
96
138
  // nothing is slotted) — a shadow <button><slot></slot></button> is named
97
139
  // by the text the page slots in.
98
140
  if (tag === 'slot') {
99
141
  const assigned = node.assignedNodes?.() ?? [];
100
- text += textFromNodes(assigned.length ? assigned : node.childNodes);
142
+ text += textFromNodes(assigned.length ? assigned : node.childNodes, includeHidden);
101
143
  continue;
102
144
  }
103
145
  if (tag === 'img' || tag === 'area') {
@@ -111,7 +153,7 @@ function textFromNodes(nodes) {
111
153
  }
112
154
  // Flat-tree descent: shadow content renders in place of a host's
113
155
  // light children (archive.org-style nested web components).
114
- const fromSubtree = textFromNodes(node.shadowRoot ? node.shadowRoot.childNodes : node.childNodes);
156
+ const fromSubtree = textFromNodes(node.shadowRoot ? node.shadowRoot.childNodes : node.childNodes, includeHidden);
115
157
  if (fromSubtree.trim()) {
116
158
  text += fromSubtree;
117
159
  continue;
@@ -24,57 +24,98 @@ export const GLOBAL_ARIA = new Set([
24
24
  'live', 'owns', 'relevant', 'roledescription', 'braillelabel', 'brailleroledescription',
25
25
  ]);
26
26
 
27
- /** Attributes each role supports IN ADDITION to the globals. */
27
+ /** Attributes each role supports IN ADDITION to the globals.
28
+ *
29
+ * Includes the four attributes ARIA 1.2 removed from the GLOBAL set
30
+ * (aria-disabled, aria-errormessage, aria-haspopup, aria-invalid). The spec
31
+ * still lists them on every role that used to inherit them, marked deprecated
32
+ * on that role. Deprecated is not forbidden: the markup conforms and browsers
33
+ * map it, so rejecting it reports a fault on a correct page. Leaving them out
34
+ * cost 74 of the 81 roles here at least one false rejection. If deprecated
35
+ * usage is ever worth surfacing, it belongs in best-practice, not in a
36
+ * critical 4.1.2 failure. */
28
37
  export const ROLE_ARIA = {
29
- link: ['disabled', 'expanded', 'haspopup'],
30
- button: ['disabled', 'expanded', 'haspopup', 'pressed'],
31
- checkbox: ['checked', 'disabled', 'errormessage', 'expanded', 'invalid', 'readonly', 'required'],
32
- switch: ['checked', 'disabled', 'errormessage', 'expanded', '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'],
38
+ link: ['disabled', 'errormessage', 'expanded', 'haspopup', 'invalid'],
39
+ button: ['disabled', 'errormessage', 'expanded', 'haspopup', 'invalid', 'pressed'],
40
+ checkbox: ['checked', 'disabled', 'errormessage', 'expanded', 'haspopup', 'invalid', 'readonly', 'required'],
41
+ switch: ['checked', 'disabled', 'errormessage', 'expanded', 'haspopup', 'invalid', 'readonly', 'required'],
42
+ radio: ['checked', 'disabled', 'errormessage', 'haspopup', 'invalid', 'posinset', 'setsize'],
43
+ option: ['checked', 'disabled', 'errormessage', 'haspopup', 'invalid', 'posinset', 'selected', 'setsize'],
44
+ tab: ['disabled', 'errormessage', 'expanded', 'haspopup', 'invalid', 'posinset', 'selected', 'setsize'],
45
+ menuitem: ['disabled', 'errormessage', 'expanded', 'haspopup', 'invalid', 'posinset', 'setsize'],
46
+ menuitemcheckbox: ['checked', 'disabled', 'errormessage', 'expanded', 'haspopup', 'invalid', 'posinset', 'setsize'],
47
+ menuitemradio: ['checked', 'disabled', 'errormessage', 'expanded', 'haspopup', 'invalid', 'posinset', 'setsize'],
39
48
  textbox: ['activedescendant', 'autocomplete', 'disabled', 'errormessage', 'haspopup', 'invalid', 'multiline', 'placeholder', 'readonly', 'required'],
40
49
  searchbox: ['activedescendant', 'autocomplete', 'disabled', 'errormessage', 'haspopup', 'invalid', 'multiline', 'placeholder', 'readonly', 'required'],
41
50
  combobox: ['activedescendant', 'autocomplete', 'disabled', 'errormessage', 'expanded', 'haspopup', 'invalid', 'readonly', 'required'],
42
- listbox: ['activedescendant', 'disabled', 'errormessage', 'expanded', 'invalid', 'multiselectable', 'orientation', 'readonly', 'required'],
51
+ listbox: ['activedescendant', 'disabled', 'errormessage', 'expanded', 'haspopup', 'invalid', 'multiselectable', 'orientation', 'readonly', 'required'],
43
52
  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: [],
53
+ spinbutton: ['activedescendant', 'disabled', 'errormessage', 'haspopup', 'invalid', 'readonly', 'required', 'valuemax', 'valuemin', 'valuenow', 'valuetext'],
54
+ progressbar: ['disabled', 'errormessage', 'haspopup', 'invalid', 'valuemax', 'valuemin', 'valuenow', 'valuetext'],
55
+ meter: ['disabled', 'errormessage', 'haspopup', 'invalid', 'valuemax', 'valuemin', 'valuenow', 'valuetext'],
56
+ scrollbar: ['disabled', 'errormessage', 'haspopup', 'invalid', 'orientation', 'valuemax', 'valuemin', 'valuenow', 'valuetext'],
57
+ heading: ['disabled', 'errormessage', 'haspopup', 'invalid', 'level'],
58
+ list: ['disabled', 'errormessage', 'haspopup', 'invalid'],
59
+ listitem: ['disabled', 'errormessage', 'haspopup', 'invalid', 'level', 'posinset', 'setsize'],
60
+ row: ['activedescendant', 'colindex', 'disabled', 'errormessage', 'expanded', 'haspopup', 'invalid', 'level', 'posinset', 'rowindex', 'selected', 'setsize'],
61
+ rowgroup: ['disabled', 'errormessage', 'haspopup', 'invalid'],
62
+ cell: ['colindex', 'colspan', 'disabled', 'errormessage', 'haspopup', 'invalid', 'rowindex', 'rowspan'],
63
+ gridcell: ['colindex', 'colspan', 'disabled', 'errormessage', 'expanded', 'haspopup', 'invalid', 'readonly', 'required', 'rowindex', 'rowspan', 'selected'],
64
+ columnheader: ['colindex', 'colspan', 'disabled', 'errormessage', 'expanded', 'haspopup', 'invalid', 'readonly', 'required', 'rowindex', 'rowspan', 'selected', 'sort'],
65
+ rowheader: ['colindex', 'colspan', 'disabled', 'errormessage', 'expanded', 'haspopup', 'invalid', 'readonly', 'required', 'rowindex', 'rowspan', 'selected', 'sort'],
66
+ table: ['colcount', 'disabled', 'errormessage', 'haspopup', 'invalid', 'rowcount'],
67
+ grid: ['activedescendant', 'colcount', 'disabled', 'errormessage', 'haspopup', 'invalid', 'multiselectable', 'readonly', 'rowcount'],
68
+ treegrid: ['activedescendant', 'colcount', 'disabled', 'errormessage', 'haspopup', 'invalid', 'multiselectable', 'orientation', 'readonly', 'required', 'rowcount'],
69
+ tablist: ['activedescendant', 'disabled', 'errormessage', 'haspopup', 'invalid', 'multiselectable', 'orientation'],
70
+ menu: ['activedescendant', 'disabled', 'errormessage', 'haspopup', 'invalid', 'orientation'],
71
+ menubar: ['activedescendant', 'disabled', 'errormessage', 'haspopup', 'invalid', 'orientation'],
72
+ tree: ['activedescendant', 'disabled', 'errormessage', 'haspopup', 'invalid', 'multiselectable', 'orientation', 'required'],
73
+ treeitem: ['checked', 'disabled', 'errormessage', 'expanded', 'haspopup', 'invalid', 'level', 'posinset', 'selected', 'setsize'],
74
+ radiogroup: ['activedescendant', 'disabled', 'errormessage', 'haspopup', 'invalid', 'orientation', 'readonly', 'required'],
75
+ group: ['activedescendant', 'disabled', 'errormessage', 'haspopup', 'invalid'],
76
+ separator: ['disabled', 'errormessage', 'haspopup', 'invalid', 'orientation', 'valuemax', 'valuemin', 'valuenow', 'valuetext'],
77
+ toolbar: ['activedescendant', 'disabled', 'errormessage', 'haspopup', 'invalid', 'orientation'],
78
+ dialog: ['disabled', 'errormessage', 'haspopup', 'invalid', 'modal'],
79
+ alertdialog: ['disabled', 'errormessage', 'haspopup', 'invalid', 'modal'],
80
+ application: ['activedescendant', 'disabled', 'errormessage', 'expanded', 'haspopup', 'invalid'],
81
+ article: ['disabled', 'errormessage', 'haspopup', 'invalid', 'posinset', 'setsize'],
82
+ img: ['disabled', 'errormessage', 'haspopup', 'invalid'],
83
+ figure: ['disabled', 'errormessage', 'haspopup', 'invalid'],
84
+ document: ['disabled', 'errormessage', 'haspopup', 'invalid'],
85
+ feed: ['disabled', 'errormessage', 'haspopup', 'invalid'],
86
+ math: ['disabled', 'errormessage', 'haspopup', 'invalid'],
87
+ note: ['disabled', 'errormessage', 'haspopup', 'invalid'],
88
+ presentation: ['disabled', 'errormessage', 'haspopup', 'invalid'],
89
+ none: ['disabled', 'errormessage', 'haspopup', 'invalid'],
90
+ banner: ['disabled', 'errormessage', 'haspopup', 'invalid'],
91
+ complementary: ['disabled', 'errormessage', 'haspopup', 'invalid'],
92
+ contentinfo: ['disabled', 'errormessage', 'haspopup', 'invalid'],
93
+ form: ['disabled', 'errormessage', 'haspopup', 'invalid'],
94
+ main: ['disabled', 'errormessage', 'haspopup', 'invalid'],
95
+ navigation: ['disabled', 'errormessage', 'haspopup', 'invalid'],
96
+ region: ['disabled', 'errormessage', 'haspopup', 'invalid'],
97
+ search: ['disabled', 'errormessage', 'haspopup', 'invalid'],
98
+ alert: ['disabled', 'errormessage', 'haspopup', 'invalid'],
99
+ log: ['disabled', 'errormessage', 'haspopup', 'invalid'],
100
+ marquee: ['disabled', 'errormessage', 'haspopup', 'invalid'],
101
+ status: ['disabled', 'errormessage', 'haspopup', 'invalid'],
102
+ timer: ['disabled', 'errormessage', 'haspopup', 'invalid'],
103
+ tabpanel: ['disabled', 'errormessage', 'haspopup', 'invalid'],
104
+ tooltip: ['disabled', 'errormessage', 'haspopup', 'invalid'],
105
+ definition: ['disabled', 'errormessage', 'haspopup', 'invalid'],
106
+ term: ['disabled', 'errormessage', 'haspopup', 'invalid'],
107
+ paragraph: ['disabled', 'errormessage', 'haspopup', 'invalid'],
108
+ generic: ['disabled', 'errormessage', 'haspopup', 'invalid'],
109
+ blockquote: ['disabled', 'errormessage', 'haspopup', 'invalid'],
110
+ caption: ['disabled', 'errormessage', 'haspopup', 'invalid'],
111
+ code: ['disabled', 'errormessage', 'haspopup', 'invalid'],
112
+ emphasis: ['disabled', 'errormessage', 'haspopup', 'invalid'],
113
+ strong: ['disabled', 'errormessage', 'haspopup', 'invalid'],
114
+ time: ['disabled', 'errormessage', 'haspopup', 'invalid'],
115
+ deletion: ['disabled', 'errormessage', 'haspopup', 'invalid'],
116
+ insertion: ['disabled', 'errormessage', 'haspopup', 'invalid'],
117
+ subscript: ['disabled', 'errormessage', 'haspopup', 'invalid'],
118
+ superscript: ['disabled', 'errormessage', 'haspopup', 'invalid'],
78
119
  };
79
120
 
80
121
  const INPUT_ROLES = {
@@ -5,6 +5,14 @@
5
5
  // component", so no success criterion (4.1.2 included) actually fails.
6
6
  // Best-practice scope is the honest home; demoted from WCAG scope after an
7
7
  // independent audit found it dominating WCAG results on news/commerce pages.
8
+ /** Roles whose accessible name is built from their contents, so a label on
9
+ * anything inside them contributes to that name rather than being dropped. */
10
+ const NAME_FROM_CONTENT = 'a[href], button, summary, h1, h2, h3, h4, h5, h6, th, td,'
11
+ + ' [role="button"], [role="link"], [role="menuitem"], [role="menuitemcheckbox"],'
12
+ + ' [role="menuitemradio"], [role="option"], [role="tab"], [role="treeitem"],'
13
+ + ' [role="checkbox"], [role="radio"], [role="switch"], [role="heading"],'
14
+ + ' [role="cell"], [role="gridcell"], [role="columnheader"], [role="rowheader"], [role="tooltip"]';
15
+
8
16
  export default {
9
17
  id: 'aria-label-misuse',
10
18
  impact: 'moderate',
@@ -13,6 +21,12 @@ export default {
13
21
  helpUrl: 'https://www.w3.org/TR/using-aria/#practical-support-aria-label-aria-labelledby-and-aria-describedby',
14
22
  selector: 'div[aria-label]:not([role]):not([tabindex]), span[aria-label]:not([role]):not([tabindex]), p[aria-label]:not([role]):not([tabindex])',
15
23
  evaluate(element) {
24
+ // Inside a name-from-content ancestor the label is NOT ignored: accname
25
+ // applies the whole algorithm to each descendant while building the
26
+ // ancestor's name, so this label becomes the button's or link's name.
27
+ // Chromium reports exactly that. Claiming it does nothing is false, and
28
+ // the advised fix would delete the control's only name.
29
+ if (element.closest(NAME_FROM_CONTENT)) return { status: 'pass' };
16
30
  return {
17
31
  status: 'fail',
18
32
  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.`,
@@ -1,5 +1,6 @@
1
1
  // WAI forms tutorial: radio/checkbox groups need a group label — each
2
2
  // input's own label isn't enough to convey what the group asks.
3
+ import { labelledByName } from '../../lib/accessible-name.js';
3
4
  export default {
4
5
  id: 'fieldset-legend',
5
6
  impact: 'moderate',
@@ -19,10 +20,20 @@ export default {
19
20
  for (const indexes of Object.values(groups)) {
20
21
  if (indexes.length < 2) continue;
21
22
  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]');
23
+ // A <fieldset> IS a group; it does not need role="group" written on it
24
+ // to be one, and a legend is one way to name it rather than the only
25
+ // way. Requiring an explicit role attribute reported a correctly named
26
+ // group as unlabelled, which the browser plainly contradicts.
27
+ // Name the GROUP the three ways a group can be named: its own legend,
28
+ // a reference, or a label. Not a general accessible-name call, which
29
+ // would fall through to name-from-contents and read the options inside
30
+ // the fieldset as if they were its name.
31
+ const group = first.closest('fieldset, [role="group"], [role="radiogroup"]');
32
+ const grouped = group && (
33
+ group.querySelector(':scope > legend')?.textContent.trim()
34
+ || labelledByName(group)
35
+ || group.getAttribute('aria-label')?.trim()
36
+ );
26
37
  if (grouped) continue;
27
38
  outcomes[indexes[0]] = {
28
39
  status: 'fail',
@@ -2,6 +2,7 @@
2
2
  // labels, and banner / contentinfo / main must be unique per page outright
3
3
  // (two "banners" are ambiguous however well they're labelled).
4
4
  import { implicitRole } from '../../lib/roles.js';
5
+ import { labelledByName } from '../../lib/accessible-name.js';
5
6
 
6
7
  const SINGLETON = new Set(['banner', 'contentinfo', 'main']);
7
8
 
@@ -13,18 +14,11 @@ const landmarkRole = (element) =>
13
14
 
14
15
  // Landmarks are not name-from-content roles: only author-provided labels
15
16
  // 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
- };
17
+ const landmarkName = (element) =>
18
+ labelledByName(element)
19
+ || element.getAttribute('aria-label')?.trim()
20
+ || element.getAttribute('title')?.trim()
21
+ || '';
28
22
 
29
23
  export default {
30
24
  id: 'landmark-unique',
@@ -1,3 +1,14 @@
1
+ import { effectiveRole } from '../../lib/roles.js';
2
+
3
+ // "Redundant" only means anything for a role that takes its name FROM its
4
+ // contents. A landmark is never named by the text inside it, and neither is
5
+ // a select (its text is the option list), so an aria-label that happens to
6
+ // match that text is the element's ONLY name. Removing it, as this rule
7
+ // advises, leaves the element anonymous.
8
+ const NAME_FROM_CONTENT = new Set(['button', 'link', 'menuitem', 'menuitemcheckbox',
9
+ 'menuitemradio', 'option', 'tab', 'treeitem', 'checkbox', 'radio', 'switch', 'heading',
10
+ 'cell', 'gridcell', 'columnheader', 'rowheader', 'tooltip']);
11
+
1
12
  export default {
2
13
  id: 'redundant-aria-label',
3
14
  impact: 'minor',
@@ -6,6 +17,7 @@ export default {
6
17
  helpUrl: 'https://www.w3.org/TR/using-aria/#rule2',
7
18
  selector: '[aria-label]',
8
19
  evaluate(element) {
20
+ if (!NAME_FROM_CONTENT.has(effectiveRole(element))) return { status: 'pass' };
9
21
  const label = element.getAttribute('aria-label').replace(/\s+/g, ' ').trim().toLowerCase();
10
22
  const visible = element.textContent.replace(/\s+/g, ' ').trim().toLowerCase();
11
23
  if (!visible || label !== visible) return { status: 'pass' };
@@ -1,6 +1,7 @@
1
1
  // WCAG SC 1.3.1 Info and Relationships (Level A)
2
2
  // Composite roles are meaningless without their required child roles —
3
3
  // role="list" with no listitems announces as an empty list.
4
+ import { implicitRole } from '../../lib/roles.js';
4
5
  const REQUIRED_CHILDREN = {
5
6
  list: ['listitem'],
6
7
  listbox: ['option', 'group'],
@@ -15,30 +16,33 @@ const REQUIRED_CHILDREN = {
15
16
  rowgroup: ['row'],
16
17
  };
17
18
 
18
- const IMPLICIT = { li: 'listitem', option: 'option', tr: 'row', tbody: 'rowgroup', thead: 'rowgroup', tfoot: 'rowgroup' };
19
-
20
19
  const roleOf = (element) =>
21
20
  element.getAttribute('role')?.trim().split(/\s+/)[0]?.toLowerCase() ??
22
- IMPLICIT[element.tagName.toLowerCase()];
21
+ implicitRole(element);
23
22
 
24
23
  /**
25
- * The children a composite role OWNS in the accessibility tree — not the
26
- * DOM children. role="presentation"/"none" removes an element but leaves
27
- * its children in place, and role-less div/span wrappers are generic nodes
28
- * the required-ownership relation passes straight through (tabs inside a
29
- * styling wrapper inside a tablist are still the tablist's tabs).
24
+ * Everything the container could plausibly own: its whole subtree, plus any
25
+ * element it claims by aria-owns.
26
+ *
27
+ * This deliberately does not model ownership precisely. The rule's job is to
28
+ * catch a container that holds NONE of the children its role needs, which is
29
+ * what makes a screen reader announce an empty list. Enumerating which wrapper
30
+ * tags ownership passes through was the wrong shape for that: the allow-list
31
+ * held div and span only, so the ordinary tabs-in-list-items pattern was
32
+ * reported as a broken tablist. A required role anywhere inside means the
33
+ * container is not empty, and that is the only claim this rule should make.
30
34
  */
31
- function ownedChildren(element) {
32
- const owned = [];
33
- for (const child of element.children) {
34
- if (child.matches('script, style, template')) continue;
35
- const role = child.getAttribute('role')?.trim().split(/\s+/)[0]?.toLowerCase();
36
- const passThrough = role === 'presentation' || role === 'none'
37
- || (!role && (child.tagName === 'DIV' || child.tagName === 'SPAN'));
38
- if (passThrough) owned.push(...ownedChildren(child));
39
- else owned.push(child);
35
+ function candidateDescendants(element) {
36
+ const found = [...element.querySelectorAll('*')].filter((el) => !el.matches('script, style, template'));
37
+ const owns = element.getAttribute('aria-owns');
38
+ if (owns) {
39
+ const root = element.getRootNode();
40
+ for (const id of owns.split(/\s+/).filter(Boolean)) {
41
+ const target = root.getElementById?.(id);
42
+ if (target) found.push(target, ...target.querySelectorAll('*'));
43
+ }
40
44
  }
41
- return owned;
45
+ return found;
42
46
  }
43
47
 
44
48
  export default {
@@ -55,11 +59,13 @@ export default {
55
59
  const children = [...element.children].filter((c) => !c.matches('script, style, template'));
56
60
  // A completely empty container is a lazy-load placeholder more often
57
61
  // than a defect — it announces as an empty list, which is accurate.
58
- if (!children.length) return { status: 'pass' };
59
- if (ownedChildren(element).some((child) => required.includes(roleOf(child)))) return { status: 'pass' };
62
+ if (!children.length && !element.hasAttribute('aria-owns')) return { status: 'pass' };
63
+ if (candidateDescendants(element).some((child) => required.includes(roleOf(child)))) {
64
+ return { status: 'pass' };
65
+ }
60
66
  return {
61
67
  status: 'fail',
62
- message: `role="${role}" contains no ${required.join('/')} children (not even through wrapper elements) — screen readers announce a broken, empty ${role}.`,
68
+ message: `role="${role}" contains no ${required.join('/')} anywhere inside it, and claims none by aria-owns — screen readers announce a broken, empty ${role}.`,
63
69
  fix: `Give the item elements role="${required[0]}", or remove role="${role}" from the container.`,
64
70
  };
65
71
  },
@@ -28,6 +28,16 @@ export default {
28
28
  evaluate(element) {
29
29
  const role = element.getAttribute('role').trim().split(/\s+/)[0].toLowerCase();
30
30
  const containers = REQUIRED_PARENT[role];
31
+ // aria-owns makes the relationship without containment: the owner can sit
32
+ // anywhere in the document and the browser still builds the structure.
33
+ // Judging by DOM ancestry alone failed every remotely-owned child.
34
+ if (element.id) {
35
+ const owner = element.getRootNode().querySelector?.(`[aria-owns~="${CSS.escape(element.id)}"]`);
36
+ const ownerRole = owner && (
37
+ owner.getAttribute('role')?.trim().split(/\s+/)[0]?.toLowerCase()
38
+ ?? IMPLICIT_CONTAINER[owner.tagName.toLowerCase()]);
39
+ if (ownerRole && containers.includes(ownerRole)) return { status: 'pass' };
40
+ }
31
41
  for (let parent = element.parentElement; parent; parent = parent.parentElement) {
32
42
  const parentRole =
33
43
  parent.getAttribute('role')?.trim().split(/\s+/)[0]?.toLowerCase() ??
@@ -2,6 +2,8 @@
2
2
  // Instructions (Level A) · SC 4.1.2 Name, Role, Value (Level A)
3
3
  // 3.3.2 mapping: a field with no label at all, or only a vanishing
4
4
  // placeholder, is failure F82 — the classic 3.3.2 pattern.
5
+ import { labelledByName } from '../../lib/accessible-name.js';
6
+
5
7
  export default {
6
8
  id: 'form-label',
7
9
  impact: 'critical',
@@ -30,12 +32,7 @@ export default {
30
32
  .join(' ')
31
33
  .trim();
32
34
  const ariaLabel = element.getAttribute('aria-label')?.trim();
33
- const labelledby = (element.getAttribute('aria-labelledby') ?? '')
34
- .split(/\s+/)
35
- .filter(Boolean)
36
- .map((id) => element.getRootNode().getElementById?.(id)?.textContent ?? '')
37
- .join(' ')
38
- .trim();
35
+ const labelledby = labelledByName(element);
39
36
  if (labelsText || ariaLabel || labelledby) return { status: 'pass' };
40
37
 
41
38
  const id = element.id ? ` (it already has id="${element.id}")` : ' (give it an id first)';
@@ -1,5 +1,9 @@
1
1
  // WCAG SC 1.3.1 Info and Relationships (Level A)
2
- const NEVER_RENDERED = new Set(['SCRIPT', 'TEMPLATE']);
2
+ // Nothing here reaches the accessibility tree, so none of it can corrupt a
3
+ // list's structure. STYLE belongs with them: CSS-in-JS libraries inject
4
+ // <style> right where the markup sits, and it was being counted as a stray
5
+ // child that broke the list.
6
+ const NEVER_RENDERED = new Set(['SCRIPT', 'TEMPLATE', 'STYLE', 'LINK', 'META']);
3
7
  const ITEM_ROLES = ['listitem', 'presentation', 'none'];
4
8
 
5
9
  export default {
@@ -9,7 +13,7 @@ export default {
9
13
  help: 'Lists must only contain list items',
10
14
  helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/info-and-relationships.html',
11
15
  selector: 'ul, ol',
12
- evaluate(element) {
16
+ evaluate(element, { isRendered }) {
13
17
  // A role attribute replaces the HTML list semantics — a <ul role="tablist">
14
18
  // is judged by ARIA structure rules, not HTML list rules.
15
19
  if (element.hasAttribute('role') && element.getAttribute('role') !== 'list') return { status: 'pass' };
@@ -29,6 +33,9 @@ export default {
29
33
  // genuinely corrupts the list.
30
34
  const isValidChild = (child) => {
31
35
  if (NEVER_RENDERED.has(child.tagName)) return true;
36
+ // A child the browser never renders is not in the accessibility tree
37
+ // either, so it cannot be corrupting the list a reader hears.
38
+ if (isRendered && !isRendered(child)) return true;
32
39
  // An explicit role REPLACES an <li>'s listitem semantics: a carousel
33
40
  // built as <ul> with <li role="group"> slides no longer owns any
34
41
  // listitems, so the list announces broken/empty. Only a role-less
@@ -15,15 +15,19 @@ export default {
15
15
  // List ownership passes through presentation/none wrappers and
16
16
  // role-less div/span grouping wrappers (generic nodes) — an <li> in a
17
17
  // framework's wrapping div inside a <ul> is still that list's item.
18
- while (parent && (
18
+ // A list CONTAINER stops the walk even when it is itself presentational:
19
+ // ARIA propagates an explicit presentation role to a role's required
20
+ // owned elements, so the <li>s in a <ul role="none"> are presentational
21
+ // too. There is no listitem left to be orphaned, and stepping over the
22
+ // <ul> used to blame whatever happened to be above it.
23
+ const listContainer = (el) => el.matches('ul, ol, menu') || el.getAttribute('role') === 'list';
24
+ while (parent && !listContainer(parent) && (
19
25
  ['presentation', 'none'].includes(parent.getAttribute('role') ?? '')
20
26
  || (!parent.hasAttribute('role') && (parent.tagName === 'DIV' || parent.tagName === 'SPAN'))
21
27
  )) {
22
28
  parent = parent.assignedSlot?.parentElement ?? parent.parentElement ?? parent.getRootNode()?.host;
23
29
  }
24
- if (parent && (parent.matches('ul, ol, menu') || parent.getAttribute('role') === 'list')) {
25
- return { status: 'pass' };
26
- }
30
+ if (parent && listContainer(parent)) return { status: 'pass' };
27
31
  return {
28
32
  status: 'fail',
29
33
  message: `This <li> sits inside <${parent?.tagName.toLowerCase() ?? 'nothing'}> — outside a list, screen readers lose the item's list context entirely.`,
@@ -22,7 +22,10 @@ export default {
22
22
  visibility: 'visual',
23
23
  evaluate(element) {
24
24
  // Inactive controls are exempt (same carve-out 1.4.3 makes).
25
- if (element.disabled || element.closest('[aria-disabled="true"]')) return { status: 'pass' };
25
+ // `:disabled`, not `element.disabled`: the property misses a control
26
+ // disabled by an ancestor <fieldset disabled>, which 1.4.11 exempts as an
27
+ // inactive user interface component.
28
+ if (element.closest(':disabled, [aria-disabled="true"]')) return { status: 'pass' };
26
29
  // Zero-area controls (JS-widget hidden duplicates) paint no boundary
27
30
  // to judge.
28
31
  const ownRect = element.getBoundingClientRect();
@@ -4,6 +4,7 @@
4
4
  // endemic ~2.8:1 gray-on-white) was previously never measured by any rule.
5
5
  import {
6
6
  parseColor, contrastRatio, composite, effectiveBackground, isLargeText,
7
+ backgroundImageSource, cumulativeOpacity, opacityAnimating, restingOpacity,
7
8
  } from '../../lib/contrast.js';
8
9
 
9
10
  const showRatio = (ratio) => (Math.floor(ratio * 100) / 100).toFixed(2);
@@ -20,19 +21,39 @@ export default {
20
21
  visibility: 'visual',
21
22
  evaluate(element) {
22
23
  // 1.4.3 exempts inactive controls.
23
- if (element.disabled || element.closest('[aria-disabled="true"]')) return { status: 'pass' };
24
+ // `:disabled`, not `element.disabled`: the property reflects only the
25
+ // element's own attribute, so a control inside <fieldset disabled> reads
26
+ // false while the browser has it inactive and 1.4.3 exempts it.
27
+ if (element.closest(':disabled, [aria-disabled="true"]')) return { status: 'pass' };
24
28
  // Zero-area controls (JS-widget hidden duplicates) present no text.
25
29
  const rect = element.getBoundingClientRect();
26
30
  if (rect.width <= 1 || rect.height <= 1) return { status: 'pass' };
27
31
  const style = getComputedStyle(element);
32
+ // Judge the opacity the text RESTS at, matching the main contrast rule:
33
+ // a control faded out by an ancestor (fade-in-on-scroll, a collapsed
34
+ // panel) presents no text at all, and one held at a low opacity presents
35
+ // far less contrast than its declared colours suggest.
36
+ const opacity = opacityAnimating(element) ? restingOpacity(element) : cumulativeOpacity(element);
37
+ if (opacity < 0.05) return { status: 'pass' };
28
38
  // The control's own background wins; else whatever shows through it.
29
39
  const own = parseColor(style.backgroundColor);
30
- const background = own && own.a >= 1
31
- ? own
32
- : (() => {
33
- const behind = effectiveBackground(element);
34
- return behind && own && own.a > 0 ? composite(own, behind) : behind;
35
- })();
40
+ let background;
41
+ if (own && own.a >= 1) {
42
+ background = own;
43
+ } else {
44
+ // An image or gradient painted under a see-through control is a
45
+ // backdrop of pixels this rule cannot sample. The main rule samples
46
+ // them; here the honest answer is to hand it to a person rather than
47
+ // judge against the colour layer alone as if the image were not there.
48
+ if (backgroundImageSource(element)) {
49
+ return {
50
+ status: 'incomplete',
51
+ message: 'This field is see-through and sits over a background image or gradient, so its real text contrast depends on the pixels behind it. Check it by eye.',
52
+ };
53
+ }
54
+ const behind = effectiveBackground(element);
55
+ background = behind && own && own.a > 0 ? composite(own, behind) : behind;
56
+ }
36
57
  if (!background) {
37
58
  return { status: 'incomplete', message: 'The control’s background could not be determined — check its text contrast by eye.' };
38
59
  }
@@ -41,7 +62,10 @@ export default {
41
62
  const judge = (color, what) => {
42
63
  const parsed = parseColor(color);
43
64
  if (!parsed || parsed.a === 0) return null;
44
- const fg = parsed.a < 1 ? composite(parsed, background) : parsed;
65
+ // Same treatment the main rule gives faded text: what reaches the eye
66
+ // is the declared colour thinned by the opacity it is painted at.
67
+ const faded = opacity < 1 ? { ...parsed, a: parsed.a * opacity } : parsed;
68
+ const fg = faded.a < 1 ? composite(faded, background) : faded;
45
69
  const ratio = contrastRatio(fg, background);
46
70
  if (ratio >= required) return null;
47
71
  return { what, ratio };
@@ -18,18 +18,41 @@ export default {
18
18
  const doc = element.ownerDocument;
19
19
  const roots = collectRoots(doc);
20
20
  const inAnyRoot = (selector) => roots.some((root) => root.querySelector?.(selector));
21
- if (inAnyRoot('main, [role="main"]')) return { status: 'pass' };
21
+ // ARIA11 accepts landmarks generally, not the main landmark alone: a
22
+ // banner and a navigation region are exactly what landmark navigation
23
+ // skips between. Requiring `main` failed pages that were already using
24
+ // the technique the spec names.
25
+ const LANDMARKS = 'main, [role="main"], header, [role="banner"], nav, [role="navigation"],'
26
+ + ' aside, [role="complementary"], footer, [role="contentinfo"], [role="search"], form[role="search"]';
27
+ if (inAnyRoot(LANDMARKS)) return { status: 'pass' };
22
28
  if (inAnyRoot('h1, h2, h3, h4, h5, h6, [role="heading"]')) return { status: 'pass' };
29
+ // G1/G123/G124: ANY in-page link that resolves is a bypass mechanism.
30
+ // Reading only the first one hid a real skip link behind the href="#"
31
+ // that scripts use as a button.
23
32
  for (const root of roots) {
24
- const firstLink = root.querySelector?.('a[href^="#"]');
25
- const id = firstLink?.getAttribute('href').slice(1);
26
- if (id && roots.some((r) => r.getElementById?.(id) || r.querySelector?.(`[id="${CSS.escape(id)}"]`))) {
27
- return { status: 'pass' };
33
+ for (const link of root.querySelectorAll?.('a[href^="#"]') ?? []) {
34
+ const id = link.getAttribute('href').slice(1);
35
+ if (!id) continue;
36
+ if (roots.some((r) => r.getElementById?.(id) || r.querySelector?.(`[id="${CSS.escape(id)}"]`))) {
37
+ return { status: 'pass' };
38
+ }
28
39
  }
29
40
  }
41
+ // H64: a titled iframe is itself a way past the block it holds.
42
+ const frames = [...(doc.querySelectorAll('iframe, frame') ?? [])];
43
+ if (frames.length && frames.every((frame) => frame.getAttribute('title')?.trim())) {
44
+ return { status: 'pass' };
45
+ }
46
+ // The criterion governs "blocks of content that are repeated on multiple
47
+ // Web pages". One page cannot prove repetition, but it can show there is
48
+ // no block to bypass at all: a page with barely any links has nothing a
49
+ // reader would need to skip, and failing it names a duty that never
50
+ // applied.
51
+ const linkCount = roots.reduce((n, root) => n + (root.querySelectorAll?.('a[href], button').length ?? 0), 0);
52
+ if (linkCount < 4) return { status: 'pass' };
30
53
  return {
31
54
  status: 'fail',
32
- message: 'No skip link, main landmark, or headings — keyboard users must Tab through the whole header/nav to reach anything.',
55
+ message: `This page has ${linkCount} links and buttons but no landmark, heading, skip link, or titled frame — keyboard and screen-reader users must go through the whole header and nav to reach anything.`,
33
56
  fix: 'Add a skip link like <a href="#content">Skip to content</a>, wrap primary content in <main>, or structure the page with headings.',
34
57
  };
35
58
  },
@@ -76,7 +76,9 @@ export default {
76
76
  && rect.top >= overlay.top && rect.bottom <= overlay.bottom;
77
77
 
78
78
  return elements.map((element) => {
79
- if (element.disabled) return { status: 'pass' };
79
+ // `:disabled` catches controls disabled by an ancestor fieldset, which
80
+ // the `disabled` property does not reflect — those never take focus.
81
+ if (element.matches(':disabled')) return { status: 'pass' };
80
82
  const rect = element.getBoundingClientRect();
81
83
  if (!rect.width || !rect.height) return { status: 'pass' };
82
84
  // Only in-viewport geometry is trustworthy (or cheap).
@@ -1,10 +1,16 @@
1
1
  // WCAG SC 3.3.8 Accessible Authentication (Minimum) (Level AA)
2
- // The SC requires a cognitive-function-test-free way to authenticate, and
3
- // names copy-paste and password managers as the mechanisms that provide
4
- // it. Blocking paste on a password/OTP field removes exactly that
5
- // mechanism — a provable failure. autocomplete="off" merely ASKS the
6
- // browser not to help; password managers often fill anyway, so that's a
7
- // review flag, not an assertion.
2
+ // The SC permits a cognitive function test when the step offers an
3
+ // Alternative, a Mechanism, Object Recognition, or Personal Content. Note 2
4
+ // names TWO example mechanisms, separately: password-manager support, and
5
+ // copy and paste.
6
+ //
7
+ // That separation is what this rule turns on. Blocking paste removes one of
8
+ // the two, but a password manager fills a field programmatically and never
9
+ // touches the clipboard, so the other mechanism usually survives — and a
10
+ // page may also offer an Alternative (a passkey, a federated sign-in, an
11
+ // emailed link) that this rule cannot see. Blocked paste is therefore
12
+ // reported for REVIEW, not failed. Both mechanisms obstructed at once is the
13
+ // case worth asserting.
8
14
  export default {
9
15
  id: 'auth-field-obstruction',
10
16
  impact: 'serious',
@@ -19,14 +25,23 @@ export default {
19
25
  const handler = element.getAttribute(attr) ?? '';
20
26
  return /return\s+false|preventDefault/.test(handler);
21
27
  });
22
- if (blocking) {
28
+ const autocompleteOff = (element.getAttribute('autocomplete') ?? '').trim().toLowerCase() === 'off';
29
+ const action = blocking === 'onpaste' ? 'pasting' : 'dropping';
30
+ if (blocking && autocompleteOff) {
23
31
  return {
24
32
  status: 'fail',
25
- message: `This authentication field blocks ${blocking === 'onpaste' ? 'pasting' : 'dropping'} — users of password managers must retype credentials from memory, a cognitive function test 3.3.8 forbids.`,
26
- fix: 'Remove the paste/drop blocking; let password managers and copy-paste work.',
33
+ message: `This authentication field blocks ${action} AND sets autocomplete="off", so both of the mechanisms 3.3.8 names are obstructed at once: copy and paste, and password-manager entry. What is left is typing the credential from memory.`,
34
+ fix: 'Remove the paste/drop blocking, and drop autocomplete="off" so a password manager can fill the field.',
35
+ };
36
+ }
37
+ if (blocking) {
38
+ return {
39
+ status: 'incomplete',
40
+ message: `This authentication field blocks ${action}, which removes copy and paste, one of the two mechanisms 3.3.8 names. A password manager fills the field directly and is unaffected, so this is only a failure if nothing else here helps. Check whether a password manager can complete this login, or whether the page offers another way in such as a passkey or a federated sign-in.`,
41
+ fix: `Remove the ${action === 'pasting' ? 'paste' : 'drop'} blocking, or make sure another way to sign in is available that does not rely on recalling the credential.`,
27
42
  };
28
43
  }
29
- if ((element.getAttribute('autocomplete') ?? '').trim().toLowerCase() === 'off') {
44
+ if (autocompleteOff) {
30
45
  return {
31
46
  status: 'incomplete',
32
47
  message: 'This authentication field sets autocomplete="off", discouraging password managers. Browsers often fill anyway — verify a password manager can complete this login without retyping.',
@@ -5,20 +5,16 @@
5
5
  // Spec note (accname): input-like roles are NOT "name from content" — the
6
6
  // text inside a listbox/textbox does not name it, only author-provided
7
7
  // naming counts. Toggle roles (checkbox/radio/switch) DO name from content.
8
+ import { labelledByName } from '../../lib/accessible-name.js';
9
+
8
10
  const AUTHOR_ONLY = ['textbox', 'searchbox', 'combobox', 'listbox', 'spinbutton', 'slider'];
9
11
  const FROM_CONTENT = ['checkbox', 'radio', 'switch'];
10
12
 
11
13
  function authorName(element) {
12
- const labelledby = element.getAttribute('aria-labelledby');
13
- if (labelledby) {
14
- const text = labelledby
15
- .split(/\s+/)
16
- .map((id) => element.getRootNode().getElementById?.(id)?.textContent ?? '')
17
- .join(' ')
18
- .trim();
19
- if (text) return text;
20
- }
21
- return element.getAttribute('aria-label')?.trim() || element.getAttribute('title')?.trim() || '';
14
+ return labelledByName(element)
15
+ || element.getAttribute('aria-label')?.trim()
16
+ || element.getAttribute('title')?.trim()
17
+ || '';
22
18
  }
23
19
 
24
20
  export default {
@@ -23,7 +23,7 @@ export default {
23
23
  // property, so a faded WRAPPER (the standard off-canvas drawer) leaves
24
24
  // every control inside it computing opacity: 1 while none of them paint.
25
25
  const focusable = [element, ...element.querySelectorAll(FOCUSABLE)].filter(
26
- (el) => el.matches?.(FOCUSABLE) && el.getAttribute('tabindex') !== '-1' && !el.disabled
26
+ (el) => el.matches?.(FOCUSABLE) && el.getAttribute('tabindex') !== '-1' && !el.matches(':disabled')
27
27
  // `inert` removes focusability outright — aria-hidden + inert is
28
28
  // the CORRECT modern pattern for off-canvas content, not a defect.
29
29
  && !el.closest('[inert]')
@@ -4,6 +4,8 @@
4
4
  // name. An open, unnamed modal announces as just "dialog", leaving
5
5
  // screen-reader users with no idea what interrupted them (cookie-consent
6
6
  // modals are the canonical offender).
7
+ import { labelledByName } from '../../lib/accessible-name.js';
8
+
7
9
  export default {
8
10
  id: 'dialog-name',
9
11
  impact: 'serious',
@@ -14,15 +16,7 @@ export default {
14
16
  // dialogs the user can actually encounter are judged.
15
17
  selector: 'dialog, [role="dialog"], [role="alertdialog"]',
16
18
  evaluate(element) {
17
- const labelledby = element.getAttribute('aria-labelledby');
18
- if (labelledby) {
19
- const text = labelledby
20
- .split(/\s+/)
21
- .map((id) => element.getRootNode().getElementById?.(id)?.textContent ?? '')
22
- .join(' ')
23
- .trim();
24
- if (text) return { status: 'pass' };
25
- }
19
+ if (labelledByName(element)) return { status: 'pass' };
26
20
  if (element.getAttribute('aria-label')?.trim() || element.getAttribute('title')?.trim()) {
27
21
  return { status: 'pass' };
28
22
  }
@@ -18,7 +18,7 @@ export default {
18
18
  // disabled controls, display:none templates, and tabindex="-1" nodes
19
19
  // inside a control are inert markup, not competing tab stops.
20
20
  const nested = [...element.querySelectorAll(INTERACTIVE)].find((el) =>
21
- !el.disabled && el.getAttribute('tabindex') !== '-1'
21
+ !el.matches(':disabled') && el.getAttribute('tabindex') !== '-1'
22
22
  && !(el.tagName === 'INPUT' && el.type === 'hidden') && isRendered(el));
23
23
  if (!nested) return { status: 'pass' };
24
24
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pour-engine",
3
- "version": "1.5.0",
3
+ "version": "1.7.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",