@masup9/a11y-audit 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +34 -0
- package/README.ja.md +128 -0
- package/README.md +130 -0
- package/dist/constants.d.ts +88 -0
- package/dist/constants.js +184 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +13 -0
- package/dist/playwright/index.d.ts +19 -0
- package/dist/playwright/index.js +19 -0
- package/dist/playwright/runAxeAudit.d.ts +28 -0
- package/dist/playwright/runAxeAudit.js +89 -0
- package/dist/playwright/runFocusIndicatorCheck.d.ts +30 -0
- package/dist/playwright/runFocusIndicatorCheck.js +740 -0
- package/dist/playwright/runReflowCheck.d.ts +36 -0
- package/dist/playwright/runReflowCheck.js +68 -0
- package/dist/playwright/runTargetSizeCheck.d.ts +35 -0
- package/dist/playwright/runTargetSizeCheck.js +389 -0
- package/dist/schemas/index.d.ts +34 -0
- package/dist/schemas/index.js +245 -0
- package/dist/test-entries/axe-audit.d.ts +13 -0
- package/dist/test-entries/axe-audit.js +20 -0
- package/dist/test-entries/focus-indicator-check.d.ts +9 -0
- package/dist/test-entries/focus-indicator-check.js +13 -0
- package/dist/test-entries/reflow-check.d.ts +9 -0
- package/dist/test-entries/reflow-check.js +21 -0
- package/dist/test-entries/target-size-check.d.ts +8 -0
- package/dist/test-entries/target-size-check.js +15 -0
- package/dist/types.d.ts +215 -0
- package/dist/types.js +4 -0
- package/dist/utils/annotations.d.ts +67 -0
- package/dist/utils/annotations.js +110 -0
- package/dist/utils/layout.d.ts +23 -0
- package/dist/utils/layout.js +144 -0
- package/dist/utils/test-harness.d.ts +76 -0
- package/dist/utils/test-harness.js +119 -0
- package/package.json +92 -0
|
@@ -0,0 +1,740 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Focus Indicator Visibility Check
|
|
3
|
+
*
|
|
4
|
+
* WCAG 2.4.7 (Focus Visible) / 2.4.12 (Focus Not Obscured) / 3.2.1 (On Focus)
|
|
5
|
+
*
|
|
6
|
+
* Unlike the other checks, this one owns its `BrowserContext`: it tabs through
|
|
7
|
+
* every focusable element and, when focus triggers a navigation (3.2.1), it
|
|
8
|
+
* restarts in a fresh context with the offending element skipped. For that
|
|
9
|
+
* reason it accepts a `browser` (not a `page`) and navigates internally.
|
|
10
|
+
*
|
|
11
|
+
* The context this function creates is closed before it returns.
|
|
12
|
+
*/
|
|
13
|
+
import { FOCUSABLE_SELECTOR, FOCUS_STYLE_PROPERTIES, EXTRA_TAB_ITERATIONS, DEFAULT_FOCUS_RESULT_FILE, DEFAULT_FOCUS_SCREENSHOT_FILE, FOCUS_OBSCURED_MIN_OVERLAP_RATIO, FOCUS_OBSCURED_MIN_OVERLAP_PX, FOCUS_OBSCURED_EXCLUDE_SELECTORS, } from '../constants.js';
|
|
14
|
+
import { resolveOutputPath, resolveScreenshotPath, saveAuditResult, takeAuditScreenshot, requireTargetUrl, logAuditHeader, logOutputPaths, } from '../utils/test-harness.js';
|
|
15
|
+
// =============================================================================
|
|
16
|
+
// Browser-injected styles for marking elements
|
|
17
|
+
// =============================================================================
|
|
18
|
+
// Overlay-based annotation styles (no content DOM modification)
|
|
19
|
+
const WARNING_STYLES = `
|
|
20
|
+
#focus-audit-overlay {
|
|
21
|
+
position: absolute;
|
|
22
|
+
top: 0;
|
|
23
|
+
left: 0;
|
|
24
|
+
width: 100%;
|
|
25
|
+
height: 100%;
|
|
26
|
+
pointer-events: none;
|
|
27
|
+
z-index: 99999;
|
|
28
|
+
}
|
|
29
|
+
.focus-audit-box {
|
|
30
|
+
position: absolute;
|
|
31
|
+
border: 3px solid #dc2626;
|
|
32
|
+
box-sizing: border-box;
|
|
33
|
+
pointer-events: none;
|
|
34
|
+
}
|
|
35
|
+
.focus-audit-box.navigation-violation {
|
|
36
|
+
border-color: #7c3aed;
|
|
37
|
+
}
|
|
38
|
+
.focus-audit-box.has-focus-style {
|
|
39
|
+
border-color: #16a34a;
|
|
40
|
+
}
|
|
41
|
+
.focus-audit-label {
|
|
42
|
+
position: absolute;
|
|
43
|
+
top: -22px;
|
|
44
|
+
left: -3px;
|
|
45
|
+
background: #dc2626;
|
|
46
|
+
color: white;
|
|
47
|
+
font-size: 11px;
|
|
48
|
+
font-weight: bold;
|
|
49
|
+
padding: 2px 6px;
|
|
50
|
+
border-radius: 3px;
|
|
51
|
+
white-space: nowrap;
|
|
52
|
+
font-family: system-ui, sans-serif;
|
|
53
|
+
}
|
|
54
|
+
.focus-audit-box.navigation-violation .focus-audit-label {
|
|
55
|
+
background: #7c3aed;
|
|
56
|
+
}
|
|
57
|
+
.focus-audit-box.has-focus-style .focus-audit-label {
|
|
58
|
+
background: #16a34a;
|
|
59
|
+
}
|
|
60
|
+
.focus-audit-box.focus-obscured {
|
|
61
|
+
border-color: #ea580c;
|
|
62
|
+
border-style: dashed;
|
|
63
|
+
}
|
|
64
|
+
.focus-audit-box.focus-obscured .focus-audit-label {
|
|
65
|
+
background: #ea580c;
|
|
66
|
+
}
|
|
67
|
+
`;
|
|
68
|
+
// Maximum retry attempts to avoid infinite loops
|
|
69
|
+
const MAX_RETRIES = 5;
|
|
70
|
+
/**
|
|
71
|
+
* Run the focus indicator check, write the result JSON (and optionally a
|
|
72
|
+
* screenshot), and return the parsed result.
|
|
73
|
+
*/
|
|
74
|
+
export async function runFocusIndicatorCheck(options) {
|
|
75
|
+
const { browser, targetUrl: targetUrlOption, screenshot = false, contextOptions, ...location } = options;
|
|
76
|
+
const targetUrl = requireTargetUrl(targetUrlOption);
|
|
77
|
+
const resolvedResultPath = resolveOutputPath({
|
|
78
|
+
...location,
|
|
79
|
+
defaultFile: DEFAULT_FOCUS_RESULT_FILE,
|
|
80
|
+
});
|
|
81
|
+
const resolvedScreenshotPath = resolveScreenshotPath(resolvedResultPath, DEFAULT_FOCUS_SCREENSHOT_FILE);
|
|
82
|
+
const onFocusViolations = [];
|
|
83
|
+
const focusObscuredIssues = [];
|
|
84
|
+
const skipSelectors = [];
|
|
85
|
+
let finalFocusHistory = [];
|
|
86
|
+
let retryCount = 0;
|
|
87
|
+
let finalPage = null;
|
|
88
|
+
// The context for the current attempt. Held outside the loop so it is always
|
|
89
|
+
// closed in `finally`, including if an attempt throws mid-way.
|
|
90
|
+
let context = null;
|
|
91
|
+
try {
|
|
92
|
+
// Retry loop - restart test when navigation violation is detected
|
|
93
|
+
while (retryCount < MAX_RETRIES) {
|
|
94
|
+
// Create a fresh context for each attempt to reset init scripts
|
|
95
|
+
context = await browser.newContext(contextOptions);
|
|
96
|
+
const page = await context.newPage();
|
|
97
|
+
const focusHistory = [];
|
|
98
|
+
let lastFocusedElement = null;
|
|
99
|
+
let navigationDetected = false;
|
|
100
|
+
// Expose function to receive focus reports from browser context
|
|
101
|
+
await page.exposeFunction('reportFocus', (data) => {
|
|
102
|
+
focusHistory.push(data);
|
|
103
|
+
lastFocusedElement = {
|
|
104
|
+
tag: data.tag,
|
|
105
|
+
role: data.role,
|
|
106
|
+
name: data.name,
|
|
107
|
+
selector: data.selector ||
|
|
108
|
+
`${data.tag.toLowerCase()}:nth-of-type(${data.id + 1})`,
|
|
109
|
+
};
|
|
110
|
+
});
|
|
111
|
+
// Expose function to receive focus obscured reports (WCAG 2.4.12)
|
|
112
|
+
await page.exposeFunction('reportFocusObscured', (data) => {
|
|
113
|
+
focusObscuredIssues.push(data);
|
|
114
|
+
});
|
|
115
|
+
// Inject focus tracking script with current skip list
|
|
116
|
+
await page.addInitScript(createFocusTrackerScript, {
|
|
117
|
+
focusableSelector: FOCUSABLE_SELECTOR,
|
|
118
|
+
styleProperties: [...FOCUS_STYLE_PROPERTIES],
|
|
119
|
+
warningStyles: WARNING_STYLES,
|
|
120
|
+
skipSelectors: [...skipSelectors],
|
|
121
|
+
obscuredConfig: {
|
|
122
|
+
minOverlapRatio: FOCUS_OBSCURED_MIN_OVERLAP_RATIO,
|
|
123
|
+
minOverlapPx: FOCUS_OBSCURED_MIN_OVERLAP_PX,
|
|
124
|
+
excludeSelectors: [...FOCUS_OBSCURED_EXCLUDE_SELECTORS],
|
|
125
|
+
},
|
|
126
|
+
});
|
|
127
|
+
// Navigate to target page
|
|
128
|
+
await page.goto(targetUrl, { waitUntil: 'networkidle' });
|
|
129
|
+
// Initialize focus tracker and get element count
|
|
130
|
+
const count = await page.evaluate(() => window.initFocusTracker());
|
|
131
|
+
// Mark elements that caused navigation violations (from previous attempts)
|
|
132
|
+
if (skipSelectors.length > 0) {
|
|
133
|
+
await page.evaluate((selectors) => {
|
|
134
|
+
selectors.forEach((sel) => {
|
|
135
|
+
const el = document.querySelector(sel);
|
|
136
|
+
if (el) {
|
|
137
|
+
el.setAttribute('data-focus-navigation-violation', '');
|
|
138
|
+
el.tabIndex = -1; // Remove from tab order
|
|
139
|
+
// Add annotation box to overlay (uses function from initFocusTracker)
|
|
140
|
+
const overlay = document.getElementById('focus-audit-overlay');
|
|
141
|
+
if (overlay) {
|
|
142
|
+
const rect = el.getBoundingClientRect();
|
|
143
|
+
const box = document.createElement('div');
|
|
144
|
+
box.className = 'focus-audit-box navigation-violation';
|
|
145
|
+
box.style.left = `${rect.left + window.scrollX}px`;
|
|
146
|
+
box.style.top = `${rect.top + window.scrollY}px`;
|
|
147
|
+
box.style.width = `${rect.width}px`;
|
|
148
|
+
box.style.height = `${rect.height}px`;
|
|
149
|
+
const labelEl = document.createElement('span');
|
|
150
|
+
labelEl.className = 'focus-audit-label';
|
|
151
|
+
labelEl.textContent = '⚠ 3.2.1 Navigation on Focus';
|
|
152
|
+
box.appendChild(labelEl);
|
|
153
|
+
overlay.appendChild(box);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
}, skipSelectors);
|
|
158
|
+
}
|
|
159
|
+
// Tab through all elements with navigation detection
|
|
160
|
+
for (let i = 0; i < count + EXTRA_TAB_ITERATIONS; i++) {
|
|
161
|
+
const urlBeforeTab = page.url();
|
|
162
|
+
await page.keyboard.press('Tab');
|
|
163
|
+
// Get currently focused element IMMEDIATELY after Tab
|
|
164
|
+
let currentFocusedElement = null;
|
|
165
|
+
try {
|
|
166
|
+
currentFocusedElement = await page.evaluate(() => {
|
|
167
|
+
const el = document.activeElement;
|
|
168
|
+
if (!el || el === document.body)
|
|
169
|
+
return null;
|
|
170
|
+
const getSelector = (element) => {
|
|
171
|
+
if (element.id)
|
|
172
|
+
return `#${element.id}`;
|
|
173
|
+
const tag = element.tagName.toLowerCase();
|
|
174
|
+
const parent = element.parentElement;
|
|
175
|
+
if (!parent)
|
|
176
|
+
return tag;
|
|
177
|
+
const siblings = [...parent.children].filter((c) => c.tagName === element.tagName);
|
|
178
|
+
if (siblings.length === 1)
|
|
179
|
+
return `${getSelector(parent)} > ${tag}`;
|
|
180
|
+
const index = siblings.indexOf(element) + 1;
|
|
181
|
+
return `${getSelector(parent)} > ${tag}:nth-of-type(${index})`;
|
|
182
|
+
};
|
|
183
|
+
return {
|
|
184
|
+
tag: el.tagName,
|
|
185
|
+
role: el.getAttribute('role'),
|
|
186
|
+
name: el.getAttribute('aria-label') ||
|
|
187
|
+
el.textContent?.slice(0, 30) ||
|
|
188
|
+
'',
|
|
189
|
+
selector: getSelector(el),
|
|
190
|
+
};
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
catch {
|
|
194
|
+
// Page might have navigated, use lastFocusedElement as fallback
|
|
195
|
+
currentFocusedElement = lastFocusedElement;
|
|
196
|
+
}
|
|
197
|
+
// Small wait to allow any navigation to start
|
|
198
|
+
await page.waitForTimeout(50);
|
|
199
|
+
// Check if navigation occurred
|
|
200
|
+
const urlAfterTab = page.url();
|
|
201
|
+
if (urlAfterTab !== urlBeforeTab) {
|
|
202
|
+
// 3.2.1 violation detected!
|
|
203
|
+
navigationDetected = true;
|
|
204
|
+
const culprit = currentFocusedElement || lastFocusedElement;
|
|
205
|
+
if (culprit && !skipSelectors.includes(culprit.selector)) {
|
|
206
|
+
onFocusViolations.push({
|
|
207
|
+
element: culprit,
|
|
208
|
+
fromUrl: urlBeforeTab,
|
|
209
|
+
toUrl: urlAfterTab,
|
|
210
|
+
changeType: 'navigation',
|
|
211
|
+
});
|
|
212
|
+
skipSelectors.push(culprit.selector);
|
|
213
|
+
console.warn(`\n⚠️ WCAG 3.2.1 Violation: Focus on element caused navigation!`);
|
|
214
|
+
console.warn(` Element: <${culprit.tag}> "${culprit.name}"`);
|
|
215
|
+
console.warn(` Selector: ${culprit.selector}`);
|
|
216
|
+
console.warn(` From: ${urlBeforeTab}`);
|
|
217
|
+
console.warn(` To: ${urlAfterTab}`);
|
|
218
|
+
console.warn(` Restarting test with this element skipped...`);
|
|
219
|
+
}
|
|
220
|
+
break;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
if (!navigationDetected) {
|
|
224
|
+
// Test completed successfully without navigation
|
|
225
|
+
finalFocusHistory = focusHistory;
|
|
226
|
+
finalPage = page;
|
|
227
|
+
break;
|
|
228
|
+
}
|
|
229
|
+
retryCount++;
|
|
230
|
+
if (retryCount >= MAX_RETRIES) {
|
|
231
|
+
console.warn(`\n⚠️ Max retries (${MAX_RETRIES}) reached. Some elements may not have been tested.`);
|
|
232
|
+
// Keep this page with its annotations for the screenshot
|
|
233
|
+
finalFocusHistory = focusHistory;
|
|
234
|
+
finalPage = page;
|
|
235
|
+
break;
|
|
236
|
+
}
|
|
237
|
+
// Close context before retry (only if we're going to retry)
|
|
238
|
+
await context.close();
|
|
239
|
+
context = null;
|
|
240
|
+
}
|
|
241
|
+
// Ensure finalPage is set (should always be set by this point)
|
|
242
|
+
if (!finalPage) {
|
|
243
|
+
throw new Error('No page available for screenshot');
|
|
244
|
+
}
|
|
245
|
+
// Report results
|
|
246
|
+
const elementsWithoutFocusStyle = finalFocusHistory.filter((f) => !f.hasFocusStyle);
|
|
247
|
+
if (elementsWithoutFocusStyle.length > 0) {
|
|
248
|
+
console.warn('Elements without visible focus indicator:', elementsWithoutFocusStyle);
|
|
249
|
+
}
|
|
250
|
+
// Build result
|
|
251
|
+
const result = {
|
|
252
|
+
url: finalPage.url(),
|
|
253
|
+
totalFocusableElements: finalFocusHistory.length,
|
|
254
|
+
elementsWithFocusStyle: finalFocusHistory.length - elementsWithoutFocusStyle.length,
|
|
255
|
+
elementsWithoutFocusStyle: elementsWithoutFocusStyle.length,
|
|
256
|
+
issues: elementsWithoutFocusStyle.map((el) => ({
|
|
257
|
+
tag: el.tag,
|
|
258
|
+
role: el.role,
|
|
259
|
+
name: el.name,
|
|
260
|
+
})),
|
|
261
|
+
onFocusViolations,
|
|
262
|
+
focusObscuredIssues,
|
|
263
|
+
elementsWithObscuredFocus: focusObscuredIssues.length,
|
|
264
|
+
allElements: finalFocusHistory,
|
|
265
|
+
interrupted: false,
|
|
266
|
+
screenshotPath: screenshot ? resolvedScreenshotPath : '',
|
|
267
|
+
};
|
|
268
|
+
// Output results
|
|
269
|
+
logAuditHeader('Focus Indicator Check Results', 'WCAG 2.4.7 / 2.4.12 / 3.2.1', result.url);
|
|
270
|
+
console.log(`Total focusable elements: ${result.totalFocusableElements}`);
|
|
271
|
+
console.log(`Elements with focus style: ${result.elementsWithFocusStyle}`);
|
|
272
|
+
console.log(`Elements WITHOUT focus style: ${result.elementsWithoutFocusStyle}`);
|
|
273
|
+
console.log(`Elements with OBSCURED focus: ${result.elementsWithObscuredFocus}`);
|
|
274
|
+
if (retryCount > 0) {
|
|
275
|
+
console.log(`\nTest restarted ${retryCount} time(s) due to navigation violations`);
|
|
276
|
+
}
|
|
277
|
+
// WCAG 2.4.7 summary
|
|
278
|
+
if (elementsWithoutFocusStyle.length > 0) {
|
|
279
|
+
console.log('\n--- WCAG 2.4.7: Elements Missing Focus Indicator ---');
|
|
280
|
+
elementsWithoutFocusStyle.forEach((el, i) => {
|
|
281
|
+
console.log(` ${i + 1}. <${el.tag}> "${el.name}" (role: ${el.role || 'none'})`);
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
// WCAG 2.4.12 summary
|
|
285
|
+
if (focusObscuredIssues.length > 0) {
|
|
286
|
+
console.log('\n--- WCAG 2.4.12: Focus Obscured by Fixed/Sticky Elements ---');
|
|
287
|
+
focusObscuredIssues.forEach((issue, i) => {
|
|
288
|
+
console.log(` ${i + 1}. <${issue.element.tag}> "${issue.element.name}"`);
|
|
289
|
+
console.log(` Selector: ${issue.element.selector}`);
|
|
290
|
+
console.log(` Obscured ratio: ${(issue.obscuredRatio * 100).toFixed(1)}%`);
|
|
291
|
+
issue.overlaps.forEach((overlap) => {
|
|
292
|
+
console.log(` Obscured by: <${overlap.obscuredBy.tag}> "${overlap.obscuredBy.name}"`);
|
|
293
|
+
});
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
// WCAG 3.2.1 summary
|
|
297
|
+
if (onFocusViolations.length > 0) {
|
|
298
|
+
console.log('\n--- WCAG 3.2.1: Focus Triggered Context Change ---');
|
|
299
|
+
onFocusViolations.forEach((v, i) => {
|
|
300
|
+
console.log(` ${i + 1}. <${v.element.tag}> "${v.element.name}"`);
|
|
301
|
+
console.log(` Selector: ${v.element.selector}`);
|
|
302
|
+
console.log(` Navigated to: ${v.toUrl}`);
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
let writtenScreenshotPath;
|
|
306
|
+
if (screenshot) {
|
|
307
|
+
writtenScreenshotPath = await takeAuditScreenshot(finalPage, {
|
|
308
|
+
path: resolvedScreenshotPath,
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
const writtenResultPath = saveAuditResult(result, {
|
|
312
|
+
...location,
|
|
313
|
+
defaultFile: DEFAULT_FOCUS_RESULT_FILE,
|
|
314
|
+
});
|
|
315
|
+
logOutputPaths(writtenResultPath, writtenScreenshotPath);
|
|
316
|
+
return result;
|
|
317
|
+
}
|
|
318
|
+
finally {
|
|
319
|
+
// Close the context for the final/in-flight attempt. Contexts from earlier
|
|
320
|
+
// retries are already closed inside the loop; this also covers the case
|
|
321
|
+
// where an attempt throws mid-way.
|
|
322
|
+
if (context) {
|
|
323
|
+
await context.close();
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
// =============================================================================
|
|
328
|
+
// Browser-injected script factory
|
|
329
|
+
// =============================================================================
|
|
330
|
+
function createFocusTrackerScript(args) {
|
|
331
|
+
const { focusableSelector, styleProperties, warningStyles, skipSelectors, obscuredConfig } = args;
|
|
332
|
+
// Add warning styles
|
|
333
|
+
const styleSheet = new CSSStyleSheet();
|
|
334
|
+
document.adoptedStyleSheets = [...document.adoptedStyleSheets, styleSheet];
|
|
335
|
+
warningStyles
|
|
336
|
+
.split('}')
|
|
337
|
+
.filter((r) => r.trim())
|
|
338
|
+
.forEach((rule, i) => {
|
|
339
|
+
try {
|
|
340
|
+
styleSheet.insertRule(rule + '}', i);
|
|
341
|
+
}
|
|
342
|
+
catch {
|
|
343
|
+
// Ignore invalid rules
|
|
344
|
+
}
|
|
345
|
+
});
|
|
346
|
+
const baseStyles = new Map();
|
|
347
|
+
const elementSelectors = new Map();
|
|
348
|
+
let focusId = 0;
|
|
349
|
+
/**
|
|
350
|
+
* Generate a CSS selector for an element
|
|
351
|
+
*/
|
|
352
|
+
const getSelector = (el) => {
|
|
353
|
+
if (el.id)
|
|
354
|
+
return `#${el.id}`;
|
|
355
|
+
const tag = el.tagName.toLowerCase();
|
|
356
|
+
const parent = el.parentElement;
|
|
357
|
+
if (!parent)
|
|
358
|
+
return tag;
|
|
359
|
+
const siblings = [...parent.children].filter((c) => c.tagName === el.tagName);
|
|
360
|
+
if (siblings.length === 1)
|
|
361
|
+
return `${getSelector(parent)} > ${tag}`;
|
|
362
|
+
const index = siblings.indexOf(el) + 1;
|
|
363
|
+
return `${getSelector(parent)} > ${tag}:nth-of-type(${index})`;
|
|
364
|
+
};
|
|
365
|
+
/**
|
|
366
|
+
* Capture computed style for focus-related properties
|
|
367
|
+
*/
|
|
368
|
+
const captureStyle = (el) => {
|
|
369
|
+
const style = window.getComputedStyle(el);
|
|
370
|
+
const result = {};
|
|
371
|
+
for (const prop of styleProperties) {
|
|
372
|
+
result[prop] = style[prop] ?? '';
|
|
373
|
+
}
|
|
374
|
+
return result;
|
|
375
|
+
};
|
|
376
|
+
/**
|
|
377
|
+
* Convert camelCase to kebab-case
|
|
378
|
+
*/
|
|
379
|
+
const toKebab = (str) => str.replace(/([A-Z])/g, '-$1').toLowerCase();
|
|
380
|
+
/**
|
|
381
|
+
* Check if element is visible
|
|
382
|
+
*/
|
|
383
|
+
const isVisible = (el) => {
|
|
384
|
+
const style = window.getComputedStyle(el);
|
|
385
|
+
return (style.display !== 'none' &&
|
|
386
|
+
style.visibility !== 'hidden' &&
|
|
387
|
+
!el.closest('[inert]'));
|
|
388
|
+
};
|
|
389
|
+
/**
|
|
390
|
+
* Check if style changes represent a visible focus indicator
|
|
391
|
+
* outline-offset alone is not enough - need actual outline to be visible
|
|
392
|
+
*/
|
|
393
|
+
const isVisibleFocusChange = (diff, focusedStyle) => {
|
|
394
|
+
const diffKeys = Object.keys(diff);
|
|
395
|
+
if (diffKeys.length === 0)
|
|
396
|
+
return false;
|
|
397
|
+
// Filter out changes that don't result in visible indicators
|
|
398
|
+
const visibleChanges = diffKeys.filter((key) => {
|
|
399
|
+
// outline-offset alone is not visible without outline
|
|
400
|
+
if (key === 'outlineOffset') {
|
|
401
|
+
const outlineStyle = focusedStyle['outlineStyle'];
|
|
402
|
+
const outlineWidth = focusedStyle['outlineWidth'];
|
|
403
|
+
if (outlineStyle === 'none' || outlineWidth === '0px') {
|
|
404
|
+
return false;
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
// outline-color change alone is not visible without outline
|
|
408
|
+
if (key === 'outlineColor') {
|
|
409
|
+
const outlineStyle = focusedStyle['outlineStyle'];
|
|
410
|
+
const outlineWidth = focusedStyle['outlineWidth'];
|
|
411
|
+
if (outlineStyle === 'none' || outlineWidth === '0px') {
|
|
412
|
+
return false;
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
// Check outline-style/outline-width actually creates visible outline
|
|
416
|
+
if (key === 'outlineStyle' || key === 'outlineWidth') {
|
|
417
|
+
const outlineStyle = focusedStyle['outlineStyle'];
|
|
418
|
+
const outlineWidth = focusedStyle['outlineWidth'];
|
|
419
|
+
if (outlineStyle === 'none' || outlineWidth === '0px') {
|
|
420
|
+
return false;
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
// box-shadow: none is not visible
|
|
424
|
+
if (key === 'boxShadow' && diff[key] === 'none') {
|
|
425
|
+
return false;
|
|
426
|
+
}
|
|
427
|
+
return true;
|
|
428
|
+
});
|
|
429
|
+
return visibleChanges.length > 0;
|
|
430
|
+
};
|
|
431
|
+
/**
|
|
432
|
+
* Check if element should be skipped (caused navigation in previous attempt)
|
|
433
|
+
*/
|
|
434
|
+
const shouldSkip = (el) => {
|
|
435
|
+
const selector = getSelector(el);
|
|
436
|
+
return skipSelectors.includes(selector);
|
|
437
|
+
};
|
|
438
|
+
/**
|
|
439
|
+
* Create overlay container for annotations
|
|
440
|
+
*/
|
|
441
|
+
const createOverlay = () => {
|
|
442
|
+
let overlay = document.getElementById('focus-audit-overlay');
|
|
443
|
+
if (!overlay) {
|
|
444
|
+
overlay = document.createElement('div');
|
|
445
|
+
overlay.id = 'focus-audit-overlay';
|
|
446
|
+
document.body.appendChild(overlay);
|
|
447
|
+
}
|
|
448
|
+
return overlay;
|
|
449
|
+
};
|
|
450
|
+
/**
|
|
451
|
+
* Add annotation box to overlay for an element
|
|
452
|
+
*/
|
|
453
|
+
const addAnnotationBox = (el, label, cssClass) => {
|
|
454
|
+
const overlay = createOverlay();
|
|
455
|
+
const rect = el.getBoundingClientRect();
|
|
456
|
+
const box = document.createElement('div');
|
|
457
|
+
box.className = `focus-audit-box ${cssClass}`;
|
|
458
|
+
box.style.left = `${rect.left + window.scrollX}px`;
|
|
459
|
+
box.style.top = `${rect.top + window.scrollY}px`;
|
|
460
|
+
box.style.width = `${rect.width}px`;
|
|
461
|
+
box.style.height = `${rect.height}px`;
|
|
462
|
+
const labelEl = document.createElement('span');
|
|
463
|
+
labelEl.className = 'focus-audit-label';
|
|
464
|
+
labelEl.textContent = label;
|
|
465
|
+
box.appendChild(labelEl);
|
|
466
|
+
overlay.appendChild(box);
|
|
467
|
+
};
|
|
468
|
+
/**
|
|
469
|
+
* Calculate rectangle intersection
|
|
470
|
+
*/
|
|
471
|
+
const getIntersection = (rect1, rect2) => {
|
|
472
|
+
const left = Math.max(rect1.left, rect2.left);
|
|
473
|
+
const top = Math.max(rect1.top, rect2.top);
|
|
474
|
+
const right = Math.min(rect1.right, rect2.right);
|
|
475
|
+
const bottom = Math.min(rect1.bottom, rect2.bottom);
|
|
476
|
+
if (left < right && top < bottom) {
|
|
477
|
+
return {
|
|
478
|
+
left,
|
|
479
|
+
top,
|
|
480
|
+
width: right - left,
|
|
481
|
+
height: bottom - top,
|
|
482
|
+
};
|
|
483
|
+
}
|
|
484
|
+
return null;
|
|
485
|
+
};
|
|
486
|
+
/**
|
|
487
|
+
* Get all fixed/sticky positioned elements that could obscure focus
|
|
488
|
+
*/
|
|
489
|
+
const getObscurerCandidates = () => {
|
|
490
|
+
const all = document.querySelectorAll('*');
|
|
491
|
+
const candidates = [];
|
|
492
|
+
all.forEach((el) => {
|
|
493
|
+
// Skip excluded selectors
|
|
494
|
+
if (obscuredConfig.excludeSelectors.some((sel) => el.matches(sel))) {
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
497
|
+
const style = window.getComputedStyle(el);
|
|
498
|
+
const position = style.position;
|
|
499
|
+
// Only fixed and sticky positioned elements can obscure
|
|
500
|
+
if (position !== 'fixed' && position !== 'sticky') {
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
// Must be visible
|
|
504
|
+
if (style.display === 'none' ||
|
|
505
|
+
style.visibility === 'hidden' ||
|
|
506
|
+
parseFloat(style.opacity) === 0) {
|
|
507
|
+
return;
|
|
508
|
+
}
|
|
509
|
+
// Must have area
|
|
510
|
+
const rect = el.getBoundingClientRect();
|
|
511
|
+
if (rect.width === 0 || rect.height === 0) {
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
514
|
+
candidates.push(el);
|
|
515
|
+
});
|
|
516
|
+
return candidates;
|
|
517
|
+
};
|
|
518
|
+
/**
|
|
519
|
+
* Check if an obscurer is actually in front of the focused element
|
|
520
|
+
*/
|
|
521
|
+
const isActuallyObscuring = (focusedEl, obscurer, intersection) => {
|
|
522
|
+
// Calculate safe sample offsets (clamped to intersection bounds)
|
|
523
|
+
const marginX = Math.min(2, intersection.width / 4);
|
|
524
|
+
const marginY = Math.min(2, intersection.height / 4);
|
|
525
|
+
// For very small intersections, only use center point
|
|
526
|
+
const useOnlyCenter = intersection.width < 6 || intersection.height < 6;
|
|
527
|
+
// Sample points within the intersection area (clamped to bounds)
|
|
528
|
+
const samplePoints = useOnlyCenter
|
|
529
|
+
? [
|
|
530
|
+
{
|
|
531
|
+
x: intersection.left + intersection.width / 2,
|
|
532
|
+
y: intersection.top + intersection.height / 2,
|
|
533
|
+
},
|
|
534
|
+
]
|
|
535
|
+
: [
|
|
536
|
+
{
|
|
537
|
+
x: intersection.left + intersection.width / 2,
|
|
538
|
+
y: intersection.top + intersection.height / 2,
|
|
539
|
+
}, // center
|
|
540
|
+
{ x: intersection.left + marginX, y: intersection.top + marginY }, // top-left
|
|
541
|
+
{
|
|
542
|
+
x: intersection.left + intersection.width - marginX,
|
|
543
|
+
y: intersection.top + marginY,
|
|
544
|
+
}, // top-right
|
|
545
|
+
{
|
|
546
|
+
x: intersection.left + marginX,
|
|
547
|
+
y: intersection.top + intersection.height - marginY,
|
|
548
|
+
}, // bottom-left
|
|
549
|
+
{
|
|
550
|
+
x: intersection.left + intersection.width - marginX,
|
|
551
|
+
y: intersection.top + intersection.height - marginY,
|
|
552
|
+
}, // bottom-right
|
|
553
|
+
];
|
|
554
|
+
let obscuringCount = 0;
|
|
555
|
+
let nullCount = 0;
|
|
556
|
+
for (const point of samplePoints) {
|
|
557
|
+
const topEl = document.elementFromPoint(point.x, point.y);
|
|
558
|
+
if (topEl) {
|
|
559
|
+
if (topEl === obscurer || obscurer.contains(topEl)) {
|
|
560
|
+
obscuringCount++;
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
else {
|
|
564
|
+
nullCount++;
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
// For small intersections (center only), require the center to confirm obscuring
|
|
568
|
+
if (useOnlyCenter) {
|
|
569
|
+
if (obscuringCount >= 1)
|
|
570
|
+
return true;
|
|
571
|
+
if (nullCount === 1) {
|
|
572
|
+
return checkZIndexOrder(focusedEl, obscurer);
|
|
573
|
+
}
|
|
574
|
+
return false;
|
|
575
|
+
}
|
|
576
|
+
// For normal intersections: require at least 2/5 points
|
|
577
|
+
if (obscuringCount >= 2)
|
|
578
|
+
return true;
|
|
579
|
+
if (nullCount >= 3) {
|
|
580
|
+
return checkZIndexOrder(focusedEl, obscurer);
|
|
581
|
+
}
|
|
582
|
+
return false;
|
|
583
|
+
};
|
|
584
|
+
/**
|
|
585
|
+
* Fallback z-index comparison for elements with pointer-events:none
|
|
586
|
+
*/
|
|
587
|
+
const checkZIndexOrder = (focusedEl, obscurer) => {
|
|
588
|
+
const focusedStyle = window.getComputedStyle(focusedEl);
|
|
589
|
+
const obscurerStyle = window.getComputedStyle(obscurer);
|
|
590
|
+
const focusedZ = parseInt(focusedStyle.zIndex, 10) || 0;
|
|
591
|
+
const obscurerZ = parseInt(obscurerStyle.zIndex, 10) || 0;
|
|
592
|
+
if (obscurerZ > focusedZ)
|
|
593
|
+
return true;
|
|
594
|
+
const focusedPos = focusedStyle.position;
|
|
595
|
+
const obscurerPos = obscurerStyle.position;
|
|
596
|
+
if (obscurerPos === 'fixed' &&
|
|
597
|
+
(focusedPos === 'static' || focusedPos === 'relative')) {
|
|
598
|
+
return true;
|
|
599
|
+
}
|
|
600
|
+
return false;
|
|
601
|
+
};
|
|
602
|
+
/**
|
|
603
|
+
* Check if focused element is obscured by fixed/sticky elements (WCAG 2.4.12)
|
|
604
|
+
*/
|
|
605
|
+
const checkFocusObscured = (focusedEl) => {
|
|
606
|
+
const focusedRect = focusedEl.getBoundingClientRect();
|
|
607
|
+
const focusedArea = focusedRect.width * focusedRect.height;
|
|
608
|
+
if (focusedArea === 0)
|
|
609
|
+
return;
|
|
610
|
+
const obscurers = getObscurerCandidates();
|
|
611
|
+
const overlaps = [];
|
|
612
|
+
let totalOverlapArea = 0;
|
|
613
|
+
obscurers.forEach((obscurer) => {
|
|
614
|
+
// Don't check against self or ancestors/descendants
|
|
615
|
+
if (obscurer === focusedEl ||
|
|
616
|
+
obscurer.contains(focusedEl) ||
|
|
617
|
+
focusedEl.contains(obscurer)) {
|
|
618
|
+
return;
|
|
619
|
+
}
|
|
620
|
+
const obscurerRect = obscurer.getBoundingClientRect();
|
|
621
|
+
const intersection = getIntersection(focusedRect, obscurerRect);
|
|
622
|
+
if (!intersection)
|
|
623
|
+
return;
|
|
624
|
+
const overlapArea = intersection.width * intersection.height;
|
|
625
|
+
// Check minimum thresholds
|
|
626
|
+
if (overlapArea < obscuredConfig.minOverlapPx)
|
|
627
|
+
return;
|
|
628
|
+
// Verify that the obscurer is actually in front using elementFromPoint
|
|
629
|
+
if (!isActuallyObscuring(focusedEl, obscurer, intersection)) {
|
|
630
|
+
return;
|
|
631
|
+
}
|
|
632
|
+
totalOverlapArea += overlapArea;
|
|
633
|
+
overlaps.push({
|
|
634
|
+
obscuredBy: {
|
|
635
|
+
tag: obscurer.tagName,
|
|
636
|
+
role: obscurer.getAttribute('role'),
|
|
637
|
+
name: obscurer.getAttribute('aria-label') ||
|
|
638
|
+
obscurer.textContent?.slice(0, 30) ||
|
|
639
|
+
'',
|
|
640
|
+
selector: getSelector(obscurer),
|
|
641
|
+
},
|
|
642
|
+
overlapRect: intersection,
|
|
643
|
+
overlapArea,
|
|
644
|
+
});
|
|
645
|
+
});
|
|
646
|
+
// Clamp ratio to max 1.0 to handle overlapping obscurers covering same region
|
|
647
|
+
const obscuredRatio = Math.min(totalOverlapArea / focusedArea, 1.0);
|
|
648
|
+
// Only report if obscured ratio exceeds threshold
|
|
649
|
+
if (obscuredRatio >= obscuredConfig.minOverlapRatio && overlaps.length > 0) {
|
|
650
|
+
// Add visual annotation
|
|
651
|
+
addAnnotationBox(focusedEl, `⚠ 2.4.12 Obscured (${(obscuredRatio * 100).toFixed(0)}%)`, 'focus-obscured');
|
|
652
|
+
// Report to test
|
|
653
|
+
window.reportFocusObscured({
|
|
654
|
+
element: {
|
|
655
|
+
tag: focusedEl.tagName,
|
|
656
|
+
role: focusedEl.getAttribute('role'),
|
|
657
|
+
name: focusedEl.getAttribute('aria-label') ||
|
|
658
|
+
focusedEl.textContent?.slice(0, 30) ||
|
|
659
|
+
'',
|
|
660
|
+
selector: getSelector(focusedEl),
|
|
661
|
+
},
|
|
662
|
+
elementRect: {
|
|
663
|
+
left: focusedRect.left,
|
|
664
|
+
top: focusedRect.top,
|
|
665
|
+
width: focusedRect.width,
|
|
666
|
+
height: focusedRect.height,
|
|
667
|
+
},
|
|
668
|
+
overlaps,
|
|
669
|
+
obscuredRatio,
|
|
670
|
+
});
|
|
671
|
+
}
|
|
672
|
+
};
|
|
673
|
+
/**
|
|
674
|
+
* Initialize focus tracker - called after page load
|
|
675
|
+
*/
|
|
676
|
+
window.initFocusTracker = () => {
|
|
677
|
+
// Create overlay container
|
|
678
|
+
createOverlay();
|
|
679
|
+
const elements = [...document.querySelectorAll(focusableSelector)]
|
|
680
|
+
.filter(isVisible)
|
|
681
|
+
.filter((el) => !shouldSkip(el));
|
|
682
|
+
elements.forEach((el) => {
|
|
683
|
+
baseStyles.set(el, captureStyle(el));
|
|
684
|
+
elementSelectors.set(el, getSelector(el));
|
|
685
|
+
});
|
|
686
|
+
return elements.length;
|
|
687
|
+
};
|
|
688
|
+
/**
|
|
689
|
+
* Handle focus events
|
|
690
|
+
*/
|
|
691
|
+
document.addEventListener('focusin', (e) => {
|
|
692
|
+
const el = e.target;
|
|
693
|
+
if (el.hasAttribute('data-focus-visited'))
|
|
694
|
+
return;
|
|
695
|
+
if (el.hasAttribute('data-focus-navigation-violation'))
|
|
696
|
+
return;
|
|
697
|
+
const pre = baseStyles.get(el);
|
|
698
|
+
const focused = captureStyle(el);
|
|
699
|
+
const diff = {};
|
|
700
|
+
if (pre) {
|
|
701
|
+
for (const p of Object.keys(pre)) {
|
|
702
|
+
const focusedValue = focused[p];
|
|
703
|
+
if (pre[p] !== focusedValue && focusedValue !== undefined) {
|
|
704
|
+
diff[p] = focusedValue;
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
const id = focusId++;
|
|
709
|
+
el.setAttribute('data-focus-visited', String(id));
|
|
710
|
+
// Check if changes are actually visible (not just outline-offset without outline)
|
|
711
|
+
const hasFocusStyle = isVisibleFocusChange(diff, focused);
|
|
712
|
+
if (hasFocusStyle) {
|
|
713
|
+
// Preserve focus appearance for screenshot
|
|
714
|
+
const cssText = Object.entries(diff)
|
|
715
|
+
.map(([p, v]) => `${toKebab(p)}: ${v}`)
|
|
716
|
+
.join('; ');
|
|
717
|
+
styleSheet.insertRule(`[data-focus-visited="${id}"] { ${cssText} }`, styleSheet.cssRules.length);
|
|
718
|
+
// Add green annotation box for elements with focus style
|
|
719
|
+
addAnnotationBox(el, '✓ Focus Style', 'has-focus-style');
|
|
720
|
+
}
|
|
721
|
+
else {
|
|
722
|
+
// Mark element as missing focus style (data attribute for tracking)
|
|
723
|
+
el.setAttribute('data-focus-missing', '');
|
|
724
|
+
// Add red annotation box to overlay (no content DOM modification)
|
|
725
|
+
addAnnotationBox(el, '⚠ No Focus Style', '');
|
|
726
|
+
}
|
|
727
|
+
// Report to test
|
|
728
|
+
window.reportFocus({
|
|
729
|
+
id,
|
|
730
|
+
tag: el.tagName,
|
|
731
|
+
role: el.getAttribute('role'),
|
|
732
|
+
name: el.getAttribute('aria-label') || el.textContent?.slice(0, 30),
|
|
733
|
+
hasFocusStyle,
|
|
734
|
+
diff,
|
|
735
|
+
selector: elementSelectors.get(el) || getSelector(el),
|
|
736
|
+
});
|
|
737
|
+
// Check for WCAG 2.4.12 - focus obscured by fixed/sticky elements
|
|
738
|
+
checkFocusObscured(el);
|
|
739
|
+
});
|
|
740
|
+
}
|