@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/stringUtils.js
CHANGED
|
@@ -14,7 +14,7 @@ const stringUtils = (function () {
|
|
|
14
14
|
* @returns {boolean} Whether the value is a string.
|
|
15
15
|
*/
|
|
16
16
|
function isString(str) {
|
|
17
|
-
return typeof str ===
|
|
17
|
+
return typeof str === 'string' || str instanceof String;
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
/**
|
|
@@ -32,7 +32,7 @@ const stringUtils = (function () {
|
|
|
32
32
|
* @returns {boolean}
|
|
33
33
|
*/
|
|
34
34
|
function isNormalInteger(str) {
|
|
35
|
-
const n =
|
|
35
|
+
const n = Math.trunc(Number(str));
|
|
36
36
|
return String(n) === str && n >= 0;
|
|
37
37
|
}
|
|
38
38
|
|
|
@@ -49,14 +49,11 @@ const stringUtils = (function () {
|
|
|
49
49
|
let char;
|
|
50
50
|
let isUpper = true;
|
|
51
51
|
|
|
52
|
-
str = str.replace(/[^\w\s]|_/g,
|
|
52
|
+
str = str.replace(/[^\w\s]|_/g, '').replace(/\s+/g, ' ');
|
|
53
53
|
|
|
54
54
|
while (i <= str.length) {
|
|
55
55
|
char = str.charAt(i);
|
|
56
|
-
if (
|
|
57
|
-
char.trim() !== "" &&
|
|
58
|
-
false === (!isNaN(parseFloat(char)) && isFinite(char))
|
|
59
|
-
) {
|
|
56
|
+
if (char.trim() !== '' && false === (!isNaN(parseFloat(char)) && isFinite(char))) {
|
|
60
57
|
if (char === char.toLowerCase()) {
|
|
61
58
|
isUpper = false;
|
|
62
59
|
}
|
|
@@ -94,14 +91,15 @@ const stringUtils = (function () {
|
|
|
94
91
|
* @returns {string} A string containing all concatenated text content from the element.
|
|
95
92
|
*/
|
|
96
93
|
function getAllText(el) {
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
);
|
|
94
|
+
// Check for form element value (input, textarea, select)
|
|
95
|
+
if (el.value !== undefined && el.value !== '') {
|
|
96
|
+
return el.value;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const walker = document.createTreeWalker(el, NodeFilter.SHOW_ALL, null, false);
|
|
103
100
|
const textNodes = [];
|
|
104
|
-
let node
|
|
101
|
+
let node;
|
|
102
|
+
let text;
|
|
105
103
|
|
|
106
104
|
while (walker.nextNode()) {
|
|
107
105
|
node = walker.currentNode;
|
|
@@ -113,15 +111,15 @@ const stringUtils = (function () {
|
|
|
113
111
|
textNodes.push(node.textContent.trim());
|
|
114
112
|
}
|
|
115
113
|
} else if (node.nodeType === Node.ELEMENT_NODE) {
|
|
116
|
-
if (node.hasAttribute(
|
|
117
|
-
textNodes.push(node.getAttribute(
|
|
118
|
-
} else if (node.tagName ===
|
|
119
|
-
textNodes.push(node.getAttribute(
|
|
114
|
+
if (node.hasAttribute('aria-label')) {
|
|
115
|
+
textNodes.push(node.getAttribute('aria-label'));
|
|
116
|
+
} else if (node.tagName === 'IMG' && node.hasAttribute('alt')) {
|
|
117
|
+
textNodes.push(node.getAttribute('alt'));
|
|
120
118
|
}
|
|
121
119
|
}
|
|
122
120
|
}
|
|
123
121
|
|
|
124
|
-
return textNodes.join(
|
|
122
|
+
return textNodes.join(' ');
|
|
125
123
|
}
|
|
126
124
|
|
|
127
125
|
/**
|
|
@@ -131,7 +129,150 @@ const stringUtils = (function () {
|
|
|
131
129
|
* @returns {boolean} True if the element contains text, false otherwise.
|
|
132
130
|
*/
|
|
133
131
|
function hasText(element) {
|
|
134
|
-
return getAllText(element).trim() !==
|
|
132
|
+
return getAllText(element).trim() !== '';
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Checks if a string is empty or contains only whitespace/invisible characters,
|
|
137
|
+
* including zero-width spaces, no-break spaces, and other Unicode whitespace.
|
|
138
|
+
* @param {string} str - The string to check
|
|
139
|
+
* @returns {boolean} True if empty or whitespace/invisible only
|
|
140
|
+
*/
|
|
141
|
+
function isEmptyOrWhitespace(str) {
|
|
142
|
+
if (!str) {
|
|
143
|
+
return true;
|
|
144
|
+
}
|
|
145
|
+
// eslint-disable-next-line no-misleading-character-class
|
|
146
|
+
const cleaned = str.replace(/[\s\u00A0\u200B\u200C\u200D\u2060\uFEFF]/g, '');
|
|
147
|
+
return cleaned.length === 0;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Checks if a title is generic, meaningless, or placeholder-like.
|
|
152
|
+
* @param {string} title - The title to check
|
|
153
|
+
* @returns {boolean} True if the title is generic/meaningless
|
|
154
|
+
*/
|
|
155
|
+
function isGenericTitle(title) {
|
|
156
|
+
if (!title) {
|
|
157
|
+
return false;
|
|
158
|
+
}
|
|
159
|
+
const genericTitles = ['iframe', 'frame', 'untitled', 'title', 'content', 'main', 'page'];
|
|
160
|
+
const normalized = title.toLowerCase().trim();
|
|
161
|
+
|
|
162
|
+
if (genericTitles.includes(normalized)) {
|
|
163
|
+
return true;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (/^(frame|iframe|untitled|title)\d*$/i.test(normalized)) {
|
|
167
|
+
return true;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return false;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Checks if the given text is generic/meaningless link text.
|
|
175
|
+
* @param {string} text - The accessible name to check
|
|
176
|
+
* @param {string[]} [genericList] - Optional custom list of generic link text
|
|
177
|
+
* @returns {boolean} True if the text is generic
|
|
178
|
+
*/
|
|
179
|
+
function isGenericLinkText(text, genericList) {
|
|
180
|
+
if (!text) {
|
|
181
|
+
return false;
|
|
182
|
+
}
|
|
183
|
+
const defaults = [
|
|
184
|
+
'click here',
|
|
185
|
+
'here',
|
|
186
|
+
'more',
|
|
187
|
+
'read more',
|
|
188
|
+
'learn more',
|
|
189
|
+
'click',
|
|
190
|
+
'link',
|
|
191
|
+
'this',
|
|
192
|
+
'page',
|
|
193
|
+
'article',
|
|
194
|
+
'continue',
|
|
195
|
+
'go',
|
|
196
|
+
'see more',
|
|
197
|
+
'view',
|
|
198
|
+
'download',
|
|
199
|
+
'pdf',
|
|
200
|
+
'document',
|
|
201
|
+
'form',
|
|
202
|
+
'submit',
|
|
203
|
+
'button',
|
|
204
|
+
'press',
|
|
205
|
+
'select',
|
|
206
|
+
'choose',
|
|
207
|
+
];
|
|
208
|
+
const list = genericList || defaults;
|
|
209
|
+
const lowerText = text.toLowerCase().trim();
|
|
210
|
+
return list.some(function (generic) {
|
|
211
|
+
return lowerText === generic;
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Get the actual visible text content of an element, ignoring aria-label.
|
|
217
|
+
* @param {Element} element - The DOM element
|
|
218
|
+
* @returns {string} The visible text content
|
|
219
|
+
*/
|
|
220
|
+
function getActualVisibleText(element) {
|
|
221
|
+
if (!element) {
|
|
222
|
+
return '';
|
|
223
|
+
}
|
|
224
|
+
return (element.textContent || '').trim();
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Checks if text contains a warning about new window/tab behavior.
|
|
229
|
+
* @param {string} text - The text to check
|
|
230
|
+
* @returns {boolean} True if the text contains a warning
|
|
231
|
+
*/
|
|
232
|
+
function hasNewWindowWarning(text) {
|
|
233
|
+
if (!text) {
|
|
234
|
+
return false;
|
|
235
|
+
}
|
|
236
|
+
const warnings = [
|
|
237
|
+
'new window',
|
|
238
|
+
'new tab',
|
|
239
|
+
'opens in new',
|
|
240
|
+
'opens in a new',
|
|
241
|
+
'opens new',
|
|
242
|
+
'external link',
|
|
243
|
+
'external site',
|
|
244
|
+
];
|
|
245
|
+
const lowerText = text.toLowerCase();
|
|
246
|
+
return warnings.some(function (warning) {
|
|
247
|
+
return lowerText.includes(warning);
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Extracts DOM text content from an element, including img alt text,
|
|
253
|
+
* but excluding ARIA attributes (aria-label, aria-labelledby).
|
|
254
|
+
* Useful for detecting actual visible/DOM text that may conflict with ARIA labels.
|
|
255
|
+
*
|
|
256
|
+
* @param {Element} root - The DOM element from which to extract text.
|
|
257
|
+
* @returns {string} Concatenated text from text nodes and img alt attributes.
|
|
258
|
+
*/
|
|
259
|
+
function textIncludingImgAlt(root) {
|
|
260
|
+
let out = '';
|
|
261
|
+
const walker = document.createTreeWalker(
|
|
262
|
+
root,
|
|
263
|
+
NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT // eslint-disable-line no-bitwise
|
|
264
|
+
);
|
|
265
|
+
let n = walker.currentNode;
|
|
266
|
+
while (n) {
|
|
267
|
+
if (n.nodeType === Node.TEXT_NODE) {
|
|
268
|
+
out += n.nodeValue;
|
|
269
|
+
}
|
|
270
|
+
if (n.nodeType === Node.ELEMENT_NODE && n.tagName === 'IMG') {
|
|
271
|
+
out += n.getAttribute('alt') || '';
|
|
272
|
+
}
|
|
273
|
+
n = walker.nextNode();
|
|
274
|
+
}
|
|
275
|
+
return out;
|
|
135
276
|
}
|
|
136
277
|
|
|
137
278
|
return {
|
|
@@ -143,7 +284,13 @@ const stringUtils = (function () {
|
|
|
143
284
|
isAlphaNumeric,
|
|
144
285
|
getPathFromUrl,
|
|
145
286
|
getAllText,
|
|
146
|
-
hasText
|
|
287
|
+
hasText,
|
|
288
|
+
textIncludingImgAlt,
|
|
289
|
+
isEmptyOrWhitespace,
|
|
290
|
+
isGenericTitle,
|
|
291
|
+
isGenericLinkText,
|
|
292
|
+
getActualVisibleText,
|
|
293
|
+
hasNewWindowWarning,
|
|
147
294
|
};
|
|
148
295
|
})();
|
|
149
296
|
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Table-related accessibility utilities
|
|
3
|
+
* @module tableUtils
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const tableUtils = {
|
|
7
|
+
/**
|
|
8
|
+
* Check if table has multiple header rows (rows containing th elements).
|
|
9
|
+
* @param {HTMLElement} table - The table element
|
|
10
|
+
* @returns {boolean} True if table has multiple header rows
|
|
11
|
+
*/
|
|
12
|
+
hasMultipleHeaderRows(table) {
|
|
13
|
+
const rows = table.querySelectorAll('tr');
|
|
14
|
+
let headerRowCount = 0;
|
|
15
|
+
|
|
16
|
+
for (let i = 0; i < rows.length; i++) {
|
|
17
|
+
const thCells = rows[i].querySelectorAll('th');
|
|
18
|
+
const tdCells = rows[i].querySelectorAll('td');
|
|
19
|
+
|
|
20
|
+
if (thCells.length > 0 && thCells.length >= tdCells.length) {
|
|
21
|
+
headerRowCount++;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return headerRowCount >= 3;
|
|
26
|
+
},
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Check if table has header cells with large colspan/rowspan values.
|
|
30
|
+
* @param {HTMLElement} table - The table element
|
|
31
|
+
* @param {number} [maxColspan=4] - Maximum acceptable colspan
|
|
32
|
+
* @param {number} [maxRowspan=3] - Maximum acceptable rowspan
|
|
33
|
+
* @returns {boolean} True if table has complex header spans
|
|
34
|
+
*/
|
|
35
|
+
hasComplexHeaderSpans(table, maxColspan, maxRowspan) {
|
|
36
|
+
maxColspan = maxColspan || 4;
|
|
37
|
+
maxRowspan = maxRowspan || 3;
|
|
38
|
+
|
|
39
|
+
const headerCells = table.querySelectorAll('th[colspan], th[rowspan]');
|
|
40
|
+
|
|
41
|
+
for (let i = 0; i < headerCells.length; i++) {
|
|
42
|
+
const colspan = parseInt(headerCells[i].getAttribute('colspan'), 10) || 1;
|
|
43
|
+
const rowspan = parseInt(headerCells[i].getAttribute('rowspan'), 10) || 1;
|
|
44
|
+
|
|
45
|
+
if (colspan > maxColspan || rowspan > maxRowspan) {
|
|
46
|
+
return true;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return false;
|
|
51
|
+
},
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Count effective columns in a table (accounting for colspan).
|
|
55
|
+
* @param {HTMLElement} table - The table element
|
|
56
|
+
* @returns {number} Maximum column count
|
|
57
|
+
*/
|
|
58
|
+
getMaxColumnCount(table) {
|
|
59
|
+
const rows = table.querySelectorAll('tr');
|
|
60
|
+
let maxCols = 0;
|
|
61
|
+
|
|
62
|
+
for (let i = 0; i < rows.length; i++) {
|
|
63
|
+
let colCount = 0;
|
|
64
|
+
const cells = rows[i].querySelectorAll('td, th');
|
|
65
|
+
|
|
66
|
+
for (let j = 0; j < cells.length; j++) {
|
|
67
|
+
const colspan = parseInt(cells[j].getAttribute('colspan'), 10) || 1;
|
|
68
|
+
colCount += colspan;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (colCount > maxCols) {
|
|
72
|
+
maxCols = colCount;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return maxCols;
|
|
77
|
+
},
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Check if table has multiple rows with colspan/rowspan on header cells,
|
|
81
|
+
* indicating multi-level grouped headers.
|
|
82
|
+
* @param {HTMLElement} table - The table element
|
|
83
|
+
* @returns {boolean} True if table has complex header groupings
|
|
84
|
+
*/
|
|
85
|
+
hasMultiLevelHeaderGroupings(table) {
|
|
86
|
+
const rows = table.querySelectorAll('tr');
|
|
87
|
+
let rowsWithHeaderSpans = 0;
|
|
88
|
+
|
|
89
|
+
for (let i = 0; i < rows.length; i++) {
|
|
90
|
+
const spannedHeaders = rows[i].querySelectorAll('th[colspan], th[rowspan]');
|
|
91
|
+
if (spannedHeaders.length > 0) {
|
|
92
|
+
rowsWithHeaderSpans++;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return rowsWithHeaderSpans >= 2;
|
|
97
|
+
},
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Checks if an HTML table has only header cells (th) and no data cells (td).
|
|
101
|
+
* Indicates headers potentially separated from their data.
|
|
102
|
+
* @param {HTMLTableElement} table - The table element to check
|
|
103
|
+
* @returns {boolean} True if table has only headers, no data
|
|
104
|
+
*/
|
|
105
|
+
isHeaderOnlyTable(table) {
|
|
106
|
+
const hasHeaders = table.querySelectorAll('th').length > 0;
|
|
107
|
+
const hasDataCells = table.querySelectorAll('td').length > 0;
|
|
108
|
+
return hasHeaders && !hasDataCells;
|
|
109
|
+
},
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Checks if an ARIA table has only header roles and no cell roles.
|
|
113
|
+
* @param {HTMLElement} ariaTable - The element with role="table"
|
|
114
|
+
* @returns {boolean} True if ARIA table has only headers, no data cells
|
|
115
|
+
*/
|
|
116
|
+
isHeaderOnlyAriaTable(ariaTable) {
|
|
117
|
+
const hasHeaders =
|
|
118
|
+
ariaTable.querySelectorAll('[role="columnheader"], [role="rowheader"]').length > 0;
|
|
119
|
+
const hasDataCells =
|
|
120
|
+
ariaTable.querySelectorAll('[role="cell"], [role="gridcell"]').length > 0;
|
|
121
|
+
return hasHeaders && !hasDataCells;
|
|
122
|
+
},
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Checks if a string contains only punctuation characters.
|
|
126
|
+
* @param {string} str - The string to check
|
|
127
|
+
* @returns {boolean} True if the string contains only punctuation
|
|
128
|
+
*/
|
|
129
|
+
isPunctuationOnly(str) {
|
|
130
|
+
return /^[\p{P}\p{S}]+$/u.test(str);
|
|
131
|
+
},
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Checks if a summary is generic/useless.
|
|
135
|
+
* @param {string} summary - The summary text to check
|
|
136
|
+
* @param {string[]} [genericList] - Optional custom list of generic summaries
|
|
137
|
+
* @returns {boolean} True if the summary is generic
|
|
138
|
+
*/
|
|
139
|
+
isGenericSummary(summary, genericList) {
|
|
140
|
+
const GENERIC_SUMMARIES = genericList || [
|
|
141
|
+
'table',
|
|
142
|
+
'data',
|
|
143
|
+
'data table',
|
|
144
|
+
'information',
|
|
145
|
+
'content',
|
|
146
|
+
'main content',
|
|
147
|
+
'layout',
|
|
148
|
+
'layout table',
|
|
149
|
+
'for layout',
|
|
150
|
+
'table for layout purposes',
|
|
151
|
+
'structural table',
|
|
152
|
+
'this table is used for page layout',
|
|
153
|
+
'header',
|
|
154
|
+
'footer',
|
|
155
|
+
'navigation',
|
|
156
|
+
'nav',
|
|
157
|
+
'body',
|
|
158
|
+
'combobox',
|
|
159
|
+
'links design table',
|
|
160
|
+
'title and navigation',
|
|
161
|
+
'main heading',
|
|
162
|
+
'spacer',
|
|
163
|
+
'spacer table',
|
|
164
|
+
'menu',
|
|
165
|
+
'n/a',
|
|
166
|
+
'na',
|
|
167
|
+
'none',
|
|
168
|
+
'null',
|
|
169
|
+
'empty',
|
|
170
|
+
'blank',
|
|
171
|
+
'undefined',
|
|
172
|
+
'table summary',
|
|
173
|
+
'summary',
|
|
174
|
+
];
|
|
175
|
+
const normalized = summary.toLowerCase().trim();
|
|
176
|
+
return GENERIC_SUMMARIES.includes(normalized);
|
|
177
|
+
},
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
module.exports = tableUtils;
|
package/src/testContrast.js
CHANGED
|
@@ -1,13 +1,21 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Get the computed background color for an element.
|
|
3
3
|
* @param {Element} el - the element to be tested
|
|
4
|
+
* @param {Object} [options] - Configuration options
|
|
5
|
+
* @param {string} [options.fallbackColor='rgb(255, 255, 255)'] - Color to use when background can't be determined
|
|
6
|
+
* @param {boolean} [options.skipBackgroundImages=true] - If true (default), return false for background images. If false, use fallbackColor instead.
|
|
4
7
|
* @returns {string|boolean} background color or false
|
|
5
8
|
*/
|
|
6
|
-
function getComputedBackgroundColor(el) {
|
|
9
|
+
function getComputedBackgroundColor(el, options) {
|
|
7
10
|
if (!window.getComputedStyle || !el || el.nodeType === 9) {
|
|
8
11
|
return false;
|
|
9
12
|
}
|
|
10
13
|
|
|
14
|
+
const opts = options || {};
|
|
15
|
+
const fallbackColor = opts.fallbackColor || 'rgb(255, 255, 255)';
|
|
16
|
+
const skipBackgroundImages =
|
|
17
|
+
opts.skipBackgroundImages !== undefined ? opts.skipBackgroundImages : true;
|
|
18
|
+
|
|
11
19
|
const styles = window.getComputedStyle(el);
|
|
12
20
|
if (!styles) {
|
|
13
21
|
return false;
|
|
@@ -17,12 +25,52 @@ function getComputedBackgroundColor(el) {
|
|
|
17
25
|
if (bgImage === 'none') {
|
|
18
26
|
const bgColor = styles.getPropertyValue('background-color');
|
|
19
27
|
|
|
20
|
-
|
|
21
|
-
|
|
28
|
+
// Check for any fully transparent background (not just rgba(0,0,0,0))
|
|
29
|
+
const parsed = parseRGB(bgColor);
|
|
30
|
+
if (
|
|
31
|
+
bgColor === 'rgba(0, 0, 0, 0)' ||
|
|
32
|
+
bgColor === 'transparent' ||
|
|
33
|
+
(parsed && parsed[4] !== undefined && parseFloat(parsed[4]) === 0)
|
|
34
|
+
) {
|
|
35
|
+
if (el.parentElement) {
|
|
36
|
+
return getComputedBackgroundColor(el.parentElement, options);
|
|
37
|
+
}
|
|
38
|
+
return opts.fallbackColor !== undefined ? fallbackColor : false;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Handle semi-transparent backgrounds by blending with parent
|
|
42
|
+
if (parsed && parsed[4] !== undefined && parseFloat(parsed[4]) < 1) {
|
|
43
|
+
const alpha = parseFloat(parsed[4]);
|
|
44
|
+
const parentBg = el.parentElement
|
|
45
|
+
? getComputedBackgroundColor(el.parentElement, options)
|
|
46
|
+
: fallbackColor;
|
|
47
|
+
|
|
48
|
+
if (parentBg && parentBg !== false) {
|
|
49
|
+
const parentParsed = parseRGB(parentBg);
|
|
50
|
+
if (parentParsed) {
|
|
51
|
+
// Alpha composite: result = fg * alpha + bg * (1 - alpha)
|
|
52
|
+
const r = Math.round(
|
|
53
|
+
parseInt(parsed[1], 10) * alpha +
|
|
54
|
+
parseInt(parentParsed[1], 10) * (1 - alpha)
|
|
55
|
+
);
|
|
56
|
+
const g = Math.round(
|
|
57
|
+
parseInt(parsed[2], 10) * alpha +
|
|
58
|
+
parseInt(parentParsed[2], 10) * (1 - alpha)
|
|
59
|
+
);
|
|
60
|
+
const b = Math.round(
|
|
61
|
+
parseInt(parsed[3], 10) * alpha +
|
|
62
|
+
parseInt(parentParsed[3], 10) * (1 - alpha)
|
|
63
|
+
);
|
|
64
|
+
return 'rgb(' + r + ', ' + g + ', ' + b + ')';
|
|
65
|
+
}
|
|
66
|
+
}
|
|
22
67
|
}
|
|
23
68
|
|
|
24
69
|
return bgColor;
|
|
25
70
|
} else {
|
|
71
|
+
if (!skipBackgroundImages) {
|
|
72
|
+
return fallbackColor;
|
|
73
|
+
}
|
|
26
74
|
return false;
|
|
27
75
|
}
|
|
28
76
|
}
|
|
@@ -40,9 +88,9 @@ function luminance(R8bit, G8bit, B8bit) {
|
|
|
40
88
|
const GsRGB = G8bit / 255;
|
|
41
89
|
const BsRGB = B8bit / 255;
|
|
42
90
|
|
|
43
|
-
const R =
|
|
44
|
-
const G =
|
|
45
|
-
const B =
|
|
91
|
+
const R = RsRGB <= 0.03928 ? RsRGB / 12.92 : Math.pow((RsRGB + 0.055) / 1.055, 2.4);
|
|
92
|
+
const G = GsRGB <= 0.03928 ? GsRGB / 12.92 : Math.pow((GsRGB + 0.055) / 1.055, 2.4);
|
|
93
|
+
const B = BsRGB <= 0.03928 ? BsRGB / 12.92 : Math.pow((BsRGB + 0.055) / 1.055, 2.4);
|
|
46
94
|
|
|
47
95
|
// For the sRGB colorspace, the relative luminance of a color is defined as:
|
|
48
96
|
const L = 0.2126 * R + 0.7152 * G + 0.0722 * B;
|
|
@@ -50,6 +98,24 @@ function luminance(R8bit, G8bit, B8bit) {
|
|
|
50
98
|
return L;
|
|
51
99
|
}
|
|
52
100
|
|
|
101
|
+
/**
|
|
102
|
+
* Calculate relative luminance from an RGB object.
|
|
103
|
+
* Wrapper around luminance() that accepts an object instead of three separate arguments.
|
|
104
|
+
* @param {{ r: number, g: number, b: number }} rgb - Object with r, g, b properties (0-255)
|
|
105
|
+
* @returns {number|null} Relative luminance value (0-1) or null if input is invalid
|
|
106
|
+
*/
|
|
107
|
+
function getRelativeLuminance(rgb) {
|
|
108
|
+
if (
|
|
109
|
+
!rgb ||
|
|
110
|
+
typeof rgb.r !== 'number' ||
|
|
111
|
+
typeof rgb.g !== 'number' ||
|
|
112
|
+
typeof rgb.b !== 'number'
|
|
113
|
+
) {
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
return luminance(rgb.r, rgb.g, rgb.b);
|
|
117
|
+
}
|
|
118
|
+
|
|
53
119
|
/**
|
|
54
120
|
* Parse an RGB or RGBA color string into its components
|
|
55
121
|
* @param {string} rgb - RGB(A) color string (e.g., "rgb(255, 0, 0)" or "rgba(255, 0, 0, 0.5)")
|
|
@@ -60,6 +126,57 @@ function parseRGB(rgb) {
|
|
|
60
126
|
return rgbvals;
|
|
61
127
|
}
|
|
62
128
|
|
|
129
|
+
/**
|
|
130
|
+
* Parse an RGB/RGBA color string into a structured object.
|
|
131
|
+
* @param {string} colorStr - e.g., 'rgb(255, 128, 0)' or 'rgba(255, 128, 0, 0.5)'
|
|
132
|
+
* @returns {{ r: number, g: number, b: number, a?: number } | null}
|
|
133
|
+
*/
|
|
134
|
+
function parseColor(colorStr) {
|
|
135
|
+
if (!colorStr) {
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const match = parseRGB(colorStr);
|
|
140
|
+
if (!match) {
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const result = {
|
|
145
|
+
r: parseInt(match[1], 10),
|
|
146
|
+
g: parseInt(match[2], 10),
|
|
147
|
+
b: parseInt(match[3], 10),
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
if (match[4] !== undefined) {
|
|
151
|
+
result.a = parseFloat(match[4]);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return result;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Calculate contrast ratio between two color strings.
|
|
159
|
+
* @param {string} color1 - e.g., 'rgb(0, 0, 0)'
|
|
160
|
+
* @param {string} color2 - e.g., 'rgb(255, 255, 255)'
|
|
161
|
+
* @returns {number|null} Contrast ratio (1 to 21) or null if colors can't be parsed
|
|
162
|
+
*/
|
|
163
|
+
function getContrastRatio(color1, color2) {
|
|
164
|
+
const c1 = parseColor(color1);
|
|
165
|
+
const c2 = parseColor(color2);
|
|
166
|
+
|
|
167
|
+
if (!c1 || !c2) {
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const lum1 = luminance(c1.r, c1.g, c1.b);
|
|
172
|
+
const lum2 = luminance(c2.r, c2.g, c2.b);
|
|
173
|
+
|
|
174
|
+
const lighter = Math.max(lum1, lum2);
|
|
175
|
+
const darker = Math.min(lum1, lum2);
|
|
176
|
+
|
|
177
|
+
return Math.round(((lighter + 0.05) / (darker + 0.05)) * 100) / 100;
|
|
178
|
+
}
|
|
179
|
+
|
|
63
180
|
/**
|
|
64
181
|
* Calculate color contrast ratio between foreground and background
|
|
65
182
|
* @param {Element} el - DOM element to check
|
|
@@ -105,7 +222,7 @@ function getColorContrast(el) {
|
|
|
105
222
|
lighter = fgLuminance;
|
|
106
223
|
}
|
|
107
224
|
|
|
108
|
-
return Math.round((lighter + 0.05) / (darker + 0.05) * 100) / 100;
|
|
225
|
+
return Math.round(((lighter + 0.05) / (darker + 0.05)) * 100) / 100;
|
|
109
226
|
}
|
|
110
227
|
|
|
111
228
|
/**
|
|
@@ -132,10 +249,11 @@ function testContrast(el, options = { level: 'AA' }) {
|
|
|
132
249
|
|
|
133
250
|
// Check if element is offscreen
|
|
134
251
|
const rect = el.getBoundingClientRect();
|
|
135
|
-
const selfOffscreen =
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
252
|
+
const selfOffscreen =
|
|
253
|
+
rect.left + rect.width <= 0 ||
|
|
254
|
+
rect.top + rect.height <= 0 ||
|
|
255
|
+
rect.left >= window.innerWidth ||
|
|
256
|
+
rect.top >= window.innerHeight;
|
|
139
257
|
|
|
140
258
|
// Get direct text (excluding child elements)
|
|
141
259
|
let selfDirectText = '';
|
|
@@ -157,14 +275,6 @@ function testContrast(el, options = { level: 'AA' }) {
|
|
|
157
275
|
return true;
|
|
158
276
|
}
|
|
159
277
|
|
|
160
|
-
// JSDOM Test Compatibility: Handle test case for identical colors
|
|
161
|
-
if (selfFG === selfBG || (selfFG === 'rgb(0, 0, 0)' && selfBG === 'rgb(0, 0, 0)')) {
|
|
162
|
-
// Special case for test
|
|
163
|
-
if (el.textContent === 'Test text') {
|
|
164
|
-
return false;
|
|
165
|
-
}
|
|
166
|
-
}
|
|
167
|
-
|
|
168
278
|
// Determine the contrast required based on the level passed to the function
|
|
169
279
|
if (level === 'AA') {
|
|
170
280
|
if (selfSize < 18) {
|
|
@@ -209,8 +319,10 @@ function testContrast(el, options = { level: 'AA' }) {
|
|
|
209
319
|
// Only test contrast if there's a difference between the element and its parent
|
|
210
320
|
// in terms of colors, font size, or font weight
|
|
211
321
|
if (
|
|
212
|
-
|
|
213
|
-
|
|
322
|
+
selfFG !== parentFG ||
|
|
323
|
+
selfBG !== parentBG ||
|
|
324
|
+
selfSize !== parentSize ||
|
|
325
|
+
selfWeight !== parentWeight
|
|
214
326
|
) {
|
|
215
327
|
const contrast = getColorContrast(el);
|
|
216
328
|
|
|
@@ -237,5 +349,8 @@ module.exports = {
|
|
|
237
349
|
getComputedBackgroundColor,
|
|
238
350
|
luminance,
|
|
239
351
|
parseRGB,
|
|
240
|
-
|
|
352
|
+
parseColor,
|
|
353
|
+
getRelativeLuminance,
|
|
354
|
+
getContrastRatio,
|
|
355
|
+
getColorContrast,
|
|
241
356
|
};
|