@afixt/test-utils 1.1.8 → 1.2.1
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/settings.local.json +5 -1
- package/.github/workflows/pr-check.yml +88 -0
- package/.github/workflows/security.yml +0 -3
- package/eslint.config.mjs +1 -1
- package/package.json +2 -1
- package/src/constants.js +231 -0
- package/src/cssUtils.js +77 -0
- package/src/domUtils.js +268 -12
- package/src/formUtils.js +175 -0
- package/src/getCSSGeneratedContent.js +39 -17
- package/src/index.js +18 -2
- package/src/stringUtils.js +168 -21
- package/src/tableUtils.js +180 -0
- package/src/testContrast.js +137 -22
- package/src/testLang.js +514 -444
- package/test/cssUtils.test.js +248 -0
- package/test/domUtils.test.js +815 -297
- package/test/formUtils.test.js +389 -0
- package/test/getCSSGeneratedContent.test.js +187 -232
- package/test/hasCSSGeneratedContent.test.js +37 -147
- package/test/playwright/css-pseudo-elements.spec.js +224 -91
- package/test/playwright/fixtures/css-pseudo-elements.html +6 -0
- package/test/stringUtils.test.js +609 -343
- package/test/tableUtils.test.js +340 -0
- package/test/testContrast.test.js +801 -651
- package/vitest.config.js +28 -28
- package/.github/dependabot.yml +0 -36
- package/test/getCSSGeneratedContent.browser.test.js +0 -125
package/src/domUtils.js
CHANGED
|
@@ -18,10 +18,8 @@ const domUtils = {
|
|
|
18
18
|
* @returns {Array} - An array of elements that have at least one attribute starting with the specified prefix.
|
|
19
19
|
*/
|
|
20
20
|
attrBegins(elements, prefix) {
|
|
21
|
-
return Array.from(elements).filter(
|
|
22
|
-
return Array.from(element.attributes).some(
|
|
23
|
-
attr.name.startsWith(prefix)
|
|
24
|
-
);
|
|
21
|
+
return Array.from(elements).filter(element => {
|
|
22
|
+
return Array.from(element.attributes).some(attr => attr.name.startsWith(prefix));
|
|
25
23
|
});
|
|
26
24
|
},
|
|
27
25
|
|
|
@@ -43,7 +41,9 @@ const domUtils = {
|
|
|
43
41
|
* @returns {Object} An object containing the element's attributes as key-value pairs.
|
|
44
42
|
*/
|
|
45
43
|
getAttributes(element) {
|
|
46
|
-
if (!element)
|
|
44
|
+
if (!element) {
|
|
45
|
+
return {};
|
|
46
|
+
}
|
|
47
47
|
return [...element.attributes].reduce((attrs, attr) => {
|
|
48
48
|
attrs[attr.name] = attr.value;
|
|
49
49
|
return attrs;
|
|
@@ -80,7 +80,7 @@ const domUtils = {
|
|
|
80
80
|
* @returns {number} The length of the document's HTML content without whitespace.
|
|
81
81
|
*/
|
|
82
82
|
getDocumentSize() {
|
|
83
|
-
return document.documentElement.outerHTML.replace(/\s+/g,
|
|
83
|
+
return document.documentElement.outerHTML.replace(/\s+/g, '').length;
|
|
84
84
|
},
|
|
85
85
|
|
|
86
86
|
/**
|
|
@@ -89,17 +89,17 @@ const domUtils = {
|
|
|
89
89
|
* @returns {Element[]} An array of elements that have duplicate IDs.
|
|
90
90
|
*/
|
|
91
91
|
getElementsWithDuplicateIds() {
|
|
92
|
-
const nodes = document.querySelectorAll(
|
|
92
|
+
const nodes = document.querySelectorAll('[id]');
|
|
93
93
|
const ids = {};
|
|
94
94
|
const duplicates = [];
|
|
95
|
-
nodes.forEach(
|
|
95
|
+
nodes.forEach(node => {
|
|
96
96
|
const id = node.id.trim();
|
|
97
97
|
ids[id] = (ids[id] || 0) + 1;
|
|
98
98
|
if (ids[id] > 1 && !duplicates.includes(id)) {
|
|
99
99
|
duplicates.push(id);
|
|
100
100
|
}
|
|
101
101
|
});
|
|
102
|
-
return duplicates.map(
|
|
102
|
+
return duplicates.map(id => document.querySelector(`#${id}`));
|
|
103
103
|
},
|
|
104
104
|
|
|
105
105
|
/**
|
|
@@ -119,13 +119,17 @@ const domUtils = {
|
|
|
119
119
|
* @returns {string} The XPath string representing the element's location in the DOM.
|
|
120
120
|
*/
|
|
121
121
|
getXPath(element) {
|
|
122
|
-
if (!element)
|
|
123
|
-
|
|
122
|
+
if (!element) {
|
|
123
|
+
return '';
|
|
124
|
+
}
|
|
125
|
+
let path = '';
|
|
124
126
|
while (element && element.nodeType === Node.ELEMENT_NODE) {
|
|
125
127
|
let index = 1;
|
|
126
128
|
let sibling = element.previousElementSibling;
|
|
127
129
|
while (sibling) {
|
|
128
|
-
if (sibling.nodeName === element.nodeName)
|
|
130
|
+
if (sibling.nodeName === element.nodeName) {
|
|
131
|
+
index++;
|
|
132
|
+
}
|
|
129
133
|
sibling = sibling.previousElementSibling;
|
|
130
134
|
}
|
|
131
135
|
path = `/${element.nodeName.toLowerCase()}[${index}]` + path;
|
|
@@ -134,6 +138,258 @@ const domUtils = {
|
|
|
134
138
|
return path;
|
|
135
139
|
},
|
|
136
140
|
|
|
141
|
+
/**
|
|
142
|
+
* Checks if a given ID is referenced by other elements in the document.
|
|
143
|
+
* References include label[for], aria-labelledby, aria-describedby,
|
|
144
|
+
* aria-controls, aria-owns, aria-activedescendant, aria-flowto,
|
|
145
|
+
* aria-errormessage, href="#id", headers, and list attributes.
|
|
146
|
+
*
|
|
147
|
+
* @param {string} id - The ID value to check for references.
|
|
148
|
+
* @returns {boolean} True if the ID is referenced by another element.
|
|
149
|
+
*/
|
|
150
|
+
isIdReferenced(id) {
|
|
151
|
+
if (!id) {
|
|
152
|
+
return false;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// CSS-escape the ID for use in attribute selectors
|
|
156
|
+
const escaped = CSS.escape(id);
|
|
157
|
+
|
|
158
|
+
// Direct attribute references
|
|
159
|
+
if (document.querySelector('[for="' + escaped + '"]')) {
|
|
160
|
+
return true;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ARIA token-list attributes that reference IDs
|
|
164
|
+
const ariaTokenAttrs = [
|
|
165
|
+
'aria-labelledby',
|
|
166
|
+
'aria-describedby',
|
|
167
|
+
'aria-controls',
|
|
168
|
+
'aria-owns',
|
|
169
|
+
'aria-flowto',
|
|
170
|
+
];
|
|
171
|
+
for (const attr of ariaTokenAttrs) {
|
|
172
|
+
const elements = document.querySelectorAll('[' + attr + ']');
|
|
173
|
+
for (const el of elements) {
|
|
174
|
+
const ids = el.getAttribute(attr).trim().split(/\s+/);
|
|
175
|
+
if (ids.includes(id)) {
|
|
176
|
+
return true;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Single-ID ARIA references
|
|
182
|
+
const ariaSingleAttrs = ['aria-activedescendant', 'aria-errormessage'];
|
|
183
|
+
for (const attr of ariaSingleAttrs) {
|
|
184
|
+
if (document.querySelector('[' + attr + '="' + escaped + '"]')) {
|
|
185
|
+
return true;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Fragment references in href
|
|
190
|
+
if (document.querySelector('a[href="#' + escaped + '"]')) {
|
|
191
|
+
return true;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Table headers attribute (space-separated list of IDs)
|
|
195
|
+
const headerElements = document.querySelectorAll('[headers]');
|
|
196
|
+
for (const el of headerElements) {
|
|
197
|
+
const ids = el.getAttribute('headers').trim().split(/\s+/);
|
|
198
|
+
if (ids.includes(id)) {
|
|
199
|
+
return true;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// list attribute on input elements
|
|
204
|
+
if (document.querySelector('input[list="' + escaped + '"]')) {
|
|
205
|
+
return true;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return false;
|
|
209
|
+
},
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Check if an element is hidden from assistive technology.
|
|
213
|
+
* @param {HTMLElement} element - The element to check
|
|
214
|
+
* @returns {boolean} True if element or ancestor has aria-hidden="true"
|
|
215
|
+
*/
|
|
216
|
+
isHiddenFromAT(element) {
|
|
217
|
+
if (!element) {
|
|
218
|
+
return false;
|
|
219
|
+
}
|
|
220
|
+
return (
|
|
221
|
+
element.getAttribute('aria-hidden') === 'true' ||
|
|
222
|
+
(element.closest && !!element.closest('[aria-hidden="true"]'))
|
|
223
|
+
);
|
|
224
|
+
},
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Check if an element is effectively interactive (not disabled, not hidden from AT,
|
|
228
|
+
* not role=presentation/none).
|
|
229
|
+
* @param {HTMLElement} element - The element to check
|
|
230
|
+
* @returns {boolean} True if the element is interactive
|
|
231
|
+
*/
|
|
232
|
+
isEffectivelyInteractive(element) {
|
|
233
|
+
if (element.disabled || element.getAttribute('aria-disabled') === 'true') {
|
|
234
|
+
return false;
|
|
235
|
+
}
|
|
236
|
+
if (element.getAttribute('aria-hidden') === 'true') {
|
|
237
|
+
return false;
|
|
238
|
+
}
|
|
239
|
+
const role = element.getAttribute('role');
|
|
240
|
+
if (role === 'presentation' || role === 'none') {
|
|
241
|
+
return false;
|
|
242
|
+
}
|
|
243
|
+
return true;
|
|
244
|
+
},
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Check if a parent-child pair constitutes a valid ARIA composite widget nesting.
|
|
248
|
+
* @param {HTMLElement} parent - The parent interactive element
|
|
249
|
+
* @param {HTMLElement} child - The nested interactive element
|
|
250
|
+
* @returns {boolean} True if this is a valid ARIA nesting pattern
|
|
251
|
+
*/
|
|
252
|
+
isValidAriaNesting(parent, child) {
|
|
253
|
+
const VALID_ARIA_NESTING = {
|
|
254
|
+
listbox: ['option'],
|
|
255
|
+
menu: ['menuitem', 'menuitemcheckbox', 'menuitemradio', 'menu'],
|
|
256
|
+
menubar: ['menuitem', 'menuitemcheckbox', 'menuitemradio', 'menu'],
|
|
257
|
+
menuitem: ['menu', 'menubar'],
|
|
258
|
+
tablist: ['tab'],
|
|
259
|
+
tree: ['treeitem', 'group'],
|
|
260
|
+
treeitem: ['group', 'tree'],
|
|
261
|
+
grid: ['gridcell', 'row', 'rowgroup'],
|
|
262
|
+
row: ['gridcell', 'columnheader', 'rowheader', 'cell'],
|
|
263
|
+
rowgroup: ['row'],
|
|
264
|
+
radiogroup: ['radio'],
|
|
265
|
+
combobox: ['listbox', 'textbox', 'tree', 'grid', 'dialog'],
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
const parentRole = (parent.getAttribute('role') || '').toLowerCase();
|
|
269
|
+
const childRole = (child.getAttribute('role') || '').toLowerCase();
|
|
270
|
+
|
|
271
|
+
if (parentRole && VALID_ARIA_NESTING[parentRole]) {
|
|
272
|
+
if (VALID_ARIA_NESTING[parentRole].includes(childRole)) {
|
|
273
|
+
return true;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
if (parent.tagName === 'LABEL' && child.closest('label') === parent) {
|
|
278
|
+
return true;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
return false;
|
|
282
|
+
},
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Check if element has event handlers that suggest interactivity.
|
|
286
|
+
* @param {HTMLElement} element - The element to check
|
|
287
|
+
* @returns {boolean} True if element has interactive handlers
|
|
288
|
+
*/
|
|
289
|
+
hasInteractiveHandler(element) {
|
|
290
|
+
return (
|
|
291
|
+
element.hasAttribute('onclick') ||
|
|
292
|
+
element.hasAttribute('onmousedown') ||
|
|
293
|
+
element.hasAttribute('onmouseup') ||
|
|
294
|
+
element.hasAttribute('ontouchstart') ||
|
|
295
|
+
element.hasAttribute('onkeydown') ||
|
|
296
|
+
element.hasAttribute('onkeyup')
|
|
297
|
+
);
|
|
298
|
+
},
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Check if an element is within a navigation context (nav, menu, menubar).
|
|
302
|
+
* @param {HTMLElement} element - The element to check
|
|
303
|
+
* @returns {boolean} True if element is within a navigation context
|
|
304
|
+
*/
|
|
305
|
+
isWithinNavContext(element) {
|
|
306
|
+
let ancestor = element.parentElement;
|
|
307
|
+
while (ancestor) {
|
|
308
|
+
const tag = ancestor.tagName;
|
|
309
|
+
const role = (ancestor.getAttribute('role') || '').toLowerCase();
|
|
310
|
+
if (tag === 'NAV' || role === 'navigation' || role === 'menu' || role === 'menubar') {
|
|
311
|
+
return true;
|
|
312
|
+
}
|
|
313
|
+
ancestor = ancestor.parentElement;
|
|
314
|
+
}
|
|
315
|
+
return false;
|
|
316
|
+
},
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Check if an element is a landmark element.
|
|
320
|
+
* @param {HTMLElement} element - The element to check
|
|
321
|
+
* @returns {boolean} True if element is a landmark
|
|
322
|
+
*/
|
|
323
|
+
isLandmark(element) {
|
|
324
|
+
const landmarkTags = ['NAV', 'MAIN', 'HEADER', 'FOOTER', 'ASIDE', 'SECTION'];
|
|
325
|
+
const landmarkRoles = [
|
|
326
|
+
'navigation',
|
|
327
|
+
'main',
|
|
328
|
+
'banner',
|
|
329
|
+
'contentinfo',
|
|
330
|
+
'complementary',
|
|
331
|
+
'region',
|
|
332
|
+
'search',
|
|
333
|
+
'form',
|
|
334
|
+
];
|
|
335
|
+
if (landmarkTags.includes(element.tagName)) {
|
|
336
|
+
return true;
|
|
337
|
+
}
|
|
338
|
+
const role = (element.getAttribute('role') || '').toLowerCase();
|
|
339
|
+
return landmarkRoles.includes(role);
|
|
340
|
+
},
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Get the nearest semantic container for an element.
|
|
344
|
+
* @param {HTMLElement} el - The element
|
|
345
|
+
* @returns {HTMLElement|null} The nearest semantic container
|
|
346
|
+
*/
|
|
347
|
+
getSemanticContainer(el) {
|
|
348
|
+
const containerTags = ['ARTICLE', 'SECTION', 'LI', 'TD', 'TH', 'BLOCKQUOTE', 'FIGURE'];
|
|
349
|
+
let ancestor = el.parentElement;
|
|
350
|
+
while (ancestor && ancestor !== document.body) {
|
|
351
|
+
if (containerTags.includes(ancestor.tagName) || ancestor.hasAttribute('role')) {
|
|
352
|
+
return ancestor;
|
|
353
|
+
}
|
|
354
|
+
ancestor = ancestor.parentElement;
|
|
355
|
+
}
|
|
356
|
+
return null;
|
|
357
|
+
},
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Gets the heading level from an element.
|
|
361
|
+
* Supports native heading elements (h1-h6) and ARIA headings (role="heading" with aria-level).
|
|
362
|
+
* @param {HTMLElement} element - The element to check
|
|
363
|
+
* @returns {number|null} The heading level (1-6+) or null if not a valid heading
|
|
364
|
+
*/
|
|
365
|
+
getHeadingLevel(element) {
|
|
366
|
+
const tagName = element.tagName.toUpperCase();
|
|
367
|
+
const role = element.getAttribute('role');
|
|
368
|
+
const ariaLevel = element.getAttribute('aria-level');
|
|
369
|
+
|
|
370
|
+
if (role === 'heading') {
|
|
371
|
+
if (ariaLevel) {
|
|
372
|
+
const level = parseInt(ariaLevel, 10);
|
|
373
|
+
if (!isNaN(level) && level >= 1) {
|
|
374
|
+
return level;
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
return null;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
if (/^H[1-6]$/.test(tagName)) {
|
|
381
|
+
if (ariaLevel) {
|
|
382
|
+
const lvl = parseInt(ariaLevel, 10);
|
|
383
|
+
if (!isNaN(lvl) && lvl >= 1) {
|
|
384
|
+
return lvl;
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
return parseInt(tagName.charAt(1), 10);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
return null;
|
|
391
|
+
},
|
|
392
|
+
|
|
137
393
|
/**
|
|
138
394
|
* Checks if the given element has focus.
|
|
139
395
|
*
|
package/src/formUtils.js
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Form-related accessibility utilities
|
|
3
|
+
* @module formUtils
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const formUtils = {
|
|
7
|
+
/**
|
|
8
|
+
* Check if an element is a labellable form control per HTML spec.
|
|
9
|
+
* @param {Element} element - The element to check
|
|
10
|
+
* @returns {boolean} True if the element is labellable
|
|
11
|
+
*/
|
|
12
|
+
isLabellable(element) {
|
|
13
|
+
if (!element) {
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
const labellable = ['input', 'select', 'textarea', 'button', 'meter', 'output', 'progress'];
|
|
17
|
+
const tagName = element.tagName.toLowerCase();
|
|
18
|
+
return labellable.includes(tagName);
|
|
19
|
+
},
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Check if an element is a hidden input.
|
|
23
|
+
* @param {Element} element - The element to check
|
|
24
|
+
* @returns {boolean} True if the element is a hidden input
|
|
25
|
+
*/
|
|
26
|
+
isHiddenInput(element) {
|
|
27
|
+
if (!element) {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
return (
|
|
31
|
+
element.tagName.toLowerCase() === 'input' &&
|
|
32
|
+
(element.getAttribute('type') || '').toLowerCase() === 'hidden'
|
|
33
|
+
);
|
|
34
|
+
},
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Checks if an element has an explicit accessible name via aria-labelledby, aria-label, or title.
|
|
38
|
+
* Text content is NOT checked (useful for containers like radiogroup/group where
|
|
39
|
+
* text content is not a valid accessible name source).
|
|
40
|
+
* @param {Element} element - The element to check
|
|
41
|
+
* @returns {boolean} True if element has an explicit accessible name
|
|
42
|
+
*/
|
|
43
|
+
hasExplicitAccessibleName(element) {
|
|
44
|
+
if (element.hasAttribute('aria-labelledby')) {
|
|
45
|
+
const ids = element.getAttribute('aria-labelledby').trim().split(/\s+/);
|
|
46
|
+
for (let i = 0; i < ids.length; i++) {
|
|
47
|
+
const labelEl = document.getElementById(ids[i]);
|
|
48
|
+
if (labelEl && labelEl.textContent.trim()) {
|
|
49
|
+
return true;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (element.hasAttribute('aria-label')) {
|
|
55
|
+
const ariaLabel = element.getAttribute('aria-label').trim();
|
|
56
|
+
if (ariaLabel) {
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (element.hasAttribute('title')) {
|
|
62
|
+
const title = element.getAttribute('title').trim();
|
|
63
|
+
if (title) {
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return false;
|
|
69
|
+
},
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Checks if a form control is properly grouped with an accessible label.
|
|
73
|
+
* Proper grouping means inside a fieldset with a legend, or inside an element
|
|
74
|
+
* with role="radiogroup" or role="group" that has an explicit accessible name.
|
|
75
|
+
* @param {HTMLElement} control - The form control to check
|
|
76
|
+
* @param {string} [groupRole='radiogroup'] - The ARIA group role to look for
|
|
77
|
+
* @returns {boolean} True if properly grouped
|
|
78
|
+
*/
|
|
79
|
+
isProperlyGrouped(control, groupRole) {
|
|
80
|
+
groupRole = groupRole || 'radiogroup';
|
|
81
|
+
|
|
82
|
+
const fieldset = control.closest('fieldset');
|
|
83
|
+
if (fieldset) {
|
|
84
|
+
const legend = fieldset.querySelector('legend');
|
|
85
|
+
if (legend && legend.textContent.trim()) {
|
|
86
|
+
return true;
|
|
87
|
+
}
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const group = control.closest('[role="' + groupRole + '"]');
|
|
92
|
+
if (group) {
|
|
93
|
+
if (formUtils.hasExplicitAccessibleName(group)) {
|
|
94
|
+
return true;
|
|
95
|
+
}
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return false;
|
|
100
|
+
},
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Finds the grouping ancestor for an element (fieldset, form, or ARIA group).
|
|
104
|
+
* @param {Element} element - The element to start from
|
|
105
|
+
* @returns {Element} The grouping ancestor or document.body if none found
|
|
106
|
+
*/
|
|
107
|
+
findGroupingAncestor(element) {
|
|
108
|
+
let current = element.parentElement;
|
|
109
|
+
|
|
110
|
+
while (current && current !== document.body) {
|
|
111
|
+
const tagName = current.tagName;
|
|
112
|
+
const role = current.getAttribute('role');
|
|
113
|
+
|
|
114
|
+
if (
|
|
115
|
+
tagName === 'FIELDSET' ||
|
|
116
|
+
tagName === 'FORM' ||
|
|
117
|
+
role === 'radiogroup' ||
|
|
118
|
+
role === 'group'
|
|
119
|
+
) {
|
|
120
|
+
return current;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
current = current.parentElement;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return document.body;
|
|
127
|
+
},
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Check if a native form element has an associated label.
|
|
131
|
+
* Checks label[for], wrapping label, aria-label, aria-labelledby, and title.
|
|
132
|
+
* @param {HTMLElement} element - The element to check
|
|
133
|
+
* @returns {boolean} True if the element has an associated label
|
|
134
|
+
*/
|
|
135
|
+
hasAssociatedLabel(element) {
|
|
136
|
+
if (element.id) {
|
|
137
|
+
const label = document.querySelector('label[for="' + element.id + '"]');
|
|
138
|
+
if (label && label.textContent.trim()) {
|
|
139
|
+
return true;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const parentLabel = element.closest('label');
|
|
144
|
+
if (parentLabel && parentLabel.textContent.trim()) {
|
|
145
|
+
return true;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return false;
|
|
149
|
+
},
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Get the text content of an element, excluding form control children.
|
|
153
|
+
* Useful for wrapped labels like <label><input type="checkbox"> Remember me</label>
|
|
154
|
+
* where the input element should not contribute to label text.
|
|
155
|
+
* @param {Element} element - The element to get text from
|
|
156
|
+
* @returns {string} The text content excluding form controls
|
|
157
|
+
*/
|
|
158
|
+
getTextContentExcludingControls(element) {
|
|
159
|
+
let text = '';
|
|
160
|
+
for (let i = 0; i < element.childNodes.length; i++) {
|
|
161
|
+
const node = element.childNodes[i];
|
|
162
|
+
if (node.nodeType === Node.TEXT_NODE) {
|
|
163
|
+
text += node.textContent;
|
|
164
|
+
} else if (node.nodeType === Node.ELEMENT_NODE) {
|
|
165
|
+
const tagName = node.tagName.toLowerCase();
|
|
166
|
+
if (!['input', 'select', 'textarea', 'button'].includes(tagName)) {
|
|
167
|
+
text += formUtils.getTextContentExcludingControls(node);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return text;
|
|
172
|
+
},
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
module.exports = formUtils;
|
|
@@ -1,44 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Checks if a CSS content value is meaningful (non-empty, non-whitespace).
|
|
3
|
+
* Strips surrounding quotes and checks for actual visible content.
|
|
4
|
+
*
|
|
5
|
+
* @param {string} rawValue - The raw CSS content value from getComputedStyle
|
|
6
|
+
* @returns {string|false} The cleaned content string or false if empty/whitespace
|
|
7
|
+
*/
|
|
8
|
+
function extractMeaningfulContent(rawValue) {
|
|
9
|
+
if (!rawValue || rawValue === 'none' || rawValue === 'normal') {
|
|
10
|
+
return false;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// Remove surrounding quotes (single or double)
|
|
14
|
+
let cleaned = rawValue.replace(/^["'](.*)["']$/, '$1');
|
|
15
|
+
|
|
16
|
+
// Trim whitespace - content that is only whitespace is not meaningful
|
|
17
|
+
cleaned = cleaned.trim();
|
|
18
|
+
|
|
19
|
+
return cleaned.length > 0 ? cleaned : false;
|
|
20
|
+
}
|
|
21
|
+
|
|
1
22
|
/**
|
|
2
23
|
* Gets the CSS generated content for an element's ::before or ::after pseudo-elements.
|
|
3
24
|
* This function only checks for content added via the CSS `content` property,
|
|
4
|
-
* not the element's own text content.
|
|
25
|
+
* not the element's own text content. Empty, whitespace-only, and blank content
|
|
26
|
+
* values are filtered out as they do not convey meaningful information.
|
|
5
27
|
*
|
|
6
28
|
* @param {Element} el - The DOM element to check
|
|
7
29
|
* @param {string} [pseudoElement='both'] - Which pseudo-element to check ('before', 'after', or 'both')
|
|
8
30
|
* @returns {string|boolean} The generated content as a string or false if none exists
|
|
9
31
|
*/
|
|
10
32
|
function getCSSGeneratedContent(el, pseudoElement = 'both') {
|
|
11
|
-
if (!el)
|
|
33
|
+
if (!el) {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
12
36
|
|
|
13
37
|
let content = '';
|
|
14
38
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
const cleanBefore = before.replace(/^["'](.*)["']$/, '$1');
|
|
39
|
+
try {
|
|
40
|
+
if (pseudoElement === 'before' || pseudoElement === 'both') {
|
|
41
|
+
const style = window.getComputedStyle(el, '::before');
|
|
42
|
+
const before = style.getPropertyValue('content');
|
|
43
|
+
const cleanBefore = extractMeaningfulContent(before);
|
|
21
44
|
if (cleanBefore) {
|
|
22
45
|
content += cleanBefore;
|
|
23
46
|
}
|
|
24
47
|
}
|
|
25
|
-
}
|
|
26
48
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
// Remove surrounding quotes if present
|
|
32
|
-
const cleanAfter = after.replace(/^["'](.*)["']$/, '$1');
|
|
49
|
+
if (pseudoElement === 'after' || pseudoElement === 'both') {
|
|
50
|
+
const style = window.getComputedStyle(el, '::after');
|
|
51
|
+
const after = style.getPropertyValue('content');
|
|
52
|
+
const cleanAfter = extractMeaningfulContent(after);
|
|
33
53
|
if (cleanAfter) {
|
|
34
54
|
content += (content ? ' ' : '') + cleanAfter;
|
|
35
55
|
}
|
|
36
56
|
}
|
|
57
|
+
} catch (_e) {
|
|
58
|
+
return false;
|
|
37
59
|
}
|
|
38
60
|
|
|
39
61
|
return content ? content.trim() : false;
|
|
40
62
|
}
|
|
41
63
|
|
|
42
64
|
module.exports = {
|
|
43
|
-
getCSSGeneratedContent
|
|
44
|
-
};
|
|
65
|
+
getCSSGeneratedContent,
|
|
66
|
+
};
|
package/src/index.js
CHANGED
|
@@ -63,6 +63,18 @@ const isValidUrl = require('./isValidUrl.js');
|
|
|
63
63
|
// String utilities
|
|
64
64
|
const stringUtils = require('./stringUtils.js');
|
|
65
65
|
|
|
66
|
+
// Constants
|
|
67
|
+
const constants = require('./constants.js');
|
|
68
|
+
|
|
69
|
+
// CSS utilities
|
|
70
|
+
const cssUtils = require('./cssUtils.js');
|
|
71
|
+
|
|
72
|
+
// Form utilities
|
|
73
|
+
const formUtils = require('./formUtils.js');
|
|
74
|
+
|
|
75
|
+
// Table utilities
|
|
76
|
+
const tableUtils = require('./tableUtils.js');
|
|
77
|
+
|
|
66
78
|
// Query cache utilities
|
|
67
79
|
const queryCache = require('./queryCache.js');
|
|
68
80
|
|
|
@@ -102,6 +114,10 @@ module.exports = {
|
|
|
102
114
|
...testOrder,
|
|
103
115
|
...isValidUrl,
|
|
104
116
|
...stringUtils,
|
|
117
|
+
...constants,
|
|
118
|
+
...cssUtils,
|
|
119
|
+
...formUtils,
|
|
120
|
+
...tableUtils,
|
|
105
121
|
...queryCache,
|
|
106
|
-
...listEventListeners
|
|
107
|
-
};
|
|
122
|
+
...listEventListeners,
|
|
123
|
+
};
|