@afixt/test-utils 2.1.1 → 2.2.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(gh issue:*)"
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.1.1",
3
+ "version": "2.2.0",
4
4
  "description": "Various utilities for accessibility testing",
5
5
  "main": "src/index.js",
6
6
  "scripts": {
package/src/constants.js CHANGED
@@ -404,6 +404,7 @@ const NON_VISIBLE_SELECTORS = [
404
404
  'param',
405
405
  'noframes',
406
406
  'ruby > rp',
407
+ 'template',
407
408
  ];
408
409
 
409
410
  /**
package/src/domUtils.js CHANGED
@@ -7,6 +7,17 @@ const {
7
7
  INTERACTIVE_HANDLER_ATTRIBUTES,
8
8
  } = require('./constants.js');
9
9
 
10
+ /**
11
+ * Escapes a string for use inside a CSS selector.
12
+ * Uses the native CSS.escape when available (all modern browsers),
13
+ * falls back to identity for environments that lack it (e.g. JSDOM).
14
+ *
15
+ * @param {string} value
16
+ * @returns {string}
17
+ */
18
+ const cssEscape =
19
+ typeof CSS !== 'undefined' && typeof CSS.escape === 'function' ? CSS.escape : value => value;
20
+
10
21
  const domUtils = {
11
22
  /**
12
23
  * Checks if the specified element has the given attribute.
@@ -108,7 +119,7 @@ const domUtils = {
108
119
  duplicates.push(id);
109
120
  }
110
121
  });
111
- return duplicates.map(id => document.querySelector(`#${id}`));
122
+ return duplicates.map(id => document.querySelector(`#${cssEscape(id)}`));
112
123
  },
113
124
 
114
125
  /**
@@ -162,7 +173,7 @@ const domUtils = {
162
173
  }
163
174
 
164
175
  // CSS-escape the ID for use in attribute selectors
165
- const escaped = CSS.escape(id);
176
+ const escaped = cssEscape(id);
166
177
 
167
178
  // Direct attribute references
168
179
  if (document.querySelector('[for="' + escaped + '"]')) {
@@ -226,10 +237,31 @@ const domUtils = {
226
237
  if (!element) {
227
238
  return false;
228
239
  }
229
- return (
240
+
241
+ // Check aria-hidden (self or ancestor)
242
+ if (
230
243
  element.getAttribute('aria-hidden') === 'true' ||
231
244
  (element.closest && !!element.closest('[aria-hidden="true"]'))
232
- );
245
+ ) {
246
+ return true;
247
+ }
248
+
249
+ // Check inert (self or ancestor) — inert elements are removed from the a11y tree
250
+ let el = element;
251
+ while (el) {
252
+ if (el.hasAttribute && el.hasAttribute('inert')) {
253
+ return true;
254
+ }
255
+ el = el.parentElement;
256
+ }
257
+
258
+ // Check content-visibility: hidden — genuinely hides content from AT
259
+ const style = window.getComputedStyle(element);
260
+ if (style.contentVisibility === 'hidden') {
261
+ return true;
262
+ }
263
+
264
+ return false;
233
265
  },
234
266
 
235
267
  /**
@@ -397,3 +429,4 @@ const domUtils = {
397
429
  };
398
430
 
399
431
  module.exports = domUtils;
432
+ module.exports.cssEscape = cssEscape;
package/src/formUtils.js CHANGED
@@ -3,6 +3,8 @@
3
3
  * @module formUtils
4
4
  */
5
5
 
6
+ const { isHiddenFromAT } = require('./domUtils.js');
7
+
6
8
  const formUtils = {
7
9
  /**
8
10
  * Check if an element is a labellable form control per HTML spec.
@@ -50,7 +52,7 @@ const formUtils = {
50
52
  const ids = element.getAttribute('aria-labelledby').trim().split(/\s+/);
51
53
  for (let i = 0; i < ids.length; i++) {
52
54
  const labelEl = document.getElementById(ids[i]);
53
- if (labelEl && labelEl.textContent.trim()) {
55
+ if (labelEl && !isHiddenFromAT(labelEl) && labelEl.textContent.trim()) {
54
56
  return true;
55
57
  }
56
58
  }
@@ -1,4 +1,4 @@
1
- const { isEmpty } = require('./stringUtils.js');
1
+ const { isEmptyOrWhitespace } = require('./stringUtils.js');
2
2
  const { getAccessibleText } = require('./getAccessibleText.js');
3
3
  const {
4
4
  TEXT_ROLES,
@@ -6,6 +6,8 @@ const {
6
6
  LANDMARK_ROLES,
7
7
  LANDMARK_ELEMENT_MAP,
8
8
  } = require('./constants.js');
9
+ const { cssEscape } = require('./domUtils.js');
10
+ const { isA11yVisible } = require('./isA11yVisible.js');
9
11
 
10
12
  /**
11
13
  * Gets the accessible name of an element according to the accessible name calculation algorithm
@@ -26,9 +28,21 @@ function getAccessibleName(element) {
26
28
  // the title won't be used in any meaningful way by Accessibility APIs
27
29
  const unlabellable = 'head *, hr, param, caption, colgroup, col, tbody, tfoot, thead, tr';
28
30
 
29
- // STEP 0 - verify item is visible and can be labelled
30
- // if it isn't visible or can't be labelled then just bail
31
- if (isNotVisible(element) || matchesSelector(element, unlabellable)) {
31
+ if (matchesSelector(element, unlabellable)) {
32
+ return false;
33
+ }
34
+
35
+ // Skip inherently non-visible semantic elements (script, style, template, etc.)
36
+ // but exclude 'area' since area elements have accessible names via alt attribute
37
+ const nonVisibleSelectorsWithoutArea = NON_VISIBLE_SELECTORS.filter(s => s !== 'area');
38
+ if (nonVisibleSelectorsWithoutArea.some(selector => matchesSelector(element, selector))) {
39
+ return false;
40
+ }
41
+
42
+ // STEP 0 - Use isA11yVisible (strict mode) for visibility check.
43
+ // This respects aria-labelledby/describedby references, preventing false
44
+ // negatives for display:none elements that provide names to other elements.
45
+ if (!isA11yVisible(element, true)) {
32
46
  return false;
33
47
  }
34
48
 
@@ -58,7 +72,7 @@ function getAccessibleName(element) {
58
72
  // STEP 2.1 - if aria-label exists, return the text in it
59
73
  if (element.hasAttribute('aria-label')) {
60
74
  const ariaLabel = element.getAttribute('aria-label');
61
- if (ariaLabel) {
75
+ if (!isEmptyOrWhitespace(ariaLabel)) {
62
76
  return ariaLabel;
63
77
  }
64
78
  // there is no 'else' here because an empty aria-label is/ should be ignored and calculation continued
@@ -75,7 +89,7 @@ function getAccessibleName(element) {
75
89
  // Use getAccessibleText which handles both text nodes and
76
90
  // image alt text in the subtree (not just textContent)
77
91
  const text = getAccessibleText(element);
78
- if (!isEmpty(text)) {
92
+ if (!isEmptyOrWhitespace(text)) {
79
93
  return text;
80
94
  }
81
95
  }
@@ -95,12 +109,12 @@ function getAccessibleName(element) {
95
109
  )
96
110
  ) {
97
111
  // first we choose the explicit relationship over all others.
98
- if (element.id && document.querySelector('label[for="' + element.id + '"]')) {
112
+ if (element.id && document.querySelector('label[for="' + cssEscape(element.id) + '"]')) {
99
113
  id = element.id;
100
114
  // Use only the *first* label that matches this ID.
101
115
  // Sometimes JS libraries screw this up by hiding one of the
102
116
  // labels or misnaming one
103
- label = document.querySelector('label[for="' + id + '"]');
117
+ label = document.querySelector('label[for="' + cssEscape(id) + '"]');
104
118
  if (label) {
105
119
  return getAccessibleText(label);
106
120
  }
@@ -210,11 +224,11 @@ function getAccessibleName(element) {
210
224
  )
211
225
  ) {
212
226
  // first we choose the explicit relationship over all others.
213
- if (element.id && document.querySelector('label[for="' + element.id + '"]')) {
227
+ if (element.id && document.querySelector('label[for="' + cssEscape(element.id) + '"]')) {
214
228
  id = element.id;
215
229
 
216
230
  //Use only the *first* label that matches this ID. Sometimes ppl screw this up
217
- label = document.querySelector('label[for="' + id + '"]');
231
+ label = document.querySelector('label[for="' + cssEscape(id) + '"]');
218
232
  if (label) {
219
233
  return getAccessibleText(label);
220
234
  }
@@ -359,7 +373,7 @@ function getAccessibleName(element) {
359
373
  if (element.tagName.toLowerCase() === 'meter') {
360
374
  // Check for label with for attribute
361
375
  if (element.id) {
362
- const label = document.querySelector('label[for="' + element.id + '"]');
376
+ const label = document.querySelector('label[for="' + cssEscape(element.id) + '"]');
363
377
  if (label && strlen(getAccessibleText(label)) > 0) {
364
378
  return getAccessibleText(label);
365
379
  }
@@ -474,44 +488,6 @@ function getAccessibleName(element) {
474
488
  }
475
489
  }
476
490
 
477
- /**
478
- * Helper function to check if element is NOT visible
479
- * @param {Element} element - The element to check
480
- * @returns {boolean} True if element is not visible, false otherwise
481
- */
482
- function isNotVisible(element) {
483
- // Importing isVisible would be better, but for this standalone function we'll check it this way
484
- if (!element) {
485
- return true;
486
- }
487
-
488
- // Note: 'area' is filtered out because area elements DO have accessible names
489
- // via the alt attribute and should participate in accessible name calculation
490
- const nonVisibleSelectorsWithoutArea = NON_VISIBLE_SELECTORS.filter(s => s !== 'area');
491
-
492
- if (nonVisibleSelectorsWithoutArea.some(selector => matchesSelector(element, selector))) {
493
- return true; // Not visible in accessibility tree
494
- }
495
-
496
- // Check if display is none
497
- const isElemDisplayed = el => window.getComputedStyle(el).display === 'none';
498
-
499
- if (isElemDisplayed(element)) {
500
- return true;
501
- }
502
-
503
- // Check parent elements
504
- let parent = element.parentElement;
505
- while (parent) {
506
- if (isElemDisplayed(parent)) {
507
- return true;
508
- }
509
- parent = parent.parentElement;
510
- }
511
-
512
- return element.getAttribute('aria-hidden') === 'true';
513
- }
514
-
515
491
  /**
516
492
  * Helper function to check if an element matches a selector
517
493
  * @param {Element} element - Element to check
@@ -541,7 +517,7 @@ function matchesSelector(element, selector) {
541
517
  * @returns {number} The string length or 0
542
518
  */
543
519
  function strlen(str) {
544
- return typeof str === 'string' && !isEmpty(str.trim()) ? str.trim().length : 0;
520
+ return typeof str === 'string' && !isEmptyOrWhitespace(str) ? str.trim().length : 0;
545
521
  }
546
522
 
547
523
  // Export the function for CommonJS module usage
@@ -1,4 +1,4 @@
1
- const { isEmpty } = require('./stringUtils.js');
1
+ const { isEmptyOrWhitespace } = require('./stringUtils.js');
2
2
 
3
3
  /**
4
4
  * Get all accessible text for an element, including aria-labels and content from children.
@@ -27,7 +27,7 @@ function getAccessibleText(el, options) {
27
27
  // Check for aria-label first (highest priority)
28
28
  if (el.hasAttribute('aria-label')) {
29
29
  const ariaLabel = el.getAttribute('aria-label').trim();
30
- if (ariaLabel) {
30
+ if (!isEmptyOrWhitespace(ariaLabel)) {
31
31
  return ariaLabel;
32
32
  }
33
33
  }
@@ -65,7 +65,7 @@ function collectSubtreeText(node, visibleOnly) {
65
65
  for (let child = node.firstChild; child; child = child.nextSibling) {
66
66
  if (child.nodeType === Node.TEXT_NODE) {
67
67
  const text = child.nodeValue.trim();
68
- if (!isEmpty(text)) {
68
+ if (!isEmptyOrWhitespace(text)) {
69
69
  parts.push(text);
70
70
  }
71
71
  } else if (child.nodeType === Node.ELEMENT_NODE) {
@@ -1,65 +1,65 @@
1
1
  const roleMapping = {
2
- 'a': {
2
+ a: {
3
3
  '[href]': 'link',
4
- ':not([href])': 'text'
4
+ ':not([href])': 'text',
5
5
  },
6
- 'abbr': 'text',
7
- 'address': 'text',
8
- 'area': {
6
+ abbr: 'text',
7
+ address: 'text',
8
+ area: {
9
9
  '[href]': 'link',
10
- ':not([href])': 'text'
10
+ ':not([href])': 'text',
11
11
  },
12
- 'article': 'article',
13
- 'aside': 'complementary',
14
- 'audio': 'group',
15
- 'b': 'text',
16
- 'base': 'noRole',
17
- 'bdi': 'noRole',
18
- 'bdo': 'text',
19
- 'blockquote': 'text',
20
- 'body': 'document',
21
- 'br': 'noRole',
22
- 'button': 'button',
23
- 'canvas': 'image',
24
- 'caption': 'text',
25
- 'cite': 'text',
26
- 'code': 'text',
27
- 'col': 'noRole',
28
- 'colgroup': 'group',
29
- 'data': 'noRole',
30
- 'datalist': 'listbox',
31
- 'dd': 'definition',
32
- 'del': 'text',
33
- 'details': 'group',
34
- 'dfn': 'term',
35
- 'dialog': 'dialog',
36
- 'div': 'group',
37
- 'dl': 'list',
38
- 'dt': 'term',
39
- 'em': 'text',
40
- 'embed': 'noRole',
41
- 'fieldset': 'group',
42
- 'figcaption': 'text',
43
- 'figure': 'figure',
44
- 'footer': 'contentinfo',
45
- 'form': 'form',
46
- 'h1': 'heading',
47
- 'h2': 'heading',
48
- 'h3': 'heading',
49
- 'h4': 'heading',
50
- 'h5': 'heading',
51
- 'h6': 'heading',
52
- 'head': 'noRole',
53
- 'header': 'banner',
54
- 'hr': 'separator',
55
- 'html': 'noRole',
56
- 'i': 'text',
57
- 'iframe': 'noRole',
58
- 'img': {
12
+ article: 'article',
13
+ aside: 'complementary',
14
+ audio: 'group',
15
+ b: 'text',
16
+ base: 'noRole',
17
+ bdi: 'noRole',
18
+ bdo: 'text',
19
+ blockquote: 'text',
20
+ body: 'document',
21
+ br: 'noRole',
22
+ button: 'button',
23
+ canvas: 'image',
24
+ caption: 'text',
25
+ cite: 'text',
26
+ code: 'text',
27
+ col: 'noRole',
28
+ colgroup: 'group',
29
+ data: 'noRole',
30
+ datalist: 'listbox',
31
+ dd: 'definition',
32
+ del: 'text',
33
+ details: 'group',
34
+ dfn: 'term',
35
+ dialog: 'dialog',
36
+ div: 'group',
37
+ dl: 'list',
38
+ dt: 'term',
39
+ em: 'text',
40
+ embed: 'noRole',
41
+ fieldset: 'group',
42
+ figcaption: 'text',
43
+ figure: 'figure',
44
+ footer: 'contentinfo',
45
+ form: 'form',
46
+ h1: 'heading',
47
+ h2: 'heading',
48
+ h3: 'heading',
49
+ h4: 'heading',
50
+ h5: 'heading',
51
+ h6: 'heading',
52
+ head: 'noRole',
53
+ header: 'banner',
54
+ hr: 'separator',
55
+ html: 'noRole',
56
+ i: 'text',
57
+ iframe: 'noRole',
58
+ img: {
59
59
  '[alt=""]': 'presentation',
60
- 'img': 'image'
60
+ img: 'image',
61
61
  },
62
- 'input': {
62
+ input: {
63
63
  '[type="button"]': 'button',
64
64
  '[type="checkbox"]': 'checkbox',
65
65
  '[type="hidden"]': 'noRole',
@@ -74,85 +74,150 @@ const roleMapping = {
74
74
  '[type="search"]': 'searchbox',
75
75
  '[type="tel"]': 'textbox',
76
76
  '[type="text"]': 'textbox',
77
- '[type="url"]': 'textbox'
77
+ '[type="url"]': 'textbox',
78
78
  },
79
- 'ins': 'text',
80
- 'kbd': 'text',
81
- 'label': 'text',
82
- 'legend': 'text',
83
- 'li': 'listitem',
84
- 'link': 'noRole',
85
- 'main': 'main',
86
- 'map': 'noRole',
87
- 'mark': 'text',
88
- 'math': 'math',
89
- 'menu': 'menu',
90
- 'meta': 'noRole',
91
- 'meter': 'text',
92
- 'nav': 'navigation',
93
- 'noscript': 'noRole',
94
- 'object': 'noRole',
95
- 'ol': 'list',
96
- 'optgroup': 'group',
97
- 'option': 'option',
98
- 'output': 'status',
99
- 'p': 'text',
100
- 'param': 'noRole',
101
- 'picture': 'noRole',
102
- 'pre': 'text',
103
- 'progress': 'progressbar',
104
- 'q': 'text',
105
- 'rb': 'noRole',
106
- 'rp': 'noRole',
107
- 'rt': 'text',
108
- 'rtc': 'noRole',
109
- 'ruby': 'text',
110
- 's': 'text',
111
- 'samp': 'text',
112
- 'script': 'noRole',
113
- 'section': 'region',
114
- 'select': 'combobox',
115
- 'small': 'text',
116
- 'source': 'noRole',
117
- 'span': 'group',
118
- 'strong': 'text',
119
- 'style': 'noRole',
120
- 'sub': 'text',
121
- 'summary': 'button',
122
- 'sup': 'text',
123
- 'svg': 'noRole',
124
- 'table': 'table',
125
- 'tbody': 'rowgroup',
126
- 'td': 'cell',
127
- 'template': 'noRole',
128
- 'textarea': 'textbox',
129
- 'tfoot': 'rowgroup',
130
- 'th': 'columnheader',
131
- 'thead': 'rowgroup',
132
- 'time': 'text',
133
- 'title': 'noRole',
134
- 'tr': 'row',
135
- 'track': 'noRole',
136
- 'u': 'text',
137
- 'ul': 'list',
138
- 'var': 'text',
139
- 'video': 'group',
140
- 'wbr': 'noRole'
79
+ ins: 'text',
80
+ kbd: 'text',
81
+ label: 'text',
82
+ legend: 'text',
83
+ li: 'listitem',
84
+ link: 'noRole',
85
+ main: 'main',
86
+ map: 'noRole',
87
+ mark: 'text',
88
+ math: 'math',
89
+ menu: 'menu',
90
+ meta: 'noRole',
91
+ meter: 'text',
92
+ nav: 'navigation',
93
+ noscript: 'noRole',
94
+ object: 'noRole',
95
+ ol: 'list',
96
+ optgroup: 'group',
97
+ option: 'option',
98
+ output: 'status',
99
+ p: 'text',
100
+ param: 'noRole',
101
+ picture: 'noRole',
102
+ pre: 'text',
103
+ progress: 'progressbar',
104
+ q: 'text',
105
+ rb: 'noRole',
106
+ rp: 'noRole',
107
+ rt: 'text',
108
+ rtc: 'noRole',
109
+ ruby: 'text',
110
+ s: 'text',
111
+ samp: 'text',
112
+ script: 'noRole',
113
+ section: 'region',
114
+ select: 'combobox',
115
+ small: 'text',
116
+ source: 'noRole',
117
+ span: 'group',
118
+ strong: 'text',
119
+ style: 'noRole',
120
+ sub: 'text',
121
+ summary: 'button',
122
+ sup: 'text',
123
+ svg: 'noRole',
124
+ table: 'table',
125
+ tbody: 'rowgroup',
126
+ td: 'cell',
127
+ template: 'noRole',
128
+ textarea: 'textbox',
129
+ tfoot: 'rowgroup',
130
+ th: 'columnheader',
131
+ thead: 'rowgroup',
132
+ time: 'text',
133
+ title: 'noRole',
134
+ tr: 'row',
135
+ track: 'noRole',
136
+ u: 'text',
137
+ ul: 'list',
138
+ var: 'text',
139
+ video: 'group',
140
+ wbr: 'noRole',
141
141
  };
142
142
 
143
143
  /**
144
- * Gets the computed role of an HTML element.
144
+ * Sectioning elements that change the implicit role of header/footer
145
+ * from landmark (banner/contentinfo) to generic.
146
+ */
147
+ const SECTIONING_ELEMENTS = ['article', 'aside', 'main', 'nav', 'section'];
148
+
149
+ /**
150
+ * Check if an element has an ancestor with a given role or tag name.
151
+ * @param {HTMLElement} element - The element to check
152
+ * @param {Object} options - Search options
153
+ * @param {string[]} [options.roles] - ARIA roles to match
154
+ * @param {string[]} [options.tags] - Tag names (lowercase) to match
155
+ * @returns {boolean} True if a matching ancestor is found
156
+ */
157
+ function hasAncestor(element, { roles = [], tags = [] }) {
158
+ let ancestor = element.parentElement;
159
+ while (ancestor) {
160
+ const ancestorRole = (ancestor.getAttribute('role') || '').toLowerCase();
161
+ if (roles.length && roles.includes(ancestorRole)) {
162
+ return true;
163
+ }
164
+ const ancestorTag = ancestor.tagName.toLowerCase();
165
+ if (tags.length && tags.includes(ancestorTag)) {
166
+ return true;
167
+ }
168
+ ancestor = ancestor.parentElement;
169
+ }
170
+ return false;
171
+ }
172
+
173
+ /**
174
+ * Gets the computed role of an HTML element, considering ancestor context
175
+ * for context-dependent implicit roles per HTML-AAM.
145
176
  *
146
177
  * @param {HTMLElement} element - The HTML element to get the role for.
147
178
  * @returns {string|undefined} The computed role of the element, or undefined if no role is found.
148
179
  */
149
180
  function getComputedRole(element) {
150
- if (!element) return undefined;
181
+ if (!element) {
182
+ return undefined;
183
+ }
151
184
 
152
185
  const roleAttr = element.getAttribute('role');
153
- if (roleAttr) return roleAttr;
186
+ if (roleAttr) {
187
+ return roleAttr;
188
+ }
154
189
 
155
190
  const tagName = element.tagName.toLowerCase();
191
+
192
+ // Context-dependent implicit roles per HTML-AAM
193
+ if (tagName === 'td') {
194
+ if (hasAncestor(element, { roles: ['grid', 'treegrid'] })) {
195
+ return 'gridcell';
196
+ }
197
+ return 'cell';
198
+ }
199
+
200
+ if (tagName === 'th') {
201
+ if (hasAncestor(element, { roles: ['grid', 'treegrid'] })) {
202
+ return 'columnheader';
203
+ }
204
+ return 'columnheader';
205
+ }
206
+
207
+ if (tagName === 'header') {
208
+ if (hasAncestor(element, { tags: SECTIONING_ELEMENTS })) {
209
+ return 'generic';
210
+ }
211
+ return 'banner';
212
+ }
213
+
214
+ if (tagName === 'footer') {
215
+ if (hasAncestor(element, { tags: SECTIONING_ELEMENTS })) {
216
+ return 'generic';
217
+ }
218
+ return 'contentinfo';
219
+ }
220
+
156
221
  const role = roleMapping[tagName];
157
222
 
158
223
  if (typeof role === 'string') {
@@ -170,5 +235,5 @@ function getComputedRole(element) {
170
235
 
171
236
  module.exports = {
172
237
  roleMapping,
173
- getComputedRole
238
+ getComputedRole,
174
239
  };
@@ -26,7 +26,10 @@ async function getImageText(imagePath, options = {}) {
26
26
  try {
27
27
  const {
28
28
  data: { text },
29
- } = await _internal.recognize(imagePath, lang, { logger, ...tesseractOptions });
29
+ // errorHandler silences the bare `throw` in tesseract.js createWorker.js when a
30
+ // job is rejected — without it the throw escapes try/catch and becomes an uncaught
31
+ // exception in the host process. The promise rejection is still caught below.
32
+ } = await _internal.recognize(imagePath, lang, { logger, errorHandler: () => {}, ...tesseractOptions });
30
33
 
31
34
  const extractedText = text.trim();
32
35
  return extractedText.length > 0 ? extractedText : false;
@@ -1,5 +1,6 @@
1
1
  const { NON_VISIBLE_SELECTORS } = require('./constants.js');
2
2
  const isHidden = require('./isHidden.js');
3
+ const { cssEscape } = require('./domUtils.js');
3
4
 
4
5
  /**
5
6
  * Checks if an element is visible to assistive technologies (AT).
@@ -67,8 +68,11 @@ function isA11yVisible(element, strict = false) {
67
68
  }
68
69
 
69
70
  // Check if element is referenced by aria-labelledby or aria-describedby
71
+ // Use CSS.escape(id) to handle IDs containing special characters like colons
72
+ // (e.g. Radix UI generates IDs like "radix-:rj:-tab-account")
73
+ const escapedId = id ? cssEscape(id) : '';
70
74
  document
71
- .querySelectorAll(`*[aria-labelledby~="${id}"], *[aria-describedby~="${id}"]`)
75
+ .querySelectorAll(`*[aria-labelledby~="${escapedId}"], *[aria-describedby~="${escapedId}"]`)
72
76
  .forEach(referencingElement => {
73
77
  if (window.getComputedStyle(referencingElement).display !== 'none') {
74
78
  visible = true;