@surea11y/core 1.7.0 → 1.8.1
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 +103 -1
- package/README.md +157 -54
- package/docs/ACT_RULE_MAPPING.md +8 -7
- package/docs/API_STABILITY.md +18 -5
- package/docs/BINDING_AUTHORS_GUIDE.md +2 -2
- package/docs/CI_INTEGRATIONS.md +43 -0
- package/docs/DESIGN_CHALLENGES.md +97 -3
- package/docs/EARL.md +2 -2
- package/docs/ENGINE_OPTIONS.md +81 -3
- package/docs/I18N.md +62 -20
- package/docs/JUNIT.md +73 -0
- package/docs/LIMITATIONS.md +1 -0
- package/docs/OUTPUT_SCHEMA.md +19 -6
- package/docs/REPORT.md +7 -2
- package/docs/RULE_AUTHORING.md +73 -6
- package/docs/RULE_CATALOG.md +139 -116
- package/docs/RULE_EXAMPLES.md +2189 -0
- package/docs/RULE_HELPERS.md +62 -5
- package/docs/RULE_TAXONOMY.md +2 -2
- package/docs/SARIF.md +2 -1
- package/docs/WCAG_CONFORMANCE.md +56 -3
- package/package.json +34 -11
- package/profiles/index.js +14 -0
- package/src/checks/automatic/area-alt-present.js +87 -31
- package/src/checks/automatic/aria-braille-equivalent.js +25 -7
- package/src/checks/automatic/aria-hidden-focus.js +74 -18
- package/src/checks/automatic/aria-prohibited-attr.js +17 -4
- package/src/checks/automatic/aria-required-attr.js +29 -0
- package/src/checks/automatic/aria-role-name-present.js +19 -2
- package/src/checks/automatic/aria-valid-attr-value.js +28 -16
- package/src/checks/automatic/autocomplete-valid.js +26 -11
- package/src/checks/automatic/avoid-inline-spacing.js +105 -40
- package/src/checks/automatic/button-name-present.js +2 -1
- package/src/checks/automatic/canvas-text-alternative-present.js +105 -14
- package/src/checks/automatic/combobox-name-present.js +34 -51
- package/src/checks/automatic/contrast-computable.js +35 -4
- package/src/checks/automatic/contrast-enhanced.js +4 -4
- package/src/checks/automatic/contrast-minimum.js +45 -11
- package/src/checks/automatic/css-orientation-lock.js +152 -30
- package/src/checks/automatic/definition-list-children-valid.js +67 -23
- package/src/checks/automatic/deprecated-elements-not-used.js +43 -38
- package/src/checks/automatic/dialog-name-present.js +28 -9
- package/src/checks/automatic/duplicate-id.js +6 -2
- package/src/checks/automatic/identical-iframes-same-purpose.js +4 -4
- package/src/checks/automatic/iframe-focusable-content.js +7 -4
- package/src/checks/automatic/iframe-title-unique.js +36 -81
- package/src/checks/automatic/input-image-alt-present.js +32 -20
- package/src/checks/automatic/label-in-name.js +40 -13
- package/src/checks/automatic/language-page-present.js +12 -6
- package/src/checks/automatic/link-in-text-block.js +272 -60
- package/src/checks/automatic/link-name-present.js +13 -5
- package/src/checks/automatic/list-children-valid.js +18 -1
- package/src/checks/automatic/listbox-name-present.js +19 -49
- package/src/checks/automatic/listitem-parent-valid.js +4 -3
- package/src/checks/automatic/meta-refresh-no-exceptions.js +3 -3
- package/src/checks/automatic/page-title-present.js +16 -4
- package/src/checks/automatic/progressbar-name-present.js +11 -1
- package/src/checks/automatic/role-img-text-alternative-present.js +9 -5
- package/src/checks/automatic/searchbox-name-present.js +32 -49
- package/src/checks/automatic/server-side-image-map-absent.js +48 -28
- package/src/checks/automatic/slider-name-present.js +38 -52
- package/src/checks/automatic/spinbutton-name-present.js +32 -49
- package/src/checks/automatic/target-size-minimum.js +0 -11
- package/src/checks/automatic/td-has-header.js +41 -5
- package/src/checks/automatic/text-spacing-content-loss.js +548 -0
- package/src/checks/automatic/textbox-name-present.js +32 -49
- package/src/checks/automatic/valid-lang.js +15 -10
- package/src/checks/manual/area-alt-quality-manual.js +113 -31
- package/src/checks/manual/canvas-text-alternative-quality-manual.js +0 -2
- package/src/checks/manual/css-focus-indicator-suppressed-manual.js +23 -10
- package/src/checks/manual/css-hidden-focus.js +215 -7
- package/src/checks/manual/form-control-label-quality-manual.js +109 -5
- package/src/checks/manual/heading-order-manual.js +9 -1
- package/src/checks/manual/heading-quality-manual.js +143 -9
- package/src/checks/manual/img-alt-decorative-manual.js +6 -3
- package/src/checks/manual/input-image-alt-quality-manual.js +81 -13
- package/src/checks/manual/link-name-quality-manual.js +130 -4
- package/src/checks/manual/media-transcript-present-manual.js +65 -8
- package/src/checks/manual/mouse-only-event-handlers-manual.js +66 -5
- package/src/checks/manual/no-autoplay-audio-manual.js +93 -6
- package/src/checks/manual/p-as-heading-manual.js +89 -44
- package/src/checks/manual/page-title-patterns-manual.js +77 -8
- package/src/checks/manual/skip-link-manual.js +42 -14
- package/src/checks/manual/table-fake-caption-manual.js +32 -1
- package/src/checks/manual/video-caption-manual.js +47 -24
- package/src/checks/manual-review.js +0 -4
- package/src/core.js +14285 -2219
- package/src/coverage/en301549-map.js +187 -0
- package/src/coverage/standards.js +279 -0
- package/src/coverage/wcag-facets.js +1119 -0
- package/src/coverage/wcag-version-map.js +101 -0
- package/src/en301549.js +33 -0
- package/src/junit.js +321 -0
- package/src/profile-kit.js +163 -0
- package/src/report.js +343 -74
- package/src/sarif.js +34 -3
- package/src/wcag.js +105 -0
- package/surea11y.browser.js +5 -4
- package/surea11y.i18n.de.js +1 -1
- package/surea11y.i18n.es.js +1 -1
- package/surea11y.i18n.fr.js +1 -1
- package/surea11y.i18n.ja.js +3 -0
- package/src/checks/manual/area-alt-decorative-manual.js +0 -255
|
@@ -12,7 +12,16 @@
|
|
|
12
12
|
* Applies to <canvas> elements included in the accessibility tree.
|
|
13
13
|
* Hidden elements are excluded whether or not they are focusable.
|
|
14
14
|
* @expectation
|
|
15
|
-
* Each applicable <canvas> provides a text alternative via fallback content
|
|
15
|
+
* Each applicable <canvas> provides a text alternative via fallback content
|
|
16
|
+
* or an accessible name, with two exceptions:
|
|
17
|
+
* - role="img" (first role token) makes the canvas's children
|
|
18
|
+
* presentational and its name comes from the author only, so fallback
|
|
19
|
+
* content does not count: aria-labelledby, aria-label or title must name
|
|
20
|
+
* it.
|
|
21
|
+
* - role="none"/"presentation" marks the canvas decorative, and it passes.
|
|
22
|
+
* The role is ignored (presentational role conflict) when the canvas is
|
|
23
|
+
* focusable or carries aria-label/aria-labelledby, and the canvas is then
|
|
24
|
+
* judged like any other.
|
|
16
25
|
*/
|
|
17
26
|
|
|
18
27
|
const id = 'canvas-text-alternative-present';
|
|
@@ -85,6 +94,64 @@ function runInPage(ctx) {
|
|
|
85
94
|
? helpers.getTextAlternativeInfo
|
|
86
95
|
: null;
|
|
87
96
|
|
|
97
|
+
const getAriaNameInfo =
|
|
98
|
+
helpers && typeof helpers.getAriaNameInfo === 'function' ? helpers.getAriaNameInfo : null;
|
|
99
|
+
|
|
100
|
+
const getFocusableInfo =
|
|
101
|
+
helpers && typeof helpers.getFocusableInfo === 'function' ? helpers.getFocusableInfo : null;
|
|
102
|
+
|
|
103
|
+
function attrText(el, name) {
|
|
104
|
+
try {
|
|
105
|
+
const v = el.getAttribute(name);
|
|
106
|
+
return v == null ? '' : String(v).trim();
|
|
107
|
+
} catch {
|
|
108
|
+
return '';
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function firstRoleToken(el) {
|
|
113
|
+
const raw = attrText(el, 'role').toLowerCase();
|
|
114
|
+
return raw ? raw.split(/\s+/)[0] : '';
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ARIA's presentational role conflict: a focusable element, or one with a
|
|
118
|
+
// global naming attribute, keeps its native role.
|
|
119
|
+
function isPresentationHonoured(el) {
|
|
120
|
+
if (attrText(el, 'aria-label') || attrText(el, 'aria-labelledby')) return false;
|
|
121
|
+
if (getFocusableInfo) {
|
|
122
|
+
try {
|
|
123
|
+
const fi = getFocusableInfo(el, ctx);
|
|
124
|
+
if (fi && fi.focusable) return false;
|
|
125
|
+
} catch {}
|
|
126
|
+
} else if (attrText(el, 'tabindex') !== '') {
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
129
|
+
return true;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// role="img": the name comes from the author (aria-labelledby, aria-label,
|
|
133
|
+
// then title), never from the children.
|
|
134
|
+
function getRoleImgNameInfo(el) {
|
|
135
|
+
let aria = null;
|
|
136
|
+
if (getAriaNameInfo) {
|
|
137
|
+
try {
|
|
138
|
+
aria = getAriaNameInfo(el, ctx);
|
|
139
|
+
} catch {
|
|
140
|
+
aria = null;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
if (aria && aria.present && aria.value) {
|
|
144
|
+
return { present: true, value: aria.value, mechanism: aria.mechanism };
|
|
145
|
+
}
|
|
146
|
+
if (!getAriaNameInfo) {
|
|
147
|
+
const label = attrText(el, 'aria-label');
|
|
148
|
+
if (label) return { present: true, value: label, mechanism: 'aria-label' };
|
|
149
|
+
}
|
|
150
|
+
const title = attrText(el, 'title');
|
|
151
|
+
if (title) return { present: true, value: title, mechanism: 'title' };
|
|
152
|
+
return { present: false, value: '', mechanism: 'none' };
|
|
153
|
+
}
|
|
154
|
+
|
|
88
155
|
const canvases = (() => {
|
|
89
156
|
try {
|
|
90
157
|
return Array.from((queryAllSmart ? queryAllSmart('canvas') : queryAll('canvas')) || []);
|
|
@@ -118,16 +185,25 @@ function runInPage(ctx) {
|
|
|
118
185
|
|
|
119
186
|
applicableCount += 1;
|
|
120
187
|
|
|
188
|
+
const role = firstRoleToken(el);
|
|
189
|
+
|
|
190
|
+
// A decorative canvas needs no text alternative.
|
|
191
|
+
if ((role === 'none' || role === 'presentation') && isPresentationHonoured(el)) continue;
|
|
192
|
+
|
|
193
|
+
const isRoleImg = role === 'img';
|
|
194
|
+
|
|
121
195
|
// Expectation: must provide a text alternative.
|
|
122
|
-
const ti =
|
|
123
|
-
? (
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
196
|
+
const ti = isRoleImg
|
|
197
|
+
? getRoleImgNameInfo(el)
|
|
198
|
+
: getTextAlternativeInfo
|
|
199
|
+
? (() => {
|
|
200
|
+
try {
|
|
201
|
+
return getTextAlternativeInfo(el, ctx);
|
|
202
|
+
} catch {
|
|
203
|
+
return null;
|
|
204
|
+
}
|
|
205
|
+
})()
|
|
206
|
+
: null;
|
|
131
207
|
|
|
132
208
|
const hasTextAlt = !!(ti && ti.present);
|
|
133
209
|
|
|
@@ -135,14 +211,29 @@ function runInPage(ctx) {
|
|
|
135
211
|
|
|
136
212
|
const eligInfo = getEligibilityInfo ? getEligibilityInfo(el, ctx, { targetSet: 'acc' }) : null;
|
|
137
213
|
|
|
214
|
+
const messages = isRoleImg
|
|
215
|
+
? {
|
|
216
|
+
summary:
|
|
217
|
+
'This <canvas role="img"> has no accessible name; with role="img" its fallback content does not count.',
|
|
218
|
+
hint: 'Name it with aria-label or aria-labelledby, or remove role="img" so that its fallback content can serve as the text alternative.',
|
|
219
|
+
summaryKey: 'canvas_textAltPresent_summary_fail_roleImg',
|
|
220
|
+
hintKey: 'canvas_textAltPresent_hint_fail_roleImg'
|
|
221
|
+
}
|
|
222
|
+
: {
|
|
223
|
+
summary: 'Missing text alternative for <canvas>.',
|
|
224
|
+
hint: 'Provide fallback text inside <canvas> or an accessible name (e.g., aria-label/aria-labelledby).',
|
|
225
|
+
summaryKey: 'canvas_textAltPresent_summary_fail',
|
|
226
|
+
hintKey: 'canvas_textAltPresent_hint_fail'
|
|
227
|
+
};
|
|
228
|
+
|
|
138
229
|
const baseOccurrence = {
|
|
139
230
|
selector: '',
|
|
140
231
|
html: '',
|
|
141
|
-
summary:
|
|
142
|
-
hint:
|
|
232
|
+
summary: messages.summary,
|
|
233
|
+
hint: messages.hint,
|
|
143
234
|
i18n: {
|
|
144
|
-
summaryKey:
|
|
145
|
-
hintKey:
|
|
235
|
+
summaryKey: messages.summaryKey,
|
|
236
|
+
hintKey: messages.hintKey,
|
|
146
237
|
params: { element: 'canvas' }
|
|
147
238
|
},
|
|
148
239
|
data: {
|
|
@@ -20,6 +20,9 @@
|
|
|
20
20
|
* accepted: text sitting inside a custom combobox widget is not reliably
|
|
21
21
|
* exposed as its name. On a labelable element (<input role="combobox">) an
|
|
22
22
|
* associated <label> counts as well.
|
|
23
|
+
* On a text-like <input> or a <textarea>, the placeholder counts last
|
|
24
|
+
* (HTML-AAM's final name source): a placeholder-only label is a 3.3.2
|
|
25
|
+
* question, not a missing name.
|
|
23
26
|
*/
|
|
24
27
|
|
|
25
28
|
const id = 'combobox-name-present';
|
|
@@ -70,21 +73,6 @@ function runInPage(ctx) {
|
|
|
70
73
|
}
|
|
71
74
|
}
|
|
72
75
|
|
|
73
|
-
function buildLabelForMap(doc) {
|
|
74
|
-
const map = new Map(); // id -> label element (first)
|
|
75
|
-
try {
|
|
76
|
-
const labels = doc && doc.getElementsByTagName ? doc.getElementsByTagName('label') : [];
|
|
77
|
-
for (let i = 0; i < labels.length; i += 1) {
|
|
78
|
-
const lab = labels[i];
|
|
79
|
-
if (!lab || !lab.getAttribute) continue;
|
|
80
|
-
const f = normalizeWs(lab.getAttribute('for'));
|
|
81
|
-
if (!f) continue;
|
|
82
|
-
if (!map.has(f)) map.set(f, lab);
|
|
83
|
-
}
|
|
84
|
-
} catch {}
|
|
85
|
-
return map;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
76
|
function getConservativeSubtreeText(document, container) {
|
|
89
77
|
// "Name from content", recurses into descendants and uses each one's
|
|
90
78
|
// own accessible name (img alt, aria-label/aria-labelledby, title) when
|
|
@@ -172,45 +160,30 @@ function runInPage(ctx) {
|
|
|
172
160
|
? helpers.queryAllSmart(selector)
|
|
173
161
|
: helpers.queryAll(selector);
|
|
174
162
|
|
|
175
|
-
//
|
|
176
|
-
//
|
|
177
|
-
|
|
178
|
-
|
|
163
|
+
// Delegates to the shared, spec-guarded lookup (dom-helpers.js's
|
|
164
|
+
// getAssociatedLabelElements): a <label> -- wrapping or via `for` --
|
|
165
|
+
// only ever associates with a genuinely labelable element, so this
|
|
166
|
+
// correctly returns nothing for role-only widgets a <label> merely sits
|
|
167
|
+
// next to, and the real thing for a labelable element like
|
|
168
|
+
// <input role="combobox">.
|
|
179
169
|
function getNativeLabelText(el) {
|
|
170
|
+
if (!helpers || typeof helpers.getAssociatedLabelElements !== 'function') return '';
|
|
171
|
+
let labels;
|
|
180
172
|
try {
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
} catch {}
|
|
193
|
-
try {
|
|
194
|
-
if (el.closest) {
|
|
195
|
-
const wrap = el.closest('label');
|
|
196
|
-
if (wrap) {
|
|
197
|
-
const t = getLabelText(wrap);
|
|
198
|
-
if (t) return t;
|
|
199
|
-
}
|
|
200
|
-
}
|
|
201
|
-
} catch {}
|
|
202
|
-
try {
|
|
203
|
-
const idAttr = getAttr(el, 'id');
|
|
204
|
-
if (idAttr && labelForMap.has(idAttr)) {
|
|
205
|
-
const lab = labelForMap.get(idAttr);
|
|
206
|
-
const t = lab ? getLabelText(lab) : '';
|
|
207
|
-
if (t) return t;
|
|
208
|
-
}
|
|
209
|
-
} catch {}
|
|
210
|
-
return '';
|
|
173
|
+
labels = helpers.getAssociatedLabelElements(el) || [];
|
|
174
|
+
} catch {
|
|
175
|
+
labels = [];
|
|
176
|
+
}
|
|
177
|
+
const parts = [];
|
|
178
|
+
const max = Math.min(4, labels.length);
|
|
179
|
+
for (let i = 0; i < max; i += 1) {
|
|
180
|
+
const t = getLabelText(labels[i]);
|
|
181
|
+
if (t) parts.push(t);
|
|
182
|
+
}
|
|
183
|
+
return normalizeWs(parts.join(' '));
|
|
211
184
|
}
|
|
212
185
|
|
|
213
|
-
function evaluate(el
|
|
186
|
+
function evaluate(el) {
|
|
214
187
|
const ariaLabel = getAttr(el, 'aria-label');
|
|
215
188
|
if (ariaLabel) return { ok: true, method: 'aria-label' };
|
|
216
189
|
|
|
@@ -224,6 +197,16 @@ function runInPage(ctx) {
|
|
|
224
197
|
const lab = getNativeLabelText(el);
|
|
225
198
|
if (lab) return { ok: true, method: 'label' };
|
|
226
199
|
|
|
200
|
+
// HTML-AAM's last name source for a text-like <input> or a <textarea>,
|
|
201
|
+
// after title: the placeholder. A placeholder-only label is a 3.3.2
|
|
202
|
+
// question, not a missing name.
|
|
203
|
+
if (helpers.getNativeHostNameInfo) {
|
|
204
|
+
try {
|
|
205
|
+
const host = helpers.getNativeHostNameInfo(el, ctx, { placeholder: true });
|
|
206
|
+
if (host && host.present) return { ok: true, method: host.mechanism };
|
|
207
|
+
} catch {}
|
|
208
|
+
}
|
|
209
|
+
|
|
227
210
|
// role="combobox" is name-from-author-only per WAI-ARIA: it must NOT
|
|
228
211
|
// fall back to subtree content. Visible text near/inside a custom
|
|
229
212
|
// combobox widget is not reliably exposed as its accessible name.
|
|
@@ -239,7 +222,7 @@ function runInPage(ctx) {
|
|
|
239
222
|
|
|
240
223
|
applicableCount += 1;
|
|
241
224
|
|
|
242
|
-
const res = evaluate(el
|
|
225
|
+
const res = evaluate(el);
|
|
243
226
|
if (res.ok) continue;
|
|
244
227
|
|
|
245
228
|
const eligInfo = getEligibilityInfo
|
|
@@ -115,6 +115,25 @@ function runInPage(ctx) {
|
|
|
115
115
|
|
|
116
116
|
const seenFailEls = new Set();
|
|
117
117
|
|
|
118
|
+
// Defined inside runInPage, which is serialized into the page and cannot
|
|
119
|
+
// reach module scope. English fallbacks for the dictionary keys below.
|
|
120
|
+
const CANT_TELL_HINT_KEYS = {
|
|
121
|
+
background: 'contrastComputable_hint_cantTell_background',
|
|
122
|
+
effect: 'contrastComputable_hint_cantTell_effect',
|
|
123
|
+
rootNotOpaque: 'contrastComputable_hint_cantTell_rootNotOpaque',
|
|
124
|
+
generic: 'contrast_hint_cantTell_manual'
|
|
125
|
+
};
|
|
126
|
+
const CANT_TELL_HINTS = {
|
|
127
|
+
background:
|
|
128
|
+
'Measure the contrast by hand on the rendered page, where the background behind the text gives the least contrast. Normal text needs at least 4.5:1 and large text 3:1 (7:1 and 4.5:1 for AAA).',
|
|
129
|
+
effect:
|
|
130
|
+
'Measure the contrast by hand on the rendered page with the effect applied, since the declared CSS colors are not what ends up on screen. Normal text needs at least 4.5:1 and large text 3:1 (7:1 and 4.5:1 for AAA).',
|
|
131
|
+
rootNotOpaque:
|
|
132
|
+
'No element behind this text, up to the root, has a fully opaque background, so the color it sits on depends on where the page is displayed. Set an opaque background color on html or body, or measure the contrast against the background the page is actually shown on.',
|
|
133
|
+
generic:
|
|
134
|
+
"Measure this text's contrast by hand on the rendered page. Normal text needs at least 4.5:1 and large text 3:1 (7:1 and 4.5:1 for AAA)."
|
|
135
|
+
};
|
|
136
|
+
|
|
118
137
|
function pushCantTellOccurrence(el, reasonCode, extraDetails) {
|
|
119
138
|
try {
|
|
120
139
|
if (!el || seenFailEls.has(el)) return;
|
|
@@ -151,6 +170,18 @@ function runInPage(ctx) {
|
|
|
151
170
|
summaryKey = 'contrastComputable_cantTell_rootNotOpaque';
|
|
152
171
|
else if (rc === 'TEXT_SHADOW') summaryKey = 'contrastComputable_cantTell_textShadow';
|
|
153
172
|
|
|
173
|
+
// Every cantTell leaves a person to measure the contrast, so each one
|
|
174
|
+
// says how, grouped by what blocked the calculation.
|
|
175
|
+
let hintKind = 'generic';
|
|
176
|
+
if (rc === 'BACKGROUND_IMAGE_OR_GRADIENT') hintKind = 'background';
|
|
177
|
+
else if (
|
|
178
|
+
rc === 'MIX_BLEND_MODE' ||
|
|
179
|
+
rc === 'BACKGROUND_FILTER_OR_BACKDROP_FILTER' ||
|
|
180
|
+
rc === 'TEXT_SHADOW'
|
|
181
|
+
)
|
|
182
|
+
hintKind = 'effect';
|
|
183
|
+
else if (rc === 'BACKGROUND_NOT_OPAQUE_AT_ROOT') hintKind = 'rootNotOpaque';
|
|
184
|
+
|
|
154
185
|
const details = Object.assign(
|
|
155
186
|
{ reasonCode: rc },
|
|
156
187
|
extraDetails && typeof extraDetails === 'object' ? extraDetails : {}
|
|
@@ -160,10 +191,10 @@ function runInPage(ctx) {
|
|
|
160
191
|
selector: '',
|
|
161
192
|
html: '',
|
|
162
193
|
summary: '',
|
|
163
|
-
hint:
|
|
194
|
+
hint: CANT_TELL_HINTS[hintKind],
|
|
164
195
|
i18n: {
|
|
165
196
|
summaryKey,
|
|
166
|
-
hintKey:
|
|
197
|
+
hintKey: CANT_TELL_HINT_KEYS[hintKind],
|
|
167
198
|
params: Object.assign(
|
|
168
199
|
{ reasonCode: rc },
|
|
169
200
|
details && typeof details === 'object'
|
|
@@ -374,11 +405,11 @@ function runInPage(ctx) {
|
|
|
374
405
|
{
|
|
375
406
|
selector: '',
|
|
376
407
|
summary: '',
|
|
377
|
-
hint:
|
|
408
|
+
hint: CANT_TELL_HINTS.generic,
|
|
378
409
|
html: '',
|
|
379
410
|
i18n: {
|
|
380
411
|
summaryKey: 'contrastComputable_cantTell_engineFailure',
|
|
381
|
-
hintKey:
|
|
412
|
+
hintKey: CANT_TELL_HINT_KEYS.generic,
|
|
382
413
|
params: { reasonCode: 'ENGINE_EXCEPTION' }
|
|
383
414
|
},
|
|
384
415
|
data: { details: { reasonCode: 'ENGINE_EXCEPTION' } }
|
|
@@ -231,10 +231,10 @@ function runInPage(ctx) {
|
|
|
231
231
|
selector: '',
|
|
232
232
|
html: '',
|
|
233
233
|
summary: '',
|
|
234
|
-
hint:
|
|
234
|
+
hint: `Change the text color, the background color, or both, so the contrast ratio reaches at least ${params && params.threshold}:1.`,
|
|
235
235
|
i18n: {
|
|
236
236
|
summaryKey: 'contrastEnhanced_fail_belowThreshold',
|
|
237
|
-
hintKey: '',
|
|
237
|
+
hintKey: 'contrastEnhanced_hint_fail',
|
|
238
238
|
params: params && typeof params === 'object' ? params : {}
|
|
239
239
|
},
|
|
240
240
|
...(uncertainty ? { uncertainty } : {}),
|
|
@@ -498,11 +498,11 @@ function runInPage(ctx) {
|
|
|
498
498
|
{
|
|
499
499
|
selector: '',
|
|
500
500
|
summary: '',
|
|
501
|
-
hint: '
|
|
501
|
+
hint: "Measure this text's contrast by hand on the rendered page. Normal text needs at least 4.5:1 and large text 3:1 (7:1 and 4.5:1 for AAA).",
|
|
502
502
|
html: '',
|
|
503
503
|
i18n: {
|
|
504
504
|
summaryKey: 'contrastEnhanced_cantTell_engineFailure',
|
|
505
|
-
hintKey: '',
|
|
505
|
+
hintKey: 'contrast_hint_cantTell_manual',
|
|
506
506
|
params: { reasonCode: 'ENGINE_EXCEPTION' }
|
|
507
507
|
},
|
|
508
508
|
data: { details: { reasonCode: 'ENGINE_EXCEPTION' } }
|
|
@@ -20,6 +20,10 @@
|
|
|
20
20
|
* size: 3:1 for large text, 4.5:1 for everything else. Text is large at
|
|
21
21
|
* 24px or more, or at 14pt (about 18.667px) or more when the computed font
|
|
22
22
|
* weight is 700 or higher.
|
|
23
|
+
* @implementation-notes
|
|
24
|
+
* - Its thresholds are settings (`settings` below), so another standard's
|
|
25
|
+
* contrast requirement is a variant of this rule rather than a copy
|
|
26
|
+
* (docs/RULE_AUTHORING.md, "Rule variants"). Without settings it is WCAG's.
|
|
23
27
|
*/
|
|
24
28
|
|
|
25
29
|
const id = 'contrast-minimum';
|
|
@@ -65,6 +69,21 @@ function runInPage(ctx) {
|
|
|
65
69
|
}
|
|
66
70
|
}
|
|
67
71
|
|
|
72
|
+
// Thresholds (see `settings` below): WCAG 1.4.3's by default. A variant of
|
|
73
|
+
// this rule, another standard's contrast requirement, passes its own in
|
|
74
|
+
// ctx.config (docs/RULE_AUTHORING.md, "Rule variants").
|
|
75
|
+
const cfg = ctx.config && typeof ctx.config === 'object' ? ctx.config : {};
|
|
76
|
+
const setting = (v, d) => (typeof v === 'number' && Number.isFinite(v) ? v : d);
|
|
77
|
+
const BOLD_LARGE_MIN_PX = setting(cfg.boldLargeMinPx, null);
|
|
78
|
+
const LARGE_TEXT_RATIO = setting(cfg.largeTextRatio, 3);
|
|
79
|
+
const NORMAL_TEXT_RATIO = setting(cfg.normalTextRatio, 4.5);
|
|
80
|
+
// The font and analysis caches hold verdicts that depend on those
|
|
81
|
+
// thresholds, so other thresholds get caches of their own.
|
|
82
|
+
const SETTINGS_KEY =
|
|
83
|
+
BOLD_LARGE_MIN_PX === null && LARGE_TEXT_RATIO === 3 && NORMAL_TEXT_RATIO === 4.5
|
|
84
|
+
? ''
|
|
85
|
+
: '|' + [BOLD_LARGE_MIN_PX, LARGE_TEXT_RATIO, NORMAL_TEXT_RATIO].join('|');
|
|
86
|
+
|
|
68
87
|
const __contrastSharedCache =
|
|
69
88
|
helpers && helpers.contrast && helpers.contrast.sharedCache
|
|
70
89
|
? helpers.contrast.sharedCache
|
|
@@ -82,8 +101,9 @@ function runInPage(ctx) {
|
|
|
82
101
|
? __contrastSharedCache.__elFgCache || (__contrastSharedCache.__elFgCache = new WeakMap())
|
|
83
102
|
: null;
|
|
84
103
|
|
|
104
|
+
const FONT_CACHE = '__elFontCache' + SETTINGS_KEY;
|
|
85
105
|
const __elFontCache = __contrastSharedCache
|
|
86
|
-
? __contrastSharedCache
|
|
106
|
+
? __contrastSharedCache[FONT_CACHE] || (__contrastSharedCache[FONT_CACHE] = new WeakMap())
|
|
87
107
|
: new WeakMap();
|
|
88
108
|
|
|
89
109
|
function safeComputedStyle(el) {
|
|
@@ -124,7 +144,10 @@ function runInPage(ctx) {
|
|
|
124
144
|
|
|
125
145
|
const sizePx = Number.isFinite(fontSizePx) ? fontSizePx : 0;
|
|
126
146
|
const isBold = Number.isFinite(fontWeightNum) && fontWeightNum >= 700;
|
|
127
|
-
const isLarge =
|
|
147
|
+
const isLarge =
|
|
148
|
+
BOLD_LARGE_MIN_PX === null
|
|
149
|
+
? helpers.contrast.isLargeText(sizePx, fontWeightNum)
|
|
150
|
+
: helpers.contrast.isLargeText(sizePx, fontWeightNum, BOLD_LARGE_MIN_PX);
|
|
128
151
|
|
|
129
152
|
const out = {
|
|
130
153
|
fontSizePx: sizePx,
|
|
@@ -228,10 +251,10 @@ function runInPage(ctx) {
|
|
|
228
251
|
selector: '',
|
|
229
252
|
html: '',
|
|
230
253
|
summary: '',
|
|
231
|
-
hint:
|
|
254
|
+
hint: `Change the text color, the background color, or both, so the contrast ratio reaches at least ${params && params.threshold}:1.`,
|
|
232
255
|
i18n: {
|
|
233
256
|
summaryKey: 'contrastMinimum_fail_belowThreshold',
|
|
234
|
-
hintKey: '',
|
|
257
|
+
hintKey: 'contrastMinimum_hint_fail',
|
|
235
258
|
params: params && typeof params === 'object' ? params : {}
|
|
236
259
|
},
|
|
237
260
|
...(uncertainty ? { uncertainty } : {}),
|
|
@@ -278,9 +301,10 @@ function runInPage(ctx) {
|
|
|
278
301
|
let __elAnalysisCache = new WeakMap();
|
|
279
302
|
if (__contrastSharedCache) {
|
|
280
303
|
try {
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
304
|
+
const ANALYSIS_CACHE = '__elAnalysisCacheAA' + SETTINGS_KEY;
|
|
305
|
+
if (!__contrastSharedCache[ANALYSIS_CACHE])
|
|
306
|
+
__contrastSharedCache[ANALYSIS_CACHE] = new WeakMap();
|
|
307
|
+
__elAnalysisCache = __contrastSharedCache[ANALYSIS_CACHE];
|
|
284
308
|
} catch {
|
|
285
309
|
__elAnalysisCache = new WeakMap();
|
|
286
310
|
}
|
|
@@ -372,7 +396,12 @@ function runInPage(ctx) {
|
|
|
372
396
|
const ratio = helpers.contrast.contrastRatio(fgOpaque, bgOpaque);
|
|
373
397
|
|
|
374
398
|
const font = getFontInfo(el);
|
|
375
|
-
const threshold =
|
|
399
|
+
const threshold =
|
|
400
|
+
SETTINGS_KEY === ''
|
|
401
|
+
? helpers.contrast.requiredRatio('AA', font.isLargeText)
|
|
402
|
+
: font.isLargeText
|
|
403
|
+
? LARGE_TEXT_RATIO
|
|
404
|
+
: NORMAL_TEXT_RATIO;
|
|
376
405
|
|
|
377
406
|
analysis = {
|
|
378
407
|
computable: true,
|
|
@@ -491,11 +520,11 @@ function runInPage(ctx) {
|
|
|
491
520
|
{
|
|
492
521
|
selector: '',
|
|
493
522
|
summary: '',
|
|
494
|
-
hint: '
|
|
523
|
+
hint: "Measure this text's contrast by hand on the rendered page. Normal text needs at least 4.5:1 and large text 3:1 (7:1 and 4.5:1 for AAA).",
|
|
495
524
|
html: '',
|
|
496
525
|
i18n: {
|
|
497
526
|
summaryKey: 'contrastMinimum_cantTell_engineFailure',
|
|
498
|
-
hintKey: '',
|
|
527
|
+
hintKey: 'contrast_hint_cantTell_manual',
|
|
499
528
|
params: { reasonCode: 'ENGINE_EXCEPTION' }
|
|
500
529
|
},
|
|
501
530
|
data: { details: { reasonCode: 'ENGINE_EXCEPTION' } }
|
|
@@ -564,4 +593,9 @@ function runInPage(ctx) {
|
|
|
564
593
|
};
|
|
565
594
|
}
|
|
566
595
|
|
|
567
|
-
|
|
596
|
+
// The thresholds a variant may change (docs/RULE_AUTHORING.md, "Rule
|
|
597
|
+
// variants"), with WCAG 1.4.3's as defaults: the size from which bold text is
|
|
598
|
+
// large (null: WCAG's 14pt), and the ratios large and other text need.
|
|
599
|
+
const settings = { boldLargeMinPx: null, largeTextRatio: 3, normalTextRatio: 4.5 };
|
|
600
|
+
|
|
601
|
+
module.exports = { id, meta, runInPage, settings };
|