ultra11y 2.22.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/LICENSE +21 -0
- package/README.md +223 -0
- package/docker/Dockerfile +7 -0
- package/docker/package.json +9 -0
- package/docker/runner.mjs +23 -0
- package/package.json +73 -0
- package/scripts/bench.mjs +151 -0
- package/scripts/build-act-corpus.mjs +143 -0
- package/scripts/build-adjudication.mjs +503 -0
- package/scripts/build-pack-rgaa.mjs +397 -0
- package/scripts/build-standards.mjs +548 -0
- package/scripts/copy-bundle.mjs +39 -0
- package/scripts/import-pack.mjs +107 -0
- package/scripts/sync-engine.mjs +84 -0
- package/scripts/sync-version.mjs +43 -0
- package/scripts/ultra11y.mjs +52739 -0
- package/scripts/vendor/act-testcases.json +12297 -0
- package/scripts/vendor/rgaa/SOURCE.json +8 -0
- package/scripts/vendor/rgaa/criteres.json +4144 -0
- package/scripts/vendor/rgaa/glossaire.json +480 -0
- package/scripts/vendor/wcag-2.2-fr.json +82 -0
- package/scripts/vendor/wcag-2.2-sc-universe.json +950 -0
- package/scripts/vendor/wcag-2.2-sc.json +574 -0
- package/scripts/verify-rgaa-source.mjs +126 -0
- package/scripts/verify-skill-bundle.mjs +91 -0
- package/skills/review-a11y/SKILL.md +123 -0
- package/skills/review-a11y/scripts/ultra11y.mjs +52739 -0
- package/skills/ultra11y/SKILL.md +292 -0
- package/skills/ultra11y/references/act.md +163 -0
- package/skills/ultra11y/references/adjudication.md +599 -0
- package/skills/ultra11y/references/audit.md +100 -0
- package/skills/ultra11y/references/authoring.md +37 -0
- package/skills/ultra11y/references/automation.md +113 -0
- package/skills/ultra11y/references/correction.md +41 -0
- package/skills/ultra11y/references/criteria.md +129 -0
- package/skills/ultra11y/references/cross-file.md +82 -0
- package/skills/ultra11y/references/dynamic.md +152 -0
- package/skills/ultra11y/references/false-positives.md +81 -0
- package/skills/ultra11y/references/fix.md +46 -0
- package/skills/ultra11y/references/focus-and-logic.md +64 -0
- package/skills/ultra11y/references/forbidden-patterns.md +61 -0
- package/skills/ultra11y/references/forms-and-errors.md +147 -0
- package/skills/ultra11y/references/guidance.md +55 -0
- package/skills/ultra11y/references/judgment.md +90 -0
- package/skills/ultra11y/references/media-and-motion.md +116 -0
- package/skills/ultra11y/references/methodology.md +72 -0
- package/skills/ultra11y/references/naming.md +117 -0
- package/skills/ultra11y/references/orchestration.md +44 -0
- package/skills/ultra11y/references/packs.md +193 -0
- package/skills/ultra11y/references/prd.md +104 -0
- package/skills/ultra11y/references/rendered.md +110 -0
- package/skills/ultra11y/references/scale.md +49 -0
- package/skills/ultra11y/references/standards.md +97 -0
- package/skills/ultra11y/references/structure.md +138 -0
- package/skills/ultra11y/references/verify.md +88 -0
- package/skills/ultra11y/references/widgets.md +106 -0
- package/skills/ultra11y/scripts/ultra11y.mjs +52739 -0
- package/src/act.ts +441 -0
- package/src/adjudicate.ts +630 -0
- package/src/aria.ts +230 -0
- package/src/audit.ts +396 -0
- package/src/auditor.ts +399 -0
- package/src/autofill.ts +102 -0
- package/src/axe-map.ts +294 -0
- package/src/baseline.ts +75 -0
- package/src/capture.ts +182 -0
- package/src/check.ts +243 -0
- package/src/cli.ts +1927 -0
- package/src/color.ts +102 -0
- package/src/config.ts +182 -0
- package/src/crawl.ts +75 -0
- package/src/criteria.ts +183 -0
- package/src/data/adjudication.json +770 -0
- package/src/data/guidance/rgaa.json +2395 -0
- package/src/data/standards/rgaa.glossary.json +478 -0
- package/src/data/standards/rgaa.json +3454 -0
- package/src/data/wcag-universe.json +530 -0
- package/src/data/wcag.json +1178 -0
- package/src/discover.ts +203 -0
- package/src/fix/codemods.ts +76 -0
- package/src/fix/diff.ts +64 -0
- package/src/fix/edits.ts +126 -0
- package/src/fix.ts +215 -0
- package/src/gh.ts +214 -0
- package/src/glob.ts +206 -0
- package/src/graph/build.ts +114 -0
- package/src/graph/engine-resolve.ts +0 -0
- package/src/graph/graph.ts +126 -0
- package/src/graph/imports.ts +359 -0
- package/src/graph/resolve.ts +73 -0
- package/src/graph/tsconfig.ts +113 -0
- package/src/grounding.ts +119 -0
- package/src/guidance/index.ts +45 -0
- package/src/guidance/types.ts +36 -0
- package/src/init.ts +113 -0
- package/src/mcp/handlers.ts +388 -0
- package/src/mcp/http.ts +260 -0
- package/src/mcp/prompts.ts +183 -0
- package/src/mcp/protocol.ts +187 -0
- package/src/mcp/resources.ts +136 -0
- package/src/mcp/server.ts +228 -0
- package/src/mcp/stdio.ts +119 -0
- package/src/mcp/tools.ts +347 -0
- package/src/messages.ts +1011 -0
- package/src/name.ts +174 -0
- package/src/orchestrate-templates.ts +231 -0
- package/src/orchestrate.ts +186 -0
- package/src/output.ts +108 -0
- package/src/pack.ts +187 -0
- package/src/parse/html.ts +258 -0
- package/src/parse/jsx-ast.ts +67 -0
- package/src/parse/jsx-bridge.ts +247 -0
- package/src/parse/jsx.ts +19 -0
- package/src/parse/source.ts +75 -0
- package/src/prd.ts +406 -0
- package/src/project-lock.ts +42 -0
- package/src/render.ts +307 -0
- package/src/report.ts +399 -0
- package/src/rules/colors.ts +125 -0
- package/src/rules/cross-registry.ts +264 -0
- package/src/rules/cross-rule.ts +83 -0
- package/src/rules/forms.ts +590 -0
- package/src/rules/frames.ts +33 -0
- package/src/rules/headings.ts +227 -0
- package/src/rules/images.ts +200 -0
- package/src/rules/links.ts +193 -0
- package/src/rules/mandatory.ts +173 -0
- package/src/rules/multimedia.ts +77 -0
- package/src/rules/navigation.ts +190 -0
- package/src/rules/presentation.ts +75 -0
- package/src/rules/registry.ts +57 -0
- package/src/rules/rule.ts +122 -0
- package/src/rules/scripts-aria.ts +636 -0
- package/src/rules/tables.ts +264 -0
- package/src/rules/timing.ts +55 -0
- package/src/sample.ts +176 -0
- package/src/scan-local.ts +866 -0
- package/src/scan.ts +538 -0
- package/src/standards/derive.ts +195 -0
- package/src/standards/index.ts +31 -0
- package/src/standards/pack-rules.ts +111 -0
- package/src/standards/pack.ts +81 -0
- package/src/standards/registry.ts +100 -0
- package/src/standards/types.ts +202 -0
- package/src/standards/validate.ts +544 -0
- package/src/standards/vocabulary.ts +75 -0
- package/src/types.ts +355 -0
- package/src/util.ts +32 -0
- package/src/vendor/codeindex-engine.d.mts +1009 -0
- package/src/vendor/codeindex-engine.mjs +13701 -0
- package/src/vendor/engine.meta.json +9 -0
- package/src/verify.ts +275 -0
- package/src/wcag.ts +128 -0
|
@@ -0,0 +1,590 @@
|
|
|
1
|
+
// Theme 11 — Forms: every field has a programmatic label + group/structure checks.
|
|
2
|
+
import type { Doc, El } from "../parse/html.js";
|
|
3
|
+
import { attr, hasAttr, hasDynamicSpread, descendants, ancestors, visibleText, textContent } from "../parse/html.js";
|
|
4
|
+
import { isIntrinsic } from "../parse/jsx-bridge.js";
|
|
5
|
+
import { controlLabel, isFormField, mayInjectContent, isNameExempt } from "../name.js";
|
|
6
|
+
import { isValidAutocomplete, CREDENTIAL_FIELDS } from "../autofill.js";
|
|
7
|
+
|
|
8
|
+
// The element's WHOLE content is a slot/snippet passthrough — a DIRECT `<slot>` child or
|
|
9
|
+
// a `{@render …}`/`{children}` render-expression text node — so the legend the caller
|
|
10
|
+
// provides is invisible here. (Must be a direct child: a fieldset that merely *contains*
|
|
11
|
+
// component fields with no legend is a real violation, not a passthrough.)
|
|
12
|
+
function contentInjected(el: El): boolean {
|
|
13
|
+
return el.children.some((c) => (c.type === "element" && c.tag === "slot") || (c.type === "text" && /\{@render|\{children/.test(c.data)));
|
|
14
|
+
}
|
|
15
|
+
import type { Rule, RuleFinding } from "./rule.js";
|
|
16
|
+
|
|
17
|
+
// title/placeholder are NOT real labels for these rules
|
|
18
|
+
const REAL_LABEL = new Set(["for", "wrapping", "aria-label", "aria-labelledby"]);
|
|
19
|
+
|
|
20
|
+
const controlLabelMissing: Rule = {
|
|
21
|
+
id: "control-label-missing",
|
|
22
|
+
criteria: ["4.1.2"],
|
|
23
|
+
severity: "bloquant",
|
|
24
|
+
run(doc: Doc): RuleFinding[] {
|
|
25
|
+
const out: RuleFinding[] = [];
|
|
26
|
+
for (const el of doc.elements) {
|
|
27
|
+
if (!isFormField(el)) continue;
|
|
28
|
+
if (isNameExempt(el)) continue; // not exposed, or explicitly presentational
|
|
29
|
+
const { via } = controlLabel(el, doc);
|
|
30
|
+
if (via && REAL_LABEL.has(via)) continue;
|
|
31
|
+
// title yields an accessible name (accname fallback / WCAG H65) — a weak one, surfaced
|
|
32
|
+
// as a minor control-name-title-only finding, not a blocking "no label".
|
|
33
|
+
if (via === "title") continue;
|
|
34
|
+
// A spread (JSX {...props} / Vue v-bind="…" / Svelte {...rest}) may inject
|
|
35
|
+
// aria-label/aria-labelledby we can't see — don't assert a missing label.
|
|
36
|
+
if (hasDynamicSpread(el)) continue;
|
|
37
|
+
if (hasAttr(el, "placeholder")) continue; // reported by placeholder-as-label
|
|
38
|
+
out.push({
|
|
39
|
+
criteriaId: "4.1.2",
|
|
40
|
+
el,
|
|
41
|
+
msgId: "control-label-missing",
|
|
42
|
+
params: { tag: el.tag },
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
return out;
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const placeholderAsLabel: Rule = {
|
|
50
|
+
id: "placeholder-as-label",
|
|
51
|
+
criteria: ["4.1.2"],
|
|
52
|
+
severity: "majeur",
|
|
53
|
+
run(doc: Doc): RuleFinding[] {
|
|
54
|
+
const out: RuleFinding[] = [];
|
|
55
|
+
for (const el of doc.elements) {
|
|
56
|
+
if (!isFormField(el)) continue;
|
|
57
|
+
if (!hasAttr(el, "placeholder")) continue;
|
|
58
|
+
const { via } = controlLabel(el, doc);
|
|
59
|
+
if (via && REAL_LABEL.has(via)) continue;
|
|
60
|
+
out.push({
|
|
61
|
+
criteriaId: "4.1.2",
|
|
62
|
+
el,
|
|
63
|
+
msgId: "placeholder-as-label",
|
|
64
|
+
params: { value: attr(el, "placeholder") ?? "" },
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
return out;
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const fieldsetLegendMissing: Rule = {
|
|
72
|
+
id: "fieldset-legend-missing",
|
|
73
|
+
criteria: ["1.3.1"],
|
|
74
|
+
severity: "majeur",
|
|
75
|
+
run(doc: Doc): RuleFinding[] {
|
|
76
|
+
const out: RuleFinding[] = [];
|
|
77
|
+
for (const el of doc.elements) {
|
|
78
|
+
if (el.tag !== "fieldset") continue;
|
|
79
|
+
const legend = el.children.find((c) => c.type === "element" && c.tag === "legend");
|
|
80
|
+
if (legend && legend.type === "element" && visibleText(legend)) continue;
|
|
81
|
+
if (hasAttr(el, "aria-label") || hasAttr(el, "aria-labelledby")) continue;
|
|
82
|
+
if (contentInjected(el)) continue; // legend supplied via slot/{@render children()}/a component
|
|
83
|
+
out.push({
|
|
84
|
+
criteriaId: "1.3.1",
|
|
85
|
+
el,
|
|
86
|
+
msgId: "fieldset-legend-missing",
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
return out;
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const formFieldMultipleLabels: Rule = {
|
|
94
|
+
id: "form-field-multiple-labels",
|
|
95
|
+
criteria: ["4.1.2"],
|
|
96
|
+
severity: "mineur",
|
|
97
|
+
run(doc: Doc): RuleFinding[] {
|
|
98
|
+
const counts = new Map<string, number>();
|
|
99
|
+
for (const el of doc.elements) {
|
|
100
|
+
if (el.tag !== "label") continue;
|
|
101
|
+
const f = attr(el, "for");
|
|
102
|
+
if (f) counts.set(f, (counts.get(f) ?? 0) + 1);
|
|
103
|
+
}
|
|
104
|
+
const out: RuleFinding[] = [];
|
|
105
|
+
for (const el of doc.elements) {
|
|
106
|
+
if (!isFormField(el)) continue;
|
|
107
|
+
const id = attr(el, "id");
|
|
108
|
+
if (id && (counts.get(id) ?? 0) > 1) {
|
|
109
|
+
out.push({
|
|
110
|
+
criteriaId: "4.1.2",
|
|
111
|
+
el,
|
|
112
|
+
msgId: "form-field-multiple-labels",
|
|
113
|
+
params: { tag: el.tag, count: counts.get(id) ?? 0, id },
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return out;
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const selectHasOption: Rule = {
|
|
122
|
+
id: "select-has-option",
|
|
123
|
+
criteria: ["4.1.2"],
|
|
124
|
+
severity: "mineur",
|
|
125
|
+
run(doc: Doc): RuleFinding[] {
|
|
126
|
+
const out: RuleFinding[] = [];
|
|
127
|
+
for (const el of doc.elements) {
|
|
128
|
+
if (el.tag !== "select") continue;
|
|
129
|
+
if (descendants(el).some((d) => d.tag === "option")) continue;
|
|
130
|
+
if (mayInjectContent(el) || contentInjected(el)) continue; // options injected via <slot>/component/{@render}
|
|
131
|
+
out.push({
|
|
132
|
+
criteriaId: "4.1.2",
|
|
133
|
+
el,
|
|
134
|
+
msgId: "select-has-option",
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
return out;
|
|
138
|
+
},
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
// An attribute key that carries an element id (`id`, `inputId`, `data-testid`, `htmlFor`…):
|
|
142
|
+
// the signature a design-system component uses to hand an id down to the control it renders.
|
|
143
|
+
const ID_ATTR_KEY = /id/i;
|
|
144
|
+
|
|
145
|
+
// A <label for="x"> whose target id exists nowhere in the document — the visible label
|
|
146
|
+
// is not programmatically associated with any field (a common typo'd for/id pairing).
|
|
147
|
+
// Same-document only; cross-file label/field association is out of scope (see references/cross-file.md).
|
|
148
|
+
const labelForDangling: Rule = {
|
|
149
|
+
id: "label-for-dangling",
|
|
150
|
+
criteria: ["1.3.1"],
|
|
151
|
+
severity: "majeur",
|
|
152
|
+
run(doc: Doc): RuleFinding[] {
|
|
153
|
+
const out: RuleFinding[] = [];
|
|
154
|
+
// value -> the elements carrying it under an id-bearing attribute key, built ONCE
|
|
155
|
+
// and only when a dangling `for` is actually seen. The probe below used to be a full
|
|
156
|
+
// `doc.elements.some(... Object.entries(e.attribs) ...)` scan *inside* the label loop:
|
|
157
|
+
// quadratic, and on a form-heavy page it dominated the entire audit (~8 s for 8k labels).
|
|
158
|
+
let idProps: Map<string, El[]> | undefined;
|
|
159
|
+
const idPropCarriers = (value: string): El[] | undefined => {
|
|
160
|
+
if (!idProps) {
|
|
161
|
+
idProps = new Map();
|
|
162
|
+
for (const e of doc.elements) {
|
|
163
|
+
for (const k in e.attribs) {
|
|
164
|
+
if (!ID_ATTR_KEY.test(k)) continue;
|
|
165
|
+
const v = e.attribs[k];
|
|
166
|
+
if (!v) continue;
|
|
167
|
+
const bucket = idProps.get(v);
|
|
168
|
+
// Attributes of one element are consecutive, so comparing the tail is enough
|
|
169
|
+
// to keep an element from being counted twice (e.g. id="x" AND data-id="x").
|
|
170
|
+
if (!bucket) idProps.set(v, [e]);
|
|
171
|
+
else if (bucket[bucket.length - 1] !== e) bucket.push(e);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return idProps.get(value);
|
|
176
|
+
};
|
|
177
|
+
for (const el of doc.elements) {
|
|
178
|
+
if (el.tag !== "label") continue;
|
|
179
|
+
const f = (attr(el, "for") ?? "").trim();
|
|
180
|
+
if (!f || f.includes("{")) continue; // empty, or a JSX expression remnant (dynamic for=)
|
|
181
|
+
if (doc.byId.has(f)) continue;
|
|
182
|
+
// Wired via an id-bearing prop on a sibling component? Design-system field/upload
|
|
183
|
+
// components take the input id as a prop (e.g. <FileUpload inputId="x"/>) and render
|
|
184
|
+
// the <input id="x"> internally — the association is real, just not a literal id here.
|
|
185
|
+
const passedAsIdProp = idPropCarriers(f)?.some((e) => e !== el) ?? false;
|
|
186
|
+
if (passedAsIdProp) continue;
|
|
187
|
+
out.push({
|
|
188
|
+
criteriaId: "1.3.1",
|
|
189
|
+
el,
|
|
190
|
+
msgId: "label-for-dangling",
|
|
191
|
+
params: { id: f },
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
return out;
|
|
195
|
+
},
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
// WCAG 3.3.1 Error Identification: a control flagged aria-invalid="true" must point AT
|
|
199
|
+
// the error text (aria-describedby or aria-errormessage) so assistive tech can restitute
|
|
200
|
+
// *why* it is invalid. aria-invalid alone announces "invalid" with no reason — the error
|
|
201
|
+
// is signalled but not associated (cross-referenced by RGAA 11.10). The inverse case
|
|
202
|
+
// (visible error text not linked at all) is a judgment call left to the auditor.
|
|
203
|
+
const ariaInvalidNoDescription: Rule = {
|
|
204
|
+
id: "aria-invalid-no-description",
|
|
205
|
+
criteria: ["3.3.1"],
|
|
206
|
+
severity: "majeur",
|
|
207
|
+
run(doc: Doc): RuleFinding[] {
|
|
208
|
+
const out: RuleFinding[] = [];
|
|
209
|
+
for (const el of doc.elements) {
|
|
210
|
+
if (!isFormField(el)) continue;
|
|
211
|
+
const invalid = attr(el, "aria-invalid");
|
|
212
|
+
if (invalid === undefined || invalid.includes("{")) continue; // absent or dynamic value — nothing to assert
|
|
213
|
+
if (invalid.trim().toLowerCase() !== "true") continue; // "false"/"grammar"/"spelling" are not unlinked errors
|
|
214
|
+
if (hasAttr(el, "aria-describedby") || hasAttr(el, "aria-errormessage")) continue; // error text is linked
|
|
215
|
+
if (hasDynamicSpread(el)) continue; // a spread (JSX {...props} / v-bind) may inject the description
|
|
216
|
+
out.push({
|
|
217
|
+
criteriaId: "3.3.1",
|
|
218
|
+
el,
|
|
219
|
+
msgId: "aria-invalid-no-description",
|
|
220
|
+
params: { tag: el.tag },
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
return out;
|
|
224
|
+
},
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
// The complement of aria-invalid-no-description: a VISIBLE error-text node that carries
|
|
228
|
+
// a stable id but is referenced by NO field (no aria-describedby/aria-errormessage points
|
|
229
|
+
// at it). The error is shown but not programmatically tied to its field (WCAG 3.3.1).
|
|
230
|
+
// Only fired on a literal, present id with no literal referrer — provable, low FP.
|
|
231
|
+
const ERROR_TEXT_CLASS = /(^|[-_ ])(fr-error-text|fr-message--error|error-text|error-message|field-error|invalid-feedback)/i;
|
|
232
|
+
|
|
233
|
+
const errorNotAssociated: Rule = {
|
|
234
|
+
id: "error-not-associated",
|
|
235
|
+
criteria: ["3.3.1"],
|
|
236
|
+
severity: "majeur",
|
|
237
|
+
run(doc: Doc): RuleFinding[] {
|
|
238
|
+
const referenced = new Set<string>();
|
|
239
|
+
// Ids targeted by a same-document in-page anchor (<a href="#id">). An error SUMMARY that
|
|
240
|
+
// a "jump to error" link points at is a page-level status region (G139), not a per-field
|
|
241
|
+
// association gap — its restitution comes from the anchor + role/aria-live, not describedby.
|
|
242
|
+
const anchorTargets = new Set<string>();
|
|
243
|
+
for (const el of doc.elements) {
|
|
244
|
+
if (el.tag === "a") {
|
|
245
|
+
const href = (attr(el, "href") ?? "").trim();
|
|
246
|
+
if (href.startsWith("#") && href.length > 1) anchorTargets.add(href.slice(1));
|
|
247
|
+
}
|
|
248
|
+
for (const a of ["aria-describedby", "aria-errormessage"]) {
|
|
249
|
+
const v = attr(el, a);
|
|
250
|
+
if (!v) continue;
|
|
251
|
+
if (!v.includes("{")) {
|
|
252
|
+
for (const id of v.split(/\s+/).filter(Boolean)) referenced.add(id);
|
|
253
|
+
} else {
|
|
254
|
+
// Dynamic value, e.g. aria-describedby={error ? "women-error" : undefined}. The
|
|
255
|
+
// JSX parser keeps the raw `{…}` slice, so pull out any string-literal ids: a
|
|
256
|
+
// conditionally-applied describedby still associates the error (common React
|
|
257
|
+
// pattern) — otherwise the matching <p id="women-error"> is a false positive.
|
|
258
|
+
for (const m of v.matchAll(/["'`]([^"'`]+)["'`]/g)) for (const id of m[1]!.split(/\s+/).filter(Boolean)) referenced.add(id);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
const out: RuleFinding[] = [];
|
|
263
|
+
for (const el of doc.elements) {
|
|
264
|
+
const cls = attr(el, "class");
|
|
265
|
+
if (!cls || !ERROR_TEXT_CLASS.test(cls)) continue;
|
|
266
|
+
const id = (attr(el, "id") ?? "").trim();
|
|
267
|
+
if (!id || id.includes("{")) continue; // no stable literal id to reference — not provable
|
|
268
|
+
if (referenced.has(id)) continue; // already linked to a field
|
|
269
|
+
// A live/alert summary region announces itself; it is not a per-field association gap.
|
|
270
|
+
const role = (attr(el, "role") ?? "").trim();
|
|
271
|
+
if (role === "alert" || role === "status" || hasAttr(el, "aria-live")) continue;
|
|
272
|
+
if (anchorTargets.has(id)) continue; // an in-page "jump to error" link targets it — a summary, not a field error
|
|
273
|
+
if (hasDynamicSpread(el)) continue;
|
|
274
|
+
out.push({
|
|
275
|
+
criteriaId: "3.3.1",
|
|
276
|
+
el,
|
|
277
|
+
msgId: "error-not-associated",
|
|
278
|
+
params: { id },
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
return out;
|
|
282
|
+
},
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
// WCAG 1.3.5 (identify input purpose) + 4.1.2 (state). Two provable, conservative gaps:
|
|
286
|
+
// (i) an identify-purpose <input> (email/tel, or a purpose token in name/id) with no
|
|
287
|
+
// autocomplete attribute — the field's purpose is not exposed programmatically.
|
|
288
|
+
// (ii) a CUSTOM widget (role=textbox/combobox/checkbox/… on an intrinsic non-native
|
|
289
|
+
// element) marked required by class but missing aria-required — required state lost.
|
|
290
|
+
const PURPOSE_TYPES = new Set(["email", "tel"]);
|
|
291
|
+
// Whole-token match (split on separators AND camelCase), so `city` doesn't match veloCITY
|
|
292
|
+
// and `tel` doesn't match hoTEL — an unanchored substring regex mislabels them 1.3.5 fields.
|
|
293
|
+
const PURPOSE_WORDS = new Set(["email", "tel", "telephone", "phone", "mobile", "postal", "zip", "address", "street", "city", "country"]);
|
|
294
|
+
const hasPurposeToken = (nameId: string): boolean => nameId.split(/[-_\s]+|(?<=[a-z0-9])(?=[A-Z])/).some((t) => PURPOSE_WORDS.has(t.toLowerCase()));
|
|
295
|
+
const SKIP_INPUT_TYPES = new Set(["search", "hidden", "submit", "reset", "button", "password", "checkbox", "radio", "file", "range", "color"]);
|
|
296
|
+
const CUSTOM_WIDGET_ROLES = new Set(["textbox", "combobox", "checkbox", "radio", "switch", "spinbutton"]);
|
|
297
|
+
const REQUIRED_SIGNAL = /(^|[-_ ])(required|mandatory|is-required)/i;
|
|
298
|
+
|
|
299
|
+
const fieldPurposeIncomplete: Rule = {
|
|
300
|
+
id: "field-purpose-incomplete",
|
|
301
|
+
criteria: ["1.3.5", "4.1.2"],
|
|
302
|
+
severity: "mineur",
|
|
303
|
+
run(doc: Doc): RuleFinding[] {
|
|
304
|
+
const out: RuleFinding[] = [];
|
|
305
|
+
for (const el of doc.elements) {
|
|
306
|
+
// (i) identify-purpose input without autocomplete (1.3.5)
|
|
307
|
+
if (el.tag === "input") {
|
|
308
|
+
const type = (attr(el, "type") ?? "text").toLowerCase();
|
|
309
|
+
if (!type.includes("{") && !SKIP_INPUT_TYPES.has(type)) {
|
|
310
|
+
const nameId = `${attr(el, "name") ?? ""} ${attr(el, "id") ?? ""}`;
|
|
311
|
+
const purpose = PURPOSE_TYPES.has(type) || hasPurposeToken(nameId);
|
|
312
|
+
if (purpose && !hasAttr(el, "autocomplete") && !hasDynamicSpread(el)) {
|
|
313
|
+
out.push({
|
|
314
|
+
criteriaId: "1.3.5",
|
|
315
|
+
el,
|
|
316
|
+
msgId: "field-purpose-incomplete.autocomplete",
|
|
317
|
+
params: { type },
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
// (ii) custom required widget without aria-required (4.1.2)
|
|
323
|
+
const role = (attr(el, "role") ?? "").trim().toLowerCase();
|
|
324
|
+
if (isIntrinsic(el.tag) && CUSTOM_WIDGET_ROLES.has(role) && REQUIRED_SIGNAL.test(attr(el, "class") ?? "")) {
|
|
325
|
+
if (!hasAttr(el, "aria-required") && !hasAttr(el, "required") && !hasDynamicSpread(el)) {
|
|
326
|
+
out.push({
|
|
327
|
+
criteriaId: "4.1.2",
|
|
328
|
+
el,
|
|
329
|
+
msgId: "field-purpose-incomplete.aria-required",
|
|
330
|
+
params: { role },
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
return out;
|
|
336
|
+
},
|
|
337
|
+
};
|
|
338
|
+
|
|
339
|
+
// ---- Grouping / read-only-context checks (RGAA 11.5 / 7.1 / 10.8) ----------------
|
|
340
|
+
|
|
341
|
+
// A group of controls shares a grouping ancestor when a <fieldset>, role="radiogroup"
|
|
342
|
+
// or role="group" wraps them — the relationship AT relies on to announce "1 of N".
|
|
343
|
+
const GROUPING_ROLES = new Set(["radiogroup", "group"]);
|
|
344
|
+
function inGroupingContext(el: El): boolean {
|
|
345
|
+
return ancestors(el).some((a) => a.tag === "fieldset" || GROUPING_ROLES.has((attr(a, "role") ?? "").trim().toLowerCase()));
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// A read-only recap wrapped in <fieldset disabled> or an [inert] container still holds
|
|
349
|
+
// controls the user perceives, but AT may skip them or announce them as unavailable
|
|
350
|
+
// (RGAA 7.1 + 10.8, WCAG 4.1.2). Suppressed when the disable is a TRANSIENT submission
|
|
351
|
+
// guard — aria-busy on the container/an ancestor, or a submit/reset control in the group
|
|
352
|
+
// (the whole form is momentarily locked while it posts), not a permanent read-only recap.
|
|
353
|
+
function isSubmitControl(el: El): boolean {
|
|
354
|
+
if (el.tag === "button") return (attr(el, "type") ?? "submit").trim().toLowerCase() !== "button"; // bare <button> defaults to submit
|
|
355
|
+
if (el.tag === "input") return ["submit", "reset"].includes((attr(el, "type") ?? "").trim().toLowerCase());
|
|
356
|
+
return false;
|
|
357
|
+
}
|
|
358
|
+
function ariaBusyInScope(el: El): boolean {
|
|
359
|
+
if ((attr(el, "aria-busy") ?? "").trim().toLowerCase() === "true") return true;
|
|
360
|
+
return ancestors(el).some((a) => (attr(a, "aria-busy") ?? "").trim().toLowerCase() === "true");
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const disabledContextContent: Rule = {
|
|
364
|
+
id: "disabled-context-content",
|
|
365
|
+
criteria: ["4.1.2"],
|
|
366
|
+
severity: "majeur",
|
|
367
|
+
run(doc: Doc): RuleFinding[] {
|
|
368
|
+
const out: RuleFinding[] = [];
|
|
369
|
+
for (const el of doc.elements) {
|
|
370
|
+
const isDisabledFieldset = el.tag === "fieldset" && hasAttr(el, "disabled");
|
|
371
|
+
const isInert = hasAttr(el, "inert");
|
|
372
|
+
if (!isDisabledFieldset && !isInert) continue;
|
|
373
|
+
const desc = descendants(el);
|
|
374
|
+
if (!desc.some(isFormField)) continue; // no wrapped controls — nothing removed from the a11y tree
|
|
375
|
+
if (hasDynamicSpread(el)) continue; // a spread may inject aria-disabled semantics we can't see
|
|
376
|
+
if (ariaBusyInScope(el)) continue; // transient submission state, not a read-only recap
|
|
377
|
+
if (desc.some(isSubmitControl)) continue; // a submit/reset in the group ⇒ form locked while posting
|
|
378
|
+
if (isInert) out.push({ criteriaId: "4.1.2", el, msgId: "disabled-context-content.inert" });
|
|
379
|
+
else out.push({ criteriaId: "4.1.2", el, msgId: "disabled-context-content.fieldset" });
|
|
380
|
+
}
|
|
381
|
+
return out;
|
|
382
|
+
},
|
|
383
|
+
};
|
|
384
|
+
|
|
385
|
+
// ≥2 radios (or a same-name checkbox cluster) sharing a `name` but NOT inside a
|
|
386
|
+
// <fieldset>/role="radiogroup"/role="group" — the group relationship is lost (RGAA 11.5,
|
|
387
|
+
// WCAG 1.3.1/3.3.2). Complements `fieldset-legend-missing` (which fires only once a
|
|
388
|
+
// fieldset already exists). Fires once per group, anchored on its first control.
|
|
389
|
+
const radioCheckboxGroupUngrouped: Rule = {
|
|
390
|
+
id: "radio-checkbox-group-ungrouped",
|
|
391
|
+
criteria: ["1.3.1", "3.3.2"],
|
|
392
|
+
severity: "majeur",
|
|
393
|
+
run(doc: Doc): RuleFinding[] {
|
|
394
|
+
const groups = new Map<string, El[]>();
|
|
395
|
+
for (const el of doc.elements) {
|
|
396
|
+
if (el.tag !== "input") continue;
|
|
397
|
+
const type = (attr(el, "type") ?? "").trim().toLowerCase();
|
|
398
|
+
if (type !== "radio" && type !== "checkbox") continue;
|
|
399
|
+
const name = (attr(el, "name") ?? "").trim();
|
|
400
|
+
if (!name || name.includes("{")) continue; // no name, or a dynamic {expr} — not a provable group
|
|
401
|
+
const key = `${type}::${name}`;
|
|
402
|
+
const list = groups.get(key);
|
|
403
|
+
if (list) list.push(el);
|
|
404
|
+
else groups.set(key, [el]);
|
|
405
|
+
}
|
|
406
|
+
const out: RuleFinding[] = [];
|
|
407
|
+
for (const [key, members] of groups) {
|
|
408
|
+
if (members.length < 2) continue; // a lone control is not a group
|
|
409
|
+
if (members.some(hasDynamicSpread)) continue; // a spread may inject role/group context
|
|
410
|
+
if (members.some(inGroupingContext)) continue; // already grouped
|
|
411
|
+
const type = key.slice(0, key.indexOf("::"));
|
|
412
|
+
out.push({
|
|
413
|
+
criteriaId: "1.3.1",
|
|
414
|
+
el: members[0]!,
|
|
415
|
+
msgId: "radio-checkbox-group-ungrouped",
|
|
416
|
+
params: { type, name: key.slice(key.indexOf("::") + 2), count: members.length },
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
return out;
|
|
420
|
+
},
|
|
421
|
+
};
|
|
422
|
+
|
|
423
|
+
// ≥2 sibling date fields ungrouped (RGAA 11.5, WCAG 3.3.2): native type=date/month, or a
|
|
424
|
+
// NARROW fr/en date lexicon on the field's name/id/label. Fires once per parent cluster —
|
|
425
|
+
// the same-immediate-parent requirement keeps it to tight day/month/year or début/fin
|
|
426
|
+
// clusters and off unrelated dates spread across separate rows.
|
|
427
|
+
const DATE_TOKENS = new Set(["jour", "mois", "annee", "année", "debut", "début", "fin", "day", "month", "year"]);
|
|
428
|
+
const NON_DATE_INPUT_TYPES = new Set([
|
|
429
|
+
"hidden",
|
|
430
|
+
"submit",
|
|
431
|
+
"reset",
|
|
432
|
+
"button",
|
|
433
|
+
"checkbox",
|
|
434
|
+
"radio",
|
|
435
|
+
"file",
|
|
436
|
+
"password",
|
|
437
|
+
"range",
|
|
438
|
+
"color",
|
|
439
|
+
"email",
|
|
440
|
+
"tel",
|
|
441
|
+
"url",
|
|
442
|
+
"search",
|
|
443
|
+
]);
|
|
444
|
+
|
|
445
|
+
function tokenize(s: string): string[] {
|
|
446
|
+
return s
|
|
447
|
+
.toLowerCase()
|
|
448
|
+
.split(/[-_\s.]+|(?<=[a-z0-9])(?=[A-Z])/)
|
|
449
|
+
.filter(Boolean);
|
|
450
|
+
}
|
|
451
|
+
function fieldText(el: El, doc: Doc): string {
|
|
452
|
+
const id = attr(el, "id");
|
|
453
|
+
const forLabel = id ? doc.elements.find((e) => e.tag === "label" && attr(e, "for") === id) : undefined;
|
|
454
|
+
const wrapping = ancestors(el).find((a) => a.tag === "label");
|
|
455
|
+
const labelText = forLabel ? textContent(forLabel) : wrapping ? textContent(wrapping) : "";
|
|
456
|
+
return `${attr(el, "name") ?? ""} ${attr(el, "id") ?? ""} ${labelText}`;
|
|
457
|
+
}
|
|
458
|
+
function isDateField(el: El, doc: Doc): boolean {
|
|
459
|
+
if (el.tag !== "input") return false;
|
|
460
|
+
const type = (attr(el, "type") ?? "text").trim().toLowerCase();
|
|
461
|
+
if (type === "date" || type === "month") return true;
|
|
462
|
+
if (type.includes("{") || NON_DATE_INPUT_TYPES.has(type)) return false;
|
|
463
|
+
return tokenize(fieldText(el, doc)).some((t) => DATE_TOKENS.has(t));
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
const dateFieldsUngrouped: Rule = {
|
|
467
|
+
id: "date-fields-ungrouped",
|
|
468
|
+
criteria: ["3.3.2"],
|
|
469
|
+
severity: "majeur",
|
|
470
|
+
run(doc: Doc): RuleFinding[] {
|
|
471
|
+
const byParent = new Map<El, El[]>();
|
|
472
|
+
for (const el of doc.elements) {
|
|
473
|
+
if (!isDateField(el, doc)) continue;
|
|
474
|
+
if (hasDynamicSpread(el) || inGroupingContext(el)) continue; // already grouped, or spread-injected context
|
|
475
|
+
const p = el.parent;
|
|
476
|
+
if (!p) continue;
|
|
477
|
+
const list = byParent.get(p);
|
|
478
|
+
if (list) list.push(el);
|
|
479
|
+
else byParent.set(p, [el]);
|
|
480
|
+
}
|
|
481
|
+
const out: RuleFinding[] = [];
|
|
482
|
+
for (const fields of byParent.values()) {
|
|
483
|
+
if (fields.length < 2) continue;
|
|
484
|
+
out.push({ criteriaId: "3.3.2", el: fields[0]!, msgId: "date-fields-ungrouped", params: { count: fields.length } });
|
|
485
|
+
}
|
|
486
|
+
return out;
|
|
487
|
+
},
|
|
488
|
+
};
|
|
489
|
+
|
|
490
|
+
// WCAG 1.3.5 asks for a token from the HTML autofill vocabulary. A MISSPELLED or invented
|
|
491
|
+
// value ("nope", "user-name", "emailaddress") is worse than none: it reads as compliant,
|
|
492
|
+
// autofills nothing, and no assistive technology can infer the field's purpose from it.
|
|
493
|
+
const autocompleteTokenInvalid: Rule = {
|
|
494
|
+
id: "autocomplete-token-invalid",
|
|
495
|
+
criteria: ["1.3.5"],
|
|
496
|
+
severity: "majeur",
|
|
497
|
+
run(doc: Doc): RuleFinding[] {
|
|
498
|
+
const out: RuleFinding[] = [];
|
|
499
|
+
for (const el of doc.elements) {
|
|
500
|
+
if (!isIntrinsic(el.tag) || !["input", "select", "textarea"].includes(el.tag)) continue;
|
|
501
|
+
const value = attr(el, "autocomplete");
|
|
502
|
+
if (value === undefined || value.includes("{")) continue; // absent, or a dynamic binding
|
|
503
|
+
if (value.trim() === "") continue; // empty is "no declaration", not a wrong one
|
|
504
|
+
if (hasAttr(el, "disabled") || attr(el, "aria-disabled") === "true") continue; // a disabled control collects nothing
|
|
505
|
+
if (isNameExempt(el)) continue; // not exposed, or explicitly presentational
|
|
506
|
+
if (isValidAutocomplete(value)) continue;
|
|
507
|
+
out.push({ criteriaId: "1.3.5", el, msgId: "autocomplete-token-invalid", params: { value: value.trim() } });
|
|
508
|
+
}
|
|
509
|
+
return out;
|
|
510
|
+
},
|
|
511
|
+
};
|
|
512
|
+
|
|
513
|
+
/** The autofill field name a control declares, ignoring qualifiers. "" when absent. */
|
|
514
|
+
function autofillField(el: El): string {
|
|
515
|
+
const tokens = (attr(el, "autocomplete") ?? "").trim().toLowerCase().split(/\s+/).filter(Boolean);
|
|
516
|
+
return tokens.find((t) => CREDENTIAL_FIELDS.has(t)) ?? "";
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// WCAG 2.2 — 3.3.8 Accessible Authentication (Minimum). An authentication step must not
|
|
520
|
+
// force the user to reproduce a secret from memory or by hand. Blocking paste on a password
|
|
521
|
+
// field, or switching autofill off on a credential field, does exactly that: it breaks
|
|
522
|
+
// password managers and makes the step a cognitive-function test.
|
|
523
|
+
const credentialEntryBlocked: Rule = {
|
|
524
|
+
id: "credential-entry-blocked",
|
|
525
|
+
criteria: ["3.3.8"],
|
|
526
|
+
severity: "bloquant",
|
|
527
|
+
run(doc: Doc): RuleFinding[] {
|
|
528
|
+
const out: RuleFinding[] = [];
|
|
529
|
+
for (const el of doc.elements) {
|
|
530
|
+
if (!isIntrinsic(el.tag) || el.tag !== "input") continue;
|
|
531
|
+
const type = (attr(el, "type") ?? "text").toLowerCase();
|
|
532
|
+
const field = autofillField(el);
|
|
533
|
+
const isCredential = type === "password" || field !== "";
|
|
534
|
+
if (!isCredential) continue;
|
|
535
|
+
// Paste blocked by a handler that cancels the event. Only a literal, self-evident
|
|
536
|
+
// cancellation counts — an arbitrary handler may well permit the paste.
|
|
537
|
+
const paste = (attr(el, "onpaste") ?? attr(el, "onPaste") ?? "").replace(/\s+/g, "");
|
|
538
|
+
if (/preventDefault|returnfalse/i.test(paste)) {
|
|
539
|
+
out.push({ criteriaId: "3.3.8", el, msgId: "credential-entry-blocked.paste", params: { type } });
|
|
540
|
+
continue;
|
|
541
|
+
}
|
|
542
|
+
if ((attr(el, "autocomplete") ?? "").trim().toLowerCase() === "off") {
|
|
543
|
+
out.push({ criteriaId: "3.3.8", el, msgId: "credential-entry-blocked.autocomplete", params: { type } });
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
return out;
|
|
547
|
+
},
|
|
548
|
+
};
|
|
549
|
+
|
|
550
|
+
// WCAG 3.2.2 On Input: changing a control's value must not, on its own, submit the form or
|
|
551
|
+
// navigate. Only literal, unmistakable handlers are reported — anything else is the agent's
|
|
552
|
+
// call, not the engine's.
|
|
553
|
+
const CONTEXT_CHANGE = /\b(submit\(\)|form\.submit|location\s*[.=]|location\.href|window\.open|router\.(push|replace)|navigate\()/;
|
|
554
|
+
|
|
555
|
+
const onInputContextChange: Rule = {
|
|
556
|
+
id: "on-input-context-change",
|
|
557
|
+
criteria: ["3.2.2"],
|
|
558
|
+
severity: "majeur",
|
|
559
|
+
run(doc: Doc): RuleFinding[] {
|
|
560
|
+
const out: RuleFinding[] = [];
|
|
561
|
+
for (const el of doc.elements) {
|
|
562
|
+
if (!isIntrinsic(el.tag) || !["input", "select", "textarea"].includes(el.tag)) continue;
|
|
563
|
+
for (const key of ["onchange", "onChange"]) {
|
|
564
|
+
const handler = attr(el, key);
|
|
565
|
+
if (!handler || !CONTEXT_CHANGE.test(handler)) continue;
|
|
566
|
+
out.push({ criteriaId: "3.2.2", el, msgId: "on-input-context-change", params: { tag: el.tag } });
|
|
567
|
+
break;
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
return out;
|
|
571
|
+
},
|
|
572
|
+
};
|
|
573
|
+
|
|
574
|
+
export const formsRules: Rule[] = [
|
|
575
|
+
controlLabelMissing,
|
|
576
|
+
placeholderAsLabel,
|
|
577
|
+
fieldsetLegendMissing,
|
|
578
|
+
formFieldMultipleLabels,
|
|
579
|
+
selectHasOption,
|
|
580
|
+
labelForDangling,
|
|
581
|
+
ariaInvalidNoDescription,
|
|
582
|
+
errorNotAssociated,
|
|
583
|
+
fieldPurposeIncomplete,
|
|
584
|
+
disabledContextContent,
|
|
585
|
+
radioCheckboxGroupUngrouped,
|
|
586
|
+
dateFieldsUngrouped,
|
|
587
|
+
autocompleteTokenInvalid,
|
|
588
|
+
credentialEntryBlocked,
|
|
589
|
+
onInputContextChange,
|
|
590
|
+
];
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// Theme 2 — Frames.
|
|
2
|
+
import type { Doc, El } from "../parse/html.js";
|
|
3
|
+
import { attr, hasBoundAttr } from "../parse/html.js";
|
|
4
|
+
import { isNameExempt } from "../name.js";
|
|
5
|
+
import type { Rule, RuleFinding } from "./rule.js";
|
|
6
|
+
|
|
7
|
+
const iframeTitleMissing: Rule = {
|
|
8
|
+
id: "iframe-title-missing",
|
|
9
|
+
criteria: ["4.1.2"],
|
|
10
|
+
severity: "bloquant",
|
|
11
|
+
run(doc: Doc): RuleFinding[] {
|
|
12
|
+
const out: RuleFinding[] = [];
|
|
13
|
+
for (const el of doc.elements) {
|
|
14
|
+
if (el.tag !== "iframe") continue;
|
|
15
|
+
if (isNameExempt(el)) continue; // not exposed, or explicitly presentational
|
|
16
|
+
const title = (attr(el, "title") ?? "").trim();
|
|
17
|
+
const aria = (attr(el, "aria-label") ?? "").trim();
|
|
18
|
+
// aria-labelledby is the highest-precedence accname source. A present value names the
|
|
19
|
+
// frame; whether its ids resolve is aria-ref-missing-id's job, not a second "no title".
|
|
20
|
+
const labelledby = (attr(el, "aria-labelledby") ?? "").trim();
|
|
21
|
+
if (title || aria || labelledby) continue;
|
|
22
|
+
if (hasBoundAttr(el, "aria-labelledby")) continue; // dynamic :aria-labelledby / aria-labelledby={x}
|
|
23
|
+
out.push({
|
|
24
|
+
criteriaId: "4.1.2",
|
|
25
|
+
el: el as El,
|
|
26
|
+
msgId: "iframe-title-missing",
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
return out;
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export const framesRules: Rule[] = [iframeTitleMissing];
|