pour-engine 1.33.0 → 1.34.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.
- package/config/project.config.js +1 -1
- package/engine/COVERAGE.md +5 -5
- package/engine/index.js +11 -4
- package/engine/lib/accessible-name.js +15 -4
- package/engine/lib/contrast.js +316 -11
- package/engine/lib/roles.js +30 -6
- package/engine/rules/best-practice/button-type.js +23 -2
- package/engine/rules/best-practice/fieldset-legend.js +7 -2
- package/engine/rules/best-practice/heading-order.js +2 -1
- package/engine/rules/best-practice/landmark-top-level.js +10 -0
- package/engine/rules/best-practice/landmark-unique.js +9 -1
- package/engine/rules/best-practice/new-window-link.js +34 -1
- package/engine/rules/best-practice/no-autofocus.js +15 -3
- package/engine/rules/best-practice/page-heading-one.js +15 -1
- package/engine/rules/best-practice/redundant-alt-phrase.js +9 -1
- package/engine/rules/best-practice/redundant-aria-label.js +24 -0
- package/engine/rules/best-practice/redundant-role.js +71 -20
- package/engine/rules/best-practice/region.js +6 -2
- package/engine/rules/index.js +13 -0
- package/engine/rules/wcag/1.1.1-input-image-alt.js +4 -1
- package/engine/rules/wcag/1.1.1-object-alt.js +17 -0
- package/engine/rules/wcag/1.2.1-audio-transcript.js +47 -0
- package/engine/rules/wcag/1.2.2-media-captions.js +16 -1
- package/engine/rules/wcag/1.3.1-aria-required-children.js +30 -3
- package/engine/rules/wcag/1.3.1-aria-required-parent.js +25 -9
- package/engine/rules/wcag/1.3.1-definition-list.js +15 -3
- package/engine/rules/wcag/1.3.1-dlitem-parent.js +14 -1
- package/engine/rules/wcag/1.3.1-form-label.js +39 -1
- package/engine/rules/wcag/1.3.1-listitem-parent.js +23 -7
- package/engine/rules/wcag/1.3.1-table-headers.js +34 -3
- package/engine/rules/wcag/1.3.2-reading-order-divergence.js +69 -0
- package/engine/rules/wcag/1.3.4-orientation.js +21 -7
- package/engine/rules/wcag/1.3.5-autocomplete-valid.js +87 -13
- package/engine/rules/wcag/1.4.1-link-in-text-block.js +224 -17
- package/engine/rules/wcag/1.4.11-non-text-contrast.js +27 -3
- package/engine/rules/wcag/1.4.2-audio-control.js +45 -14
- package/engine/rules/wcag/1.4.3-color-contrast.js +337 -47
- package/engine/rules/wcag/1.4.3-control-contrast.js +26 -5
- package/engine/rules/wcag/2.2.1-meta-refresh.js +25 -1
- package/engine/rules/wcag/2.2.2-pause-stop-hide.js +26 -3
- package/engine/rules/wcag/2.4.1-bypass-blocks.js +29 -5
- package/engine/rules/wcag/2.4.2-document-title.js +8 -2
- package/engine/rules/wcag/2.4.3-visual-order-divergence.js +6 -4
- package/engine/rules/wcag/2.4.4-link-name.js +11 -0
- package/engine/rules/wcag/2.4.4-link-text-generic.js +22 -1
- package/engine/rules/wcag/2.4.7-focus-visible.js +47 -10
- package/engine/rules/wcag/2.4.9-link-text-generic-only.js +36 -5
- package/engine/rules/wcag/2.5.3-label-in-name.js +14 -5
- package/engine/rules/wcag/2.5.5-target-size-enhanced.js +3 -1
- package/engine/rules/wcag/2.5.7-drag-alternative.js +38 -0
- package/engine/rules/wcag/2.5.8-target-size.js +122 -3
- package/engine/rules/wcag/3.1.1-html-lang.js +12 -1
- package/engine/rules/wcag/3.2.2-on-input-navigation.js +18 -8
- package/engine/rules/wcag/3.3.7-redundant-entry.js +55 -0
- package/engine/rules/wcag/3.3.8-auth-field-obstruction.js +50 -20
- package/engine/rules/wcag/4.1.2-aria-attr-valid.js +49 -13
- package/engine/rules/wcag/4.1.2-aria-hidden-focus.js +6 -1
- package/engine/rules/wcag/4.1.2-aria-valid-refs.js +44 -73
- package/engine/rules/wcag/4.1.2-button-name.js +11 -0
- package/engine/rules/wcag/4.1.2-composite-widget-name.js +12 -6
- package/engine/rules/wcag/4.1.2-label-for-valid.js +41 -8
- package/engine/rules/wcag/4.1.2-nested-interactive.js +18 -1
- package/engine/rules/wcag/4.1.2-valid-role.js +16 -0
- package/engine/wcag22.js +4 -4
- package/package.json +1 -1
|
@@ -1,6 +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
|
+
import { accessibleName, labelledByName } from '../../lib/accessible-name.js';
|
|
4
4
|
export default {
|
|
5
5
|
id: 'fieldset-legend',
|
|
6
6
|
name: 'Grouped field labels',
|
|
@@ -29,9 +29,14 @@ export default {
|
|
|
29
29
|
// a reference, or a label. Not a general accessible-name call, which
|
|
30
30
|
// would fall through to name-from-contents and read the options inside
|
|
31
31
|
// the fieldset as if they were its name.
|
|
32
|
+
// The legend itself IS named by the accessible-name computation: a
|
|
33
|
+
// legend holding an <img alt="Choose a colour"> names the group (the
|
|
34
|
+
// browser exposes group "Choose a colour"), and raw textContent called
|
|
35
|
+
// it empty (2026-08-25 overnight audit).
|
|
32
36
|
const group = first.closest('fieldset, [role="group"], [role="radiogroup"]');
|
|
37
|
+
const legend = group?.querySelector(':scope > legend');
|
|
33
38
|
const grouped = group && (
|
|
34
|
-
|
|
39
|
+
(legend && accessibleName(legend))
|
|
35
40
|
|| labelledByName(group)
|
|
36
41
|
|| group.getAttribute('aria-label')?.trim()
|
|
37
42
|
);
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { effectiveRole } from '../../lib/roles.js';
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
// Shared with page-heading-one so both rules agree on what level 1 is.
|
|
4
|
+
export const level = (el) =>
|
|
4
5
|
el.hasAttribute('aria-level')
|
|
5
6
|
? parseInt(el.getAttribute('aria-level'), 10)
|
|
6
7
|
: parseInt(el.tagName[1], 10) || 2; // role="heading" without aria-level defaults to 2
|
|
@@ -9,6 +9,15 @@ const LANDMARK_ROLES = new Set([
|
|
|
9
9
|
'main', 'navigation', 'banner', 'contentinfo', 'complementary', 'region', 'search', 'form',
|
|
10
10
|
]);
|
|
11
11
|
|
|
12
|
+
/** HTML-AAM (form, section): "If a form has no accessible name, do not
|
|
13
|
+
* expose the element as a landmark", and a section is a region only with
|
|
14
|
+
* an accessible name. An unnamed page-wrapping <form> (the WebForms
|
|
15
|
+
* form runat="server" shape) is therefore no landmark container at all,
|
|
16
|
+
* and the banner, main and contentinfo inside it are top level. Mirrors
|
|
17
|
+
* region.js, decided locally here (2026-08-25 overnight audit). */
|
|
18
|
+
const named = (element) =>
|
|
19
|
+
element.hasAttribute('aria-label') || element.hasAttribute('aria-labelledby') || element.hasAttribute('title');
|
|
20
|
+
|
|
12
21
|
/** The nearest ancestor that is a landmark in the accessibility tree —
|
|
13
22
|
* explicit role OR implicit (nav, labelled section/form, top-level
|
|
14
23
|
* header/footer). A selector can't express the implicit cases; a
|
|
@@ -18,6 +27,7 @@ function landmarkAncestor(element) {
|
|
|
18
27
|
for (let parent = element.parentElement; parent; parent = parent.parentElement) {
|
|
19
28
|
const explicit = parent.getAttribute('role')?.trim().split(/\s+/)[0]?.toLowerCase();
|
|
20
29
|
const role = explicit ?? implicitRole(parent);
|
|
30
|
+
if (!explicit && (role === 'form' || role === 'region') && !named(parent)) continue;
|
|
21
31
|
if (role && LANDMARK_ROLES.has(role)) return { element: parent, role };
|
|
22
32
|
}
|
|
23
33
|
return null;
|
|
@@ -6,6 +6,14 @@ import { labelledByName } from '../../lib/accessible-name.js';
|
|
|
6
6
|
|
|
7
7
|
const SINGLETON = new Set(['banner', 'contentinfo', 'main']);
|
|
8
8
|
|
|
9
|
+
// ARIA 1.2, Landmark Roles: the eight roles that are landmarks. An explicit
|
|
10
|
+
// non-landmark role on a landmark element (<nav role="menubar">) makes it a
|
|
11
|
+
// widget, not a landmark, so it has nothing to be distinguished from
|
|
12
|
+
// (2026-08-25 overnight audit).
|
|
13
|
+
const LANDMARK_ROLES = new Set([
|
|
14
|
+
'banner', 'complementary', 'contentinfo', 'form', 'main', 'navigation', 'region', 'search',
|
|
15
|
+
]);
|
|
16
|
+
|
|
9
17
|
/** Landmark role of the element in the accessibility tree — explicit role
|
|
10
18
|
* first, else the implicit role (which handles header/footer demotion
|
|
11
19
|
* inside sectioning content and unlabelled sections being generic). */
|
|
@@ -39,7 +47,7 @@ export default {
|
|
|
39
47
|
const outcomes = elements.map(() => ({ status: 'pass' }));
|
|
40
48
|
const landmarkIndexes = elements
|
|
41
49
|
.map((element, index) => ({ element, index, role: roles[index] }))
|
|
42
|
-
.filter(({ role }) => role
|
|
50
|
+
.filter(({ role }) => LANDMARK_ROLES.has(role));
|
|
43
51
|
|
|
44
52
|
// Singleton roles: more than one banner/contentinfo/main is a failure
|
|
45
53
|
// regardless of labels.
|
|
@@ -2,6 +2,27 @@
|
|
|
2
2
|
// users who can't see the window change. Heuristic is English-only for now.
|
|
3
3
|
const MENTIONS_NEW_WINDOW = /new (window|tab)|opens? in/i;
|
|
4
4
|
|
|
5
|
+
/** The nearest declared language. An undeclared language is judged as
|
|
6
|
+
* English (html-lang reports the missing declaration itself). */
|
|
7
|
+
function effectiveLang(element) {
|
|
8
|
+
return element.closest('[lang]')?.getAttribute('lang')?.trim()
|
|
9
|
+
|| element.ownerDocument.documentElement.getAttribute('lang')?.trim()
|
|
10
|
+
|| '';
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** G201 asks only that a warning is "spoken in assistive technology"; its
|
|
14
|
+
* own second example delivers it through aria-describedby, so the
|
|
15
|
+
* description joins the text tested. Targets are read hidden or not, the
|
|
16
|
+
* way the description computation reads them (2026-08-25 overnight audit). */
|
|
17
|
+
function describedByText(element) {
|
|
18
|
+
const refs = element.getAttribute('aria-describedby');
|
|
19
|
+
if (!refs) return '';
|
|
20
|
+
const root = element.getRootNode();
|
|
21
|
+
return refs.split(/\s+/).filter(Boolean)
|
|
22
|
+
.map((id) => root.getElementById?.(id)?.textContent ?? '')
|
|
23
|
+
.join(' ');
|
|
24
|
+
}
|
|
25
|
+
|
|
5
26
|
export default {
|
|
6
27
|
id: 'new-window-link',
|
|
7
28
|
name: 'New-window warnings',
|
|
@@ -11,8 +32,20 @@ export default {
|
|
|
11
32
|
helpUrl: 'https://www.w3.org/WAI/WCAG22/Techniques/general/G201',
|
|
12
33
|
selector: 'a[target="_blank"], a[target="blank"]',
|
|
13
34
|
evaluate(element, { accessibleName }) {
|
|
14
|
-
const name = `${accessibleName(element)} ${element.getAttribute('title') ?? ''}`;
|
|
35
|
+
const name = `${accessibleName(element)} ${element.getAttribute('title') ?? ''} ${describedByText(element)}`;
|
|
15
36
|
if (MENTIONS_NEW_WINDOW.test(name)) return { status: 'pass' };
|
|
37
|
+
// The wording test reads English only. In another language the DOM
|
|
38
|
+
// cannot prove the warning is missing ("nouvelle fenêtre" satisfies
|
|
39
|
+
// G201 just as well), so the verdict is a question, not a failure
|
|
40
|
+
// (2026-08-25 overnight audit).
|
|
41
|
+
const lang = effectiveLang(element);
|
|
42
|
+
if (lang && !/^en(?:[-_]|$)/i.test(lang)) {
|
|
43
|
+
return {
|
|
44
|
+
status: 'incomplete',
|
|
45
|
+
message: `This link opens a new window or tab. Does its name, title or description say so in the page's language ("${lang}")? The automated wording check reads English only.`,
|
|
46
|
+
fix: 'Say so in the link text or a visually hidden span (for example the local-language equivalent of "opens in new window"), or drop target="_blank".',
|
|
47
|
+
};
|
|
48
|
+
}
|
|
16
49
|
return {
|
|
17
50
|
status: 'fail',
|
|
18
51
|
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.',
|
|
@@ -1,3 +1,13 @@
|
|
|
1
|
+
// Inside a dialog or popover the attribute never acts on page load: the HTML
|
|
2
|
+
// dialog focusing steps pick the autofocus descendant as the control to
|
|
3
|
+
// focus when the dialog is SHOWN, and the spec's own note tells authors to
|
|
4
|
+
// put autofocus on exactly that element (or on the dialog itself when there
|
|
5
|
+
// is none). Outside those containers the spec's caution ("use careful
|
|
6
|
+
// consideration") is real, but its own example of a page whose main purpose
|
|
7
|
+
// IS the control is legitimate use, so the DOM cannot prove a failure: the
|
|
8
|
+
// verdict is a review question (2026-08-25 overnight audit).
|
|
9
|
+
const FOCUS_CONTAINER = 'dialog, [role="dialog"], [role="alertdialog"], [popover]';
|
|
10
|
+
|
|
1
11
|
export default {
|
|
2
12
|
id: 'no-autofocus',
|
|
3
13
|
name: 'Autofocus on load',
|
|
@@ -8,10 +18,12 @@ export default {
|
|
|
8
18
|
selector: '[autofocus]',
|
|
9
19
|
visibleOnly: false,
|
|
10
20
|
evaluate(element) {
|
|
21
|
+
if (element.closest(FOCUS_CONTAINER)) return { status: 'pass' };
|
|
22
|
+
const tag = element.tagName.toLowerCase();
|
|
11
23
|
return {
|
|
12
|
-
status: '
|
|
13
|
-
message:
|
|
14
|
-
fix: `Remove autofocus from the <${
|
|
24
|
+
status: 'incomplete',
|
|
25
|
+
message: `autofocus moves focus to this <${tag}> as soon as the page loads, so screen-reader and magnifier users skip everything before it. Review: the HTML spec allows this only when the control is the page's main purpose (a search box on a search page, say); otherwise it disorients.`,
|
|
26
|
+
fix: `Remove autofocus from the <${tag}> unless this control is the page's whole purpose; let users reach it in document order.`,
|
|
15
27
|
};
|
|
16
28
|
},
|
|
17
29
|
};
|
|
@@ -1,3 +1,15 @@
|
|
|
1
|
+
import { collectRoots } from '../../lib/dom.js';
|
|
2
|
+
import { level } from './heading-order.js';
|
|
3
|
+
|
|
4
|
+
// The outline is what the accessibility tree builds: it spans every open
|
|
5
|
+
// shadow root (a web-component shell whose only h1 renders in its shadow
|
|
6
|
+
// tree HAS an h1, exactly as landmark-one-main counts mains), and a
|
|
7
|
+
// heading's level is aria-level when set, else the tag's own digit, on any
|
|
8
|
+
// h1-h6 or role="heading". heading-order's level helper decides both rules,
|
|
9
|
+
// so <h2 aria-level="1"> starts the outline and <h1 aria-level="2"> does
|
|
10
|
+
// not (2026-08-25 overnight audit).
|
|
11
|
+
const HEADING = 'h1, h2, h3, h4, h5, h6, [role="heading"]';
|
|
12
|
+
|
|
1
13
|
export default {
|
|
2
14
|
id: 'page-heading-one',
|
|
3
15
|
name: 'Leading page heading',
|
|
@@ -8,7 +20,9 @@ export default {
|
|
|
8
20
|
selector: 'html',
|
|
9
21
|
visibleOnly: false,
|
|
10
22
|
evaluate(element) {
|
|
11
|
-
|
|
23
|
+
const hasLevelOne = collectRoots(element.ownerDocument)
|
|
24
|
+
.some((root) => [...root.querySelectorAll(HEADING)].some((heading) => level(heading) === 1));
|
|
25
|
+
if (hasLevelOne) return { status: 'pass' };
|
|
12
26
|
return {
|
|
13
27
|
status: 'fail',
|
|
14
28
|
message: 'No <h1> — the page outline has no starting point for screen-reader navigation.',
|
|
@@ -1,7 +1,15 @@
|
|
|
1
1
|
// "photo of"/"photograph of" is deliberately NOT flagged: for portraits it
|
|
2
2
|
// conveys the medium (a photo of a person vs. a logo or illustration),
|
|
3
3
|
// which is widely accepted alt style — flagging it is noise.
|
|
4
|
-
|
|
4
|
+
//
|
|
5
|
+
// The tip this rule cites (WAI images tutorial) is about announcing the
|
|
6
|
+
// MEDIUM, so only the prefix shape counts: the word followed by "of", a
|
|
7
|
+
// colon, a hyphen, "showing"/"depicting", or the word alone as the whole
|
|
8
|
+
// alt. "Graphic design services" and "Picture frame, oak" are content, and
|
|
9
|
+
// stripping their first word destroys the meaning; "graphic" and "picture"
|
|
10
|
+
// are common nouns, so they count only with "of" (2026-08-25 overnight audit).
|
|
11
|
+
const REDUNDANT_PHRASE =
|
|
12
|
+
/^(?:(?:image|icon)s?(?:\s*[:-]\s*|\s+(?:of|showing|depicting)\b\s*|\s*$)|(?:picture|graphic)s?\s+of\b\s*)/i;
|
|
5
13
|
|
|
6
14
|
export default {
|
|
7
15
|
id: 'redundant-alt-phrase',
|
|
@@ -9,6 +9,29 @@ const NAME_FROM_CONTENT = new Set(['button', 'link', 'menuitem', 'menuitemcheckb
|
|
|
9
9
|
'menuitemradio', 'option', 'tab', 'treeitem', 'checkbox', 'radio', 'switch', 'heading',
|
|
10
10
|
'cell', 'gridcell', 'columnheader', 'rowheader', 'tooltip']);
|
|
11
11
|
|
|
12
|
+
const EMPTY_CONTENT = new Set(['none', 'normal', '""', "''"]);
|
|
13
|
+
|
|
14
|
+
/** Name-from-content is not textContent. accname 2F adds ::before/::after
|
|
15
|
+
* text, image alternatives and svg titles, and drops hidden subtrees; when
|
|
16
|
+
* any of those sit inside the element, the aria-label is what keeps the
|
|
17
|
+
* announced name clean (an icon-font glyph before "Menu" would otherwise
|
|
18
|
+
* be spoken as part of the name), so it is not redundant. Only computed
|
|
19
|
+
* for elements whose label already matched their text, so the per-element
|
|
20
|
+
* style reads stay bounded to real candidates (2026-08-25 overnight audit). */
|
|
21
|
+
function contentBeyondText(element) {
|
|
22
|
+
const nodes = [element, ...element.querySelectorAll('*')];
|
|
23
|
+
for (const node of nodes) {
|
|
24
|
+
const tag = node.tagName.toLowerCase();
|
|
25
|
+
if (tag === 'img' || tag === 'area' || tag === 'svg') return true;
|
|
26
|
+
if (node !== element && (node.getAttribute('aria-hidden') === 'true' || node.hasAttribute('hidden'))) return true;
|
|
27
|
+
const style = getComputedStyle(node);
|
|
28
|
+
if (node !== element && (style.display === 'none' || style.visibility === 'hidden')) return true;
|
|
29
|
+
if (!EMPTY_CONTENT.has(getComputedStyle(node, '::before').content)) return true;
|
|
30
|
+
if (!EMPTY_CONTENT.has(getComputedStyle(node, '::after').content)) return true;
|
|
31
|
+
}
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
|
|
12
35
|
export default {
|
|
13
36
|
id: 'redundant-aria-label',
|
|
14
37
|
name: 'Redundant aria-label',
|
|
@@ -22,6 +45,7 @@ export default {
|
|
|
22
45
|
const label = element.getAttribute('aria-label').replace(/\s+/g, ' ').trim().toLowerCase();
|
|
23
46
|
const visible = element.textContent.replace(/\s+/g, ' ').trim().toLowerCase();
|
|
24
47
|
if (!visible || label !== visible) return { status: 'pass' };
|
|
48
|
+
if (contentBeyondText(element)) return { status: 'pass' };
|
|
25
49
|
return {
|
|
26
50
|
status: 'fail',
|
|
27
51
|
message: `aria-label="${element.getAttribute('aria-label')}" is identical to the element's visible text — it adds nothing and will drift out of sync when the text changes.`,
|
|
@@ -1,29 +1,76 @@
|
|
|
1
1
|
// "Overdoing it" family: explicit role attributes that duplicate the
|
|
2
2
|
// element's built-in (implicit) ARIA role are noise — they add maintenance
|
|
3
3
|
// risk without helping assistive technology.
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
4
|
+
//
|
|
5
|
+
// The implicit role comes from lib/roles.js, not a private table: the
|
|
6
|
+
// contextual cases (an unnamed aside inside sectioning content is generic,
|
|
7
|
+
// so role="complementary" on it is the ONLY thing making it a landmark) are
|
|
8
|
+
// modelled there and verified against Chromium. The set of elements judged
|
|
9
|
+
// stays as it was, so nothing new is asserted (2026-08-25 overnight audit).
|
|
10
|
+
import { implicitRole as modelledRole } from '../../lib/roles.js';
|
|
11
|
+
|
|
12
|
+
const JUDGED_TAGS = new Set([
|
|
13
|
+
'ul', 'ol', 'li', 'button', 'nav', 'main', 'aside', 'form', 'table', 'article', 'img', 'hr',
|
|
14
|
+
'progress', 'textarea', 'dialog', 'option', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
|
|
10
15
|
// Table structure (th is skipped: its implicit role is contextual —
|
|
11
16
|
// columnheader or rowheader). header/footer/section are contextual too.
|
|
12
|
-
tr
|
|
13
|
-
|
|
14
|
-
};
|
|
17
|
+
'tr', 'thead', 'tbody', 'tfoot', 'td', 'fieldset', 'a', 'input',
|
|
18
|
+
]);
|
|
15
19
|
|
|
16
|
-
const
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
};
|
|
20
|
+
const JUDGED_INPUT_TYPES = new Set([
|
|
21
|
+
'checkbox', 'radio', 'button', 'submit', 'reset', 'text', 'email', 'tel', 'url',
|
|
22
|
+
'range', 'number', 'search',
|
|
23
|
+
]);
|
|
21
24
|
|
|
22
25
|
function implicitRole(element) {
|
|
23
26
|
const tag = element.tagName.toLowerCase();
|
|
24
|
-
if (tag
|
|
25
|
-
if (tag === 'input'
|
|
26
|
-
|
|
27
|
+
if (!JUDGED_TAGS.has(tag)) return null;
|
|
28
|
+
if (tag === 'input' && !JUDGED_INPUT_TYPES.has(element.type)) return null;
|
|
29
|
+
const role = modelledRole(element);
|
|
30
|
+
// generic / presentation are "no role to duplicate" here, never a match.
|
|
31
|
+
return role && role !== 'generic' && role !== 'presentation' ? role : null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Native display value of each table-structure element. When CSS has
|
|
35
|
+
// replaced it (display:flex / grid / block responsive tables), browsers
|
|
36
|
+
// stop exposing a data table and the explicit role is what restores the
|
|
37
|
+
// semantics (MDN, table role: "when CSS's display property overrides the
|
|
38
|
+
// native semantics of a table ... you can use the ARIA table roles to
|
|
39
|
+
// re-add the semantics"). Same shape as the list-style exception
|
|
40
|
+
// (2026-08-25 overnight audit).
|
|
41
|
+
const TABLE_DISPLAY = {
|
|
42
|
+
table: ['table', 'inline-table'],
|
|
43
|
+
thead: ['table-header-group'], tbody: ['table-row-group'], tfoot: ['table-footer-group'],
|
|
44
|
+
tr: ['table-row'], td: ['table-cell'],
|
|
45
|
+
};
|
|
46
|
+
const TABLE_ROLES = new Set(['table', 'rowgroup', 'row', 'cell']);
|
|
47
|
+
|
|
48
|
+
function tableSemanticsStripped(element, tag) {
|
|
49
|
+
const table = tag === 'table' ? element : element.closest('table');
|
|
50
|
+
if (table && !TABLE_DISPLAY.table.includes(getComputedStyle(table).display)) return true;
|
|
51
|
+
const native = TABLE_DISPLAY[tag];
|
|
52
|
+
return !!native && !native.includes(getComputedStyle(element).display);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** An item with no marker: list-style-type none on the item itself, or a
|
|
56
|
+
* ::marker emptied by CSS. Safari/VoiceOver drops list semantics whenever
|
|
57
|
+
* the bullets go, however the CSS spells it (the write-up the exception
|
|
58
|
+
* is based on: "any CSS that would remove the bullet or number indicators
|
|
59
|
+
* of a list's items will also remove the semantics"). The ul's own
|
|
60
|
+
* list-style-type stays "disc" when the property sits on the li, which is
|
|
61
|
+
* the more common spelling (2026-08-25 overnight audit). */
|
|
62
|
+
function markerRemoved(item) {
|
|
63
|
+
if (getComputedStyle(item).listStyleType === 'none') return true;
|
|
64
|
+
const marker = getComputedStyle(item, '::marker').content;
|
|
65
|
+
return marker === 'none' || marker === '""' || marker === "''";
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function listMarkersRemoved(list) {
|
|
69
|
+
if (getComputedStyle(list).listStyleType === 'none') return true;
|
|
70
|
+
for (const child of list.children) {
|
|
71
|
+
if (child.tagName === 'LI' && markerRemoved(child)) return true;
|
|
72
|
+
}
|
|
73
|
+
return false;
|
|
27
74
|
}
|
|
28
75
|
|
|
29
76
|
export default {
|
|
@@ -38,19 +85,23 @@ export default {
|
|
|
38
85
|
const explicit = element.getAttribute('role').trim().split(/\s+/)[0]?.toLowerCase();
|
|
39
86
|
const implicit = implicitRole(element);
|
|
40
87
|
if (!implicit || explicit !== implicit) return { status: 'pass' };
|
|
88
|
+
const tag = element.tagName.toLowerCase();
|
|
41
89
|
// The exceptions this rule's own advice describes are honoured, not
|
|
42
90
|
// just documented:
|
|
43
|
-
// - role="list"/"listitem" on
|
|
91
|
+
// - role="list"/"listitem" on marker-less lists is the standard
|
|
44
92
|
// fix for Safari/VoiceOver dropping list semantics — keep it.
|
|
45
93
|
// - role="img" on an SVG-source <img> is the standard fix for
|
|
46
94
|
// VoiceOver skipping the alt text of SVG images — keep it.
|
|
47
|
-
|
|
95
|
+
// - table roles on a table whose CSS display is no longer table-* are
|
|
96
|
+
// what keeps it a data table: keep them.
|
|
97
|
+
if (explicit === 'list' && listMarkersRemoved(element)) return { status: 'pass' };
|
|
48
98
|
if (explicit === 'listitem') {
|
|
99
|
+
if (markerRemoved(element)) return { status: 'pass' };
|
|
49
100
|
const list = element.closest('ul, ol');
|
|
50
101
|
if (list && getComputedStyle(list).listStyleType === 'none') return { status: 'pass' };
|
|
51
102
|
}
|
|
103
|
+
if (TABLE_ROLES.has(explicit) && tableSemanticsStripped(element, tag)) return { status: 'pass' };
|
|
52
104
|
if (explicit === 'img' && /\.svg([?#]|$)/i.test(element.currentSrc || element.src || '')) return { status: 'pass' };
|
|
53
|
-
const tag = element.tagName.toLowerCase();
|
|
54
105
|
const caveat = implicit === 'list'
|
|
55
106
|
? ' (Exception: keep it if you set list-style: none — Safari/VoiceOver drops list semantics without it.)'
|
|
56
107
|
: '';
|
|
@@ -4,14 +4,18 @@
|
|
|
4
4
|
// Divs do NOT demote — a header wrapped in plain divs is still a banner.
|
|
5
5
|
// role="alertdialog" joins dialog: both are modal containers whose content
|
|
6
6
|
// region navigation reaches through the dialog itself, and consent banners
|
|
7
|
-
// (measured on a live news home) are exactly this shape.
|
|
7
|
+
// (measured on a live news home) are exactly this shape. The native
|
|
8
|
+
// <dialog> element has the same implicit role and the same policy; only a
|
|
9
|
+
// showing one (the open attribute is set by show() and showModal()) counts,
|
|
10
|
+
// so a closed dialog cannot make a landmark-less page look landmarked
|
|
11
|
+
// (2026-08-25 overnight audit).
|
|
8
12
|
const LANDMARK =
|
|
9
13
|
'main, nav, aside, form[aria-label], form[aria-labelledby], ' +
|
|
10
14
|
'header:not(:is(article, aside, main, nav, section) header), ' +
|
|
11
15
|
'footer:not(:is(article, aside, main, nav, section) footer), ' +
|
|
12
16
|
'section[aria-label], section[aria-labelledby], [role="main"], [role="navigation"], ' +
|
|
13
17
|
'[role="banner"], [role="contentinfo"], [role="complementary"], [role="region"], ' +
|
|
14
|
-
'[role="search"], [role="form"], [role="dialog"], [role="alertdialog"]';
|
|
18
|
+
'[role="search"], [role="form"], [role="dialog"], [role="alertdialog"], dialog[open]';
|
|
15
19
|
|
|
16
20
|
export default {
|
|
17
21
|
id: 'region',
|
package/engine/rules/index.js
CHANGED
|
@@ -90,6 +90,15 @@ import embedAlt from './wcag/1.1.1-embed-alt.js';
|
|
|
90
90
|
import canvasAlt from './wcag/1.1.1-canvas-alt.js';
|
|
91
91
|
import videoLoopMotion from './wcag/2.2.2-video-loop-motion.js';
|
|
92
92
|
import visualOrderDivergence from './wcag/2.4.3-visual-order-divergence.js';
|
|
93
|
+
// 2026-08-25 gap batch (reports/coverage-gaps/2026-08-25-1915.html): the four
|
|
94
|
+
// A/AA criteria neither engine covered where an honest review question
|
|
95
|
+
// exists. All four ask, none asserts — each SC's failure hinges on a fact
|
|
96
|
+
// (a transcript nearby, an essential confirmation, a no-drag path, meaning
|
|
97
|
+
// in a sequence) the DOM cannot prove.
|
|
98
|
+
import audioTranscript from './wcag/1.2.1-audio-transcript.js';
|
|
99
|
+
import readingOrderDivergence from './wcag/1.3.2-reading-order-divergence.js';
|
|
100
|
+
import dragAlternative from './wcag/2.5.7-drag-alternative.js';
|
|
101
|
+
import redundantEntry from './wcag/3.3.7-redundant-entry.js';
|
|
93
102
|
import onInputNavigation from './wcag/3.2.2-on-input-navigation.js';
|
|
94
103
|
import errorMessageLinkage from './wcag/3.3.1-error-message-linkage.js';
|
|
95
104
|
import compositeWidgetName from './wcag/4.1.2-composite-widget-name.js';
|
|
@@ -185,6 +194,10 @@ export default [
|
|
|
185
194
|
canvasAlt,
|
|
186
195
|
videoLoopMotion,
|
|
187
196
|
visualOrderDivergence,
|
|
197
|
+
audioTranscript,
|
|
198
|
+
readingOrderDivergence,
|
|
199
|
+
dragAlternative,
|
|
200
|
+
redundantEntry,
|
|
188
201
|
onInputNavigation,
|
|
189
202
|
errorMessageLinkage,
|
|
190
203
|
compositeWidgetName,
|
|
@@ -11,7 +11,10 @@ export default {
|
|
|
11
11
|
if (accessibleName(element)) return { status: 'pass' };
|
|
12
12
|
return {
|
|
13
13
|
status: 'fail',
|
|
14
|
-
|
|
14
|
+
// HTML-AAM: a nameless image input takes an implementation-defined
|
|
15
|
+
// name such as "Submit", which says nothing about what THIS button
|
|
16
|
+
// does (2026-08-25 overnight audit).
|
|
17
|
+
message: 'This image button has no alt text, so browsers fall back to a generic "Submit" name that does not describe what the button does.',
|
|
15
18
|
fix: 'Add alt="What the button does", e.g. alt="Search".',
|
|
16
19
|
};
|
|
17
20
|
},
|
|
@@ -1,4 +1,9 @@
|
|
|
1
1
|
// WCAG SC 1.1.1 Non-text Content (Level A)
|
|
2
|
+
// An <object> that declares a TEXT document (type="text/…", PDF, XHTML, or
|
|
3
|
+
// a typeless data URL whose extension says document) embeds text, not
|
|
4
|
+
// non-text content, so 1.1.1's applicability is itself in question there.
|
|
5
|
+
// That case asks instead of asserting, the same gate embed-alt applies to
|
|
6
|
+
// its sibling element (2026-08-25 overnight audit closed the asymmetry).
|
|
2
7
|
export default {
|
|
3
8
|
id: 'object-alt',
|
|
4
9
|
name: 'Object alt text',
|
|
@@ -9,6 +14,18 @@ export default {
|
|
|
9
14
|
selector: 'object',
|
|
10
15
|
evaluate(element, { accessibleName }) {
|
|
11
16
|
if (accessibleName(element) || element.textContent.trim()) return { status: 'pass' };
|
|
17
|
+
const type = (element.getAttribute('type') ?? '').trim().toLowerCase();
|
|
18
|
+
const data = (element.getAttribute('data') ?? '').split(/[?#]/)[0].toLowerCase();
|
|
19
|
+
const isDocument = type.startsWith('text/')
|
|
20
|
+
|| type === 'application/pdf' || type === 'application/xhtml+xml'
|
|
21
|
+
|| (!type && /\.(html?|xhtml|pdf|txt)$/.test(data));
|
|
22
|
+
if (isDocument) {
|
|
23
|
+
return {
|
|
24
|
+
status: 'incomplete',
|
|
25
|
+
message: 'This embedded object renders a text document but has no accessible name, so assistive technology users get no indication of what the embedded document is. Check it is identified in the surrounding context.',
|
|
26
|
+
fix: 'Add aria-label="…" naming the embedded document, or fallback content inside the <object> element.',
|
|
27
|
+
};
|
|
28
|
+
}
|
|
12
29
|
return {
|
|
13
30
|
status: 'fail',
|
|
14
31
|
message: 'This embedded object has no text alternative — users who can’t see it get nothing.',
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// WCAG SC 1.2.1 Audio-only and Video-only (Prerecorded) (Level A) — the
|
|
2
|
+
// audio-only slice
|
|
3
|
+
//
|
|
4
|
+
// Prerecorded audio-only content needs a text alternative that presents
|
|
5
|
+
// equivalent information: a transcript. Whether a transcript exists — a
|
|
6
|
+
// link beside the player, a paragraph below it, a page it points to — is
|
|
7
|
+
// not decidable from the DOM, so this rule never asserts. It asks, once
|
|
8
|
+
// per audio element that can actually present sound to someone.
|
|
9
|
+
//
|
|
10
|
+
// The video-only half of the criterion (silent video needing a transcript
|
|
11
|
+
// or audio track) is a DISCLOSED gap: whether a video file carries an
|
|
12
|
+
// audio stream is not visible from the DOM, so flagging every <video>
|
|
13
|
+
// would drown real findings in noise.
|
|
14
|
+
//
|
|
15
|
+
// The "cannot present sound" logic mirrors media-captions: muteness and
|
|
16
|
+
// controls decide whether audio can reach anyone (properties first —
|
|
17
|
+
// frameworks set them without reflecting attributes), and an element with
|
|
18
|
+
// no media resource presents nothing. The live exemption (1.2.1 covers
|
|
19
|
+
// prerecorded only) is undecidable from the DOM and stays with the human:
|
|
20
|
+
// the message names it rather than the rule guessing.
|
|
21
|
+
export default {
|
|
22
|
+
id: 'audio-transcript',
|
|
23
|
+
name: 'Audio transcript',
|
|
24
|
+
impact: 'serious',
|
|
25
|
+
tags: ['wcag2a', 'wcag121'],
|
|
26
|
+
help: 'Prerecorded audio-only content needs a transcript',
|
|
27
|
+
helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/audio-only-and-video-only-prerecorded.html',
|
|
28
|
+
selector: 'audio',
|
|
29
|
+
visibleOnly: false,
|
|
30
|
+
evaluate(element) {
|
|
31
|
+
// No media resource: nothing is presented, nothing to transcribe
|
|
32
|
+
// (lazy players before a script sets their src).
|
|
33
|
+
if (!element.currentSrc && !element.getAttribute('src') && !element.querySelector('source')) {
|
|
34
|
+
return { status: 'pass' };
|
|
35
|
+
}
|
|
36
|
+
// Muted with no controls: no user path to any sound, so whatever the
|
|
37
|
+
// file carries is never presented to anyone.
|
|
38
|
+
if ((element.muted || element.volume === 0) && !element.hasAttribute('controls')) {
|
|
39
|
+
return { status: 'pass' };
|
|
40
|
+
}
|
|
41
|
+
return {
|
|
42
|
+
status: 'incomplete',
|
|
43
|
+
message: 'This audio content needs a transcript nearby — deaf and hard-of-hearing users get nothing from the recording itself. Check that an equivalent text version exists and is easy to find from the player (1.2.1 covers prerecorded audio; a live stream is 1.2.9 instead).',
|
|
44
|
+
fix: 'Provide a transcript of the recording as text on the page or behind a clearly labelled link next to the player.',
|
|
45
|
+
};
|
|
46
|
+
},
|
|
47
|
+
};
|
|
@@ -14,9 +14,24 @@ export default {
|
|
|
14
14
|
// state — so a bare <track> is a subtitle track and counts. (An invalid
|
|
15
15
|
// value defaults to metadata, which does not, so `kind=""` and
|
|
16
16
|
// `kind="banana"` are deliberately left out.)
|
|
17
|
-
if (element.querySelector('track[kind="captions" i]
|
|
17
|
+
if (element.querySelector('track[kind="captions" i]')) {
|
|
18
18
|
return { status: 'pass' };
|
|
19
19
|
}
|
|
20
|
+
// A subtitles track (explicit, or the kind-less default) is not proof
|
|
21
|
+
// of captions: HTML defines subtitles as dialogue for hearers who do
|
|
22
|
+
// not understand the language, captions as dialogue PLUS sound effects
|
|
23
|
+
// and other audio information for people who cannot hear it. H95 says
|
|
24
|
+
// a subtitles track "will not be sufficient" where other audio
|
|
25
|
+
// information matters, while a mislabelled one may be. The DOM cannot
|
|
26
|
+
// tell which, so this is a review, not a pass (David, 2026-08-26; it
|
|
27
|
+
// had passed since the rule was written).
|
|
28
|
+
if (element.querySelector('track[kind="subtitles" i], track:not([kind])')) {
|
|
29
|
+
return {
|
|
30
|
+
status: 'incomplete',
|
|
31
|
+
message: 'This video has a subtitles track but no captions track. Subtitles carry dialogue for people who can hear the audio; captions also carry sound effects and other audio information deaf and hard-of-hearing users need. Check the track covers those, or mark it kind="captions" if it does.',
|
|
32
|
+
fix: 'Use <track kind="captions" src="…" srclang="…" label="…"> for a track that includes non-speech audio information.',
|
|
33
|
+
};
|
|
34
|
+
}
|
|
20
35
|
// A video with no media resource at all presents nothing — there is no
|
|
21
36
|
// audio content to caption (lazy players before their source is set).
|
|
22
37
|
if (!element.currentSrc && !element.getAttribute('src') && !element.querySelector('source')) {
|
|
@@ -20,6 +20,32 @@ const roleOf = (element) =>
|
|
|
20
20
|
element.getAttribute('role')?.trim().split(/\s+/)[0]?.toLowerCase() ??
|
|
21
21
|
implicitRole(element);
|
|
22
22
|
|
|
23
|
+
/**
|
|
24
|
+
* Every element in the COMPOSED subtree of `element`: its light-DOM
|
|
25
|
+
* descendants plus, for each element carrying an open shadow root
|
|
26
|
+
* (`element` itself included), everything inside that shadow tree, again
|
|
27
|
+
* recursively. Assistive technology reads the flat tree, so a role="list"
|
|
28
|
+
* whose items are custom elements rendering role="listitem" inside their
|
|
29
|
+
* own shadow roots is a list of listitems (Chromium exposes list > listitem,
|
|
30
|
+
* listitem); querySelectorAll stops at the shadow boundary and reported it
|
|
31
|
+
* as empty (2026-08-25 overnight audit; same reasoning as
|
|
32
|
+
* aria-required-parent's flat-tree walk). Closed roots stay invisible, as
|
|
33
|
+
* everywhere else in the engine.
|
|
34
|
+
*/
|
|
35
|
+
function composedDescendants(element) {
|
|
36
|
+
const found = [];
|
|
37
|
+
const pending = [element];
|
|
38
|
+
for (let i = 0; i < pending.length; i++) {
|
|
39
|
+
const scope = pending[i];
|
|
40
|
+
if (scope.shadowRoot) pending.push(scope.shadowRoot);
|
|
41
|
+
for (const el of scope.querySelectorAll('*')) {
|
|
42
|
+
found.push(el);
|
|
43
|
+
if (el.shadowRoot) pending.push(el.shadowRoot);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return found;
|
|
47
|
+
}
|
|
48
|
+
|
|
23
49
|
/**
|
|
24
50
|
* Everything the container could plausibly own: its whole subtree, plus any
|
|
25
51
|
* element it claims by aria-owns.
|
|
@@ -33,13 +59,13 @@ const roleOf = (element) =>
|
|
|
33
59
|
* container is not empty, and that is the only claim this rule should make.
|
|
34
60
|
*/
|
|
35
61
|
function candidateDescendants(element) {
|
|
36
|
-
const found =
|
|
62
|
+
const found = composedDescendants(element).filter((el) => !el.matches('script, style, template'));
|
|
37
63
|
const owns = element.getAttribute('aria-owns');
|
|
38
64
|
if (owns) {
|
|
39
65
|
const root = element.getRootNode();
|
|
40
66
|
for (const id of owns.split(/\s+/).filter(Boolean)) {
|
|
41
67
|
const target = root.getElementById?.(id);
|
|
42
|
-
if (target) found.push(target, ...target
|
|
68
|
+
if (target) found.push(target, ...composedDescendants(target));
|
|
43
69
|
}
|
|
44
70
|
}
|
|
45
71
|
return found;
|
|
@@ -57,7 +83,8 @@ export default {
|
|
|
57
83
|
if (element.getAttribute('aria-busy') === 'true') return { status: 'pass' }; // still loading
|
|
58
84
|
const role = element.getAttribute('role').trim().split(/\s+/)[0].toLowerCase();
|
|
59
85
|
const required = REQUIRED_CHILDREN[role];
|
|
60
|
-
const children = [...element.children
|
|
86
|
+
const children = [...element.children, ...(element.shadowRoot?.children ?? [])]
|
|
87
|
+
.filter((c) => !c.matches('script, style, template'));
|
|
61
88
|
// A completely empty container is a lazy-load placeholder more often
|
|
62
89
|
// than a defect — it announces as an empty list, which is accurate.
|
|
63
90
|
if (!children.length && !element.hasAttribute('aria-owns')) return { status: 'pass' };
|