testaro 78.0.8 → 78.4.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/.gitattributes +94 -0
- package/.github/workflows/ci.yml +58 -0
- package/.github/workflows/publish.yml +49 -0
- package/.github/workflows/typescript.yml +35 -0
- package/AGENTS.md +2 -2
- package/CLAUDE.md +24 -18
- package/CONTAINERS.md +9 -6
- package/CONTRIBUTING.md +29 -3
- package/Dockerfile +1 -1
- package/README.md +73 -19
- package/UPGRADES.md +4 -0
- package/actSpecs-doc.md +13 -1
- package/actSpecs.js +29 -5
- package/call.js +6 -5
- package/docker-compose.yml +8 -2
- package/docs/checkpoint-scanning.md +199 -0
- package/docs/standard-result-outcome.md +165 -0
- package/env.example +44 -10
- package/eslint.config.mjs +101 -0
- package/netWatch.js +372 -222
- package/package.json +46 -31
- package/pour/README.md +47 -0
- package/pour/pour.min.js +8 -0
- package/procs/actDo.js +808 -0
- package/procs/catalog.d.ts +17 -0
- package/procs/catalog.js +312 -221
- package/procs/catalog.ts +398 -0
- package/procs/checkpoint.js +110 -0
- package/procs/config.d.ts +35 -0
- package/procs/config.js +62 -0
- package/procs/dateTime.js +2 -1
- package/procs/doActs.js +442 -849
- package/procs/doTestAct.js +27 -121
- package/procs/flow.js +221 -0
- package/procs/generateRuleRegistry.js +94 -0
- package/procs/getSource.d.ts +26 -0
- package/procs/job.js +115 -1
- package/procs/launch.d.ts +40 -0
- package/procs/launch.js +239 -108
- package/procs/nu.d.ts +48 -0
- package/procs/scope.js +196 -0
- package/procs/shoot.d.ts +33 -0
- package/procs/standard.d.ts +18 -0
- package/procs/standard.js +83 -0
- package/procs/standard.ts +126 -0
- package/procs/testAct.js +145 -0
- package/procs/testaro.d.ts +20 -0
- package/procs/testaro.js +236 -189
- package/procs/testaro.ts +321 -0
- package/procs/userPath.js +126 -0
- package/procs/xPath.d.ts +4 -0
- package/procs/xPath.js +81 -69
- package/procs/xPath.ts +106 -0
- package/procs/xPathScript.d.ts +6 -0
- package/procs/xPathScript.js +70 -0
- package/run.js +3 -2
- package/surea11y/README.md +44 -0
- package/surea11y/surea11y.browser.js +14 -0
- package/testaro/adbID.d.ts +3 -0
- package/testaro/adbID.js +38 -41
- package/testaro/adbID.ts +66 -0
- package/testaro/allCapStyle.d.ts +3 -0
- package/testaro/allCapStyle.js +31 -34
- package/testaro/allCapStyle.ts +55 -0
- package/testaro/allCaps.d.ts +16 -0
- package/testaro/allCaps.js +179 -151
- package/testaro/allCaps.ts +228 -0
- package/testaro/allHidden.d.ts +13 -0
- package/testaro/allHidden.js +29 -30
- package/testaro/allHidden.ts +50 -0
- package/testaro/allSlanted.d.ts +3 -0
- package/testaro/allSlanted.js +30 -33
- package/testaro/allSlanted.ts +54 -0
- package/testaro/altScheme.d.ts +3 -0
- package/testaro/altScheme.js +27 -30
- package/testaro/altScheme.ts +50 -0
- package/testaro/attVal.d.ts +3 -0
- package/testaro/attVal.js +20 -21
- package/testaro/attVal.ts +52 -0
- package/testaro/autocomplete.d.ts +3 -0
- package/testaro/autocomplete.js +59 -75
- package/testaro/autocomplete.ts +101 -0
- package/testaro/bulk.d.ts +13 -0
- package/testaro/bulk.js +32 -33
- package/testaro/bulk.ts +55 -0
- package/testaro/buttonMenu.d.ts +9 -0
- package/testaro/buttonMenu.js +317 -319
- package/testaro/buttonMenu.ts +391 -0
- package/testaro/captionLoc.d.ts +3 -0
- package/testaro/captionLoc.js +17 -20
- package/testaro/captionLoc.ts +40 -0
- package/testaro/datalistRef.d.ts +3 -0
- package/testaro/datalistRef.js +33 -36
- package/testaro/datalistRef.ts +55 -0
- package/testaro/distortion.d.ts +3 -0
- package/testaro/distortion.js +57 -26
- package/testaro/distortion.ts +81 -0
- package/testaro/docType.d.ts +15 -0
- package/testaro/docType.js +25 -25
- package/testaro/docType.ts +45 -0
- package/testaro/dupAtt.d.ts +16 -0
- package/testaro/dupAtt.js +113 -104
- package/testaro/dupAtt.ts +144 -0
- package/testaro/elements.d.ts +6 -0
- package/testaro/elements.js +153 -153
- package/testaro/elements.ts +215 -0
- package/testaro/embAc.d.ts +3 -0
- package/testaro/embAc.js +19 -20
- package/testaro/embAc.ts +40 -0
- package/testaro/focAll.d.ts +13 -0
- package/testaro/focAll.js +185 -191
- package/testaro/focAll.ts +217 -0
- package/testaro/focAndOp.d.ts +3 -0
- package/testaro/focAndOp.js +100 -104
- package/testaro/focAndOp.ts +128 -0
- package/testaro/focInd.d.ts +3 -0
- package/testaro/focInd.js +64 -66
- package/testaro/focInd.ts +96 -0
- package/testaro/focVis.d.ts +3 -0
- package/testaro/focVis.js +28 -30
- package/testaro/focVis.ts +52 -0
- package/testaro/headEl.d.ts +10 -0
- package/testaro/headEl.js +61 -62
- package/testaro/headEl.ts +82 -0
- package/testaro/headingAmb.d.ts +3 -0
- package/testaro/headingAmb.js +54 -63
- package/testaro/headingAmb.ts +72 -0
- package/testaro/hovInd.d.ts +12 -0
- package/testaro/hovInd.js +163 -130
- package/testaro/hovInd.ts +199 -0
- package/testaro/hover.d.ts +3 -0
- package/testaro/hover.js +155 -126
- package/testaro/hover.ts +154 -0
- package/testaro/hr.d.ts +3 -0
- package/testaro/hr.js +14 -17
- package/testaro/hr.ts +36 -0
- package/testaro/imageLink.d.ts +3 -0
- package/testaro/imageLink.js +17 -20
- package/testaro/imageLink.ts +42 -0
- package/testaro/labClash.d.ts +3 -0
- package/testaro/labClash.js +31 -33
- package/testaro/labClash.ts +54 -0
- package/testaro/legendLoc.d.ts +3 -0
- package/testaro/legendLoc.js +17 -20
- package/testaro/legendLoc.ts +42 -0
- package/testaro/lineHeight.d.ts +3 -0
- package/testaro/lineHeight.js +43 -48
- package/testaro/lineHeight.ts +70 -0
- package/testaro/linkAmb.d.ts +7 -0
- package/testaro/linkAmb.js +79 -80
- package/testaro/linkAmb.ts +105 -0
- package/testaro/linkExt.d.ts +3 -0
- package/testaro/linkExt.js +13 -16
- package/testaro/linkExt.ts +35 -0
- package/testaro/linkOldAtt.d.ts +3 -0
- package/testaro/linkOldAtt.js +25 -28
- package/testaro/linkOldAtt.ts +48 -0
- package/testaro/linkTo.d.ts +3 -0
- package/testaro/linkTo.js +22 -22
- package/testaro/linkTo.ts +43 -0
- package/testaro/linkUl.d.ts +3 -0
- package/testaro/linkUl.js +30 -34
- package/testaro/linkUl.ts +54 -0
- package/testaro/miniText.d.ts +3 -0
- package/testaro/miniText.js +41 -44
- package/testaro/miniText.ts +68 -0
- package/testaro/motion.d.ts +10 -0
- package/testaro/motion.js +92 -96
- package/testaro/motion.ts +125 -0
- package/testaro/nonTable.d.ts +3 -0
- package/testaro/nonTable.js +39 -45
- package/testaro/nonTable.ts +66 -0
- package/testaro/optRoleSel.d.ts +3 -0
- package/testaro/optRoleSel.js +16 -19
- package/testaro/optRoleSel.ts +41 -0
- package/testaro/phOnly.d.ts +3 -0
- package/testaro/phOnly.js +18 -21
- package/testaro/phOnly.ts +43 -0
- package/testaro/pseudoP.d.ts +3 -0
- package/testaro/pseudoP.js +36 -38
- package/testaro/pseudoP.ts +59 -0
- package/testaro/radioSet.d.ts +3 -0
- package/testaro/radioSet.js +57 -59
- package/testaro/radioSet.ts +79 -0
- package/testaro/registry.d.ts +63 -0
- package/testaro/registry.js +67 -0
- package/testaro/registry.ts +141 -0
- package/testaro/role.d.ts +3 -0
- package/testaro/role.js +27 -29
- package/testaro/role.ts +53 -0
- package/testaro/secHeading.d.ts +3 -0
- package/testaro/secHeading.js +30 -33
- package/testaro/secHeading.ts +53 -0
- package/testaro/styleDiff.d.ts +25 -0
- package/testaro/styleDiff.js +248 -252
- package/testaro/styleDiff.ts +303 -0
- package/testaro/tabNav.d.ts +33 -0
- package/testaro/tabNav.js +272 -342
- package/testaro/tabNav.ts +454 -0
- package/testaro/targetsNear.d.ts +9 -0
- package/testaro/targetsNear.js +130 -132
- package/testaro/targetsNear.ts +160 -0
- package/testaro/textNodes.d.ts +6 -0
- package/testaro/textNodes.js +139 -135
- package/testaro/textNodes.ts +185 -0
- package/testaro/textSem.d.ts +3 -0
- package/testaro/textSem.js +25 -28
- package/testaro/textSem.ts +47 -0
- package/testaro/title.d.ts +9 -0
- package/testaro/title.js +16 -13
- package/testaro/title.ts +31 -0
- package/testaro/titledEl.d.ts +3 -0
- package/testaro/titledEl.js +15 -18
- package/testaro/titledEl.ts +38 -0
- package/testaro/zIndex.d.ts +3 -0
- package/testaro/zIndex.js +19 -22
- package/testaro/zIndex.ts +42 -0
- package/tests/alfa.d.ts +45 -0
- package/tests/alfa.js +138 -141
- package/tests/alfa.ts +214 -0
- package/tests/aslint.d.ts +33 -0
- package/tests/aslint.js +272 -249
- package/tests/aslint.ts +301 -0
- package/tests/axe.d.ts +27 -0
- package/tests/axe.js +199 -200
- package/tests/axe.ts +277 -0
- package/tests/ed11y.d.ts +28 -0
- package/tests/ed11y.js +141 -99
- package/tests/ed11y.ts +178 -0
- package/tests/htmlcs.d.ts +21 -0
- package/tests/htmlcs.js +174 -140
- package/tests/htmlcs.ts +181 -0
- package/tests/ibm.d.ts +52 -0
- package/tests/ibm.js +164 -166
- package/tests/ibm.ts +251 -0
- package/tests/nuVal.d.ts +13 -0
- package/tests/nuVal.js +107 -112
- package/tests/nuVal.ts +145 -0
- package/tests/nuVnu.d.ts +15 -0
- package/tests/nuVnu.js +141 -111
- package/tests/nuVnu.ts +144 -0
- package/tests/pour.d.ts +31 -0
- package/tests/pour.js +242 -0
- package/tests/pour.ts +273 -0
- package/tests/qualWeb.d.ts +39 -0
- package/tests/qualWeb.js +302 -272
- package/tests/qualWeb.ts +415 -0
- package/tests/surea11y.d.ts +33 -0
- package/tests/surea11y.js +288 -0
- package/tests/surea11y.ts +334 -0
- package/tests/testaro.d.ts +25 -0
- package/tests/testaro.js +745 -652
- package/tests/testaro.ts +862 -0
- package/tests/wave.d.ts +46 -0
- package/tests/wave.js +166 -177
- package/tests/wave.ts +252 -0
- package/tsconfig.json +17 -0
- package/types.d.ts +243 -0
- package/types.js +10 -0
- package/types.ts +376 -0
- package/validation/act/README.md +46 -0
- package/validation/act/capture.js +424 -0
- package/validation/act/chromium-issue-draft.md +66 -0
- package/validation/act/fp-triage-2026-08-22.md +86 -0
- package/validation/act/isolation-notes.md +159 -0
- package/validation/act/playwright-issue-draft.md +73 -0
- package/validation/act/propose-mappings.js +0 -0
- package/validation/act/repro-cdp-raw.js +105 -0
- package/validation/act/repro-metarefresh.js +65 -0
- package/validation/act/score.js +184 -0
- package/validation/act/stage3a-stress-report.md +69 -0
- package/validation/act/stage3b-mapping-proposals.md +94 -0
- package/validation/act/stage3b-triage-draft.md +138 -0
- package/validation/act/surea11y-track-a-2026-09-01.md +50 -0
- package/validation/executors/netWatch.js +180 -90
- package/validation/executors/test.js +17 -2
- package/validation/executors/tests.js +118 -10
- package/validation/jobs/reports/raw/260901T1000-surea11y-validation.json +964 -0
- package/validation/jobs/todo/240101T1200-simple-example.json +14 -6
- package/validation/jobs/todo/240101T1300-shoot-example.json +2 -1
- package/validation/jobs/todo/260821T1900-pour-validation.json +45 -0
- package/validation/jobs/todo/260901T1000-surea11y-validation.json +45 -0
- package/validation/knownFailures.json +1 -0
- package/validation/tests/jobProperties/adbID.json +27 -2
- package/validation/tests/jobProperties/{focOp.json → allCapStyle.json} +56 -53
- package/validation/tests/jobProperties/allCaps.json +25 -0
- package/validation/tests/jobProperties/allHidden.json +133 -13
- package/validation/tests/jobProperties/allSlanted.json +3 -3
- package/validation/tests/jobProperties/altScheme.json +23 -8
- package/validation/tests/jobProperties/attVal.json +57 -57
- package/validation/tests/jobProperties/autocomplete.json +11 -1
- package/validation/tests/jobProperties/bulk.json +6 -1
- package/validation/tests/jobProperties/buttonMenu.json +63 -42
- package/validation/tests/jobProperties/captionLoc.json +1 -6
- package/validation/tests/jobProperties/checkpoint-browser.json +401 -0
- package/validation/tests/jobProperties/checkpoint-page.json +397 -0
- package/validation/tests/jobProperties/checkpoint.json +395 -0
- package/validation/tests/jobProperties/datalistRef.json +20 -5
- package/validation/tests/jobProperties/distortion.json +28 -3
- package/validation/tests/jobProperties/docType.json +2 -2
- package/validation/tests/jobProperties/dupAtt.json +48 -33
- package/validation/tests/jobProperties/elements.json +28 -28
- package/validation/tests/jobProperties/embAc.json +36 -31
- package/validation/tests/jobProperties/focAndOp.json +284 -0
- package/validation/tests/jobProperties/focInd.json +34 -34
- package/validation/tests/jobProperties/focVis.json +1 -1
- package/validation/tests/jobProperties/hover.json +39 -37
- package/validation/tests/jobProperties/hr.json +13 -3
- package/validation/tests/jobProperties/imageLink.json +0 -5
- package/validation/tests/jobProperties/labClash.json +68 -33
- package/validation/tests/jobProperties/legendLoc.json +1 -6
- package/validation/tests/jobProperties/lineHeight.json +2 -2
- package/validation/tests/jobProperties/linkAmb.json +13 -18
- package/validation/tests/jobProperties/linkExt.json +1 -1
- package/validation/tests/jobProperties/linkOldAtt.json +12 -2
- package/validation/tests/jobProperties/linkTo.json +1 -1
- package/validation/tests/jobProperties/linkUl.json +65 -65
- package/validation/tests/jobProperties/miniText.json +7 -2
- package/validation/tests/jobProperties/motion.json +4 -50
- package/validation/tests/jobProperties/nonTable.json +48 -3
- package/validation/tests/jobProperties/optRoleSel.json +12 -2
- package/validation/tests/jobProperties/phOnly.json +6 -16
- package/validation/tests/jobProperties/pseudoP.json +17 -2
- package/validation/tests/jobProperties/radioSet.json +30 -30
- package/validation/tests/jobProperties/role.json +25 -5
- package/validation/tests/jobProperties/secHeading.json +26 -21
- package/validation/tests/jobProperties/styleDiff.json +35 -5
- package/validation/tests/jobProperties/tabNav.json +3 -1
- package/validation/tests/jobProperties/targetsNear.json +237 -0
- package/validation/tests/jobProperties/textNodes.json +29 -29
- package/validation/tests/jobProperties/textSem.json +26 -1
- package/validation/tests/jobProperties/title.json +12 -2
- package/validation/tests/jobProperties/titledEl.json +39 -9
- package/validation/tests/jobProperties/userPath.json +317 -0
- package/validation/tests/jobProperties/zIndex.json +40 -35
- package/validation/tests/targets/allCapStyle/index.html +26 -0
- package/validation/tests/targets/checkpoint/index.html +38 -0
- package/validation/tests/targets/datalistRef/index.html +1 -1
- package/validation/tests/targets/focAndOp/bad.html +29 -0
- package/validation/tests/targets/{focOp → focAndOp}/good.html +3 -1
- package/validation/tests/targets/focInd/bad.html +2 -1
- package/validation/tests/targets/headEl/index.html +10 -1
- package/validation/tests/targets/{targetSmall → targetsNear}/index.html +15 -1
- package/validation/tests/targets/userPath/index.html +42 -0
- package/validation/validateTest.js +61 -9
- package/.claude/settings.local.json +0 -11
- package/.eslintrc.json +0 -41
- package/htmlcs/.eslintrc.json +0 -67
- package/validation/tests/jobProperties/linkTitle.json +0 -127
- package/validation/tests/jobProperties/opFoc.json +0 -164
- package/validation/tests/jobProperties/targetSmall.json +0 -152
- package/validation/tests/jobProperties/targetTiny.json +0 -142
- package/validation/tests/targets/focOp/bad.html +0 -25
- package/validation/tests/targets/linkTitle/index.html +0 -24
- package/validation/tests/targets/opFoc/bad.html +0 -26
- package/validation/tests/targets/opFoc/good.html +0 -23
package/testaro/focAll.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
"use strict";
|
|
1
2
|
/*
|
|
2
3
|
© 2021–2023 CVS Health and/or one of its affiliates. All rights reserved.
|
|
3
4
|
© 2026 Jeff Witt.
|
|
@@ -7,206 +8,199 @@
|
|
|
7
8
|
|
|
8
9
|
SPDX-License-Identifier: MIT
|
|
9
10
|
*/
|
|
10
|
-
|
|
11
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
|
+
exports.reporter = void 0;
|
|
13
|
+
const xPath_1 = require("../procs/xPath");
|
|
11
14
|
/*
|
|
12
15
|
focAll
|
|
13
16
|
This test reports discrepancies between the visible focusable elements of a page and the elements actually reached with the Tab (or, in webkit, Option-Tab) key. The test first identifies and marks all the visible focusable elements, i.e. those with a nonnegative tabIndex, except elements that browsers exclude from Tab navigation (disabled elements, inert elements, and a and area elements without href attributes). Radio buttons are grouped as browsers group them: radio buttons sharing a name and a form owner belong to one group, and the group is reachable with one Tab keypress, but each radio button without a name is independently reachable. Then the test repeatedly presses the Tab (or Option-Tab) key until it has reached all the elements it can, marking each reached element. Finally, the test reports each focusable element that was never reached (for example, because keypresses cause surprising changes in content or focus) and each reached element that was not a visible focusable element when the page was loaded (for example, because it was revealed only during navigation or was invisible although Tab-focusable). Any such element can make navigation with the Tab key difficult or impossible.
|
|
17
|
+
Compiled to focAll.js by tsc (issue #73); edit this file, not the emitted one.
|
|
14
18
|
*/
|
|
15
|
-
|
|
16
|
-
// IMPORTS
|
|
17
|
-
|
|
18
|
-
const {getXPathCatalogIndex} = require('../procs/xPath');
|
|
19
|
-
|
|
20
19
|
// FUNCTIONS
|
|
21
|
-
|
|
22
20
|
// Runs the test and returns the result.
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
21
|
+
const reporter = async (page, report, _, withItems) => {
|
|
22
|
+
// Get locators of visible elements.
|
|
23
|
+
const locAll = await page.locator('body *:visible');
|
|
24
|
+
// Mark the focusable elements and get the count of Tab stops among them.
|
|
25
|
+
const focusableCount = await locAll.evaluateAll(elements => {
|
|
26
|
+
// Get the focusable elements, i.e. those that can be reached with the Tab key.
|
|
27
|
+
const focusables = elements.filter(element => {
|
|
28
|
+
// If the element has a negative tabIndex, it is not focusable.
|
|
29
|
+
if (element.tabIndex < 0) {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
// If the element or an ancestor is disabled or inert, it is not focusable.
|
|
33
|
+
if (element.matches(':disabled') || element.closest('[inert]')) {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
// If the element is an a or area element without an href attribute, it is not focusable.
|
|
37
|
+
if (['A', 'AREA'].includes(element.tagName) && !element.hasAttribute('href')) {
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
return true;
|
|
41
|
+
});
|
|
42
|
+
// Mark them.
|
|
43
|
+
focusables.forEach(element => {
|
|
44
|
+
element.dataset.testarofocusable = 'true';
|
|
45
|
+
});
|
|
46
|
+
// Get the radio buttons among them that browsers group, i.e. those with names.
|
|
47
|
+
const namedRadios = focusables.filter((element) => element.tagName === 'INPUT' && element.type === 'radio'
|
|
48
|
+
&& !!element.name);
|
|
49
|
+
// Get the identifiers (form owner and name) of their groups.
|
|
50
|
+
const groupIDs = new Set(namedRadios.map(radio => {
|
|
51
|
+
const formIndex = radio.form ? Array.prototype.indexOf.call(document.forms, radio.form) : -1;
|
|
52
|
+
return `${formIndex}:${radio.name}`;
|
|
53
|
+
}));
|
|
54
|
+
// Return the count of Tab stops, counting each radio-button group as only 1.
|
|
55
|
+
return focusables.length - namedRadios.length + groupIDs.size;
|
|
43
56
|
});
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
57
|
+
/*
|
|
58
|
+
Repeatedly perform a Tab or (in webkit) Opt-Tab keypress and count the focused elements.
|
|
59
|
+
Asumptions:
|
|
60
|
+
0. No page has more than 2000 focusable elements.
|
|
61
|
+
1. No shadow root that reports itself as the active element has more than 100 focusable
|
|
62
|
+
descendants.
|
|
63
|
+
*/
|
|
64
|
+
let tabFocused = 0;
|
|
65
|
+
let refocused = 0;
|
|
66
|
+
const keyName = page.context().browser()?.browserType().name() === 'webkit' ? 'Alt+Tab' : 'Tab';
|
|
67
|
+
while (refocused < 100 && tabFocused < 2000) {
|
|
68
|
+
await page.keyboard.press(keyName);
|
|
69
|
+
const isNewFocus = await page.evaluate(() => {
|
|
70
|
+
let focus = document.activeElement;
|
|
71
|
+
// If the focus is in a shadow DOM, get the innermost focused element.
|
|
72
|
+
while (focus && focus.shadowRoot && focus.shadowRoot.activeElement) {
|
|
73
|
+
focus = focus.shadowRoot.activeElement;
|
|
74
|
+
}
|
|
75
|
+
if (focus === null || focus.tagName === 'BODY' || focus.dataset.testarofocused) {
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
else {
|
|
79
|
+
focus.dataset.testarofocused = 'true';
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
if (isNewFocus) {
|
|
84
|
+
tabFocused++;
|
|
85
|
+
}
|
|
86
|
+
else {
|
|
87
|
+
refocused++;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
// Get the XPaths of the elements violating the rule, in both directions.
|
|
91
|
+
const { unreached, unexpected } = await page.evaluate(() => {
|
|
92
|
+
// Get all elements, including any in open shadow roots.
|
|
93
|
+
const allElements = [];
|
|
94
|
+
const addElements = (root) => {
|
|
95
|
+
root.querySelectorAll('*').forEach(element => {
|
|
96
|
+
allElements.push(element);
|
|
97
|
+
if (element.shadowRoot) {
|
|
98
|
+
addElements(element.shadowRoot);
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
};
|
|
102
|
+
addElements(document);
|
|
103
|
+
// Returns the identifier of the group of a radio button.
|
|
104
|
+
const getGroupID = (radio) => {
|
|
105
|
+
const formIndex = radio.form ? Array.prototype.indexOf.call(document.forms, radio.form) : -1;
|
|
106
|
+
return `${formIndex}:${radio.name}`;
|
|
107
|
+
};
|
|
108
|
+
// Get the identifiers of the radio-button groups with any reached member.
|
|
109
|
+
const reachedGroupIDs = new Set(allElements
|
|
110
|
+
.filter((element) => !!element.dataset.testarofocused
|
|
111
|
+
&& element.tagName === 'INPUT'
|
|
112
|
+
&& element.type === 'radio'
|
|
113
|
+
&& !!element.name)
|
|
114
|
+
.map(getGroupID));
|
|
115
|
+
// Initialize the violation data.
|
|
116
|
+
const unreached = [];
|
|
117
|
+
const unexpected = [];
|
|
118
|
+
// For each element:
|
|
119
|
+
allElements.forEach(element => {
|
|
120
|
+
const wasFocusable = !!element.dataset.testarofocusable;
|
|
121
|
+
const wasFocused = !!element.dataset.testarofocused;
|
|
122
|
+
// If it was focusable but was never reached:
|
|
123
|
+
if (wasFocusable && !wasFocused) {
|
|
124
|
+
const isGroupedRadio = element.tagName === 'INPUT'
|
|
125
|
+
&& element.type === 'radio'
|
|
126
|
+
&& element.name;
|
|
127
|
+
// If it is not a member of a radio-button group with another reached member:
|
|
128
|
+
if (!(isGroupedRadio && reachedGroupIDs.has(getGroupID(element)))) {
|
|
129
|
+
// Add its XPath to the violation data.
|
|
130
|
+
unreached.push(window.getXPath(element) ?? '/html');
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
// Otherwise, if it was reached but was not a visible focusable element:
|
|
134
|
+
else if (wasFocused && !wasFocusable) {
|
|
135
|
+
// Get whether it is a scrollable container, which some browsers make Tab-focusable.
|
|
136
|
+
const styleDec = window.getComputedStyle(element);
|
|
137
|
+
const isScroller = ['auto', 'scroll'].includes(styleDec.overflowY)
|
|
138
|
+
&& element.scrollHeight > element.clientHeight
|
|
139
|
+
|| ['auto', 'scroll'].includes(styleDec.overflowX)
|
|
140
|
+
&& element.scrollWidth > element.clientWidth;
|
|
141
|
+
// If it is not a scrollable container made focusable by the browser alone:
|
|
142
|
+
if (!(isScroller && element.tabIndex < 0)) {
|
|
143
|
+
// Add its XPath to the violation data.
|
|
144
|
+
unexpected.push(window.getXPath(element) ?? '/html');
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
// Remove the marker attributes from the element.
|
|
148
|
+
delete element.dataset.testarofocusable;
|
|
149
|
+
delete element.dataset.testarofocused;
|
|
150
|
+
});
|
|
151
|
+
return { unreached, unexpected };
|
|
85
152
|
});
|
|
86
|
-
|
|
87
|
-
|
|
153
|
+
const count = unreached.length + unexpected.length;
|
|
154
|
+
const data = {
|
|
155
|
+
focusableCount,
|
|
156
|
+
tabFocused,
|
|
157
|
+
discrepancy: tabFocused - focusableCount,
|
|
158
|
+
unreachedCount: unreached.length,
|
|
159
|
+
unexpectedCount: unexpected.length
|
|
160
|
+
};
|
|
161
|
+
// Initialize the standard instances.
|
|
162
|
+
const standardInstances = [];
|
|
163
|
+
// If itemization is required:
|
|
164
|
+
if (withItems) {
|
|
165
|
+
// For each element that was focusable but was never reached:
|
|
166
|
+
unreached.forEach(xPath => {
|
|
167
|
+
// Add an instance to the standard instances.
|
|
168
|
+
standardInstances.push({
|
|
169
|
+
ruleID: 'focAll',
|
|
170
|
+
what: 'Element is focusable but was not reached by Tab navigation',
|
|
171
|
+
ordinalSeverity: 2,
|
|
172
|
+
count: 1,
|
|
173
|
+
catalogIndex: (0, xPath_1.getXPathCatalogIndex)(report, xPath)
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
// For each element that was reached but was not a visible focusable element:
|
|
177
|
+
unexpected.forEach(xPath => {
|
|
178
|
+
// Add an instance to the standard instances.
|
|
179
|
+
standardInstances.push({
|
|
180
|
+
ruleID: 'focAll',
|
|
181
|
+
what: 'Element was reached by Tab navigation but was not a visible focusable element',
|
|
182
|
+
ordinalSeverity: 2,
|
|
183
|
+
count: 1,
|
|
184
|
+
catalogIndex: (0, xPath_1.getXPathCatalogIndex)(report, xPath)
|
|
185
|
+
});
|
|
186
|
+
});
|
|
88
187
|
}
|
|
89
|
-
|
|
90
|
-
|
|
188
|
+
// Otherwise, if there were any violations:
|
|
189
|
+
else if (count) {
|
|
190
|
+
// Add a summary instance to the standard instances.
|
|
191
|
+
standardInstances.push({
|
|
192
|
+
ruleID: 'focAll',
|
|
193
|
+
what: 'Some focusable elements are not Tab-focusable or vice versa',
|
|
194
|
+
ordinalSeverity: 2,
|
|
195
|
+
count,
|
|
196
|
+
catalogIndex: (0, xPath_1.getXPathCatalogIndex)(report, '/html/body')
|
|
197
|
+
});
|
|
91
198
|
}
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
const addElements = root => {
|
|
98
|
-
root.querySelectorAll('*').forEach(element => {
|
|
99
|
-
allElements.push(element);
|
|
100
|
-
if (element.shadowRoot) {
|
|
101
|
-
addElements(element.shadowRoot);
|
|
102
|
-
}
|
|
103
|
-
});
|
|
104
|
-
};
|
|
105
|
-
addElements(document);
|
|
106
|
-
// Returns the identifier of the group of a radio button.
|
|
107
|
-
const getGroupID = radio => {
|
|
108
|
-
const formIndex = radio.form ? Array.prototype.indexOf.call(document.forms, radio.form) : -1;
|
|
109
|
-
return `${formIndex}:${radio.name}`;
|
|
199
|
+
// Return the result.
|
|
200
|
+
return {
|
|
201
|
+
data,
|
|
202
|
+
totals: [0, 0, count, 0],
|
|
203
|
+
standardInstances
|
|
110
204
|
};
|
|
111
|
-
// Get the identifiers of the radio-button groups with any reached member.
|
|
112
|
-
const reachedGroupIDs = new Set(
|
|
113
|
-
allElements
|
|
114
|
-
.filter(
|
|
115
|
-
element => element.dataset.testarofocused
|
|
116
|
-
&& element.tagName === 'INPUT'
|
|
117
|
-
&& element.type === 'radio'
|
|
118
|
-
&& element.name
|
|
119
|
-
)
|
|
120
|
-
.map(getGroupID)
|
|
121
|
-
);
|
|
122
|
-
// Initialize the violation data.
|
|
123
|
-
const unreached = [];
|
|
124
|
-
const unexpected = [];
|
|
125
|
-
// For each element:
|
|
126
|
-
allElements.forEach(element => {
|
|
127
|
-
const wasFocusable = !! element.dataset.testarofocusable;
|
|
128
|
-
const wasFocused = !! element.dataset.testarofocused;
|
|
129
|
-
// If it was focusable but was never reached:
|
|
130
|
-
if (wasFocusable && ! wasFocused) {
|
|
131
|
-
const isGroupedRadio = element.tagName === 'INPUT'
|
|
132
|
-
&& element.type === 'radio'
|
|
133
|
-
&& element.name;
|
|
134
|
-
// If it is not a member of a radio-button group with another reached member:
|
|
135
|
-
if (! (isGroupedRadio && reachedGroupIDs.has(getGroupID(element)))) {
|
|
136
|
-
// Add its XPath to the violation data.
|
|
137
|
-
unreached.push(window.getXPath(element) ?? '/html');
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
// Otherwise, if it was reached but was not a visible focusable element:
|
|
141
|
-
else if (wasFocused && ! wasFocusable) {
|
|
142
|
-
// Get whether it is a scrollable container, which some browsers make Tab-focusable.
|
|
143
|
-
const styleDec = window.getComputedStyle(element);
|
|
144
|
-
const isScroller = ['auto', 'scroll'].includes(styleDec.overflowY)
|
|
145
|
-
&& element.scrollHeight > element.clientHeight
|
|
146
|
-
|| ['auto', 'scroll'].includes(styleDec.overflowX)
|
|
147
|
-
&& element.scrollWidth > element.clientWidth;
|
|
148
|
-
// If it is not a scrollable container made focusable by the browser alone:
|
|
149
|
-
if (! (isScroller && element.tabIndex < 0)) {
|
|
150
|
-
// Add its XPath to the violation data.
|
|
151
|
-
unexpected.push(window.getXPath(element) ?? '/html');
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
// Remove the marker attributes from the element.
|
|
155
|
-
delete element.dataset.testarofocusable;
|
|
156
|
-
delete element.dataset.testarofocused;
|
|
157
|
-
});
|
|
158
|
-
return {unreached, unexpected};
|
|
159
|
-
});
|
|
160
|
-
const count = unreached.length + unexpected.length;
|
|
161
|
-
const data = {
|
|
162
|
-
focusableCount,
|
|
163
|
-
tabFocused,
|
|
164
|
-
discrepancy: tabFocused - focusableCount,
|
|
165
|
-
unreachedCount: unreached.length,
|
|
166
|
-
unexpectedCount: unexpected.length
|
|
167
|
-
};
|
|
168
|
-
// Initialize the standard instances.
|
|
169
|
-
const standardInstances = [];
|
|
170
|
-
// If itemization is required:
|
|
171
|
-
if (withItems) {
|
|
172
|
-
// For each element that was focusable but was never reached:
|
|
173
|
-
unreached.forEach(xPath => {
|
|
174
|
-
// Add an instance to the standard instances.
|
|
175
|
-
standardInstances.push({
|
|
176
|
-
ruleID: 'focAll',
|
|
177
|
-
what: 'Element is focusable but was not reached by Tab navigation',
|
|
178
|
-
ordinalSeverity: 2,
|
|
179
|
-
count: 1,
|
|
180
|
-
catalogIndex: getXPathCatalogIndex(report, xPath)
|
|
181
|
-
});
|
|
182
|
-
});
|
|
183
|
-
// For each element that was reached but was not a visible focusable element:
|
|
184
|
-
unexpected.forEach(xPath => {
|
|
185
|
-
// Add an instance to the standard instances.
|
|
186
|
-
standardInstances.push({
|
|
187
|
-
ruleID: 'focAll',
|
|
188
|
-
what: 'Element was reached by Tab navigation but was not a visible focusable element',
|
|
189
|
-
ordinalSeverity: 2,
|
|
190
|
-
count: 1,
|
|
191
|
-
catalogIndex: getXPathCatalogIndex(report, xPath)
|
|
192
|
-
});
|
|
193
|
-
});
|
|
194
|
-
}
|
|
195
|
-
// Otherwise, if there were any violations:
|
|
196
|
-
else if (count) {
|
|
197
|
-
// Add a summary instance to the standard instances.
|
|
198
|
-
standardInstances.push({
|
|
199
|
-
ruleID: 'focAll',
|
|
200
|
-
what: 'Some focusable elements are not Tab-focusable or vice versa',
|
|
201
|
-
ordinalSeverity: 2,
|
|
202
|
-
count,
|
|
203
|
-
catalogIndex: getXPathCatalogIndex(report, '/html/body')
|
|
204
|
-
});
|
|
205
|
-
}
|
|
206
|
-
// Return the result.
|
|
207
|
-
return {
|
|
208
|
-
data,
|
|
209
|
-
totals: [0, 0, count, 0],
|
|
210
|
-
standardInstances
|
|
211
|
-
};
|
|
212
205
|
};
|
|
206
|
+
exports.reporter = reporter;
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/*
|
|
2
|
+
© 2021–2023 CVS Health and/or one of its affiliates. All rights reserved.
|
|
3
|
+
© 2026 Jeff Witt.
|
|
4
|
+
© 2025–2026 Jonathan Robert Pool.
|
|
5
|
+
|
|
6
|
+
Licensed under the MIT License. See LICENSE file at the project root or https://opensource.org/license/mit/ for details.
|
|
7
|
+
|
|
8
|
+
SPDX-License-Identifier: MIT
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
// IMPORTS
|
|
12
|
+
|
|
13
|
+
import type {Page} from 'playwright';
|
|
14
|
+
import {getXPathCatalogIndex} from '../procs/xPath';
|
|
15
|
+
import type {Report, StandardInstance} from '../types';
|
|
16
|
+
|
|
17
|
+
/*
|
|
18
|
+
focAll
|
|
19
|
+
This test reports discrepancies between the visible focusable elements of a page and the elements actually reached with the Tab (or, in webkit, Option-Tab) key. The test first identifies and marks all the visible focusable elements, i.e. those with a nonnegative tabIndex, except elements that browsers exclude from Tab navigation (disabled elements, inert elements, and a and area elements without href attributes). Radio buttons are grouped as browsers group them: radio buttons sharing a name and a form owner belong to one group, and the group is reachable with one Tab keypress, but each radio button without a name is independently reachable. Then the test repeatedly presses the Tab (or Option-Tab) key until it has reached all the elements it can, marking each reached element. Finally, the test reports each focusable element that was never reached (for example, because keypresses cause surprising changes in content or focus) and each reached element that was not a visible focusable element when the page was loaded (for example, because it was revealed only during navigation or was invisible although Tab-focusable). Any such element can make navigation with the Tab key difficult or impossible.
|
|
20
|
+
Compiled to focAll.js by tsc (issue #73); edit this file, not the emitted one.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
// FUNCTIONS
|
|
24
|
+
|
|
25
|
+
// Runs the test and returns the result.
|
|
26
|
+
export const reporter = async (page: Page, report: Report, _: unknown, withItems: boolean) => {
|
|
27
|
+
// Get locators of visible elements.
|
|
28
|
+
const locAll = await page.locator('body *:visible');
|
|
29
|
+
// Mark the focusable elements and get the count of Tab stops among them.
|
|
30
|
+
const focusableCount = await locAll.evaluateAll(elements => {
|
|
31
|
+
// Get the focusable elements, i.e. those that can be reached with the Tab key.
|
|
32
|
+
const focusables = elements.filter(element => {
|
|
33
|
+
// If the element has a negative tabIndex, it is not focusable.
|
|
34
|
+
if (element.tabIndex < 0) {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
// If the element or an ancestor is disabled or inert, it is not focusable.
|
|
38
|
+
if (element.matches(':disabled') || element.closest('[inert]')) {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
// If the element is an a or area element without an href attribute, it is not focusable.
|
|
42
|
+
if (['A', 'AREA'].includes(element.tagName) && ! element.hasAttribute('href')) {
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
return true;
|
|
46
|
+
});
|
|
47
|
+
// Mark them.
|
|
48
|
+
focusables.forEach(element => {
|
|
49
|
+
element.dataset.testarofocusable = 'true';
|
|
50
|
+
});
|
|
51
|
+
// Get the radio buttons among them that browsers group, i.e. those with names.
|
|
52
|
+
const namedRadios = focusables.filter(
|
|
53
|
+
(element): element is HTMLInputElement =>
|
|
54
|
+
element.tagName === 'INPUT' && (element as HTMLInputElement).type === 'radio'
|
|
55
|
+
&& !! (element as HTMLInputElement).name
|
|
56
|
+
);
|
|
57
|
+
// Get the identifiers (form owner and name) of their groups.
|
|
58
|
+
const groupIDs = new Set(namedRadios.map(radio => {
|
|
59
|
+
const formIndex = radio.form ? Array.prototype.indexOf.call(document.forms, radio.form) : -1;
|
|
60
|
+
return `${formIndex}:${radio.name}`;
|
|
61
|
+
}));
|
|
62
|
+
// Return the count of Tab stops, counting each radio-button group as only 1.
|
|
63
|
+
return focusables.length - namedRadios.length + groupIDs.size;
|
|
64
|
+
});
|
|
65
|
+
/*
|
|
66
|
+
Repeatedly perform a Tab or (in webkit) Opt-Tab keypress and count the focused elements.
|
|
67
|
+
Asumptions:
|
|
68
|
+
0. No page has more than 2000 focusable elements.
|
|
69
|
+
1. No shadow root that reports itself as the active element has more than 100 focusable
|
|
70
|
+
descendants.
|
|
71
|
+
*/
|
|
72
|
+
let tabFocused = 0;
|
|
73
|
+
let refocused = 0;
|
|
74
|
+
const keyName = page.context().browser()?.browserType().name() === 'webkit' ? 'Alt+Tab' : 'Tab';
|
|
75
|
+
while (refocused < 100 && tabFocused < 2000) {
|
|
76
|
+
await page.keyboard.press(keyName);
|
|
77
|
+
const isNewFocus = await page.evaluate(() => {
|
|
78
|
+
let focus = document.activeElement;
|
|
79
|
+
// If the focus is in a shadow DOM, get the innermost focused element.
|
|
80
|
+
while (focus && focus.shadowRoot && focus.shadowRoot.activeElement) {
|
|
81
|
+
focus = focus.shadowRoot.activeElement;
|
|
82
|
+
}
|
|
83
|
+
if (focus === null || focus.tagName === 'BODY' || (focus as HTMLElement).dataset.testarofocused) {
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
else {
|
|
87
|
+
(focus as HTMLElement).dataset.testarofocused = 'true';
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
if (isNewFocus) {
|
|
92
|
+
tabFocused++;
|
|
93
|
+
}
|
|
94
|
+
else {
|
|
95
|
+
refocused++;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
// Get the XPaths of the elements violating the rule, in both directions.
|
|
99
|
+
const {unreached, unexpected} = await page.evaluate(() => {
|
|
100
|
+
// Get all elements, including any in open shadow roots.
|
|
101
|
+
const allElements: HTMLElement[] = [];
|
|
102
|
+
const addElements = (root: Document | ShadowRoot) => {
|
|
103
|
+
root.querySelectorAll<HTMLElement>('*').forEach(element => {
|
|
104
|
+
allElements.push(element);
|
|
105
|
+
if (element.shadowRoot) {
|
|
106
|
+
addElements(element.shadowRoot);
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
};
|
|
110
|
+
addElements(document);
|
|
111
|
+
// Returns the identifier of the group of a radio button.
|
|
112
|
+
const getGroupID = (radio: HTMLInputElement) => {
|
|
113
|
+
const formIndex = radio.form ? Array.prototype.indexOf.call(document.forms, radio.form) : -1;
|
|
114
|
+
return `${formIndex}:${radio.name}`;
|
|
115
|
+
};
|
|
116
|
+
// Get the identifiers of the radio-button groups with any reached member.
|
|
117
|
+
const reachedGroupIDs = new Set(
|
|
118
|
+
allElements
|
|
119
|
+
.filter(
|
|
120
|
+
(element): element is HTMLInputElement => !! element.dataset.testarofocused
|
|
121
|
+
&& element.tagName === 'INPUT'
|
|
122
|
+
&& (element as HTMLInputElement).type === 'radio'
|
|
123
|
+
&& !! (element as HTMLInputElement).name
|
|
124
|
+
)
|
|
125
|
+
.map(getGroupID)
|
|
126
|
+
);
|
|
127
|
+
// Initialize the violation data.
|
|
128
|
+
const unreached: string[] = [];
|
|
129
|
+
const unexpected: string[] = [];
|
|
130
|
+
// For each element:
|
|
131
|
+
allElements.forEach(element => {
|
|
132
|
+
const wasFocusable = !! element.dataset.testarofocusable;
|
|
133
|
+
const wasFocused = !! element.dataset.testarofocused;
|
|
134
|
+
// If it was focusable but was never reached:
|
|
135
|
+
if (wasFocusable && ! wasFocused) {
|
|
136
|
+
const isGroupedRadio = element.tagName === 'INPUT'
|
|
137
|
+
&& (element as HTMLInputElement).type === 'radio'
|
|
138
|
+
&& (element as HTMLInputElement).name;
|
|
139
|
+
// If it is not a member of a radio-button group with another reached member:
|
|
140
|
+
if (! (isGroupedRadio && reachedGroupIDs.has(getGroupID(element as HTMLInputElement)))) {
|
|
141
|
+
// Add its XPath to the violation data.
|
|
142
|
+
unreached.push(window.getXPath(element) ?? '/html');
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
// Otherwise, if it was reached but was not a visible focusable element:
|
|
146
|
+
else if (wasFocused && ! wasFocusable) {
|
|
147
|
+
// Get whether it is a scrollable container, which some browsers make Tab-focusable.
|
|
148
|
+
const styleDec = window.getComputedStyle(element);
|
|
149
|
+
const isScroller = ['auto', 'scroll'].includes(styleDec.overflowY)
|
|
150
|
+
&& element.scrollHeight > element.clientHeight
|
|
151
|
+
|| ['auto', 'scroll'].includes(styleDec.overflowX)
|
|
152
|
+
&& element.scrollWidth > element.clientWidth;
|
|
153
|
+
// If it is not a scrollable container made focusable by the browser alone:
|
|
154
|
+
if (! (isScroller && element.tabIndex < 0)) {
|
|
155
|
+
// Add its XPath to the violation data.
|
|
156
|
+
unexpected.push(window.getXPath(element) ?? '/html');
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
// Remove the marker attributes from the element.
|
|
160
|
+
delete element.dataset.testarofocusable;
|
|
161
|
+
delete element.dataset.testarofocused;
|
|
162
|
+
});
|
|
163
|
+
return {unreached, unexpected};
|
|
164
|
+
});
|
|
165
|
+
const count = unreached.length + unexpected.length;
|
|
166
|
+
const data = {
|
|
167
|
+
focusableCount,
|
|
168
|
+
tabFocused,
|
|
169
|
+
discrepancy: tabFocused - focusableCount,
|
|
170
|
+
unreachedCount: unreached.length,
|
|
171
|
+
unexpectedCount: unexpected.length
|
|
172
|
+
};
|
|
173
|
+
// Initialize the standard instances.
|
|
174
|
+
const standardInstances: StandardInstance[] = [];
|
|
175
|
+
// If itemization is required:
|
|
176
|
+
if (withItems) {
|
|
177
|
+
// For each element that was focusable but was never reached:
|
|
178
|
+
unreached.forEach(xPath => {
|
|
179
|
+
// Add an instance to the standard instances.
|
|
180
|
+
standardInstances.push({
|
|
181
|
+
ruleID: 'focAll',
|
|
182
|
+
what: 'Element is focusable but was not reached by Tab navigation',
|
|
183
|
+
ordinalSeverity: 2,
|
|
184
|
+
count: 1,
|
|
185
|
+
catalogIndex: getXPathCatalogIndex(report, xPath)
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
// For each element that was reached but was not a visible focusable element:
|
|
189
|
+
unexpected.forEach(xPath => {
|
|
190
|
+
// Add an instance to the standard instances.
|
|
191
|
+
standardInstances.push({
|
|
192
|
+
ruleID: 'focAll',
|
|
193
|
+
what: 'Element was reached by Tab navigation but was not a visible focusable element',
|
|
194
|
+
ordinalSeverity: 2,
|
|
195
|
+
count: 1,
|
|
196
|
+
catalogIndex: getXPathCatalogIndex(report, xPath)
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
// Otherwise, if there were any violations:
|
|
201
|
+
else if (count) {
|
|
202
|
+
// Add a summary instance to the standard instances.
|
|
203
|
+
standardInstances.push({
|
|
204
|
+
ruleID: 'focAll',
|
|
205
|
+
what: 'Some focusable elements are not Tab-focusable or vice versa',
|
|
206
|
+
ordinalSeverity: 2,
|
|
207
|
+
count,
|
|
208
|
+
catalogIndex: getXPathCatalogIndex(report, '/html/body')
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
// Return the result.
|
|
212
|
+
return {
|
|
213
|
+
data,
|
|
214
|
+
totals: [0, 0, count, 0],
|
|
215
|
+
standardInstances
|
|
216
|
+
};
|
|
217
|
+
};
|