@afixt/test-utils 1.2.0 → 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.
@@ -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) return false;
33
+ if (!el) {
34
+ return false;
35
+ }
12
36
 
13
37
  let content = '';
14
38
 
15
- if (pseudoElement === 'before' || pseudoElement === 'both') {
16
- const style = window.getComputedStyle(el, '::before');
17
- const before = style.getPropertyValue('content');
18
- if (before && before !== 'none' && before !== 'normal' && before !== '""' && before !== "''") {
19
- // Remove surrounding quotes if present
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
- if (pseudoElement === 'after' || pseudoElement === 'both') {
28
- const style = window.getComputedStyle(el, '::after');
29
- const after = style.getPropertyValue('content');
30
- if (after && after !== 'none' && after !== 'normal' && after !== '""' && after !== "''") {
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
+ };
@@ -132,6 +132,149 @@ const stringUtils = (function () {
132
132
  return getAllText(element).trim() !== '';
133
133
  }
134
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;
276
+ }
277
+
135
278
  return {
136
279
  isEmpty,
137
280
  isString,
@@ -142,6 +285,12 @@ const stringUtils = (function () {
142
285
  getPathFromUrl,
143
286
  getAllText,
144
287
  hasText,
288
+ textIncludingImgAlt,
289
+ isEmptyOrWhitespace,
290
+ isGenericTitle,
291
+ isGenericLinkText,
292
+ getActualVisibleText,
293
+ hasNewWindowWarning,
145
294
  };
146
295
  })();
147
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;
@@ -25,13 +25,47 @@ function getComputedBackgroundColor(el, options) {
25
25
  if (bgImage === 'none') {
26
26
  const bgColor = styles.getPropertyValue('background-color');
27
27
 
28
- if (bgColor === 'rgba(0, 0, 0, 0)') {
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
+ ) {
29
35
  if (el.parentElement) {
30
36
  return getComputedBackgroundColor(el.parentElement, options);
31
37
  }
32
38
  return opts.fallbackColor !== undefined ? fallbackColor : false;
33
39
  }
34
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
+ }
67
+ }
68
+
35
69
  return bgColor;
36
70
  } else {
37
71
  if (!skipBackgroundImages) {