@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,480 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @check aria-hidden-focus
|
|
5
|
+
* @atomic true
|
|
6
|
+
* @summary aria-hidden elements must not be focusable or contain focusable elements
|
|
7
|
+
* @standard WCAG 2.2
|
|
8
|
+
* @sc 4.1.2
|
|
9
|
+
* @applicability
|
|
10
|
+
* Applies to elements that have aria-hidden="true".
|
|
11
|
+
* @expectation
|
|
12
|
+
* No element with aria-hidden="true" may itself be focusable, and no focusable element
|
|
13
|
+
* may exist within an aria-hidden="true" subtree.
|
|
14
|
+
*
|
|
15
|
+
* Notes on parity with a widely-used reference engine's aria-hidden-focus check:
|
|
16
|
+
* - Focusability is computed via ctx.helpers.getFocusableInfo (native + tabindex + contenteditable).
|
|
17
|
+
* - Elements that are not rendered (e.g., display:none, visibility:hidden, [hidden]) are excluded.
|
|
18
|
+
* - Elements hidden via CSS in ways that still allow keyboard focus (e.g., opacity:0, off-screen, clip)
|
|
19
|
+
* remain in-scope and will be flagged when focusable.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const id = 'aria-hidden-focus';
|
|
23
|
+
|
|
24
|
+
const meta = {
|
|
25
|
+
title: 'ARIA hidden elements must not be focusable',
|
|
26
|
+
description:
|
|
27
|
+
'Checks that aria-hidden="true" elements are not focusable and do not contain focusable descendants.',
|
|
28
|
+
i18n: {
|
|
29
|
+
titleKey: 'ariaHidden_focus_title',
|
|
30
|
+
descriptionKey: 'ariaHidden_focus_description'
|
|
31
|
+
},
|
|
32
|
+
helpUrl: null,
|
|
33
|
+
// NOTE: taxonomy contract requires exactly one content category tag.
|
|
34
|
+
// This rule is about ARIA subtree exposure / structural AT tree consistency.
|
|
35
|
+
tags: ['wcag2a', 'wcag2aa', 'wcag412', 'structure', 'aria', 'focus', 'atomic', 'automatic'],
|
|
36
|
+
wcagSc: ['4.1.2'],
|
|
37
|
+
normativeMappings: [
|
|
38
|
+
{ standard: 'WCAG', version: '2.2', requirement: '2.4.7', title: 'Focus Visible', conformanceLevel: 'AA' },
|
|
39
|
+
{ standard: 'WCAG', version: '2.2', requirement: '4.1.2', title: 'Name, Role, Value', conformanceLevel: 'A' }
|
|
40
|
+
],
|
|
41
|
+
defaultSeverity: 'serious',
|
|
42
|
+
category: 'robust',
|
|
43
|
+
type: 'automatic',
|
|
44
|
+
defaultConfidence: 'high',
|
|
45
|
+
coverage: {
|
|
46
|
+
facetsBySc: {
|
|
47
|
+
'4.1.2': ['aria-hidden-focusable']
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
function runInPage(ctx) {
|
|
53
|
+
const { document, root, helpers, rule } = ctx;
|
|
54
|
+
const safeRoot = root || document;
|
|
55
|
+
|
|
56
|
+
const queryAllSmart = helpers && typeof helpers.queryAllSmart === 'function' ? helpers.queryAllSmart : null;
|
|
57
|
+
|
|
58
|
+
const getFocusableInfo = helpers && typeof helpers.getFocusableInfo === 'function' ? helpers.getFocusableInfo : null;
|
|
59
|
+
|
|
60
|
+
// Used only to exclude non-rendered elements; we explicitly DO NOT exclude opacity:0.
|
|
61
|
+
const isDomVisibleEligible = helpers && typeof helpers.isDomVisibleEligible === 'function' ? helpers.isDomVisibleEligible : null;
|
|
62
|
+
|
|
63
|
+
const getEligibilityInfo = helpers && typeof helpers.getEligibilityInfo === 'function' ? helpers.getEligibilityInfo : null;
|
|
64
|
+
|
|
65
|
+
// Prefer helper occurrence wrapper so the engine can attach selector/snippet later.
|
|
66
|
+
const reportOccurrence = helpers && typeof helpers.reportOccurrence === 'function'
|
|
67
|
+
? helpers.reportOccurrence
|
|
68
|
+
: null;
|
|
69
|
+
|
|
70
|
+
const trim = (v) => (v == null ? '' : String(v)).trim();
|
|
71
|
+
const lower = (v) => trim(v).toLowerCase();
|
|
72
|
+
|
|
73
|
+
function qAll(sel) {
|
|
74
|
+
try {
|
|
75
|
+
if (queryAllSmart) {
|
|
76
|
+
const r = queryAllSmart(sel);
|
|
77
|
+
if (Array.isArray(r)) return r;
|
|
78
|
+
return Array.from(r || []);
|
|
79
|
+
}
|
|
80
|
+
} catch {
|
|
81
|
+
// fall through
|
|
82
|
+
}
|
|
83
|
+
try {
|
|
84
|
+
if (safeRoot && typeof safeRoot.querySelectorAll === 'function') return Array.from(safeRoot.querySelectorAll(sel));
|
|
85
|
+
} catch {
|
|
86
|
+
// fall through
|
|
87
|
+
}
|
|
88
|
+
return [];
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Flat-tree ancestor walk (assignedSlot wins over parentNode, then shadow
|
|
92
|
+
// host) — shared with every other rule via ctx.helpers.composedParent
|
|
93
|
+
// (src/core/dom-helpers.js), not reimplemented here, so a fix to the one
|
|
94
|
+
// canonical definition can't drift out of sync with this rule's copy.
|
|
95
|
+
const composedParent = helpers && typeof helpers.composedParent === 'function'
|
|
96
|
+
? helpers.composedParent
|
|
97
|
+
: function (n) { return n && n.parentElement ? n.parentElement : null; };
|
|
98
|
+
|
|
99
|
+
function closestAriaHiddenTrue(node) {
|
|
100
|
+
let cur = node;
|
|
101
|
+
let guard = 0;
|
|
102
|
+
while (cur && guard++ < 200) {
|
|
103
|
+
try {
|
|
104
|
+
if (cur.getAttribute) {
|
|
105
|
+
const v = cur.getAttribute('aria-hidden');
|
|
106
|
+
if (v != null && lower(v) === 'true') return cur;
|
|
107
|
+
}
|
|
108
|
+
} catch {
|
|
109
|
+
// ignore
|
|
110
|
+
}
|
|
111
|
+
cur = composedParent(cur);
|
|
112
|
+
}
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Lightweight "invisible but still focusable" hints.
|
|
117
|
+
// Only computed for a capped set of offenders per aria-hidden root.
|
|
118
|
+
function getVisibilityHints(el) {
|
|
119
|
+
const out = [];
|
|
120
|
+
if (!el) return out;
|
|
121
|
+
|
|
122
|
+
let cs = null;
|
|
123
|
+
try {
|
|
124
|
+
const w = (document && document.defaultView) ? document.defaultView : (typeof window !== 'undefined' ? window : null);
|
|
125
|
+
cs = w && w.getComputedStyle ? w.getComputedStyle(el) : null;
|
|
126
|
+
} catch {
|
|
127
|
+
cs = null;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// opacity:0 (still focusable)
|
|
131
|
+
try {
|
|
132
|
+
const rawOp = cs && cs.opacity != null ? String(cs.opacity).trim() : '';
|
|
133
|
+
const op = rawOp ? Number.parseFloat(rawOp) : 1;
|
|
134
|
+
if (Number.isFinite(op) && op <= 0.0001) out.push('opacityZero');
|
|
135
|
+
} catch {
|
|
136
|
+
// ignore
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// clip / clip-path (still focusable)
|
|
140
|
+
try {
|
|
141
|
+
const clip = cs && cs.clip != null ? String(cs.clip).trim() : '';
|
|
142
|
+
const clipPath = cs && cs.clipPath != null ? String(cs.clipPath).trim() : '';
|
|
143
|
+
const clipLow = (clip || '').toLowerCase();
|
|
144
|
+
const clipPathLow = (clipPath || '').toLowerCase();
|
|
145
|
+
|
|
146
|
+
// Common visually-hidden patterns
|
|
147
|
+
if (clipLow && clipLow !== 'auto') {
|
|
148
|
+
// Examples: rect(0px, 0px, 0px, 0px) / rect(0,0,0,0)
|
|
149
|
+
if (clipLow.indexOf('rect(') !== -1 && clipLow.replace(/\s+/g, '').indexOf('rect(0') !== -1) out.push('clipped');
|
|
150
|
+
}
|
|
151
|
+
if (clipPathLow && clipPathLow !== 'none') {
|
|
152
|
+
// Examples: inset(100%) / inset(50%)
|
|
153
|
+
if (clipPathLow.indexOf('inset(') !== -1 && (clipPathLow.indexOf('100%') !== -1 || clipPathLow.indexOf('50%') !== -1)) {
|
|
154
|
+
out.push('clipped');
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
} catch {
|
|
158
|
+
// ignore
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// zero-size + overflow hidden/clip (still focusable)
|
|
162
|
+
try {
|
|
163
|
+
const wv = cs && cs.width != null ? String(cs.width).trim() : '';
|
|
164
|
+
const hv = cs && cs.height != null ? String(cs.height).trim() : '';
|
|
165
|
+
const ov = cs && cs.overflow != null ? String(cs.overflow).trim().toLowerCase() : '';
|
|
166
|
+
const isZeroW = wv === '0px' || wv === '0';
|
|
167
|
+
const isZeroH = hv === '0px' || hv === '0';
|
|
168
|
+
const hidesOverflow = ov === 'hidden' || ov === 'clip';
|
|
169
|
+
if ((isZeroW || isZeroH) && hidesOverflow) out.push('zeroSizeOverflowHidden');
|
|
170
|
+
} catch {
|
|
171
|
+
// ignore
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Off-screen heuristic (still focusable)
|
|
175
|
+
// Deterministic string parsing; does not require layout geometry.
|
|
176
|
+
try {
|
|
177
|
+
const pos = cs && cs.position != null ? String(cs.position).trim().toLowerCase() : '';
|
|
178
|
+
const left = cs && cs.left != null ? String(cs.left).trim().toLowerCase() : '';
|
|
179
|
+
const top = cs && cs.top != null ? String(cs.top).trim().toLowerCase() : '';
|
|
180
|
+
const ti = cs && cs.textIndent != null ? String(cs.textIndent).trim().toLowerCase() : '';
|
|
181
|
+
|
|
182
|
+
const parsePx = (s) => {
|
|
183
|
+
if (!s || s === 'auto') return null;
|
|
184
|
+
const m = String(s).match(/-?\d+(\.\d+)?/);
|
|
185
|
+
if (!m) return null;
|
|
186
|
+
const n = Number.parseFloat(m[0]);
|
|
187
|
+
return Number.isFinite(n) ? n : null;
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
const l = parsePx(left);
|
|
191
|
+
const t = parsePx(top);
|
|
192
|
+
const ind = parsePx(ti);
|
|
193
|
+
|
|
194
|
+
if (pos === 'absolute' || pos === 'fixed') {
|
|
195
|
+
if ((l != null && l <= -5000) || (t != null && t <= -5000)) out.push('offscreen');
|
|
196
|
+
}
|
|
197
|
+
// Common text-indent technique
|
|
198
|
+
if (ind != null && ind <= -5000) out.push('offscreen');
|
|
199
|
+
} catch {
|
|
200
|
+
// ignore
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Dedup while preserving stable order
|
|
204
|
+
const seen = new Set();
|
|
205
|
+
const uniq = [];
|
|
206
|
+
for (const k of out) {
|
|
207
|
+
const kk = String(k);
|
|
208
|
+
if (!seen.has(kk)) { seen.add(kk); uniq.push(kk); }
|
|
209
|
+
}
|
|
210
|
+
return uniq;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// DOM-visibility gate to avoid false positives:
|
|
214
|
+
// Exclude structural/CSS hidden cases that prevent focus (display:none, visibility:hidden, hidden attr, etc.).
|
|
215
|
+
// IMPORTANT: Do NOT exclude opacity-based invisibility; opacity:0 remains in-scope.
|
|
216
|
+
function isActuallyFocusable(el) {
|
|
217
|
+
if (!el || !el.getAttribute) return false;
|
|
218
|
+
|
|
219
|
+
// Hard blockers that should always win (even if fallback logic would say "focusable")
|
|
220
|
+
if (hasInertAncestor(el)) return false;
|
|
221
|
+
if (isDisabledFormControl(el)) return false;
|
|
222
|
+
|
|
223
|
+
// An explicit negative tabindex removes the element from the keyboard
|
|
224
|
+
// tab sequence entirely, regardless of tag — the standard, WAI-
|
|
225
|
+
// recommended technique for safely hiding focusable content behind
|
|
226
|
+
// aria-hidden (verified against a widely-used reference engine's own aria-hidden-focus check,
|
|
227
|
+
// which requires tabbability — not raw focusability — via its
|
|
228
|
+
// `focusable-not-tabbable` sub-check; confirmed via a real page:
|
|
229
|
+
// Wikipedia's sticky header uses <button tabindex="-1">/<a tabindex="-1">
|
|
230
|
+
// inside aria-hidden divs, a correct pattern this rule was previously
|
|
231
|
+
// flagging as a false positive). Such an element is still
|
|
232
|
+
// programmatically focusable (script could call .focus()), but that's
|
|
233
|
+
// not what "no focusable content behind aria-hidden" cares about.
|
|
234
|
+
const explicitTabindex = trim(el.getAttribute('tabindex'));
|
|
235
|
+
if (explicitTabindex !== '' && !Number.isNaN(Number(explicitTabindex)) && Number(explicitTabindex) < 0) {
|
|
236
|
+
return false;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// 1) "DOM focusability" check (ignore aria-hidden)
|
|
240
|
+
// Prefer helper for broad coverage, but do not let aria-hidden flip focusable->false.
|
|
241
|
+
let helperInfo = null;
|
|
242
|
+
if (getFocusableInfo) {
|
|
243
|
+
try { helperInfo = getFocusableInfo(el, ctx); } catch { helperInfo = null; }
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Local fallback that does NOT care about aria-hidden
|
|
247
|
+
const tag = lower(el.tagName || '');
|
|
248
|
+
let fallbackFocusable = false;
|
|
249
|
+
|
|
250
|
+
if (tag === 'a' || tag === 'area') {
|
|
251
|
+
const href = trim(el.getAttribute('href'));
|
|
252
|
+
fallbackFocusable = !!href;
|
|
253
|
+
} else if (tag === 'button' || tag === 'select' || tag === 'textarea' || tag === 'summary') {
|
|
254
|
+
fallbackFocusable = true;
|
|
255
|
+
} else if (tag === 'input') {
|
|
256
|
+
const type = lower(el.getAttribute('type') || '');
|
|
257
|
+
fallbackFocusable = type !== 'hidden';
|
|
258
|
+
} else if (tag === 'iframe') {
|
|
259
|
+
fallbackFocusable = true;
|
|
260
|
+
} else if ((tag === 'audio' || tag === 'video') && el.hasAttribute && el.hasAttribute('controls')) {
|
|
261
|
+
fallbackFocusable = true;
|
|
262
|
+
} else if (el.hasAttribute && el.hasAttribute('contenteditable')) {
|
|
263
|
+
// contenteditable="false" explicitly disables the editing host and
|
|
264
|
+
// does not by itself add the element to the tab order; only treat
|
|
265
|
+
// presence/""/"true"/"plaintext-only" as focus-enabling.
|
|
266
|
+
const ceVal = lower(trim(el.getAttribute('contenteditable')));
|
|
267
|
+
fallbackFocusable = ceVal !== 'false';
|
|
268
|
+
} else {
|
|
269
|
+
const ti = el.getAttribute('tabindex');
|
|
270
|
+
const s = trim(ti);
|
|
271
|
+
if (ti != null && s !== '' && !Number.isNaN(Number(s))) {
|
|
272
|
+
fallbackFocusable = true; // tabindex makes it programmatically focusable
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Use helper focusable if it says true; otherwise use fallback focusable.
|
|
277
|
+
// This specifically prevents aria-hidden from suppressing "self focusable".
|
|
278
|
+
const focusable = (helperInfo && helperInfo.focusable === true) || fallbackFocusable;
|
|
279
|
+
if (!focusable) return false;
|
|
280
|
+
|
|
281
|
+
// 2) exclude non-rendered / non-visible-by-style blockers
|
|
282
|
+
// IMPORTANT: Do NOT exclude opacity-based invisibility; opacity:0 remains in-scope.
|
|
283
|
+
if (isDomVisibleEligible) {
|
|
284
|
+
try {
|
|
285
|
+
const vis = isDomVisibleEligible(el, ctx, { visibilityMode: 'styleOnly', disableGeometry: true });
|
|
286
|
+
if (vis && vis.eligible === false) {
|
|
287
|
+
const rs = Array.isArray(vis.reasons) ? vis.reasons : [];
|
|
288
|
+
const nonOpacity = rs.filter((r) => String(r) !== 'opacityZero');
|
|
289
|
+
if (nonOpacity.length) return false;
|
|
290
|
+
}
|
|
291
|
+
} catch {
|
|
292
|
+
// ignore
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
return true;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function hasInertAncestor(el) {
|
|
300
|
+
let cur = el;
|
|
301
|
+
let guard = 0;
|
|
302
|
+
while (cur && guard++ < 200) {
|
|
303
|
+
try {
|
|
304
|
+
if (cur.nodeType === 1 && cur.hasAttribute && cur.hasAttribute('inert')) return true;
|
|
305
|
+
} catch {
|
|
306
|
+
// ignore
|
|
307
|
+
}
|
|
308
|
+
cur = composedParent(cur);
|
|
309
|
+
}
|
|
310
|
+
return false;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function isDisabledFormControl(el) {
|
|
314
|
+
try {
|
|
315
|
+
// Covers button/input/select/textarea/option/optgroup/fieldset etc.
|
|
316
|
+
if (typeof el.disabled === 'boolean' && el.disabled) return true;
|
|
317
|
+
} catch {
|
|
318
|
+
// ignore
|
|
319
|
+
}
|
|
320
|
+
try {
|
|
321
|
+
const tag = lower(el.tagName || '');
|
|
322
|
+
if ((tag === 'button' || tag === 'input' || tag === 'select' || tag === 'textarea' || tag === 'option' || tag === 'optgroup') &&
|
|
323
|
+
el.hasAttribute && el.hasAttribute('disabled')) {
|
|
324
|
+
return true;
|
|
325
|
+
}
|
|
326
|
+
} catch {
|
|
327
|
+
// ignore
|
|
328
|
+
}
|
|
329
|
+
return false;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// 1) Find aria-hidden="true" roots.
|
|
333
|
+
const ariaHiddenRoots = qAll('[aria-hidden="true"]');
|
|
334
|
+
if (!ariaHiddenRoots.length) {
|
|
335
|
+
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// 2) Find focusable candidates once (performance) and bucket those inside aria-hidden.
|
|
339
|
+
// Keep selector fairly small to avoid huge candidate sets while still covering the reference-engine cases.
|
|
340
|
+
const focusableCandidates = qAll(
|
|
341
|
+
'a[href],area[href],button,input,select,textarea,summary,iframe,audio[controls],video[controls],[tabindex],[contenteditable]'
|
|
342
|
+
);
|
|
343
|
+
|
|
344
|
+
const bucket = new Map(); // ariaHiddenRoot -> { rootEl, count, offenders: [], hints:Set, rootIsFocusable }
|
|
345
|
+
const maxOffendersPerRoot = 5;
|
|
346
|
+
|
|
347
|
+
for (let i = 0; i < focusableCandidates.length; i++) {
|
|
348
|
+
const el = focusableCandidates[i];
|
|
349
|
+
if (!el || !el.getAttribute) continue;
|
|
350
|
+
|
|
351
|
+
// Cheap check first: a plain ancestor-attribute walk with no CSS
|
|
352
|
+
// computation, vs. isActuallyFocusable's getComputedStyle-per-ancestor
|
|
353
|
+
// cost. Both conditions are required (AND), so checking whichever is
|
|
354
|
+
// cheaper first cannot change which elements end up in the bucket —
|
|
355
|
+
// it only skips the expensive check for the (typically vast) majority
|
|
356
|
+
// of focusable candidates that were never inside an aria-hidden root
|
|
357
|
+
// in the first place. On a real page with a large focusable-candidate
|
|
358
|
+
// count and a complex stylesheet (Daily Mail: ~1400 links, ~3600 CSS
|
|
359
|
+
// rules), this cut this check's runtime from ~30s to well under 1s —
|
|
360
|
+
// a pure ordering change, not a behavior change.
|
|
361
|
+
const rootEl = closestAriaHiddenTrue(el);
|
|
362
|
+
if (!rootEl) continue;
|
|
363
|
+
|
|
364
|
+
if (!isActuallyFocusable(el)) continue;
|
|
365
|
+
|
|
366
|
+
let entry = bucket.get(rootEl);
|
|
367
|
+
if (!entry) {
|
|
368
|
+
entry = { rootEl, count: 0, offenders: [], hints: new Set(), rootIsFocusable: false };
|
|
369
|
+
bucket.set(rootEl, entry);
|
|
370
|
+
}
|
|
371
|
+
entry.count += 1;
|
|
372
|
+
|
|
373
|
+
// Capture a small, deterministic offender summary + visibility hints.
|
|
374
|
+
if (entry.offenders.length < maxOffendersPerRoot) {
|
|
375
|
+
let tag = '';
|
|
376
|
+
let ti = null;
|
|
377
|
+
let href = null;
|
|
378
|
+
let type = null;
|
|
379
|
+
|
|
380
|
+
try { tag = lower(el.tagName || ''); } catch { tag = ''; }
|
|
381
|
+
try { ti = el.getAttribute('tabindex'); } catch { ti = null; }
|
|
382
|
+
try { href = (tag === 'a' || tag === 'area') ? trim(el.getAttribute('href')) : null; } catch { href = null; }
|
|
383
|
+
try { type = tag === 'input' ? lower(el.getAttribute('type') || '') : null; } catch { type = null; }
|
|
384
|
+
|
|
385
|
+
const hints = getVisibilityHints(el);
|
|
386
|
+
for (const h of hints) entry.hints.add(h);
|
|
387
|
+
|
|
388
|
+
entry.offenders.push({
|
|
389
|
+
tag: tag || null,
|
|
390
|
+
tabindex: ti == null ? null : String(ti),
|
|
391
|
+
href: href || null,
|
|
392
|
+
type: type || null,
|
|
393
|
+
visibilityHints: hints
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
if (!bucket.size) {
|
|
399
|
+
return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// Ensure deterministic root focusability computation (done once per failing root).
|
|
403
|
+
for (const [, entry] of bucket) {
|
|
404
|
+
const el = entry.rootEl;
|
|
405
|
+
entry.rootIsFocusable = isActuallyFocusable(el);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// 3) Report one occurrence per aria-hidden root that contains focusable content.
|
|
409
|
+
const occurrences = [];
|
|
410
|
+
for (const [, entry] of bucket) {
|
|
411
|
+
const el = entry.rootEl;
|
|
412
|
+
|
|
413
|
+
const eligInfo = getEligibilityInfo
|
|
414
|
+
? (() => { try { return getEligibilityInfo(el, ctx, { targetSet: 'acc' }); } catch { return null; } })()
|
|
415
|
+
: null;
|
|
416
|
+
|
|
417
|
+
// stable visibility hint ordering
|
|
418
|
+
const hintOrder = ['opacityZero', 'offscreen', 'clipped', 'zeroSizeOverflowHidden'];
|
|
419
|
+
const hintsArr = [];
|
|
420
|
+
for (const k of hintOrder) if (entry.hints.has(k)) hintsArr.push(k);
|
|
421
|
+
|
|
422
|
+
const tagName = (() => { try { return lower(el.tagName || ''); } catch { return ''; } })();
|
|
423
|
+
|
|
424
|
+
// Split counts deterministically
|
|
425
|
+
const selfFocusable = !!entry.rootIsFocusable;
|
|
426
|
+
const totalFocusable = entry.count;
|
|
427
|
+
const descendantFocusable = selfFocusable ? Math.max(0, totalFocusable - 1) : totalFocusable;
|
|
428
|
+
|
|
429
|
+
const summaryKey = selfFocusable
|
|
430
|
+
? (descendantFocusable > 0
|
|
431
|
+
? 'ariaHidden_focus_summary_fail_self_and_desc'
|
|
432
|
+
: 'ariaHidden_focus_summary_fail_self')
|
|
433
|
+
: 'ariaHidden_focus_summary_fail_desc';
|
|
434
|
+
|
|
435
|
+
const reasonCode = selfFocusable
|
|
436
|
+
? (descendantFocusable > 0 ? 'ariaHiddenSelfAndDescendantsFocusable' : 'ariaHiddenSelfFocusable')
|
|
437
|
+
: 'ariaHiddenContainsFocusable';
|
|
438
|
+
|
|
439
|
+
const summaryText = selfFocusable
|
|
440
|
+
? (descendantFocusable > 0
|
|
441
|
+
? `aria-hidden ${tagName} is focusable and contains ${descendantFocusable} focusable descendant(s) (${totalFocusable} focusable element(s) total).`
|
|
442
|
+
: `aria-hidden ${tagName} is focusable (${totalFocusable} focusable element(s)).`)
|
|
443
|
+
: `aria-hidden ${tagName} contains ${totalFocusable} focusable element(s).`;
|
|
444
|
+
|
|
445
|
+
const baseOccurrence = {
|
|
446
|
+
summary: summaryText,
|
|
447
|
+
hint: 'Remove focusability from descendants or remove aria-hidden; ensure focus and accessibility trees stay aligned.',
|
|
448
|
+
i18n: {
|
|
449
|
+
summaryKey,
|
|
450
|
+
hintKey: 'ariaHidden_focus_hint_fail',
|
|
451
|
+
params: {
|
|
452
|
+
element: tagName,
|
|
453
|
+
focusableCount: String(totalFocusable),
|
|
454
|
+
descendantFocusableCount: String(descendantFocusable)
|
|
455
|
+
}
|
|
456
|
+
},
|
|
457
|
+
data: {
|
|
458
|
+
details: {
|
|
459
|
+
reasonCode,
|
|
460
|
+
metrics: {
|
|
461
|
+
focusableTotal: totalFocusable,
|
|
462
|
+
focusableDescendants: descendantFocusable,
|
|
463
|
+
rootIsFocusable: selfFocusable,
|
|
464
|
+
offendersCaptured: entry.offenders.length,
|
|
465
|
+
visibilityHints: hintsArr.slice(0)
|
|
466
|
+
},
|
|
467
|
+
offenders: entry.offenders.slice(0)
|
|
468
|
+
},
|
|
469
|
+
visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] }
|
|
470
|
+
}
|
|
471
|
+
};
|
|
472
|
+
|
|
473
|
+
if (reportOccurrence) occurrences.push(reportOccurrence(el, baseOccurrence));
|
|
474
|
+
else occurrences.push({ __node: el, selector: '', html: '', ...baseOccurrence });
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
return { ruleId: rule.ruleId, outcome: 'fail', severity: rule.defaultSeverity || 'minor', occurrences };
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
module.exports = { id, meta, runInPage };
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @check aria-prohibited-attr
|
|
5
|
+
* @atomic true
|
|
6
|
+
* @summary Certain ARIA naming attributes are explicitly prohibited on specific roles
|
|
7
|
+
* @standard WCAG 2.2
|
|
8
|
+
* @sc 4.1.2
|
|
9
|
+
* @applicability
|
|
10
|
+
* Applies to elements whose explicit, valid role is one of the small set
|
|
11
|
+
* of WAI-ARIA 1.2 roles with a documented "Prohibited ARIA States and
|
|
12
|
+
* Properties" list (pure text-semantics / non-naming structural roles:
|
|
13
|
+
* caption, code, deletion, emphasis, generic, insertion, mark, none,
|
|
14
|
+
* paragraph, presentation, strong, subscript, suggestion, superscript,
|
|
15
|
+
* time), and that also carry aria-label or aria-labelledby.
|
|
16
|
+
* @expectation
|
|
17
|
+
* Prohibited attributes must not be present, since these roles are
|
|
18
|
+
* defined to never carry an accessible name — a naming attribute on them
|
|
19
|
+
* is a real, deterministic spec violation, not a style preference.
|
|
20
|
+
* @implementation-notes
|
|
21
|
+
* - Deliberately scoped to the single, well-established prohibition class
|
|
22
|
+
* (naming attributes on pure text-semantics roles) rather than
|
|
23
|
+
* attempting an exhaustive per-role prohibited-attribute table; see
|
|
24
|
+
* src/core/aria-helpers.js file header for this engine's confidence-
|
|
25
|
+
* scoping rationale.
|
|
26
|
+
* - Role list widened 2026-07-19 (Tier 4) from 10 to 13 roles, adding
|
|
27
|
+
* `mark`, `suggestion`, and `time` — the other ARIA 1.2 "HTML-alignment"
|
|
28
|
+
* text-level roles that share the same documented prohibition as the
|
|
29
|
+
* original 10. Still deliberately not claiming full parity with a widely-used
|
|
30
|
+
* reference engine:
|
|
31
|
+
* only roles/attrs this engine has high confidence in from the spec
|
|
32
|
+
* text are included, per the file's own "wrong entries cause false-
|
|
33
|
+
* positive fails" caution.
|
|
34
|
+
* - Widened again 2026-07-21 to add `presentation`/`none`, verified
|
|
35
|
+
* directly against a widely-used reference engine's own role data table
|
|
36
|
+
* (both have `prohibitedAttrs: ['aria-label', 'aria-labelledby']`), and
|
|
37
|
+
* corroborated by the W3C
|
|
38
|
+
* WAI-ARIA 1.2 spec's own §5.2.8.6 "Roles which cannot be named"
|
|
39
|
+
* listing `presentation` explicitly (`none` is `presentation`'s
|
|
40
|
+
* documented 1.2-introduced alias, identical semantics). The
|
|
41
|
+
* pre-existing `presentation-role-conflict` rule already treats
|
|
42
|
+
* aria-label/aria-labelledby as conflicting on these two roles, but at
|
|
43
|
+
* `manual`/cantTell confidence across a ~24-attribute general list —
|
|
44
|
+
* this addition lets the specific, unambiguous naming-prohibition case
|
|
45
|
+
* also fire as a hard, WCAG-normative `fail` via this rule, matching
|
|
46
|
+
* this engine's "one rule = one normative decision" pattern rather than
|
|
47
|
+
* only ever surfacing it as advisory.
|
|
48
|
+
* - Investigated, but deliberately did NOT add, `definition`/`term`
|
|
49
|
+
* despite both appearing on MDN's aria-label reference page's
|
|
50
|
+
* "not supported" list: that MDN list is demonstrably wrong for these
|
|
51
|
+
* two — a widely-used reference engine's own role data explicitly declares
|
|
52
|
+
* `nameFrom: ['author']` (`definition`) / `nameFrom: ['author',
|
|
53
|
+
* 'contents']` (`term`), and the W3C spec's own §5.2.8.4 "Roles
|
|
54
|
+
* Supporting Name From Author" index lists both by name; MDN's
|
|
55
|
+
* `definition_role` page even demonstrates `aria-labelledby` usage on
|
|
56
|
+
* it directly. A real, confirmed documentation bug on MDN's side, not
|
|
57
|
+
* a gap here.
|
|
58
|
+
* - Not gated on isAccTreeEligible: this is a static markup property.
|
|
59
|
+
*/
|
|
60
|
+
|
|
61
|
+
const id = 'aria-prohibited-attr';
|
|
62
|
+
|
|
63
|
+
const meta = {
|
|
64
|
+
title: 'ARIA naming attributes must not be used on roles that prohibit them',
|
|
65
|
+
description: 'Checks that aria-label/aria-labelledby are not present on WAI-ARIA roles whose specification explicitly prohibits ARIA naming (e.g. generic, emphasis, strong, paragraph).',
|
|
66
|
+
i18n: {
|
|
67
|
+
titleKey: 'ariaProhibitedAttr_title',
|
|
68
|
+
descriptionKey: 'ariaProhibitedAttr_description'
|
|
69
|
+
},
|
|
70
|
+
helpUrl: null,
|
|
71
|
+
tags: ['wcag2a', 'wcag412', 'aria', 'structure', 'atomic', 'automatic'],
|
|
72
|
+
wcagSc: ['4.1.2'],
|
|
73
|
+
normativeMappings: [
|
|
74
|
+
{ standard: 'WCAG', version: '2.2', requirement: '4.1.2', title: 'Name, Role, Value', conformanceLevel: 'A' }
|
|
75
|
+
],
|
|
76
|
+
defaultSeverity: 'moderate',
|
|
77
|
+
category: 'robust',
|
|
78
|
+
type: 'automatic',
|
|
79
|
+
defaultConfidence: 'high',
|
|
80
|
+
coverage: { facetsBySc: { '4.1.2': ['aria-attr-not-prohibited'] } }
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
function runInPage(ctx) {
|
|
84
|
+
const { document, root, helpers, rule } = ctx;
|
|
85
|
+
const safeRoot = root || document;
|
|
86
|
+
|
|
87
|
+
const ariaHelpers = helpers && helpers.aria ? helpers.aria : null;
|
|
88
|
+
if (!ariaHelpers) {
|
|
89
|
+
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Roles whose WAI-ARIA 1.2 definition lists a "Prohibited ARIA States and
|
|
93
|
+
// Properties" entry for naming attributes (these roles must never carry an
|
|
94
|
+
// accessible name). Declared inside runInPage (rather than at module
|
|
95
|
+
// scope) because the build inlines only this function's own source text
|
|
96
|
+
// — see scripts/build-core.js header ("runInPage MUST be self-contained").
|
|
97
|
+
const ROLES_PROHIBITING_NAME = new Set([
|
|
98
|
+
'caption', 'code', 'deletion', 'emphasis', 'generic', 'insertion',
|
|
99
|
+
'mark', 'none', 'paragraph', 'presentation', 'strong', 'subscript',
|
|
100
|
+
'suggestion', 'superscript', 'time'
|
|
101
|
+
]);
|
|
102
|
+
|
|
103
|
+
const PROHIBITED_NAMING_ATTRS = ['aria-label', 'aria-labelledby'];
|
|
104
|
+
|
|
105
|
+
const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('[role]', safeRoot) : helpers.queryAll('[role]', safeRoot);
|
|
106
|
+
|
|
107
|
+
const occurrences = [];
|
|
108
|
+
let applicableCount = 0;
|
|
109
|
+
|
|
110
|
+
for (const el of nodes) {
|
|
111
|
+
if (!el || !el.getAttribute) continue;
|
|
112
|
+
|
|
113
|
+
const role = ariaHelpers.getExplicitRole(el);
|
|
114
|
+
if (!role || !ROLES_PROHIBITING_NAME.has(role)) continue;
|
|
115
|
+
|
|
116
|
+
applicableCount += 1;
|
|
117
|
+
|
|
118
|
+
const present = [];
|
|
119
|
+
for (const attr of PROHIBITED_NAMING_ATTRS) {
|
|
120
|
+
const v = el.getAttribute(attr);
|
|
121
|
+
if (v != null && String(v).trim() !== '') present.push(attr);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (!present.length) continue;
|
|
125
|
+
|
|
126
|
+
const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
|
|
127
|
+
const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
|
|
128
|
+
|
|
129
|
+
for (const attr of present) {
|
|
130
|
+
occurrences.push({
|
|
131
|
+
selector: stableSelector,
|
|
132
|
+
html,
|
|
133
|
+
summary: 'This attribute is prohibited on this element’s role.',
|
|
134
|
+
hint: 'Remove this attribute; this role must not carry an accessible name.',
|
|
135
|
+
i18n: {
|
|
136
|
+
summaryKey: 'ariaProhibitedAttr_summary_fail',
|
|
137
|
+
hintKey: 'ariaProhibitedAttr_hint_fail',
|
|
138
|
+
params: { attr, role }
|
|
139
|
+
},
|
|
140
|
+
data: {
|
|
141
|
+
details: { reasonCode: 'ARIA_ATTR_PROHIBITED', attr, role }
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (applicableCount === 0) {
|
|
148
|
+
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
149
|
+
}
|
|
150
|
+
if (occurrences.length) {
|
|
151
|
+
return { ruleId: rule.ruleId, outcome: 'fail', severity: rule.defaultSeverity || 'moderate', occurrences };
|
|
152
|
+
}
|
|
153
|
+
return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
module.exports = { id, meta, runInPage };
|