@afixt/test-utils 2.3.0 → 2.5.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.
@@ -18,7 +18,8 @@
18
18
  "Bash(node:*)",
19
19
  "Bash(npx vitest run:*)",
20
20
  "Bash(1)",
21
- "Bash(npm run test:playwright:css:*)"
21
+ "Bash(npm run test:playwright:css:*)",
22
+ "Bash(npm run:*)"
22
23
  ]
23
24
  },
24
25
  "enableAllProjectMcpServers": false
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@afixt/test-utils",
3
- "version": "2.3.0",
3
+ "version": "2.5.0",
4
4
  "description": "Various utilities for accessibility testing",
5
5
  "main": "src/index.js",
6
6
  "scripts": {
package/src/constants.js CHANGED
@@ -520,6 +520,52 @@ const VALID_ARIA_ROLES = new Set([
520
520
  'region',
521
521
  'search',
522
522
  'timer',
523
+ // WAI-ARIA Graphics Module roles
524
+ 'graphics-document',
525
+ 'graphics-object',
526
+ 'graphics-symbol',
527
+ // DPub-ARIA roles
528
+ 'doc-abstract',
529
+ 'doc-acknowledgments',
530
+ 'doc-afterword',
531
+ 'doc-appendix',
532
+ 'doc-backlink',
533
+ 'doc-biblioentry',
534
+ 'doc-bibliography',
535
+ 'doc-biblioref',
536
+ 'doc-chapter',
537
+ 'doc-colophon',
538
+ 'doc-conclusion',
539
+ 'doc-cover',
540
+ 'doc-credit',
541
+ 'doc-credits',
542
+ 'doc-dedication',
543
+ 'doc-endnote',
544
+ 'doc-endnotes',
545
+ 'doc-epigraph',
546
+ 'doc-epilogue',
547
+ 'doc-errata',
548
+ 'doc-example',
549
+ 'doc-footnote',
550
+ 'doc-foreword',
551
+ 'doc-glossary',
552
+ 'doc-glossref',
553
+ 'doc-index',
554
+ 'doc-introduction',
555
+ 'doc-noteref',
556
+ 'doc-notice',
557
+ 'doc-pagebreak',
558
+ 'doc-pagefooter',
559
+ 'doc-pageheader',
560
+ 'doc-pagelist',
561
+ 'doc-part',
562
+ 'doc-preface',
563
+ 'doc-prologue',
564
+ 'doc-pullquote',
565
+ 'doc-qna',
566
+ 'doc-subtitle',
567
+ 'doc-tip',
568
+ 'doc-toc',
523
569
  ]);
524
570
 
525
571
  /**
package/src/cssUtils.js CHANGED
@@ -3,6 +3,53 @@
3
3
  * @module cssUtils
4
4
  */
5
5
 
6
+ const { hasCSSGeneratedContent } = require('./hasCSSGeneratedContent.js');
7
+
8
+ const ICON_FONT_CLASS_PREFIXES = ['icon-', 'i-'];
9
+ const ICON_FONT_CLASSES = [
10
+ 'fa',
11
+ 'fas',
12
+ 'far',
13
+ 'fal',
14
+ 'fad',
15
+ 'fab',
16
+ 'glyphicon',
17
+ 'material-icons',
18
+ 'material-symbols-outlined',
19
+ 'material-symbols-rounded',
20
+ 'dashicons',
21
+ 'bi',
22
+ 'octicon',
23
+ 'feather',
24
+ 'fi',
25
+ 'typcn',
26
+ 'wi',
27
+ 'zmdi',
28
+ 'icofont',
29
+ ];
30
+
31
+ /**
32
+ * Check if a single class name matches known icon font patterns.
33
+ * @param {string} cls - The class name to check
34
+ * @returns {boolean} True if the class matches an icon font pattern
35
+ */
36
+ function matchesIconFontPattern(cls) {
37
+ if (ICON_FONT_CLASSES.includes(cls)) {
38
+ return true;
39
+ }
40
+ for (const prefix of ICON_FONT_CLASS_PREFIXES) {
41
+ if (cls.startsWith(prefix)) {
42
+ return true;
43
+ }
44
+ }
45
+ for (const iconClass of ICON_FONT_CLASSES) {
46
+ if (cls.startsWith(iconClass + '-')) {
47
+ return true;
48
+ }
49
+ }
50
+ return false;
51
+ }
52
+
6
53
  const cssUtils = {
7
54
  /**
8
55
  * Check if an element's background is transparent/none.
@@ -29,12 +76,81 @@ const cssUtils = {
29
76
  return style.borderStyle === 'none' || parseFloat(style.borderWidth) === 0;
30
77
  },
31
78
 
79
+ /**
80
+ * Check if the element or any descendant has a class matching common icon font patterns.
81
+ * @param {HTMLElement} element - The element to check
82
+ * @returns {boolean} True if an icon font class is found
83
+ */
84
+ hasIconFontClass(element) {
85
+ if (!element) {
86
+ return false;
87
+ }
88
+
89
+ const elements = [element, ...element.querySelectorAll('*')];
90
+ for (const el of elements) {
91
+ if (el.classList) {
92
+ for (const cls of el.classList) {
93
+ if (matchesIconFontPattern(cls)) {
94
+ return true;
95
+ }
96
+ }
97
+ }
98
+ }
99
+ return false;
100
+ },
101
+
102
+ /**
103
+ * Check if the element has any visual indicator beyond text styling,
104
+ * such as CSS-generated content, icon font classes, background images on children,
105
+ * or inline img/svg elements.
106
+ * @param {HTMLElement} element - The element to check
107
+ * @returns {boolean} True if the element has a visual indicator
108
+ */
109
+ hasVisualIndicator(element) {
110
+ if (!element) {
111
+ return false;
112
+ }
113
+
114
+ // Check for CSS-generated content on the element itself
115
+ if (hasCSSGeneratedContent(element)) {
116
+ return true;
117
+ }
118
+
119
+ // Check for icon font classes on element or descendants
120
+ if (cssUtils.hasIconFontClass(element)) {
121
+ return true;
122
+ }
123
+
124
+ // Check descendants for background-image, <img>, or <svg>
125
+ const descendants = element.querySelectorAll('*');
126
+ for (const child of descendants) {
127
+ const tagName = child.tagName ? child.tagName.toLowerCase() : '';
128
+ if (tagName === 'img' || tagName === 'svg') {
129
+ return true;
130
+ }
131
+ try {
132
+ const childStyle = window.getComputedStyle(child);
133
+ if (childStyle.backgroundImage && childStyle.backgroundImage !== 'none') {
134
+ return true;
135
+ }
136
+ } catch {
137
+ // skip if getComputedStyle fails
138
+ }
139
+ }
140
+
141
+ return false;
142
+ },
143
+
32
144
  /**
33
145
  * Check if an element looks like regular text (no underline, background, or border).
34
146
  * @param {HTMLElement} element - The element to check
35
147
  * @returns {boolean} True if element looks like regular text
36
148
  */
37
149
  looksLikeText(element) {
150
+ if (cssUtils.hasVisualIndicator(element)) {
151
+ return false;
152
+ }
153
+
38
154
  const style = window.getComputedStyle(element);
39
155
 
40
156
  const noUnderline = !style.textDecoration.toLowerCase().includes('underline');
@@ -0,0 +1,484 @@
1
+ 'use strict';
2
+
3
+ const { getFocusableElements } = require('./getFocusableElements.js');
4
+ const { getEventListeners } = require('./listEventListeners.js');
5
+
6
+ /**
7
+ * Default result indicating no focus trap detected.
8
+ * @returns {{ isTrapped: boolean, focusableCount: number, indicators: string[], trapType: string }}
9
+ */
10
+ function noTrap() {
11
+ return { isTrapped: false, focusableCount: 0, indicators: [], trapType: 'none', concerns: [] };
12
+ }
13
+
14
+ /**
15
+ * Detects whether a container element appears to implement a focus trap
16
+ * through static DOM analysis. Does not fire focus events or change page state.
17
+ * @param {Element} container - The container element to analyze
18
+ * @returns {{ isTrapped: boolean, focusableCount: number, indicators: string[], trapType: string, concerns: string[] }}
19
+ */
20
+ function detectFocusTrap(container) {
21
+ if (!container || typeof container !== 'object' || !container.querySelectorAll) {
22
+ return noTrap();
23
+ }
24
+
25
+ const indicators = [];
26
+ let trapType = 'none';
27
+
28
+ const focusableCount = getFocusableElements(container).length;
29
+
30
+ // Check for modal dialog pattern
31
+ if (checkModalDialog(container)) {
32
+ indicators.push('modal-dialog');
33
+ trapType = 'modal';
34
+ }
35
+
36
+ // Check for inert siblings
37
+ if (trapType === 'none' && checkInertSiblings(container)) {
38
+ indicators.push('inert-siblings');
39
+ trapType = 'inert';
40
+ }
41
+
42
+ // Check for library markers
43
+ if (checkLibraryMarkers(container)) {
44
+ indicators.push('library-markers');
45
+ if (trapType === 'none') {
46
+ trapType = 'library';
47
+ }
48
+ }
49
+
50
+ // Check for keyboard event listeners
51
+ if (checkKeyboardListeners(container)) {
52
+ indicators.push('keyboard-listeners');
53
+ if (trapType === 'none') {
54
+ trapType = 'custom';
55
+ }
56
+ }
57
+
58
+ // Check for focus event listeners
59
+ if (checkFocusListeners(container)) {
60
+ indicators.push('focus-listeners');
61
+ if (trapType === 'none') {
62
+ trapType = 'custom';
63
+ }
64
+ }
65
+
66
+ // Check for aria-hidden siblings (screen reader trap pattern)
67
+ const hasAriaHiddenSiblings = checkAriaHiddenSiblings(container);
68
+ if (hasAriaHiddenSiblings) {
69
+ indicators.push('aria-hidden-siblings');
70
+ if (trapType === 'none') {
71
+ trapType = 'custom';
72
+ }
73
+ }
74
+
75
+ // Check for tabindex manipulation on elements outside the container
76
+ const hasTabindexManip = checkTabindexManipulation(container);
77
+ if (hasTabindexManip) {
78
+ indicators.push('tabindex-manipulation');
79
+ if (trapType === 'none') {
80
+ trapType = 'custom';
81
+ }
82
+ }
83
+
84
+ const isTrapped = indicators.length > 0 && focusableCount > 0;
85
+
86
+ // Assess concerns — problematic focus trap patterns
87
+ const concerns = [];
88
+ if (isTrapped) {
89
+ const isModal = trapType === 'modal' || trapType === 'inert';
90
+
91
+ // Non-modal trap: focus is constrained without modal dialog semantics
92
+ if (!isModal) {
93
+ concerns.push('non-modal-trap');
94
+ }
95
+
96
+ // Single focusable element: user has nowhere to navigate within the trap
97
+ if (focusableCount === 1) {
98
+ concerns.push('single-focusable');
99
+ }
100
+
101
+ // No escape mechanism: no close/cancel button and no modal semantics
102
+ if (!isModal && !hasEscapeMechanism(container)) {
103
+ concerns.push('no-escape-mechanism');
104
+ }
105
+
106
+ // aria-hidden siblings without modal: screen reader trap
107
+ if (hasAriaHiddenSiblings && !checkModalDialog(container)) {
108
+ concerns.push('aria-hidden-no-modal');
109
+ }
110
+
111
+ // tabindex manipulation without modal: manual focus removal on outside elements
112
+ if (hasTabindexManip && !isModal) {
113
+ concerns.push('tabindex-manipulation');
114
+ }
115
+
116
+ // Inappropriate role for a focus trap (menus, tooltips, nav, listboxes, etc.)
117
+ if (hasInappropriateTrapRole(container)) {
118
+ concerns.push('inappropriate-trap-role');
119
+ }
120
+
121
+ // Nested focus trap: container is inside another focus trap
122
+ if (isNestedTrap(container)) {
123
+ concerns.push('nested-trap');
124
+ }
125
+
126
+ // Contenteditable trap: keyboard trap on an editable region
127
+ if (hasContenteditableTrap(container)) {
128
+ concerns.push('contenteditable-trap');
129
+ }
130
+
131
+ // Full page trap: focus trap covers the whole page (body or sole child of body)
132
+ // Modal dialogs are exempt — it's normal for a modal to be the only non-inert element
133
+ if (!isModal && isFullPageTrap(container)) {
134
+ concerns.push('full-page-trap');
135
+ }
136
+ }
137
+
138
+ return { isTrapped, focusableCount, indicators, trapType, concerns };
139
+ }
140
+
141
+ /**
142
+ * Check if container or ancestor is a modal dialog
143
+ * @param {Element} container
144
+ * @returns {boolean}
145
+ */
146
+ function checkModalDialog(container) {
147
+ let el = container;
148
+ while (el && el !== document.documentElement) {
149
+ // Native <dialog open>
150
+ if (el.tagName === 'DIALOG' && el.hasAttribute('open')) {
151
+ return true;
152
+ }
153
+ // ARIA modal dialog
154
+ const role = el.getAttribute('role');
155
+ if (
156
+ (role === 'dialog' || role === 'alertdialog') &&
157
+ el.getAttribute('aria-modal') === 'true'
158
+ ) {
159
+ return true;
160
+ }
161
+ el = el.parentElement;
162
+ }
163
+ return false;
164
+ }
165
+
166
+ /**
167
+ * Check if all sibling elements of the container have the inert attribute
168
+ * @param {Element} container
169
+ * @returns {boolean}
170
+ */
171
+ function checkInertSiblings(container) {
172
+ const parent = container.parentElement;
173
+ if (!parent) {
174
+ return false;
175
+ }
176
+ const siblings = Array.from(parent.children).filter(child => child !== container);
177
+ if (siblings.length === 0) {
178
+ return false;
179
+ }
180
+ return siblings.every(sibling => sibling.hasAttribute('inert'));
181
+ }
182
+
183
+ /**
184
+ * Check for common focus trap library markers on container or ancestors
185
+ * @param {Element} container
186
+ * @returns {boolean}
187
+ */
188
+ function checkLibraryMarkers(container) {
189
+ const libraryAttrs = [
190
+ 'data-focus-trap',
191
+ 'data-focus-lock',
192
+ 'data-focus-guard',
193
+ 'data-focus-lock-disabled',
194
+ 'data-focus-on-hidden',
195
+ 'data-no-focus-lock',
196
+ ];
197
+
198
+ // Check container and ancestors
199
+ let el = container;
200
+ while (el && el !== document.documentElement) {
201
+ for (const attr of libraryAttrs) {
202
+ if (el.hasAttribute(attr)) {
203
+ return true;
204
+ }
205
+ }
206
+ el = el.parentElement;
207
+ }
208
+
209
+ // Check for sentinel/guard elements: empty <div tabindex="0"> at container boundaries
210
+ const children = container.children;
211
+ if (children.length >= 2) {
212
+ const first = children[0];
213
+ const last = children[children.length - 1];
214
+ if (isSentinelElement(first) || isSentinelElement(last)) {
215
+ return true;
216
+ }
217
+ }
218
+
219
+ return false;
220
+ }
221
+
222
+ /**
223
+ * Check if element looks like a focus trap sentinel (guard) element
224
+ * @param {Element} el
225
+ * @returns {boolean}
226
+ */
227
+ function isSentinelElement(el) {
228
+ if (!el) {
229
+ return false;
230
+ }
231
+ return (
232
+ el.tagName === 'DIV' &&
233
+ el.getAttribute('tabindex') === '0' &&
234
+ el.textContent.trim() === '' &&
235
+ el.children.length === 0
236
+ );
237
+ }
238
+
239
+ /**
240
+ * Check for keyboard event listeners on container or ancestors
241
+ * @param {Element} container
242
+ * @returns {boolean}
243
+ */
244
+ function checkKeyboardListeners(container) {
245
+ const keyboardEvents = ['keydown', 'keypress'];
246
+ return hasEventOnChain(container, keyboardEvents);
247
+ }
248
+
249
+ /**
250
+ * Check for focus-related event listeners on container or ancestors
251
+ * @param {Element} container
252
+ * @returns {boolean}
253
+ */
254
+ function checkFocusListeners(container) {
255
+ const focusEvents = ['focusin', 'focusout', 'focus', 'blur'];
256
+ return hasEventOnChain(container, focusEvents);
257
+ }
258
+
259
+ /**
260
+ * Check if any element in the ancestor chain has one of the given event types
261
+ * @param {Element} container
262
+ * @param {string[]} eventTypes
263
+ * @returns {boolean}
264
+ */
265
+ function hasEventOnChain(container, eventTypes) {
266
+ let el = container;
267
+ while (el && el !== document.documentElement) {
268
+ // Check via getEventListeners (tracks addEventListener + property handlers)
269
+ const listeners = getEventListeners(el);
270
+ for (const eventType of eventTypes) {
271
+ if (listeners[eventType] && listeners[eventType].length > 0) {
272
+ return true;
273
+ }
274
+ }
275
+ // Check inline attribute handlers
276
+ for (const eventType of eventTypes) {
277
+ if (el.hasAttribute('on' + eventType)) {
278
+ return true;
279
+ }
280
+ }
281
+ el = el.parentElement;
282
+ }
283
+ return false;
284
+ }
285
+
286
+ /**
287
+ * Selectors for elements that are naturally focusable (before tabindex override)
288
+ * @type {string}
289
+ */
290
+ const NATURALLY_FOCUSABLE = 'a[href], button, input:not([type="hidden"]), select, textarea';
291
+
292
+ /**
293
+ * Check if siblings of the container have tabindex="-1" on naturally focusable elements,
294
+ * indicating manual focus removal to create a trap
295
+ * @param {Element} container
296
+ * @returns {boolean}
297
+ */
298
+ function checkTabindexManipulation(container) {
299
+ const parent = container.parentElement;
300
+ if (!parent) {
301
+ return false;
302
+ }
303
+ const siblings = Array.from(parent.children).filter(child => child !== container);
304
+ if (siblings.length === 0) {
305
+ return false;
306
+ }
307
+ // Check if any sibling has naturally focusable descendants with tabindex="-1"
308
+ for (const sibling of siblings) {
309
+ const focusable = sibling.querySelectorAll(NATURALLY_FOCUSABLE);
310
+ if (
311
+ focusable.length > 0 &&
312
+ Array.from(focusable).every(el => el.getAttribute('tabindex') === '-1')
313
+ ) {
314
+ return true;
315
+ }
316
+ }
317
+ return false;
318
+ }
319
+
320
+ /**
321
+ * Check if all sibling elements of the container have aria-hidden="true"
322
+ * @param {Element} container
323
+ * @returns {boolean}
324
+ */
325
+ function checkAriaHiddenSiblings(container) {
326
+ const parent = container.parentElement;
327
+ if (!parent) {
328
+ return false;
329
+ }
330
+ const siblings = Array.from(parent.children).filter(child => child !== container);
331
+ if (siblings.length === 0) {
332
+ return false;
333
+ }
334
+ return siblings.every(sibling => sibling.getAttribute('aria-hidden') === 'true');
335
+ }
336
+
337
+ /**
338
+ * Check if container has a visible escape mechanism (close/cancel/dismiss button)
339
+ * @param {Element} container
340
+ * @returns {boolean}
341
+ */
342
+ function hasEscapeMechanism(container) {
343
+ const escapePatterns = /\b(close|cancel|dismiss|exit|back|done|ok|go\s*back)\b/i;
344
+ const buttons = container.querySelectorAll(
345
+ 'button, [role="button"], input[type="button"], input[type="submit"]'
346
+ );
347
+ for (const btn of buttons) {
348
+ const text = btn.textContent.trim();
349
+ const ariaLabel = btn.getAttribute('aria-label') || '';
350
+ const title = btn.getAttribute('title') || '';
351
+ if (
352
+ escapePatterns.test(text) ||
353
+ escapePatterns.test(ariaLabel) ||
354
+ escapePatterns.test(title)
355
+ ) {
356
+ return true;
357
+ }
358
+ }
359
+ return false;
360
+ }
361
+
362
+ /**
363
+ * Roles and element types where a focus trap is inappropriate
364
+ * @type {Set<string>}
365
+ */
366
+ const INAPPROPRIATE_TRAP_ROLES = new Set([
367
+ 'menu',
368
+ 'menubar',
369
+ 'tooltip',
370
+ 'listbox',
371
+ 'tree',
372
+ 'grid',
373
+ 'tablist',
374
+ 'tabpanel',
375
+ 'toolbar',
376
+ 'navigation',
377
+ 'complementary',
378
+ 'banner',
379
+ 'contentinfo',
380
+ 'search',
381
+ 'status',
382
+ 'log',
383
+ 'marquee',
384
+ 'timer',
385
+ 'feed',
386
+ ]);
387
+
388
+ /**
389
+ * HTML elements that map to landmark/widget roles where trapping is inappropriate
390
+ * @type {Set<string>}
391
+ */
392
+ const INAPPROPRIATE_TRAP_ELEMENTS = new Set([
393
+ 'NAV',
394
+ 'ASIDE',
395
+ 'HEADER',
396
+ 'FOOTER',
397
+ 'FORM',
398
+ 'SECTION',
399
+ ]);
400
+
401
+ /**
402
+ * Check if the container has an ARIA role or element type that is inappropriate for focus trapping
403
+ * @param {Element} container
404
+ * @returns {boolean}
405
+ */
406
+ function hasInappropriateTrapRole(container) {
407
+ const role = container.getAttribute('role');
408
+ if (role && INAPPROPRIATE_TRAP_ROLES.has(role)) {
409
+ return true;
410
+ }
411
+ if (INAPPROPRIATE_TRAP_ELEMENTS.has(container.tagName)) {
412
+ return true;
413
+ }
414
+ return false;
415
+ }
416
+
417
+ /**
418
+ * Check if the container is nested inside another focus trap
419
+ * @param {Element} container
420
+ * @returns {boolean}
421
+ */
422
+ function isNestedTrap(container) {
423
+ const trapAttrs = [
424
+ 'data-focus-trap',
425
+ 'data-focus-lock',
426
+ 'data-focus-guard',
427
+ 'data-focus-lock-disabled',
428
+ ];
429
+ let el = container.parentElement;
430
+ while (el && el !== document.documentElement) {
431
+ // Check for modal dialog ancestor
432
+ if (el.tagName === 'DIALOG' && el.hasAttribute('open')) {
433
+ return true;
434
+ }
435
+ const role = el.getAttribute('role');
436
+ if (
437
+ (role === 'dialog' || role === 'alertdialog') &&
438
+ el.getAttribute('aria-modal') === 'true'
439
+ ) {
440
+ return true;
441
+ }
442
+ // Check for library trap markers on ancestors (but not the container itself)
443
+ for (const attr of trapAttrs) {
444
+ if (el.hasAttribute(attr)) {
445
+ return true;
446
+ }
447
+ }
448
+ el = el.parentElement;
449
+ }
450
+ return false;
451
+ }
452
+
453
+ /**
454
+ * Check if the container or its descendants include a contenteditable element
455
+ * @param {Element} container
456
+ * @returns {boolean}
457
+ */
458
+ function hasContenteditableTrap(container) {
459
+ if (container.getAttribute('contenteditable') === 'true') {
460
+ return true;
461
+ }
462
+ return container.querySelector('[contenteditable="true"]') !== null;
463
+ }
464
+
465
+ /**
466
+ * Check if the trap covers the entire page (body or sole child of body)
467
+ * @param {Element} container
468
+ * @returns {boolean}
469
+ */
470
+ function isFullPageTrap(container) {
471
+ if (container === document.body) {
472
+ return true;
473
+ }
474
+ // Sole child of body (no meaningful siblings)
475
+ if (container.parentElement === document.body) {
476
+ const siblings = Array.from(document.body.children).filter(child => child !== container);
477
+ if (siblings.length === 0) {
478
+ return true;
479
+ }
480
+ }
481
+ return false;
482
+ }
483
+
484
+ module.exports = { detectFocusTrap };