@surea11y/core 1.0.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/CHANGELOG.md +49 -0
- package/LICENSE +21 -0
- package/README.md +145 -0
- package/bin/core.js +244 -0
- package/docs/BINDING_AUTHORS_GUIDE.md +41 -0
- package/docs/CLI.md +49 -0
- package/docs/ENGINE_OPTIONS.md +155 -0
- package/docs/I18N.md +47 -0
- package/docs/INTEGRATION.md +156 -0
- package/docs/LIMITATIONS.md +31 -0
- package/docs/OUTPUT_SCHEMA.md +237 -0
- package/docs/POLICY.md +71 -0
- package/docs/RULE_AUTHORING.md +375 -0
- package/docs/RULE_CATALOG.md +180 -0
- package/docs/RULE_TAXONOMY.md +145 -0
- package/docs/TROUBLESHOOTING.md +48 -0
- package/docs/WCAG_CONFORMANCE.md +49 -0
- package/package.json +60 -0
- package/src/catalogs/composites.wcag.js +490 -0
- package/src/checks/automatic/area-alt-present.js +225 -0
- package/src/checks/automatic/aria-allowed-attr.js +206 -0
- package/src/checks/automatic/aria-allowed-role.js +102 -0
- package/src/checks/automatic/aria-braille-equivalent.js +139 -0
- package/src/checks/automatic/aria-conditional-attr.js +110 -0
- package/src/checks/automatic/aria-deprecated-role.js +106 -0
- package/src/checks/automatic/aria-hidden-body.js +87 -0
- package/src/checks/automatic/aria-hidden-focus.js +480 -0
- package/src/checks/automatic/aria-prohibited-attr.js +156 -0
- package/src/checks/automatic/aria-prohibited-children.js +265 -0
- package/src/checks/automatic/aria-required-attr.js +154 -0
- package/src/checks/automatic/aria-required-children.js +274 -0
- package/src/checks/automatic/aria-required-parent.js +222 -0
- package/src/checks/automatic/aria-role-name-present.js +201 -0
- package/src/checks/automatic/aria-roles-valid.js +110 -0
- package/src/checks/automatic/aria-valid-attr-value.js +123 -0
- package/src/checks/automatic/aria-valid-attr.js +109 -0
- package/src/checks/automatic/autocomplete-valid.js +134 -0
- package/src/checks/automatic/avoid-inline-spacing.js +107 -0
- package/src/checks/automatic/binary-control-name-present.js +294 -0
- package/src/checks/automatic/button-name-present.js +146 -0
- package/src/checks/automatic/bypass-blocks-present.js +162 -0
- package/src/checks/automatic/canvas-text-alternative-present.js +140 -0
- package/src/checks/automatic/combobox-name-present.js +267 -0
- package/src/checks/automatic/contrast-computable.js +378 -0
- package/src/checks/automatic/contrast-enhanced.js +517 -0
- package/src/checks/automatic/contrast-minimum.js +512 -0
- package/src/checks/automatic/css-orientation-lock.js +206 -0
- package/src/checks/automatic/definition-list-children-valid.js +148 -0
- package/src/checks/automatic/deprecated-elements-not-used.js +91 -0
- package/src/checks/automatic/dialog-name-present.js +209 -0
- package/src/checks/automatic/dlitem-parent-valid.js +100 -0
- package/src/checks/automatic/duplicate-id-aria.js +126 -0
- package/src/checks/automatic/embed-text-alternative-present.js +190 -0
- package/src/checks/automatic/form-control-programmatic-label-present.js +409 -0
- package/src/checks/automatic/form-control-single-label.js +117 -0
- package/src/checks/automatic/html-xml-lang-mismatch.js +91 -0
- package/src/checks/automatic/iframe-focusable-content.js +141 -0
- package/src/checks/automatic/iframe-name-present.js +102 -0
- package/src/checks/automatic/iframe-title-unique.js +107 -0
- package/src/checks/automatic/img-alt-present.js +223 -0
- package/src/checks/automatic/input-image-alt-present.js +155 -0
- package/src/checks/automatic/label-in-name.js +326 -0
- package/src/checks/automatic/language-page-present.js +159 -0
- package/src/checks/automatic/link-in-text-block.js +218 -0
- package/src/checks/automatic/link-name-present.js +114 -0
- package/src/checks/automatic/list-children-valid.js +152 -0
- package/src/checks/automatic/listbox-name-present.js +236 -0
- package/src/checks/automatic/listitem-parent-valid.js +118 -0
- package/src/checks/automatic/menuitem-name-present.js +201 -0
- package/src/checks/automatic/meta-refresh-no-exceptions.js +105 -0
- package/src/checks/automatic/meta-refresh-timing-absent.js +107 -0
- package/src/checks/automatic/meta-viewport-zoom-enabled.js +118 -0
- package/src/checks/automatic/meter-name-present.js +160 -0
- package/src/checks/automatic/nested-interactive-controls-absent.js +135 -0
- package/src/checks/automatic/object-text-alternative-present.js +193 -0
- package/src/checks/automatic/option-name-present.js +157 -0
- package/src/checks/automatic/page-title-present.js +86 -0
- package/src/checks/automatic/progressbar-name-present.js +165 -0
- package/src/checks/automatic/role-img-alt-present.js +206 -0
- package/src/checks/automatic/searchbox-name-present.js +236 -0
- package/src/checks/automatic/server-side-image-map-absent.js +88 -0
- package/src/checks/automatic/slider-name-present.js +276 -0
- package/src/checks/automatic/spinbutton-name-present.js +236 -0
- package/src/checks/automatic/summary-name-present.js +153 -0
- package/src/checks/automatic/svg-image-text-alternative-present.js +220 -0
- package/src/checks/automatic/svg-text-alternative-present.js +298 -0
- package/src/checks/automatic/tab-name-present.js +200 -0
- package/src/checks/automatic/table-headers-attr-valid.js +122 -0
- package/src/checks/automatic/table-th-has-data-cells.js +117 -0
- package/src/checks/automatic/target-size-minimum.js +605 -0
- package/src/checks/automatic/td-has-header.js +151 -0
- package/src/checks/automatic/textbox-name-present.js +236 -0
- package/src/checks/automatic/tooltip-name-present.js +158 -0
- package/src/checks/automatic/treeitem-name-present.js +157 -0
- package/src/checks/automatic/valid-lang.js +100 -0
- package/src/checks/automatic/video-poster-text-alternative-present.js +193 -0
- package/src/checks/manual/accesskeys-manual.js +93 -0
- package/src/checks/manual/area-alt-decorative-manual.js +247 -0
- package/src/checks/manual/area-alt-quality-manual.js +204 -0
- package/src/checks/manual/aria-checked-state-mismatch-manual.js +141 -0
- package/src/checks/manual/aria-text-manual.js +109 -0
- package/src/checks/manual/canvas-text-alternative-quality-manual.js +170 -0
- package/src/checks/manual/css-hidden-focus.js +259 -0
- package/src/checks/manual/embed-text-alternative-quality-manual.js +204 -0
- package/src/checks/manual/empty-heading-manual.js +182 -0
- package/src/checks/manual/empty-table-header-manual.js +163 -0
- package/src/checks/manual/focus-order-semantics-manual.js +117 -0
- package/src/checks/manual/form-control-programmatic-label-quality-manual.js +291 -0
- package/src/checks/manual/heading-order-manual.js +130 -0
- package/src/checks/manual/identical-links-same-purpose-manual.js +142 -0
- package/src/checks/manual/image-redundant-alt-manual.js +118 -0
- package/src/checks/manual/img-alt-decorative-manual.js +148 -0
- package/src/checks/manual/img-alt-quality-manual.js +182 -0
- package/src/checks/manual/input-image-alt-decorative-manual.js +144 -0
- package/src/checks/manual/input-image-alt-quality-manual.js +144 -0
- package/src/checks/manual/label-title-only-manual.js +115 -0
- package/src/checks/manual/landmark-banner-is-top-level-manual.js +180 -0
- package/src/checks/manual/landmark-contentinfo-is-top-level-manual.js +169 -0
- package/src/checks/manual/landmark-main-is-top-level-manual.js +167 -0
- package/src/checks/manual/landmark-no-duplicate-banner-manual.js +177 -0
- package/src/checks/manual/landmark-no-duplicate-contentinfo-manual.js +169 -0
- package/src/checks/manual/landmark-no-duplicate-main-manual.js +132 -0
- package/src/checks/manual/landmark-one-main-manual.js +151 -0
- package/src/checks/manual/landmark-unique-manual.js +252 -0
- package/src/checks/manual/link-name-quality-manual.js +143 -0
- package/src/checks/manual/media-transcript-present-manual.js +373 -0
- package/src/checks/manual/meta-viewport-large-manual.js +119 -0
- package/src/checks/manual/mouse-only-event-handlers-manual.js +134 -0
- package/src/checks/manual/no-autoplay-audio-manual.js +116 -0
- package/src/checks/manual/object-text-alternative-quality-manual.js +194 -0
- package/src/checks/manual/p-as-heading-manual.js +163 -0
- package/src/checks/manual/page-has-heading-one-manual.js +110 -0
- package/src/checks/manual/page-title-patterns-manual.js +262 -0
- package/src/checks/manual/presentation-role-conflict-manual.js +159 -0
- package/src/checks/manual/region-manual.js +183 -0
- package/src/checks/manual/scope-attr-valid-manual.js +93 -0
- package/src/checks/manual/scrollable-region-focusable-manual.js +168 -0
- package/src/checks/manual/skip-link-manual.js +150 -0
- package/src/checks/manual/svg-text-alternative-quality-manual.js +209 -0
- package/src/checks/manual/tabindex-manual.js +94 -0
- package/src/checks/manual/table-duplicate-name-manual.js +99 -0
- package/src/checks/manual/table-fake-caption-manual.js +122 -0
- package/src/checks/manual/video-caption-manual.js +118 -0
- package/src/checks/manual-review.js +95 -0
- package/src/checks/rules-and-tags.full.csv +19 -0
- package/src/checks/rules-and-tags.full.json +259 -0
- package/src/core/aria-helpers.js +906 -0
- package/src/core/contrast-helpers.js +1147 -0
- package/src/core/dom-helpers.js +4085 -0
- package/src/core/dom-runner.js +627 -0
- package/src/core/frame-messaging.js +210 -0
- package/src/core/frame-scan.js +178 -0
- package/src/core/rollup-composites.js +135 -0
- package/src/core/rule-meta.js +140 -0
- package/src/core.js +79055 -0
- package/src/coverage/wcag-facets.js +1079 -0
- package/src/coverage/wcag-version-map.js +84 -0
- package/src/i18n/en.js +919 -0
- package/src/i18n/fr.js +527 -0
- package/src/index.js +4 -0
- package/src/policy/contracts.js +18 -0
- package/src/policy/resolvePolicy.js +55 -0
- package/src/policy/schemas/engine-options.schema.json +103 -0
- package/src/policy/schemas/policy-contract.schema.json +40 -0
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @check css-orientation-lock
|
|
5
|
+
* @atomic true
|
|
6
|
+
* @summary CSS must not lock the page to a single display orientation via a rotate() hack
|
|
7
|
+
* @standard WCAG 2.2
|
|
8
|
+
* @sc 1.3.4
|
|
9
|
+
* @applicability
|
|
10
|
+
* Any accessible (same-document, non-cross-origin) stylesheet — inline
|
|
11
|
+
* `<style>` blocks and same-origin `<link>` stylesheets already loaded
|
|
12
|
+
* into `document.styleSheets`.
|
|
13
|
+
* @expectation
|
|
14
|
+
* No `@media (orientation: portrait)` or `@media (orientation:
|
|
15
|
+
* landscape)` block sets a `transform`/`-webkit-transform`/`rotate`
|
|
16
|
+
* rotation of approximately 90 degrees (mod 180, i.e. ~90 or ~270) —
|
|
17
|
+
* the well-known technique for visually forcing one orientation
|
|
18
|
+
* regardless of the device's actual orientation, which defeats WCAG
|
|
19
|
+
* 1.3.4's requirement that content not restrict its view to a single
|
|
20
|
+
* display orientation unless that orientation is essential.
|
|
21
|
+
* @implementation-notes
|
|
22
|
+
* - The rotation DEGREE is what makes this the exploit signature, not
|
|
23
|
+
* merely the presence of a `rotate()` function — verified against a
|
|
24
|
+
* widely-used reference engine's own `css-orientation-lock` check
|
|
25
|
+
* (`cssOrientationLockEvaluate`), which computes the actual rotation angle and only
|
|
26
|
+
* flags ~90/~270 degrees, explicitly excluding ~0/~180 (a no-op or a
|
|
27
|
+
* flip, neither of which changes portrait<->landscape). A prior
|
|
28
|
+
* version of this rule flagged the bare presence of "rotate(" in an
|
|
29
|
+
* orientation-media-query rule regardless of angle — a real, confirmed
|
|
30
|
+
* false positive found via the live-DOM cross-engine corpus 2026-07-23
|
|
31
|
+
* on a real page (Times of India) whose `@media (orientation:portrait)`
|
|
32
|
+
* block only rotated a small decorative arrow icon 45 degrees, nothing
|
|
33
|
+
* resembling a page-orientation lock.
|
|
34
|
+
* - Only `rotate`/`rotateZ` (transform functions) and the standalone CSS
|
|
35
|
+
* `rotate` property are parsed for degrees — matches that reference engine's
|
|
36
|
+
* own two sources (`style.transform`/`style.rotate`) but, like that engine,
|
|
37
|
+
* does not attempt to decompose `matrix()`/`matrix3d()`/`rotate3d()` into an
|
|
38
|
+
* equivalent angle (that engine does handle those; deliberately deferred here
|
|
39
|
+
* as the same class of "genuinely more complex, lower real-world
|
|
40
|
+
* value" work already deferred elsewhere in this engine, e.g.
|
|
41
|
+
* `table-th-has-data-cells`'s narrower positional-header algorithm).
|
|
42
|
+
* - Cross-origin stylesheets throw on `.cssRules` access (browser
|
|
43
|
+
* security model) and are skipped — same class of limitation as any
|
|
44
|
+
* check that can only see same-origin/inspectable content (compare
|
|
45
|
+
* `iframe-focusable-content`).
|
|
46
|
+
* - Not per-element: this is a whole-document/whole-stylesheet concern,
|
|
47
|
+
* so occurrences are reported against `document.documentElement`,
|
|
48
|
+
* matching the "whole-document checks" precedent documented in
|
|
49
|
+
* `docs/RULE_AUTHORING.md` §11.2 (e.g. `aria-hidden-body`,
|
|
50
|
+
* `meta-viewport-zoom-enabled`).
|
|
51
|
+
*/
|
|
52
|
+
|
|
53
|
+
const id = 'css-orientation-lock';
|
|
54
|
+
|
|
55
|
+
const meta = {
|
|
56
|
+
title: 'CSS must not lock the page to a single orientation',
|
|
57
|
+
description:
|
|
58
|
+
'Checks that no @media (orientation: portrait|landscape) rule sets a transform: rotate(...) on the page, a known technique for defeating device orientation.',
|
|
59
|
+
i18n: {
|
|
60
|
+
titleKey: 'cssOrientationLock_title',
|
|
61
|
+
descriptionKey: 'cssOrientationLock_description'
|
|
62
|
+
},
|
|
63
|
+
helpUrl: null,
|
|
64
|
+
tags: ['wcag21aa', 'wcag134', 'structure', 'atomic', 'automatic'],
|
|
65
|
+
wcagSc: ['1.3.4'],
|
|
66
|
+
normativeMappings: [
|
|
67
|
+
{ standard: 'WCAG', version: '2.2', requirement: '1.3.4', title: 'Orientation', conformanceLevel: 'AA' }
|
|
68
|
+
],
|
|
69
|
+
defaultSeverity: 'serious',
|
|
70
|
+
category: 'perceivable',
|
|
71
|
+
type: 'automatic',
|
|
72
|
+
defaultConfidence: 'high',
|
|
73
|
+
coverage: { facetsBySc: { '1.3.4': ['css-orientation-lock'] } }
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
function runInPage(ctx) {
|
|
77
|
+
const { document, helpers, rule } = ctx;
|
|
78
|
+
|
|
79
|
+
const CSS_MEDIA_RULE = 4;
|
|
80
|
+
const CSS_STYLE_RULE = 1;
|
|
81
|
+
|
|
82
|
+
function trim(v) { return (v == null ? '' : String(v)).trim(); }
|
|
83
|
+
|
|
84
|
+
// Converts a rotate()/rotateZ() angle argument (with unit) to degrees.
|
|
85
|
+
// Matches a widely-used reference engine's own getAngleInDegrees: only deg/grad/rad/turn are
|
|
86
|
+
// recognized; a unitless or unrecognized value contributes 0 (ignored,
|
|
87
|
+
// not treated as a lock -- an unparseable angle isn't a confirmed exploit).
|
|
88
|
+
function angleToDegrees(raw) {
|
|
89
|
+
const m = /(-?[\d.]+)\s*(deg|grad|rad|turn)/i.exec(raw);
|
|
90
|
+
if (!m) return 0;
|
|
91
|
+
const value = parseFloat(m[1]);
|
|
92
|
+
if (!Number.isFinite(value)) return 0;
|
|
93
|
+
const unit = m[2].toLowerCase();
|
|
94
|
+
if (unit === 'rad') return value * (180 / Math.PI);
|
|
95
|
+
if (unit === 'grad') return value * (360 / 400);
|
|
96
|
+
if (unit === 'turn') return value * 360;
|
|
97
|
+
return value; // deg
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Sums the degrees of every rotate()/rotateZ() function found in a
|
|
101
|
+
// transform value (a real transform can legitimately compose more than
|
|
102
|
+
// one, e.g. "translate(-50%) rotate(90deg)").
|
|
103
|
+
function rotateDegreesFromTransform(t) {
|
|
104
|
+
if (!t) return 0;
|
|
105
|
+
let total = 0;
|
|
106
|
+
const re = /rotate(?:Z)?\s*\(([^)]*)\)/gi;
|
|
107
|
+
let m;
|
|
108
|
+
while ((m = re.exec(t)) !== null) {
|
|
109
|
+
total += angleToDegrees(m[1]);
|
|
110
|
+
}
|
|
111
|
+
return total;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Whole-page-orientation-lock detection, matching a widely-used reference engine's own degree
|
|
115
|
+
// math exactly (see @implementation-notes): a rotation near 0 or 180
|
|
116
|
+
// degrees (mod 180) is a no-op or a flip, neither of which changes
|
|
117
|
+
// portrait<->landscape, so it's explicitly NOT a lock; only a rotation
|
|
118
|
+
// near 90 or 270 degrees (mod 90, once the 0/180 case is excluded) is.
|
|
119
|
+
function isLockingRotation(styleDecl) {
|
|
120
|
+
if (!styleDecl) return false;
|
|
121
|
+
const transformVal = trim(styleDecl.transform) || trim(styleDecl.getPropertyValue ? styleDecl.getPropertyValue('-webkit-transform') : '');
|
|
122
|
+
// The standalone CSS `rotate` property (distinct from `transform: rotate()`) -- that reference engine checks this too.
|
|
123
|
+
const rotatePropVal = trim(styleDecl.getPropertyValue ? styleDecl.getPropertyValue('rotate') : '');
|
|
124
|
+
|
|
125
|
+
let degrees = rotateDegreesFromTransform(transformVal);
|
|
126
|
+
if (!degrees && rotatePropVal) degrees = angleToDegrees(rotatePropVal);
|
|
127
|
+
|
|
128
|
+
if (!degrees) return false;
|
|
129
|
+
|
|
130
|
+
const abs = Math.abs(degrees);
|
|
131
|
+
if (Math.abs(abs - 180) % 180 <= 0) return false; // near 0/180: not a lock
|
|
132
|
+
return Math.abs(abs - 90) % 90 <= 0; // near 90/270: a lock
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function isOrientationMedia(mediaText) {
|
|
136
|
+
const m = trim(mediaText).toLowerCase();
|
|
137
|
+
return m.includes('orientation') && (m.includes('portrait') || m.includes('landscape'));
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function scanRuleList(rules, mediaText, findings) {
|
|
141
|
+
if (!rules) return;
|
|
142
|
+
for (const r of rules) {
|
|
143
|
+
if (!r) continue;
|
|
144
|
+
if (r.type === CSS_STYLE_RULE && isLockingRotation(r.style)) {
|
|
145
|
+
findings.push({ mediaText, selectorText: trim(r.selectorText) || '(unknown selector)' });
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const findings = [];
|
|
151
|
+
let sheetCount = 0;
|
|
152
|
+
|
|
153
|
+
try {
|
|
154
|
+
const sheets = document.styleSheets || [];
|
|
155
|
+
for (const sheet of sheets) {
|
|
156
|
+
let rules = null;
|
|
157
|
+
try {
|
|
158
|
+
rules = sheet && sheet.cssRules ? sheet.cssRules : null;
|
|
159
|
+
} catch {
|
|
160
|
+
continue; // cross-origin stylesheet, not inspectable
|
|
161
|
+
}
|
|
162
|
+
if (!rules) continue;
|
|
163
|
+
sheetCount += 1;
|
|
164
|
+
|
|
165
|
+
for (const rule2 of rules) {
|
|
166
|
+
if (!rule2 || rule2.type !== CSS_MEDIA_RULE) continue;
|
|
167
|
+
const mediaText = rule2.media ? rule2.media.mediaText : '';
|
|
168
|
+
if (!isOrientationMedia(mediaText)) continue;
|
|
169
|
+
scanRuleList(rule2.cssRules, mediaText, findings);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
} catch {
|
|
173
|
+
// no-throw: treat as no accessible stylesheets
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (sheetCount === 0) {
|
|
177
|
+
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (!findings.length) {
|
|
181
|
+
return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const target = document.documentElement || (document.body || null);
|
|
185
|
+
const stableSelector = helpers.buildSelector && target ? helpers.buildSelector(target) : 'html';
|
|
186
|
+
const html = helpers.getOuterHtmlSnippet && target ? helpers.getOuterHtmlSnippet(target) : '<html>';
|
|
187
|
+
|
|
188
|
+
const occurrences = findings.map((f) => ({
|
|
189
|
+
selector: stableSelector,
|
|
190
|
+
html,
|
|
191
|
+
summary: `A "${f.mediaText}" media query rotates "${f.selectorText}", locking the page to one orientation.`,
|
|
192
|
+
hint: 'Remove the rotate() transform from the orientation media query; let the page respond naturally to device orientation instead of forcing a visual rotation.',
|
|
193
|
+
i18n: {
|
|
194
|
+
summaryKey: 'cssOrientationLock_summary_fail',
|
|
195
|
+
hintKey: 'cssOrientationLock_hint_fail',
|
|
196
|
+
params: { mediaText: f.mediaText, selectorText: f.selectorText }
|
|
197
|
+
},
|
|
198
|
+
data: {
|
|
199
|
+
details: { reasonCode: 'ORIENTATION_MEDIA_ROTATE_TRANSFORM', mediaText: f.mediaText, selectorText: f.selectorText }
|
|
200
|
+
}
|
|
201
|
+
}));
|
|
202
|
+
|
|
203
|
+
return { ruleId: rule.ruleId, outcome: 'fail', severity: rule.defaultSeverity || 'serious', occurrences };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
module.exports = { id, meta, runInPage };
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @check definition-list-children-valid
|
|
5
|
+
* @atomic true
|
|
6
|
+
* @summary <dl> must only contain <dt>/<dd> groups (optionally wrapped in one <div>), <script>, <template>, or <style>
|
|
7
|
+
* @standard WCAG 2.2
|
|
8
|
+
* @sc 1.3.1
|
|
9
|
+
* @applicability
|
|
10
|
+
* Applies to <dl> elements that have at least one direct element child.
|
|
11
|
+
* @expectation
|
|
12
|
+
* Every direct element child is <dt>, <dd>, <script>, <template>, <style>,
|
|
13
|
+
* or a <div> whose own children are drawn from that same set (a single
|
|
14
|
+
* level of wrapping div is allowed, matching how authors commonly group
|
|
15
|
+
* dt/dd pairs). If the flattened set contains any <dt> or <dd> at all, it
|
|
16
|
+
* must contain BOTH (an unbalanced dt-without-dd or dd-without-dt is
|
|
17
|
+
* invalid) — a flattened set with neither is vacuously fine, not a
|
|
18
|
+
* violation (see implementation-notes). Any other direct or wrapped child
|
|
19
|
+
* breaks the description-list semantics assistive technologies rely on.
|
|
20
|
+
* @implementation-notes
|
|
21
|
+
* - Only one level of <div> wrapping is flattened, matching a widely-used
|
|
22
|
+
* reference engine's definition-list algorithm — a <div> nested inside
|
|
23
|
+
* another wrapping <div> is not flattened further and its contents are
|
|
24
|
+
* reported invalid.
|
|
25
|
+
* - Verified 2026-07-20 against a widely-used reference engine: the dt/dd pairing is
|
|
26
|
+
* only required "when not empty". A flattened set with NEITHER dt nor dd
|
|
27
|
+
* — whether from an empty wrapping <div>, only <script>/<template>/
|
|
28
|
+
* <style> content, or a genuinely childless <dl> — is not flagged; only
|
|
29
|
+
* an unbalanced dt/dd pairing is a real structural problem.
|
|
30
|
+
* - Distinct, atomic decision from dlitem-parent-valid (the
|
|
31
|
+
* inverse relationship: does a given <dt>/<dd> have a valid parent).
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
const id = 'definition-list-children-valid';
|
|
35
|
+
|
|
36
|
+
const meta = {
|
|
37
|
+
title: 'Description lists must be structured correctly',
|
|
38
|
+
description: 'Checks that <dl> elements only directly contain <dt>/<dd> groups (optionally wrapped in one <div>), <script>, <template>, or <style>.',
|
|
39
|
+
i18n: {
|
|
40
|
+
titleKey: 'definitionListChildrenValid_title',
|
|
41
|
+
descriptionKey: 'definitionListChildrenValid_description'
|
|
42
|
+
},
|
|
43
|
+
helpUrl: null,
|
|
44
|
+
tags: ['wcag2a', 'wcag131', 'structure', 'atomic', 'automatic', 'list'],
|
|
45
|
+
wcagSc: ['1.3.1'],
|
|
46
|
+
normativeMappings: [
|
|
47
|
+
{ standard: 'WCAG', version: '2.2', requirement: '1.3.1', title: 'Info and Relationships', conformanceLevel: 'A' }
|
|
48
|
+
],
|
|
49
|
+
defaultSeverity: 'serious',
|
|
50
|
+
category: 'perceivable',
|
|
51
|
+
type: 'automatic',
|
|
52
|
+
defaultConfidence: 'high',
|
|
53
|
+
coverage: { facetsBySc: { '1.3.1': ['definition-list-children-valid'] } }
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
function runInPage(ctx) {
|
|
57
|
+
const { document, root, helpers, rule } = ctx;
|
|
58
|
+
const safeRoot = root || document;
|
|
59
|
+
|
|
60
|
+
// Declared inside runInPage — see scripts/build-core.js header
|
|
61
|
+
// ("runInPage MUST be self-contained").
|
|
62
|
+
const PASSTHROUGH_TAGS = new Set(['dt', 'dd', 'script', 'template', 'style']);
|
|
63
|
+
|
|
64
|
+
const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('dl', safeRoot) : helpers.queryAll('dl', safeRoot);
|
|
65
|
+
|
|
66
|
+
const occurrences = [];
|
|
67
|
+
let applicableCount = 0;
|
|
68
|
+
|
|
69
|
+
for (const el of nodes) {
|
|
70
|
+
if (!el || !el.children) continue;
|
|
71
|
+
if (!el.children.length) continue;
|
|
72
|
+
|
|
73
|
+
applicableCount += 1;
|
|
74
|
+
|
|
75
|
+
// Flatten one level of wrapping <div> (common dt/dd grouping pattern).
|
|
76
|
+
const flattened = [];
|
|
77
|
+
for (const child of el.children) {
|
|
78
|
+
if (!child || !child.tagName) continue;
|
|
79
|
+
if (child.tagName.toLowerCase() === 'div') {
|
|
80
|
+
for (const grandchild of child.children || []) {
|
|
81
|
+
if (grandchild && grandchild.tagName) flattened.push(grandchild);
|
|
82
|
+
}
|
|
83
|
+
} else {
|
|
84
|
+
flattened.push(child);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
let hasDt = false;
|
|
89
|
+
let hasDd = false;
|
|
90
|
+
const invalidTags = [];
|
|
91
|
+
for (const node of flattened) {
|
|
92
|
+
const tag = node.tagName.toLowerCase();
|
|
93
|
+
if (tag === 'dt') { hasDt = true; continue; }
|
|
94
|
+
if (tag === 'dd') { hasDd = true; continue; }
|
|
95
|
+
if (!PASSTHROUGH_TAGS.has(tag)) invalidTags.push(tag);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Verified 2026-07-20 against a widely-used reference engine's definition-list check:
|
|
99
|
+
// the dt/dd pairing is only required "when not empty" — a <dl> with
|
|
100
|
+
// NEITHER dt nor dd (whether genuinely childless after flattening, only
|
|
101
|
+
// passthrough script/template/style content, or an empty wrapping div)
|
|
102
|
+
// is vacuously fine, not a violation. Only an UNBALANCED pairing (dt
|
|
103
|
+
// present without any dd, or vice versa) is a real structural problem.
|
|
104
|
+
const reasonCode = invalidTags.length
|
|
105
|
+
? 'DL_INVALID_CHILD'
|
|
106
|
+
: ((hasDt || hasDd) && !(hasDt && hasDd) ? 'DL_NO_DT_DD' : null);
|
|
107
|
+
if (!reasonCode) continue;
|
|
108
|
+
|
|
109
|
+
const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
|
|
110
|
+
const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
|
|
111
|
+
|
|
112
|
+
const summary = invalidTags.length
|
|
113
|
+
? 'This description list contains a direct or wrapped child that is not part of a dt/dd group.'
|
|
114
|
+
: 'This description list has no <dt>/<dd> term-definition group.';
|
|
115
|
+
const hint = invalidTags.length
|
|
116
|
+
? 'Only use <dt>/<dd> (optionally wrapped in one <div>), <script>, <template>, or <style> inside <dl>.'
|
|
117
|
+
: 'Add at least one <dt>/<dd> pair inside this <dl>.';
|
|
118
|
+
|
|
119
|
+
occurrences.push({
|
|
120
|
+
selector: stableSelector,
|
|
121
|
+
html,
|
|
122
|
+
summary,
|
|
123
|
+
hint,
|
|
124
|
+
i18n: {
|
|
125
|
+
summaryKey: invalidTags.length
|
|
126
|
+
? 'definitionListChildrenValid_summary_fail_invalidChild'
|
|
127
|
+
: 'definitionListChildrenValid_summary_fail_noDtDd',
|
|
128
|
+
hintKey: invalidTags.length
|
|
129
|
+
? 'definitionListChildrenValid_hint_fail_invalidChild'
|
|
130
|
+
: 'definitionListChildrenValid_hint_fail_noDtDd',
|
|
131
|
+
params: { invalidChildren: invalidTags.join(', ') }
|
|
132
|
+
},
|
|
133
|
+
data: {
|
|
134
|
+
details: { reasonCode, invalidChildren: invalidTags }
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (applicableCount === 0) {
|
|
140
|
+
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
141
|
+
}
|
|
142
|
+
if (occurrences.length) {
|
|
143
|
+
return { ruleId: rule.ruleId, outcome: 'fail', severity: rule.defaultSeverity || 'serious', occurrences };
|
|
144
|
+
}
|
|
145
|
+
return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
module.exports = { id, meta, runInPage };
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @check deprecated-elements-not-used
|
|
5
|
+
* @atomic true
|
|
6
|
+
* @summary Obsolete non-stoppable elements must not be used
|
|
7
|
+
* @standard WCAG 2.2
|
|
8
|
+
* @sc 2.2.2
|
|
9
|
+
* @applicability
|
|
10
|
+
* Applies to any <blink> or <marquee> element present in scope. These are
|
|
11
|
+
* obsolete, non-standard HTML elements whose defining behavior (blinking
|
|
12
|
+
* or auto-scrolling text) has no built-in user mechanism to pause, stop,
|
|
13
|
+
* or hide it.
|
|
14
|
+
* @expectation
|
|
15
|
+
* Neither element is present. Since their movement can never be paused,
|
|
16
|
+
* stopped, or hidden by the user, presence is itself the violation — this
|
|
17
|
+
* rule has no partial-pass case, matching a widely-used reference engine's `blink`/`marquee`
|
|
18
|
+
* rules (which report only when the element is found).
|
|
19
|
+
* @implementation-notes
|
|
20
|
+
* - Not gated on isAccTreeEligible: presence in markup is itself the
|
|
21
|
+
* violation, independent of visibility (moving/blinking content inside a
|
|
22
|
+
* hidden ancestor could still become visible later without a code
|
|
23
|
+
* change, so hiding it today does not remove the underlying defect).
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
const id = 'deprecated-elements-not-used';
|
|
27
|
+
|
|
28
|
+
const meta = {
|
|
29
|
+
title: 'Obsolete non-stoppable elements (<blink>, <marquee>) must not be used',
|
|
30
|
+
description: 'Checks that deprecated, non-standard HTML elements whose blinking/scrolling content cannot be paused, stopped, or hidden by the user (<blink>, <marquee>) are not present.',
|
|
31
|
+
i18n: {
|
|
32
|
+
titleKey: 'deprecatedElements_title',
|
|
33
|
+
descriptionKey: 'deprecatedElements_description'
|
|
34
|
+
},
|
|
35
|
+
helpUrl: null,
|
|
36
|
+
tags: ['wcag2a', 'wcag222', 'structure', 'atomic', 'automatic'],
|
|
37
|
+
wcagSc: ['2.2.2'],
|
|
38
|
+
normativeMappings: [
|
|
39
|
+
{ standard: 'WCAG', version: '2.2', requirement: '2.2.2', title: 'Pause, Stop, Hide', conformanceLevel: 'A' }
|
|
40
|
+
],
|
|
41
|
+
defaultSeverity: 'serious',
|
|
42
|
+
category: 'operable',
|
|
43
|
+
type: 'automatic',
|
|
44
|
+
defaultConfidence: 'high',
|
|
45
|
+
coverage: { facetsBySc: { '2.2.2': ['deprecated-non-stoppable-elements-absent'] } }
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
function runInPage(ctx) {
|
|
49
|
+
const { document, root, helpers, rule } = ctx;
|
|
50
|
+
const safeRoot = root || document;
|
|
51
|
+
|
|
52
|
+
const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('blink, marquee', safeRoot) : helpers.queryAll('blink, marquee', safeRoot);
|
|
53
|
+
|
|
54
|
+
const occurrences = [];
|
|
55
|
+
let applicableCount = 0;
|
|
56
|
+
|
|
57
|
+
for (const el of nodes) {
|
|
58
|
+
if (!el || !el.tagName) continue;
|
|
59
|
+
|
|
60
|
+
applicableCount += 1;
|
|
61
|
+
|
|
62
|
+
const tag = el.tagName.toLowerCase();
|
|
63
|
+
const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
|
|
64
|
+
const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
|
|
65
|
+
|
|
66
|
+
occurrences.push({
|
|
67
|
+
selector: stableSelector,
|
|
68
|
+
html,
|
|
69
|
+
summary: 'This element’s content cannot be paused, stopped, or hidden by the user.',
|
|
70
|
+
hint: 'Remove this element; use static content, or an animation with a user-facing pause/stop control, instead.',
|
|
71
|
+
i18n: {
|
|
72
|
+
summaryKey: 'deprecatedElements_summary_fail',
|
|
73
|
+
hintKey: 'deprecatedElements_hint_fail',
|
|
74
|
+
params: { element: tag }
|
|
75
|
+
},
|
|
76
|
+
data: {
|
|
77
|
+
details: { reasonCode: 'DEPRECATED_NON_STOPPABLE_ELEMENT', element: tag }
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (applicableCount === 0) {
|
|
83
|
+
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
84
|
+
}
|
|
85
|
+
if (occurrences.length) {
|
|
86
|
+
return { ruleId: rule.ruleId, outcome: 'fail', severity: rule.defaultSeverity || 'serious', occurrences };
|
|
87
|
+
}
|
|
88
|
+
return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
module.exports = { id, meta, runInPage };
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const id = 'dialog-name-present';
|
|
4
|
+
|
|
5
|
+
const meta = {
|
|
6
|
+
title: 'Dialogs have an accessible name',
|
|
7
|
+
description: 'Checks that elements with role="dialog" or role="alertdialog" expose a non-empty accessible name.',
|
|
8
|
+
i18n: {
|
|
9
|
+
titleKey: 'dialogNamePresent_title',
|
|
10
|
+
descriptionKey: 'dialogNamePresent_description'
|
|
11
|
+
},
|
|
12
|
+
helpUrl: null,
|
|
13
|
+
tags: ['wcag2a','wcag412','structure','atomic','automatic','name','dialog'],
|
|
14
|
+
wcagSc: ['4.1.2'],
|
|
15
|
+
normativeMappings: [
|
|
16
|
+
{ standard: 'WCAG', version: '2.2', requirement: '4.1.2', title: 'Name, Role, Value', conformanceLevel: 'A' }
|
|
17
|
+
],
|
|
18
|
+
defaultSeverity: 'serious',
|
|
19
|
+
category: 'robust',
|
|
20
|
+
type: 'automatic',
|
|
21
|
+
defaultConfidence: 'high',
|
|
22
|
+
coverage: { facetsBySc: { '4.1.2': ['dialog-name-present'] } }
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
function runInPage(ctx) {
|
|
26
|
+
const { document, root, helpers, rule } = ctx;
|
|
27
|
+
const getEligibilityInfo = helpers && typeof helpers.getEligibilityInfo === 'function'
|
|
28
|
+
? helpers.getEligibilityInfo
|
|
29
|
+
: null;
|
|
30
|
+
const safeRoot = root || document;
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
function normalizeWs(s) {
|
|
34
|
+
return String(s || '').replace(/\s+/g, ' ').trim();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function getAttr(el, name) {
|
|
38
|
+
try {
|
|
39
|
+
if (!el || !el.getAttribute) return '';
|
|
40
|
+
return normalizeWs(el.getAttribute(name));
|
|
41
|
+
} catch { return ''; }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function hasAttr(el, name) {
|
|
45
|
+
try { return !!(el && el.hasAttribute && el.hasAttribute(name)); } catch { return false; }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function isExplicitProgrammatic(el) {
|
|
49
|
+
// Programmatic name mechanisms we treat as authoritative (presence-only):
|
|
50
|
+
// - aria-label (non-empty)
|
|
51
|
+
// - aria-labelledby (non-empty)
|
|
52
|
+
// - title (non-empty) [weak but allowed for presence]
|
|
53
|
+
const al = getAttr(el, 'aria-label');
|
|
54
|
+
if (al) return true;
|
|
55
|
+
const alb = getAttr(el, 'aria-labelledby');
|
|
56
|
+
if (alb) return true;
|
|
57
|
+
const t = getAttr(el, 'title');
|
|
58
|
+
if (t) return true;
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function buildLabelForMap(doc) {
|
|
63
|
+
const map = new Map(); // id -> label element (first)
|
|
64
|
+
try {
|
|
65
|
+
const labels = doc && doc.getElementsByTagName ? doc.getElementsByTagName('label') : [];
|
|
66
|
+
for (let i = 0; i < labels.length; i += 1) {
|
|
67
|
+
const lab = labels[i];
|
|
68
|
+
if (!lab || !lab.getAttribute) continue;
|
|
69
|
+
const f = normalizeWs(lab.getAttribute('for'));
|
|
70
|
+
if (!f) continue;
|
|
71
|
+
if (!map.has(f)) map.set(f, lab);
|
|
72
|
+
}
|
|
73
|
+
} catch {}
|
|
74
|
+
return map;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function getConservativeSubtreeText(document, container) {
|
|
78
|
+
// "Name from content" — recurses into descendants and uses each one's
|
|
79
|
+
// own accessible name (img alt, aria-label/aria-labelledby, title) when
|
|
80
|
+
// it has one, not just literal text nodes. See getContentNameInfo's
|
|
81
|
+
// header comment in src/core/dom-helpers.js for the full rationale
|
|
82
|
+
// (this replaced a text-node-only TreeWalker that missed the common
|
|
83
|
+
// "<a><img alt='...'></a>" logo-link / "<button><img alt='...'></button>"
|
|
84
|
+
// icon-button pattern).
|
|
85
|
+
if (helpers.getContentNameInfo) {
|
|
86
|
+
const info = helpers.getContentNameInfo(container, ctx);
|
|
87
|
+
return info && info.present ? info.value : '';
|
|
88
|
+
}
|
|
89
|
+
const t = (container && container.textContent) ? String(container.textContent) : '';
|
|
90
|
+
return t.replace(/\s+/g, ' ').trim();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function resolveAriaLabelledbyText(document, el, maxRefs) {
|
|
94
|
+
const raw = getAttr(el, 'aria-labelledby');
|
|
95
|
+
if (!raw) return '';
|
|
96
|
+
// Resolve via the shared getTextFromIdRefs helper — computes each
|
|
97
|
+
// referenced element's own ACCESSIBLE NAME (aria-label, then
|
|
98
|
+
// aria-labelledby, then a value-like name, then content, then title),
|
|
99
|
+
// not just its content text. Found via a real page (BBC News' cookie-
|
|
100
|
+
// consent dialog, 2026-07-22): aria-labelledby pointed at an
|
|
101
|
+
// <iframe title="SP Consent Message">, whose only name source is its
|
|
102
|
+
// title attribute (an iframe's content is opaque/cross-origin per
|
|
103
|
+
// HTML-AAM, so "name from content" is always empty). The previous
|
|
104
|
+
// version here only ever computed name-from-content of the referenced
|
|
105
|
+
// node (via getConservativeSubtreeText), silently missing the title
|
|
106
|
+
// fallback and reporting no accessible name at all — the identical
|
|
107
|
+
// pattern was hand-copied into 15 other *-name-present rules; all
|
|
108
|
+
// fixed the same way.
|
|
109
|
+
if (helpers.getTextFromIdRefs) {
|
|
110
|
+
try {
|
|
111
|
+
const r = helpers.getTextFromIdRefs(raw, ctx, { maxRefs: maxRefs || 8 });
|
|
112
|
+
return normalizeWs(r && r.text);
|
|
113
|
+
} catch {}
|
|
114
|
+
}
|
|
115
|
+
return '';
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function getInputValueName(el) {
|
|
119
|
+
try {
|
|
120
|
+
const type = getAttr(el, 'type').toLowerCase();
|
|
121
|
+
if (type !== 'button' && type !== 'submit' && type !== 'reset') return '';
|
|
122
|
+
const v = getAttr(el, 'value');
|
|
123
|
+
return v;
|
|
124
|
+
} catch { return ''; }
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function isEligibleAcc(helpers, el, ctx) {
|
|
128
|
+
const fn = helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
|
|
129
|
+
if (!fn) return true;
|
|
130
|
+
try {
|
|
131
|
+
const r = fn(el, ctx);
|
|
132
|
+
if (typeof r === 'boolean') return r;
|
|
133
|
+
return !!(r && r.eligible);
|
|
134
|
+
} catch {
|
|
135
|
+
return true;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
const occurrences = [];
|
|
141
|
+
let applicableCount = 0;
|
|
142
|
+
|
|
143
|
+
const selector = '[role="dialog"],[role="alertdialog"]';
|
|
144
|
+
const nodes = helpers.queryAllSmart ? helpers.queryAllSmart(selector, safeRoot) : helpers.queryAll(selector, safeRoot);
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
function evaluate(el) {
|
|
148
|
+
const ariaLabel = getAttr(el, 'aria-label');
|
|
149
|
+
if (ariaLabel) return { ok: true, method: 'aria-label' };
|
|
150
|
+
|
|
151
|
+
const ariaLabelledby = resolveAriaLabelledbyText(document, el, 8);
|
|
152
|
+
if (ariaLabelledby) return { ok: true, method: 'aria-labelledby' };
|
|
153
|
+
|
|
154
|
+
const title = getAttr(el, 'title');
|
|
155
|
+
if (title) return { ok: true, method: 'title' };
|
|
156
|
+
|
|
157
|
+
// role="dialog"/"alertdialog" is name-from-author-only per WAI-ARIA: it
|
|
158
|
+
// must NOT fall back to subtree content. A dialog's heading or body
|
|
159
|
+
// text is not reliably exposed to assistive technologies as the
|
|
160
|
+
// dialog's accessible name unless explicitly wired via aria-labelledby.
|
|
161
|
+
return { ok: false, method: 'none' };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
for (const el of nodes) {
|
|
165
|
+
if (!el) continue;
|
|
166
|
+
if (!isEligibleAcc(helpers, el, ctx)) continue;
|
|
167
|
+
|
|
168
|
+
const role = getAttr(el, 'role').toLowerCase();
|
|
169
|
+
if (role !== 'dialog' && role !== 'alertdialog') continue;
|
|
170
|
+
|
|
171
|
+
applicableCount += 1;
|
|
172
|
+
|
|
173
|
+
const res = evaluate(el);
|
|
174
|
+
if (res.ok) continue;
|
|
175
|
+
|
|
176
|
+
const eligInfo = getEligibilityInfo
|
|
177
|
+
? (() => { try { return getEligibilityInfo(el, ctx, { targetSet: 'acc' }); } catch { return null; } })()
|
|
178
|
+
: null;
|
|
179
|
+
const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
|
|
180
|
+
const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
|
|
181
|
+
|
|
182
|
+
occurrences.push({
|
|
183
|
+
selector: stableSelector,
|
|
184
|
+
html,
|
|
185
|
+
summary: 'This dialog has no accessible name.',
|
|
186
|
+
hint: 'Provide aria-labelledby (preferred) or aria-label so assistive technologies can announce the dialog.',
|
|
187
|
+
i18n: {
|
|
188
|
+
summaryKey: 'dialogNamePresent_summary_fail',
|
|
189
|
+
hintKey: 'dialogNamePresent_hint_fail',
|
|
190
|
+
params: { role }
|
|
191
|
+
},
|
|
192
|
+
data: {
|
|
193
|
+
details: { reasonCode: 'name_missing', controlType: role, methodTried: res.method },
|
|
194
|
+
visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] }
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
if (applicableCount === 0) {
|
|
201
|
+
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
202
|
+
}
|
|
203
|
+
if (occurrences.length) {
|
|
204
|
+
return { ruleId: rule.ruleId, outcome: 'fail', severity: rule.defaultSeverity || 'minor', occurrences };
|
|
205
|
+
}
|
|
206
|
+
return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
module.exports = { id, meta, runInPage };
|