a11y-loop 0.2.3 → 0.2.5
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/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +22 -7
- package/package.json +1 -1
- package/skill/a11y-loop/SKILL.md +1 -1
- package/src/lib/browser-utils.js +57 -5
- package/src/lib/checks/dialog.js +150 -19
- package/src/lib/checks/keyboard.js +6 -4
|
@@ -6,13 +6,13 @@
|
|
|
6
6
|
},
|
|
7
7
|
"metadata": {
|
|
8
8
|
"description": "Makes AI coding agents decide accessibility while the work is still being planned and write accessible UI by default, then verify it in a real browser across the states they built — and say exactly what could not be checked. Ships the a11y-loop Agent Skill, a /a11y-plan command, and a plan-mode gate that declines a UI plan with no accessibility content in it.",
|
|
9
|
-
"version": "0.2.
|
|
9
|
+
"version": "0.2.5"
|
|
10
10
|
},
|
|
11
11
|
"plugins": [
|
|
12
12
|
{
|
|
13
13
|
"name": "a11y-loop",
|
|
14
14
|
"source": "./",
|
|
15
|
-
"version": "0.2.
|
|
15
|
+
"version": "0.2.5",
|
|
16
16
|
"description": "Accessibility in the plan, not after the pull request. Adds §0 plan rules (conformance target, per-component criteria, the product decisions that foreclose accessibility, color tokens before components), standing WCAG 2.2 AA generation rules, and a PreToolUse hook on ExitPlanMode that declines a UI-touching plan with no accessibility content — once, handing back the section to fill in. Verification is the a11y-loop CLI: axe-core in Chromium across default, dark, forced-colors, reduced-motion and 320px passes.",
|
|
17
17
|
"author": {
|
|
18
18
|
"name": "Chan Meng",
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
|
|
3
3
|
"name": "a11y-loop",
|
|
4
4
|
"displayName": "a11y-loop",
|
|
5
|
-
"version": "0.2.
|
|
5
|
+
"version": "0.2.5",
|
|
6
6
|
"description": "Hard-gates plan mode: a plan that changes UI has to say what it decided about accessibility before it can be approved. Ships the a11y-loop skill and a /a11y-plan command alongside it.",
|
|
7
7
|
"author": {
|
|
8
8
|
"name": "Chan Meng",
|
package/README.md
CHANGED
|
@@ -80,6 +80,13 @@ a11y-loop is two things working together:
|
|
|
80
80
|
2. **A Node CLI** (`a11y-loop audit` / `contrast --fix` / `diff`) that verifies the result in a
|
|
81
81
|
real browser, feeds failures back to the agent to fix, and re-audits until the loop converges.
|
|
82
82
|
|
|
83
|
+
**About the name.** `a11y` is the standard numeronym for *accessibility*: the first letter, the last
|
|
84
|
+
letter, and the 11 letters between them replaced by their count — the same construction as `i18n`
|
|
85
|
+
(internationalization) and `k8s` (Kubernetes). Spelled out it reads as "ally", which is the pun the
|
|
86
|
+
accessibility community has always liked, but `11` is the conventional form. `loop` is the other half
|
|
87
|
+
of the method: the CLI is not a one-shot audit but a write → audit → fix → re-audit cycle that runs
|
|
88
|
+
until the report converges.
|
|
89
|
+
|
|
83
90
|
The skill follows the open [Agent Skills](https://agentskills.io/specification) standard, so the
|
|
84
91
|
same `SKILL.md` is portable across 40+ clients — Claude Code, Cursor, GitHub Copilot, Codex,
|
|
85
92
|
Gemini CLI, and more — not just one vendor's agent. The CLI runs the same checks standalone in CI
|
|
@@ -152,7 +159,9 @@ graph LR
|
|
|
152
159
|
`2` **Checks axe-core can't run** — tab order, focus visibility (including focus-ring contrast),
|
|
153
160
|
dialog focus trap / Escape / focus-return, target size (24×24 CSS px, SC 2.5.8), reduced-motion
|
|
154
161
|
effectiveness, ambiguous link text, div-as-button, and positive `tabindex`. These sit alongside
|
|
155
|
-
axe-core, not instead of it.
|
|
162
|
+
axe-core, not instead of it. The dialog and tab-order checks understand **portalled** dialogs —
|
|
163
|
+
a `<div role="dialog">` in a portal, the way Base UI, Radix, Headless UI and anything on floating-ui
|
|
164
|
+
ships one — not only a native `<dialog>` opened with `showModal()`.
|
|
156
165
|
|
|
157
166
|
`3` **State coverage, not just page-load** — `--interact` drives the states an agent just built
|
|
158
167
|
(a modal opened, a menu expanded, a form in its error state) through the same five passes, because
|
|
@@ -263,9 +272,9 @@ illustration of the effect's shape, not a controlled study or a precise effect s
|
|
|
263
272
|
- **SARIF conversion:** [axe-sarif-converter](https://github.com/microsoft/axe-sarif-converter)
|
|
264
273
|
- **Agent integration:** the open [Agent Skills](https://agentskills.io/specification) standard —
|
|
265
274
|
`SKILL.md` + `references/`, no client-proprietary format
|
|
266
|
-
- **Tests:** the built-in `node --test` runner,
|
|
267
|
-
including
|
|
268
|
-
gradient regression test
|
|
275
|
+
- **Tests:** the built-in `node --test` runner, 378 tests across unit and integration suites,
|
|
276
|
+
including a 19-fixture matrix of seeded violations and correct-behaviour regression guards, a
|
|
277
|
+
demo end-to-end run, and a forced-colors gradient regression test
|
|
269
278
|
|
|
270
279
|
## 🏗️ Architecture
|
|
271
280
|
|
|
@@ -550,11 +559,17 @@ npm run test:integration # integration tests only (drives real Chromium)
|
|
|
550
559
|
The optional plugin layer is plain Node with no dependencies. Exercise the plan gate directly:
|
|
551
560
|
|
|
552
561
|
```bash
|
|
553
|
-
echo '{"session_id":"s1","tool_name":"ExitPlanMode","tool_input":{"plan":"
|
|
554
|
-
| node hooks/plan-gate.mjs
|
|
555
|
-
claude plugin validate . --strict
|
|
562
|
+
echo '{"session_id":"s1","tool_name":"ExitPlanMode","tool_input":{"plan":"Add a settings page in React: a modal dialog, a members table with row actions, tabs, and a dark mode toggle styled with Tailwind CSS."}}' \
|
|
563
|
+
| node hooks/plan-gate.mjs # expect a "deny" verdict
|
|
564
|
+
claude plugin validate . --strict # shape only — see AGENTS.md on why this is not proof
|
|
556
565
|
```
|
|
557
566
|
|
|
567
|
+
Silence and exit 0 is the gate's normal answer, so an example that prints nothing proves nothing:
|
|
568
|
+
the plan has to clear 40 characters and be unambiguously about UI, or you are testing the pass path
|
|
569
|
+
by accident. Re-running the *same* plan returns `defer` rather than `deny` — the loop guard spends
|
|
570
|
+
each plan's hash once, deliberately — so prefix with `CLAUDE_PLUGIN_DATA="$(mktemp -d)"` for a clean
|
|
571
|
+
verdict. See [`AGENTS.md`](AGENTS.md) for the rest of the plugin-layer gotchas.
|
|
572
|
+
|
|
558
573
|
See [`AGENTS.md`](AGENTS.md) for AI-agent-oriented project conventions, the fixture-manifest
|
|
559
574
|
testing pattern, the loop discipline expected when touching `demo/` or other UI, and the rule that
|
|
560
575
|
keeps `skill/` free of any Claude-Code-specific mechanism.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "a11y-loop",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.5",
|
|
4
4
|
"description": "Makes AI coding agents decide accessibility while the work is still being planned and write accessible UI by default, then proves what it can prove with a real browser audit across the states they built — and tells you exactly what it could not check.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/skill/a11y-loop/SKILL.md
CHANGED
|
@@ -28,7 +28,7 @@ compatibility: >-
|
|
|
28
28
|
The generation and honesty rules apply with or without the CLI; every step
|
|
29
29
|
that says "audit" requires it.
|
|
30
30
|
metadata:
|
|
31
|
-
"a11y-loop/version": "0.2.
|
|
31
|
+
"a11y-loop/version": "0.2.5"
|
|
32
32
|
allowed-tools: 'Bash(a11y-loop *) Bash(npx a11y-loop *) Bash(node ${CLAUDE_SKILL_DIR}/../../src/cli.js *)'
|
|
33
33
|
---
|
|
34
34
|
|
package/src/lib/browser-utils.js
CHANGED
|
@@ -133,6 +133,10 @@ export const HELPERS_SOURCE = `
|
|
|
133
133
|
// this, every check that surveys "visible" elements would flood a report
|
|
134
134
|
// with the rest of the page once a modal is open — not a real defect,
|
|
135
135
|
// just this helper not knowing the platform already handled it.
|
|
136
|
+
//
|
|
137
|
+
// A PORTALLED dialog needs no equivalent clause here: it makes the rest of
|
|
138
|
+
// the page unreachable by marking it aria-hidden, which the check above
|
|
139
|
+
// already catches. modalDialogRoot() is what reads that shape.
|
|
136
140
|
var modal = document.querySelector(':modal');
|
|
137
141
|
if (modal && !modal.contains(el)) return false;
|
|
138
142
|
return true;
|
|
@@ -190,15 +194,62 @@ export const HELPERS_SOURCE = `
|
|
|
190
194
|
return 'rgb(255, 255, 255)';
|
|
191
195
|
}
|
|
192
196
|
|
|
197
|
+
/**
|
|
198
|
+
* Has the page been taken out of reach around this element? That is how a
|
|
199
|
+
* portalled dialog declares modality when it does not set aria-modal: the
|
|
200
|
+
* library marks the dialog's body-level SIBLINGS aria-hidden (or inert) and
|
|
201
|
+
* leaves its own portal alone. Base UI sets no aria-modal attribute at all
|
|
202
|
+
* and relies on this entirely, which is the currently recommended pattern.
|
|
203
|
+
*/
|
|
204
|
+
function outsideIsHidden(el) {
|
|
205
|
+
var top = el;
|
|
206
|
+
while (top.parentElement && top.parentElement !== document.body) top = top.parentElement;
|
|
207
|
+
return Array.prototype.some.call(document.body.children, function (sibling) {
|
|
208
|
+
if (sibling === top || sibling.contains(el)) return false;
|
|
209
|
+
if (!isRendered(sibling)) return false;
|
|
210
|
+
return sibling.getAttribute('aria-hidden') === 'true' || sibling.hasAttribute('inert');
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* The open modal dialog, or null. The :modal pseudo-class is the cheap
|
|
216
|
+
* answer and the right one for a native <dialog> opened with showModal() —
|
|
217
|
+
* but it is ONLY that. Every React popup library (Base UI, Radix, Headless
|
|
218
|
+
* UI, floating-ui) ships a portalled <div role="dialog"> instead, which
|
|
219
|
+
* :modal never matches, so anything relying on it alone silently treats an
|
|
220
|
+
* open sheet as though no dialog were there at all.
|
|
221
|
+
*
|
|
222
|
+
* The portalled shape is recognised by what it actually does: a visible
|
|
223
|
+
* dialog-role element that is itself exposed, and that has either declared
|
|
224
|
+
* aria-modal="true" or hidden the rest of the page around itself. The LAST
|
|
225
|
+
* such element in document order wins, because a portal appends to <body> —
|
|
226
|
+
* so the most recently opened dialog is the topmost one.
|
|
227
|
+
*/
|
|
228
|
+
function modalDialogRoot() {
|
|
229
|
+
var native = document.querySelector(':modal');
|
|
230
|
+
if (native) return native;
|
|
231
|
+
|
|
232
|
+
var open = Array.prototype.filter.call(
|
|
233
|
+
document.querySelectorAll('[role="dialog"], [role="alertdialog"], [aria-modal="true"]'),
|
|
234
|
+
function (el) {
|
|
235
|
+
if (el.getAttribute('aria-modal') === 'false') return false;
|
|
236
|
+
if (!isVisible(el)) return false;
|
|
237
|
+
return el.getAttribute('aria-modal') === 'true' || outsideIsHidden(el);
|
|
238
|
+
},
|
|
239
|
+
);
|
|
240
|
+
return open.length ? open[open.length - 1] : null;
|
|
241
|
+
}
|
|
242
|
+
|
|
193
243
|
/**
|
|
194
244
|
* Where a forward Tab walk should be scoped: inside the open modal dialog
|
|
195
|
-
* if there is one (everything outside it is
|
|
196
|
-
* otherwise the whole document. tabbable()
|
|
197
|
-
*
|
|
198
|
-
* this as the container rather than always
|
|
245
|
+
* if there is one (everything outside it is unreachable while it is open),
|
|
246
|
+
* otherwise the whole document. tabbable() has no idea a dialog is open —
|
|
247
|
+
* it reads display and visibility, not aria-hidden, and knows nothing about
|
|
248
|
+
* :modal — so callers must pass this as the container rather than always
|
|
249
|
+
* walking document.body.
|
|
199
250
|
*/
|
|
200
251
|
function tabbableRoot() {
|
|
201
|
-
return
|
|
252
|
+
return modalDialogRoot() || document.body;
|
|
202
253
|
}
|
|
203
254
|
|
|
204
255
|
/** The CSS path of whatever was last clicked, or null if nothing was. */
|
|
@@ -212,6 +263,7 @@ export const HELPERS_SOURCE = `
|
|
|
212
263
|
accessibleName: accessibleName,
|
|
213
264
|
isRendered: isRendered,
|
|
214
265
|
isVisible: isVisible,
|
|
266
|
+
modalDialogRoot: modalDialogRoot,
|
|
215
267
|
tabbableRoot: tabbableRoot,
|
|
216
268
|
lastClickSelector: lastClickSelector,
|
|
217
269
|
interactiveElements: interactiveElements,
|
package/src/lib/checks/dialog.js
CHANGED
|
@@ -16,6 +16,28 @@ import { makeFinding, SEVERITY } from '../finding.js';
|
|
|
16
16
|
/** Tab presses used to probe a focus trap. */
|
|
17
17
|
export const TRAP_PROBE_STEPS = 12;
|
|
18
18
|
|
|
19
|
+
/**
|
|
20
|
+
* How many consecutive presses may land outside the dialog on something no user
|
|
21
|
+
* can act on before it stops counting as a wrap and starts counting as an escape.
|
|
22
|
+
*
|
|
23
|
+
* Not a fudge factor — it is the length of a real wrap, measured. A portalled
|
|
24
|
+
* dialog wraps through a CHAIN: floating-ui (Base UI, Radix) renders a focus
|
|
25
|
+
* guard beside the floating element and another pair at the edges of <body>, and
|
|
26
|
+
* hands focus back an animation frame later, so a forward Tab off the end of the
|
|
27
|
+
* sheet goes guard → body → back inside. Observed on a live Base UI sheet, three
|
|
28
|
+
* runs: two of them spent two presses out (guard, then body) and one spent a
|
|
29
|
+
* single press. Allowing exactly one press, as this check used to, therefore
|
|
30
|
+
* reported a perfectly trapped dialog as broken about two runs in three — which
|
|
31
|
+
* is what made the row look flaky rather than wrong.
|
|
32
|
+
*
|
|
33
|
+
* It stays small on purpose, and it only applies AFTER focus has been inside the
|
|
34
|
+
* dialog at least once: presses spent outside before that are the probe finding
|
|
35
|
+
* its way in from wherever the earlier surveys left focus, not a wrap. Landing on
|
|
36
|
+
* a genuine, exposed element outside the dialog is an escape on the first press
|
|
37
|
+
* either way, whatever this bound says.
|
|
38
|
+
*/
|
|
39
|
+
export const MAX_WRAP_TICKS = 3;
|
|
40
|
+
|
|
19
41
|
/**
|
|
20
42
|
* @param {object} observation
|
|
21
43
|
* @param {string} observation.selector
|
|
@@ -196,6 +218,11 @@ export async function captureDialogInitialState(page, opts = {}) {
|
|
|
196
218
|
selector: helpers.cssPath(dialog),
|
|
197
219
|
html: helpers.shortHtml(dialog),
|
|
198
220
|
trigger,
|
|
221
|
+
// Was this dialog MODAL at the moment it opened? Recorded now because
|
|
222
|
+
// it is the only way to tell, later, between a dialog that never
|
|
223
|
+
// trapped anything and one whose modal treatment was torn down while
|
|
224
|
+
// the probe was running. See surveyDialog.
|
|
225
|
+
modal: helpers.modalDialogRoot() === dialog,
|
|
199
226
|
focusMovedIntoDialog: Boolean(active && dialog.contains(active)),
|
|
200
227
|
initialFocus: active ? helpers.cssPath(active) : null,
|
|
201
228
|
};
|
|
@@ -204,6 +231,26 @@ export async function captureDialogInitialState(page, opts = {}) {
|
|
|
204
231
|
);
|
|
205
232
|
}
|
|
206
233
|
|
|
234
|
+
/**
|
|
235
|
+
* Let the page finish reacting to a key before reading where focus went.
|
|
236
|
+
*
|
|
237
|
+
* A focus trap built on requestAnimationFrame — floating-ui's enqueueFocus, and
|
|
238
|
+
* so Base UI's and Radix's — hands focus back one frame after a guard receives
|
|
239
|
+
* it, and cancels a pending hand-back when another arrives. Synthetic Tab
|
|
240
|
+
* presses can be dispatched faster than a frame, which cancels the redirect over
|
|
241
|
+
* and over and leaves focus parked outside the dialog: the probe outruns the
|
|
242
|
+
* page and then reports the page for not keeping up. No keyboard user can press
|
|
243
|
+
* Tab twice inside one frame, so waiting two frames does not weaken the check —
|
|
244
|
+
* it stops it measuring itself.
|
|
245
|
+
*
|
|
246
|
+
* @param {import('playwright').Page} page
|
|
247
|
+
*/
|
|
248
|
+
async function settleFrames(page) {
|
|
249
|
+
await page.evaluate(
|
|
250
|
+
() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))),
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
|
|
207
254
|
/**
|
|
208
255
|
* Probe focus trap, Escape, and focus return, given a dialog snapshot already
|
|
209
256
|
* captured by `captureDialogInitialState`. This drives the keyboard and
|
|
@@ -217,22 +264,66 @@ export async function captureDialogInitialState(page, opts = {}) {
|
|
|
217
264
|
export async function surveyDialog(page, found) {
|
|
218
265
|
if (!found) return null;
|
|
219
266
|
|
|
267
|
+
// The probe asks exactly one question: with focus INSIDE this dialog, can Tab
|
|
268
|
+
// take it out? So it has to start inside. By the time it runs, three other
|
|
269
|
+
// surveys have each walked the page with real Tab presses and left focus
|
|
270
|
+
// wherever they happened to finish — and on a portalled dialog, focus sitting
|
|
271
|
+
// outside is itself enough for the library's focus-out handling to begin
|
|
272
|
+
// dismantling the modal treatment it put up. The probe would then walk a page
|
|
273
|
+
// that is no longer behind a modal and report a working trap as broken.
|
|
274
|
+
//
|
|
275
|
+
// Measured on a live Base UI sheet, ten runs: without this, four began outside
|
|
276
|
+
// the dialog and every one of those four reported the trap broken. With it,
|
|
277
|
+
// ten of ten began inside and none did.
|
|
278
|
+
const enteredDialog = await page.evaluate(
|
|
279
|
+
({ selector }) => {
|
|
280
|
+
const helpers = window.__a11yLoop;
|
|
281
|
+
const dialog = Array.from(document.querySelectorAll(selector)).find((el) =>
|
|
282
|
+
helpers.isVisible(el),
|
|
283
|
+
);
|
|
284
|
+
if (!dialog) return false;
|
|
285
|
+
if (dialog.contains(document.activeElement)) return true;
|
|
286
|
+
const stops = window.tabbable ? window.tabbable.tabbable(dialog) : [];
|
|
287
|
+
if (!stops.length) return false;
|
|
288
|
+
stops[0].focus();
|
|
289
|
+
return dialog.contains(document.activeElement);
|
|
290
|
+
},
|
|
291
|
+
{ selector: DIALOG_SELECTOR },
|
|
292
|
+
);
|
|
293
|
+
|
|
220
294
|
// Focus trap: Tab repeatedly and watch for focus leaving the dialog.
|
|
221
295
|
//
|
|
222
|
-
//
|
|
223
|
-
//
|
|
224
|
-
//
|
|
225
|
-
//
|
|
226
|
-
//
|
|
227
|
-
//
|
|
228
|
-
//
|
|
229
|
-
//
|
|
230
|
-
//
|
|
231
|
-
|
|
296
|
+
// Every modal dialog spends one tick outside itself when it wraps, and the
|
|
297
|
+
// shape of that tick depends on who implements the trap:
|
|
298
|
+
//
|
|
299
|
+
// - Real Chromium, for a genuinely native <dialog> opened with showModal(),
|
|
300
|
+
// transiently moves document.activeElement to document.body for exactly
|
|
301
|
+
// one Tab press when wrapping past the dialog's last (or before its first)
|
|
302
|
+
// focusable descendant, then redirects back inside on the very next press.
|
|
303
|
+
// Body itself stays inert throughout, so nothing is reachable there.
|
|
304
|
+
// - A portalled <div role="dialog"> — Base UI, Radix, Headless UI, anything
|
|
305
|
+
// on floating-ui — wraps through a focus guard instead: a focusable span
|
|
306
|
+
// outside the dialog, hidden from assistive technology, whose whole job is
|
|
307
|
+
// to catch the wrap and hand focus back. Nothing is reachable there either.
|
|
308
|
+
//
|
|
309
|
+
// Treating either as an escape flags a correctly trapped dialog as broken,
|
|
310
|
+
// and that is precisely what happened to portalled dialogs while this gate
|
|
311
|
+
// asked `:modal` — which matches only the native case. It is a real failure
|
|
312
|
+
// if focus lands outside the dialog on a genuine, exposed element, or if it
|
|
313
|
+
// does not come back within MAX_WRAP_TICKS presses: a portalled dialog wraps
|
|
314
|
+
// through a chain of those ticks, not a single one.
|
|
315
|
+
|
|
316
|
+
// A dialog focus cannot be placed inside is a dialog whose trap this check
|
|
317
|
+
// cannot judge, so it says so with null rather than guessing — dialogFindings
|
|
318
|
+
// reports only on an explicit false.
|
|
319
|
+
let focusTrapped = enteredDialog ? true : null;
|
|
232
320
|
let escapedTo = null;
|
|
233
|
-
let
|
|
234
|
-
|
|
321
|
+
let insideAtLeastOnce = false;
|
|
322
|
+
let consecutiveWrapTicks = 0;
|
|
323
|
+
let tornDownMidProbe = false;
|
|
324
|
+
for (let i = 0; enteredDialog && i < TRAP_PROBE_STEPS; i++) {
|
|
235
325
|
await page.keyboard.press('Tab');
|
|
326
|
+
await settleFrames(page);
|
|
236
327
|
const check = await page.evaluate(
|
|
237
328
|
({ selector }) => {
|
|
238
329
|
const helpers = window.__a11yLoop;
|
|
@@ -240,13 +331,31 @@ export async function surveyDialog(page, found) {
|
|
|
240
331
|
helpers.isVisible(el),
|
|
241
332
|
);
|
|
242
333
|
const active = document.activeElement;
|
|
243
|
-
if (!dialog || !active)
|
|
244
|
-
|
|
334
|
+
if (!dialog || !active) {
|
|
335
|
+
return { inside: false, transient: false, label: null, stillModalRoot: false };
|
|
336
|
+
}
|
|
337
|
+
if (dialog.contains(active)) {
|
|
338
|
+
return { inside: true, transient: false, label: null, stillModalRoot: true };
|
|
339
|
+
}
|
|
245
340
|
const isBodyOrRoot = active === document.body || active === document.documentElement;
|
|
246
|
-
|
|
341
|
+
// A focus guard is an EMPTY element that is not exposed to assistive
|
|
342
|
+
// technology — every library builds one the same way, as a bare
|
|
343
|
+
// aria-hidden span with nothing in it. Emptiness is what separates it
|
|
344
|
+
// from the page behind the dialog, which is also aria-hidden but is
|
|
345
|
+
// full of real content: focus landing THERE is a trap that failed, and
|
|
346
|
+
// must still be reported.
|
|
347
|
+
const isFocusGuard =
|
|
348
|
+
!isBodyOrRoot &&
|
|
349
|
+
!helpers.isVisible(active) &&
|
|
350
|
+
active.children.length === 0 &&
|
|
351
|
+
!(active.textContent || '').trim();
|
|
352
|
+
const stillModal =
|
|
353
|
+
(typeof dialog.matches === 'function' && dialog.matches(':modal')) ||
|
|
354
|
+
helpers.modalDialogRoot() === dialog;
|
|
247
355
|
return {
|
|
248
356
|
inside: false,
|
|
249
|
-
transient: isBodyOrRoot && stillModal,
|
|
357
|
+
transient: (isBodyOrRoot || isFocusGuard) && stillModal,
|
|
358
|
+
stillModalRoot: helpers.modalDialogRoot() === dialog,
|
|
250
359
|
label: isBodyOrRoot ? 'the browser UI / document root' : helpers.cssPath(active),
|
|
251
360
|
};
|
|
252
361
|
},
|
|
@@ -254,18 +363,40 @@ export async function surveyDialog(page, found) {
|
|
|
254
363
|
);
|
|
255
364
|
|
|
256
365
|
if (check.inside) {
|
|
257
|
-
|
|
366
|
+
insideAtLeastOnce = true;
|
|
367
|
+
consecutiveWrapTicks = 0;
|
|
258
368
|
continue;
|
|
259
369
|
}
|
|
260
|
-
if (check.transient && !
|
|
261
|
-
|
|
370
|
+
if (check.transient && (!insideAtLeastOnce || consecutiveWrapTicks < MAX_WRAP_TICKS)) {
|
|
371
|
+
if (insideAtLeastOnce) consecutiveWrapTicks += 1;
|
|
262
372
|
continue;
|
|
263
373
|
}
|
|
374
|
+
// The dialog was modal when it opened and is not any more: the page took
|
|
375
|
+
// the modal treatment down — the backdrop, the aria-hidden on everything
|
|
376
|
+
// behind it — while this probe was running, usually because the sheet is
|
|
377
|
+
// closing. Focus is free to walk the page because the page let it, not
|
|
378
|
+
// because a trap failed. There is nothing to judge here, so judge nothing:
|
|
379
|
+
// reporting a trap failure off a dialog that stopped being modal mid-probe
|
|
380
|
+
// is how a correctly trapped sheet ends up accused about one run in seven.
|
|
381
|
+
if (found.modal && !check.stillModalRoot) {
|
|
382
|
+
tornDownMidProbe = true;
|
|
383
|
+
break;
|
|
384
|
+
}
|
|
264
385
|
focusTrapped = false;
|
|
265
386
|
escapedTo = check.label;
|
|
266
387
|
break;
|
|
267
388
|
}
|
|
268
389
|
|
|
390
|
+
if (tornDownMidProbe) {
|
|
391
|
+
return {
|
|
392
|
+
...found,
|
|
393
|
+
focusTrapped: null,
|
|
394
|
+
escapedTo: null,
|
|
395
|
+
escapeClosed: null,
|
|
396
|
+
focusReturnedToTrigger: null,
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
|
|
269
400
|
await page.keyboard.press('Escape');
|
|
270
401
|
await page.waitForTimeout(150);
|
|
271
402
|
|
|
@@ -155,10 +155,12 @@ export function keyboardFindings(survey, ctx) {
|
|
|
155
155
|
* matters whenever a state setup function has clicked something, which is most of
|
|
156
156
|
* the time. A focused sentinel at the start of the body fixes the origin.
|
|
157
157
|
*
|
|
158
|
-
* "Top of the document" is not always `document.body`: if a
|
|
159
|
-
*
|
|
160
|
-
*
|
|
161
|
-
*
|
|
158
|
+
* "Top of the document" is not always `document.body`: if a modal dialog is
|
|
159
|
+
* open, everything outside it is out of reach, so a sentinel inserted into
|
|
160
|
+
* `body` is either unfocusable (a native `<dialog>`) or sits in the very region
|
|
161
|
+
* the dialog's focus trap exists to keep focus out of (a portalled one — as far
|
|
162
|
+
* from the sheet as the document allows). The sentinel goes into whichever
|
|
163
|
+
* element `tabbableRoot()` says is actually reachable right now.
|
|
162
164
|
*
|
|
163
165
|
* @param {import('playwright').Page} page
|
|
164
166
|
*/
|