@afixt/test-utils 2.0.0 → 2.1.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.
- package/CHANGELOG.md +7 -0
- package/CLAUDE.md +1 -1
- package/README.md +40 -1
- package/docs/arrayUtils.js.html +8 -13
- package/docs/colorConversions.js.html +238 -0
- package/docs/constants.js.html +671 -0
- package/docs/cssUtils.js.html +80 -0
- package/docs/data/search.json +1 -1
- package/docs/domUtils.js.html +252 -32
- package/docs/formUtils.js.html +161 -0
- package/docs/getAccessibleName.js.html +215 -120
- package/docs/getAccessibleText.js.html +103 -48
- package/docs/getAriaAttributesByElement.js.html +4 -4
- package/docs/getCSSGeneratedContent.js.html +50 -41
- package/docs/getComputedRole.js.html +8 -3
- package/docs/getFocusableElements.js.html +25 -21
- package/docs/getGeneratedContent.js.html +24 -13
- package/docs/getImageText.js.html +31 -9
- package/docs/getStyleObject.js.html +7 -3
- package/docs/global.html +1 -1
- package/docs/hasAccessibleName.js.html +2 -10
- package/docs/hasAttribute.js.html +7 -3
- package/docs/hasCSSGeneratedContent.js.html +18 -14
- package/docs/hasHiddenParent.js.html +4 -4
- package/docs/hasParent.js.html +7 -3
- package/docs/hasValidAriaAttributes.js.html +7 -3
- package/docs/index.html +1 -1
- package/docs/index.js.html +98 -32
- package/docs/isA11yVisible.js.html +98 -0
- package/docs/isAriaAttributesValid.js.html +10 -64
- package/docs/isComplexTable.js.html +13 -6
- package/docs/isDataTable.js.html +11 -6
- package/docs/isFocusable.js.html +36 -12
- package/docs/isHidden.js.html +47 -11
- package/docs/isOffScreen.js.html +7 -3
- package/docs/isValidUrl.js.html +7 -3
- package/docs/listEventListeners.js.html +203 -0
- package/docs/module-QueryCache.html +3 -0
- package/docs/module-afixt-test-utils.html +1 -1
- package/docs/module-colorConversions.html +3 -0
- package/docs/module-constants.html +3 -0
- package/docs/module-cssUtils.html +3 -0
- package/docs/module-formUtils.html +3 -0
- package/docs/module-suggestContrast.html +3 -0
- package/docs/module-tableUtils.html +3 -0
- package/docs/queryCache.js.html +360 -0
- package/docs/scripts/core.js +726 -726
- package/docs/scripts/core.min.js +22 -22
- package/docs/scripts/resize.js +90 -90
- package/docs/scripts/search.js +265 -265
- package/docs/scripts/third-party/Apache-License-2.0.txt +202 -202
- package/docs/scripts/third-party/fuse.js +8 -8
- package/docs/scripts/third-party/hljs-line-num-original.js +369 -369
- package/docs/scripts/third-party/hljs-original.js +5171 -5171
- package/docs/scripts/third-party/popper.js +5 -5
- package/docs/scripts/third-party/tippy.js +1 -1
- package/docs/scripts/third-party/tocbot.js +671 -671
- package/docs/styles/clean-jsdoc-theme-base.css +1159 -1159
- package/docs/styles/clean-jsdoc-theme-dark.css +412 -412
- package/docs/styles/clean-jsdoc-theme-light.css +482 -482
- package/docs/styles/clean-jsdoc-theme-scrollbar.css +29 -29
- package/docs/suggestContrast.js.html +389 -0
- package/docs/tableUtils.js.html +151 -0
- package/docs/testContrast.js.html +201 -24
- package/docs/testLang.js.html +533 -451
- package/docs/testOrder.js.html +9 -4
- package/package.json +1 -1
- package/src/colorConversions.js +235 -0
- package/src/index.js +6 -0
- package/src/stringUtils.js +35 -0
- package/src/suggestContrast.js +386 -0
- package/test/colorConversions.test.js +223 -0
- package/test/stringUtils.test.js +60 -0
- package/test/suggestContrast.test.js +394 -0
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Suggest color pairs that meet a minimum WCAG contrast ratio.
|
|
3
|
+
* @module suggestContrast
|
|
4
|
+
* @description Inspired by Tanaguru Contrast-Finder's HSL-space "Psycho" search strategy.
|
|
5
|
+
* Given a foreground/background pair, this utility finds nearby colors that meet
|
|
6
|
+
* the specified contrast ratio while staying perceptually close to the originals.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const { luminance } = require('./testContrast.js');
|
|
10
|
+
const { rgbToHex, rgbToHsl, hslToRgb, parseAnyColor } = require('./colorConversions.js');
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Maximum per-channel RGB drift from the original color.
|
|
14
|
+
* Adapted from Tanaguru's DEFAULT_COLOR_COMPONENT_BOUNDER.
|
|
15
|
+
* @type {number}
|
|
16
|
+
*/
|
|
17
|
+
const MAX_COMPONENT_DRIFT = 40;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Number of suggestions to return.
|
|
21
|
+
* @type {number}
|
|
22
|
+
*/
|
|
23
|
+
const SUGGESTION_COUNT = 12;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Compute the WCAG contrast ratio between two RGB color objects.
|
|
27
|
+
* @param {{ r: number, g: number, b: number }} rgb1
|
|
28
|
+
* @param {{ r: number, g: number, b: number }} rgb2
|
|
29
|
+
* @returns {number} Contrast ratio rounded to 2 decimal places (1 to 21)
|
|
30
|
+
*/
|
|
31
|
+
function computeContrastRatio(rgb1, rgb2) {
|
|
32
|
+
const lum1 = luminance(rgb1.r, rgb1.g, rgb1.b);
|
|
33
|
+
const lum2 = luminance(rgb2.r, rgb2.g, rgb2.b);
|
|
34
|
+
const lighter = Math.max(lum1, lum2);
|
|
35
|
+
const darker = Math.min(lum1, lum2);
|
|
36
|
+
return Math.round(((lighter + 0.05) / (darker + 0.05)) * 100) / 100;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Calculate the RGB Manhattan distance between two color objects.
|
|
41
|
+
* @param {{ r: number, g: number, b: number }} a
|
|
42
|
+
* @param {{ r: number, g: number, b: number }} b
|
|
43
|
+
* @returns {number} Sum of absolute differences across R, G, B channels
|
|
44
|
+
*/
|
|
45
|
+
function rgbDistance(a, b) {
|
|
46
|
+
return Math.abs(a.r - b.r) + Math.abs(a.g - b.g) + Math.abs(a.b - b.b);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Check if a candidate color is within the allowed RGB drift from the original.
|
|
51
|
+
* @param {{ r: number, g: number, b: number }} candidate
|
|
52
|
+
* @param {{ r: number, g: number, b: number }} original
|
|
53
|
+
* @returns {boolean}
|
|
54
|
+
*/
|
|
55
|
+
function withinDrift(candidate, original) {
|
|
56
|
+
return (
|
|
57
|
+
Math.abs(candidate.r - original.r) <= MAX_COMPONENT_DRIFT &&
|
|
58
|
+
Math.abs(candidate.g - original.g) <= MAX_COMPONENT_DRIFT &&
|
|
59
|
+
Math.abs(candidate.b - original.b) <= MAX_COMPONENT_DRIFT
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Clamp a number between min and max.
|
|
65
|
+
* @param {number} val
|
|
66
|
+
* @param {number} min
|
|
67
|
+
* @param {number} max
|
|
68
|
+
* @returns {number}
|
|
69
|
+
*/
|
|
70
|
+
function clamp(val, min, max) {
|
|
71
|
+
return Math.max(min, Math.min(max, val));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Wrap a hue value to stay within 0-360.
|
|
76
|
+
* @param {number} h
|
|
77
|
+
* @returns {number}
|
|
78
|
+
*/
|
|
79
|
+
function wrapHue(h) {
|
|
80
|
+
return ((h % 360) + 360) % 360;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Format an RGB object into a full suggestion color object with hex, rgb, and hsl representations.
|
|
85
|
+
* @param {{ r: number, g: number, b: number }} rgb
|
|
86
|
+
* @returns {{ hex: string, rgb: { r: number, g: number, b: number }, hsl: { h: number, s: number, l: number } }}
|
|
87
|
+
*/
|
|
88
|
+
function formatColor(rgb) {
|
|
89
|
+
return {
|
|
90
|
+
hex: rgbToHex(rgb),
|
|
91
|
+
rgb: { r: rgb.r, g: rgb.g, b: rgb.b },
|
|
92
|
+
hsl: rgbToHsl(rgb),
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Derive the default minimum contrast ratio based on font size and weight per WCAG 2.x.
|
|
98
|
+
* @param {number} fontSize - Font size in pixels
|
|
99
|
+
* @param {boolean} isBold - Whether the text is bold
|
|
100
|
+
* @returns {number} Minimum contrast ratio (3.0 for large text, 4.5 for normal text)
|
|
101
|
+
*/
|
|
102
|
+
function deriveMinRatio(fontSize, isBold) {
|
|
103
|
+
if (fontSize >= 18) {
|
|
104
|
+
return 3.0;
|
|
105
|
+
}
|
|
106
|
+
if (fontSize >= 14 && isBold) {
|
|
107
|
+
return 3.0;
|
|
108
|
+
}
|
|
109
|
+
return 4.5;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Search for valid colors by adjusting lightness in one direction.
|
|
114
|
+
* @param {{ h: number, s: number, l: number }} baseHsl - Starting HSL color
|
|
115
|
+
* @param {{ r: number, g: number, b: number }} originalRgb - Original RGB for drift checking
|
|
116
|
+
* @param {{ r: number, g: number, b: number }} fixedRgb - The fixed color to check contrast against
|
|
117
|
+
* @param {number} targetRatio - Required contrast ratio
|
|
118
|
+
* @param {number} direction - +1 for lighter, -1 for darker
|
|
119
|
+
* @param {number} maxResults - Maximum results to collect
|
|
120
|
+
* @returns {Array<{ r: number, g: number, b: number }>} Array of valid RGB colors found
|
|
121
|
+
*/
|
|
122
|
+
function searchLightness(baseHsl, originalRgb, fixedRgb, targetRatio, direction, maxResults) {
|
|
123
|
+
const results = [];
|
|
124
|
+
|
|
125
|
+
for (let step = 1; step <= 100; step++) {
|
|
126
|
+
const newL = baseHsl.l + direction * step;
|
|
127
|
+
if (newL < 0 || newL > 100) {
|
|
128
|
+
break;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const candidateHsl = { h: baseHsl.h, s: baseHsl.s, l: Math.round(newL) };
|
|
132
|
+
const candidateRgb = hslToRgb(candidateHsl);
|
|
133
|
+
const ratio = computeContrastRatio(candidateRgb, fixedRgb);
|
|
134
|
+
|
|
135
|
+
if (ratio >= targetRatio && withinDrift(candidateRgb, originalRgb)) {
|
|
136
|
+
results.push(candidateRgb);
|
|
137
|
+
if (results.length >= maxResults) {
|
|
138
|
+
break;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return results;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Search for valid colors by adjusting lightness, without the drift constraint.
|
|
148
|
+
* Used as a fallback when drift-constrained search yields too few results.
|
|
149
|
+
* @param {{ h: number, s: number, l: number }} baseHsl
|
|
150
|
+
* @param {{ r: number, g: number, b: number }} fixedRgb
|
|
151
|
+
* @param {number} targetRatio
|
|
152
|
+
* @param {number} direction
|
|
153
|
+
* @param {number} maxResults
|
|
154
|
+
* @returns {Array<{ r: number, g: number, b: number }>}
|
|
155
|
+
*/
|
|
156
|
+
function searchLightnessUnconstrained(baseHsl, fixedRgb, targetRatio, direction, maxResults) {
|
|
157
|
+
const results = [];
|
|
158
|
+
|
|
159
|
+
for (let step = 1; step <= 100; step++) {
|
|
160
|
+
const newL = baseHsl.l + direction * step;
|
|
161
|
+
if (newL < 0 || newL > 100) {
|
|
162
|
+
break;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const candidateHsl = { h: baseHsl.h, s: baseHsl.s, l: Math.round(newL) };
|
|
166
|
+
const candidateRgb = hslToRgb(candidateHsl);
|
|
167
|
+
const ratio = computeContrastRatio(candidateRgb, fixedRgb);
|
|
168
|
+
|
|
169
|
+
if (ratio >= targetRatio) {
|
|
170
|
+
results.push(candidateRgb);
|
|
171
|
+
if (results.length >= maxResults) {
|
|
172
|
+
break;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return results;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Generate hue/saturation variations from a base color that meet the contrast requirement.
|
|
182
|
+
* @param {{ r: number, g: number, b: number }} baseRgb - Base color found from lightness search
|
|
183
|
+
* @param {{ r: number, g: number, b: number }} fixedRgb - The color held constant
|
|
184
|
+
* @param {number} targetRatio - Required contrast ratio
|
|
185
|
+
* @param {Set<string>} seen - Set of already-seen hex pair keys for dedup
|
|
186
|
+
* @param {string} fixedHex - Hex of the fixed color for building dedup keys
|
|
187
|
+
* @param {boolean} isForeground - Whether baseRgb is the foreground in the pair
|
|
188
|
+
* @returns {Array<{ candidate: { r: number, g: number, b: number }, key: string }>}
|
|
189
|
+
*/
|
|
190
|
+
function generateVariations(baseRgb, fixedRgb, targetRatio, seen, fixedHex, isForeground) {
|
|
191
|
+
const results = [];
|
|
192
|
+
const baseHsl = rgbToHsl(baseRgb);
|
|
193
|
+
const hueOffsets = [-10, -5, 5, 10];
|
|
194
|
+
const satOffsets = [-6, -3, 3, 6];
|
|
195
|
+
|
|
196
|
+
for (const hOff of hueOffsets) {
|
|
197
|
+
for (const sOff of satOffsets) {
|
|
198
|
+
const candidateHsl = {
|
|
199
|
+
h: wrapHue(baseHsl.h + hOff),
|
|
200
|
+
s: clamp(baseHsl.s + sOff, 0, 100),
|
|
201
|
+
l: baseHsl.l,
|
|
202
|
+
};
|
|
203
|
+
const candidateRgb = hslToRgb(candidateHsl);
|
|
204
|
+
const ratio = computeContrastRatio(candidateRgb, fixedRgb);
|
|
205
|
+
|
|
206
|
+
if (ratio >= targetRatio) {
|
|
207
|
+
const candHex = rgbToHex(candidateRgb);
|
|
208
|
+
const key = isForeground ? candHex + '|' + fixedHex : fixedHex + '|' + candHex;
|
|
209
|
+
if (!seen.has(key)) {
|
|
210
|
+
results.push({ candidate: candidateRgb, key });
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return results;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Suggest color pairs that meet a minimum contrast ratio.
|
|
221
|
+
*
|
|
222
|
+
* Takes a foreground/background color pair and returns up to 12 alternative color pair
|
|
223
|
+
* suggestions that meet the specified (or WCAG-derived) minimum contrast ratio.
|
|
224
|
+
* Each suggestion stays perceptually close to the original colors.
|
|
225
|
+
*
|
|
226
|
+
* @param {Object} options
|
|
227
|
+
* @param {string} options.foreground - Foreground color (hex, rgb(), rgba(), or hsl() string)
|
|
228
|
+
* @param {string} options.background - Background color (hex, rgb(), rgba(), or hsl() string)
|
|
229
|
+
* @param {number} [options.fontSize=16] - Text size in pixels
|
|
230
|
+
* @param {number} [options.minRatio] - Minimum desired contrast ratio (1-21).
|
|
231
|
+
* Defaults based on fontSize: 4.5 for normal text, 3.0 for large text (>=18px or >=14px bold).
|
|
232
|
+
* @param {boolean} [options.isBold=false] - Whether text is bold (affects default ratio for 14-17px text)
|
|
233
|
+
* @returns {Array<Object>} Array of up to 12 suggestion objects sorted by proximity to originals.
|
|
234
|
+
* Each object has: foreground ({hex, rgb, hsl}), background ({hex, rgb, hsl}), contrastRatio, distance.
|
|
235
|
+
* Returns empty array on invalid input.
|
|
236
|
+
*/
|
|
237
|
+
function suggestContrast(options) {
|
|
238
|
+
if (!options || typeof options !== 'object') {
|
|
239
|
+
return [];
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const fgRgb = parseAnyColor(options.foreground);
|
|
243
|
+
const bgRgb = parseAnyColor(options.background);
|
|
244
|
+
|
|
245
|
+
if (!fgRgb || !bgRgb) {
|
|
246
|
+
return [];
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const fontSize =
|
|
250
|
+
typeof options.fontSize === 'number' && options.fontSize > 0 ? options.fontSize : 16;
|
|
251
|
+
const isBold = options.isBold === true;
|
|
252
|
+
|
|
253
|
+
let targetRatio;
|
|
254
|
+
if (typeof options.minRatio === 'number') {
|
|
255
|
+
if (options.minRatio < 1 || options.minRatio > 21) {
|
|
256
|
+
return [];
|
|
257
|
+
}
|
|
258
|
+
targetRatio = options.minRatio;
|
|
259
|
+
} else {
|
|
260
|
+
targetRatio = deriveMinRatio(fontSize, isBold);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const fgHsl = rgbToHsl(fgRgb);
|
|
264
|
+
const bgHsl = rgbToHsl(bgRgb);
|
|
265
|
+
const fgHex = rgbToHex(fgRgb);
|
|
266
|
+
const bgHex = rgbToHex(bgRgb);
|
|
267
|
+
|
|
268
|
+
const candidates = [];
|
|
269
|
+
const seen = new Set();
|
|
270
|
+
|
|
271
|
+
// --- Phase 1: Lightness adjustments ---
|
|
272
|
+
|
|
273
|
+
// 1a. Adjust foreground lightness (hold background fixed)
|
|
274
|
+
const fgDarker = searchLightness(fgHsl, fgRgb, bgRgb, targetRatio, -1, 3);
|
|
275
|
+
const fgLighter = searchLightness(fgHsl, fgRgb, bgRgb, targetRatio, 1, 3);
|
|
276
|
+
const fgResults = [...fgDarker, ...fgLighter];
|
|
277
|
+
|
|
278
|
+
// Fallback: if drift constraint was too tight, search without it
|
|
279
|
+
if (fgResults.length < 3) {
|
|
280
|
+
const needed = 3 - fgResults.length;
|
|
281
|
+
const fallbackDarker = searchLightnessUnconstrained(fgHsl, bgRgb, targetRatio, -1, needed);
|
|
282
|
+
const fallbackLighter = searchLightnessUnconstrained(fgHsl, bgRgb, targetRatio, 1, needed);
|
|
283
|
+
const fallback = [...fallbackDarker, ...fallbackLighter];
|
|
284
|
+
// Deduplicate against what we already have
|
|
285
|
+
const existingHexes = new Set(fgResults.map(c => rgbToHex(c)));
|
|
286
|
+
for (const c of fallback) {
|
|
287
|
+
if (!existingHexes.has(rgbToHex(c)) && fgResults.length < 6) {
|
|
288
|
+
fgResults.push(c);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
for (const fg of fgResults) {
|
|
294
|
+
const hex = rgbToHex(fg);
|
|
295
|
+
const key = hex + '|' + bgHex;
|
|
296
|
+
if (!seen.has(key)) {
|
|
297
|
+
seen.add(key);
|
|
298
|
+
candidates.push({
|
|
299
|
+
fg,
|
|
300
|
+
bg: bgRgb,
|
|
301
|
+
ratio: computeContrastRatio(fg, bgRgb),
|
|
302
|
+
dist: rgbDistance(fg, fgRgb),
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// 1b. Adjust background lightness (hold foreground fixed)
|
|
308
|
+
const bgDarker = searchLightness(bgHsl, bgRgb, fgRgb, targetRatio, -1, 3);
|
|
309
|
+
const bgLighter = searchLightness(bgHsl, bgRgb, fgRgb, targetRatio, 1, 3);
|
|
310
|
+
const bgResults = [...bgDarker, ...bgLighter];
|
|
311
|
+
|
|
312
|
+
if (bgResults.length < 3) {
|
|
313
|
+
const needed = 3 - bgResults.length;
|
|
314
|
+
const fallbackDarker = searchLightnessUnconstrained(bgHsl, fgRgb, targetRatio, -1, needed);
|
|
315
|
+
const fallbackLighter = searchLightnessUnconstrained(bgHsl, fgRgb, targetRatio, 1, needed);
|
|
316
|
+
const fallback = [...fallbackDarker, ...fallbackLighter];
|
|
317
|
+
const existingHexes = new Set(bgResults.map(c => rgbToHex(c)));
|
|
318
|
+
for (const c of fallback) {
|
|
319
|
+
if (!existingHexes.has(rgbToHex(c)) && bgResults.length < 6) {
|
|
320
|
+
bgResults.push(c);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
for (const bg of bgResults) {
|
|
326
|
+
const hex = rgbToHex(bg);
|
|
327
|
+
const key = fgHex + '|' + hex;
|
|
328
|
+
if (!seen.has(key)) {
|
|
329
|
+
seen.add(key);
|
|
330
|
+
candidates.push({
|
|
331
|
+
fg: fgRgb,
|
|
332
|
+
bg,
|
|
333
|
+
ratio: computeContrastRatio(fgRgb, bg),
|
|
334
|
+
dist: rgbDistance(bg, bgRgb),
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// --- Phase 2: Hue/Saturation diversity ---
|
|
340
|
+
|
|
341
|
+
// Use the first few Phase 1 results as bases for variation
|
|
342
|
+
const fgBases = fgResults.slice(0, 2);
|
|
343
|
+
const bgBases = bgResults.slice(0, 2);
|
|
344
|
+
|
|
345
|
+
for (const base of fgBases) {
|
|
346
|
+
const variations = generateVariations(base, bgRgb, targetRatio, seen, bgHex, true);
|
|
347
|
+
for (const v of variations) {
|
|
348
|
+
seen.add(v.key);
|
|
349
|
+
candidates.push({
|
|
350
|
+
fg: v.candidate,
|
|
351
|
+
bg: bgRgb,
|
|
352
|
+
ratio: computeContrastRatio(v.candidate, bgRgb),
|
|
353
|
+
dist: rgbDistance(v.candidate, fgRgb),
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
for (const base of bgBases) {
|
|
359
|
+
const variations = generateVariations(base, fgRgb, targetRatio, seen, fgHex, false);
|
|
360
|
+
for (const v of variations) {
|
|
361
|
+
seen.add(v.key);
|
|
362
|
+
candidates.push({
|
|
363
|
+
fg: fgRgb,
|
|
364
|
+
bg: v.candidate,
|
|
365
|
+
ratio: computeContrastRatio(fgRgb, v.candidate),
|
|
366
|
+
dist: rgbDistance(v.candidate, bgRgb),
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// --- Sort by distance and return top SUGGESTION_COUNT ---
|
|
372
|
+
|
|
373
|
+
candidates.sort((a, b) => a.dist - b.dist);
|
|
374
|
+
|
|
375
|
+
return candidates.slice(0, SUGGESTION_COUNT).map(c => ({
|
|
376
|
+
foreground: formatColor(c.fg),
|
|
377
|
+
background: formatColor(c.bg),
|
|
378
|
+
contrastRatio: c.ratio,
|
|
379
|
+
distance: c.dist,
|
|
380
|
+
}));
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
module.exports = {
|
|
384
|
+
suggestContrast,
|
|
385
|
+
computeContrastRatio,
|
|
386
|
+
};
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
hexToRgb,
|
|
4
|
+
rgbToHex,
|
|
5
|
+
rgbToHsl,
|
|
6
|
+
hslToRgb,
|
|
7
|
+
parseAnyColor,
|
|
8
|
+
hslToString,
|
|
9
|
+
rgbToString,
|
|
10
|
+
} from '../src/colorConversions.js';
|
|
11
|
+
|
|
12
|
+
describe('colorConversions', () => {
|
|
13
|
+
// ---- hexToRgb ----
|
|
14
|
+
describe('hexToRgb', () => {
|
|
15
|
+
it('should parse 6-digit hex with # prefix', () => {
|
|
16
|
+
expect(hexToRgb('#FF8800')).toEqual({ r: 255, g: 136, b: 0 });
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('should parse 3-digit hex with # prefix', () => {
|
|
20
|
+
expect(hexToRgb('#F80')).toEqual({ r: 255, g: 136, b: 0 });
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('should return null for invalid input', () => {
|
|
24
|
+
expect(hexToRgb('not-a-color')).toBeNull();
|
|
25
|
+
expect(hexToRgb('')).toBeNull();
|
|
26
|
+
expect(hexToRgb(null)).toBeNull();
|
|
27
|
+
expect(hexToRgb(undefined)).toBeNull();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('should parse hex without # prefix', () => {
|
|
31
|
+
expect(hexToRgb('FF8800')).toEqual({ r: 255, g: 136, b: 0 });
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('should handle lowercase hex', () => {
|
|
35
|
+
expect(hexToRgb('#ff8800')).toEqual({ r: 255, g: 136, b: 0 });
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('should handle black and white', () => {
|
|
39
|
+
expect(hexToRgb('#000000')).toEqual({ r: 0, g: 0, b: 0 });
|
|
40
|
+
expect(hexToRgb('#FFFFFF')).toEqual({ r: 255, g: 255, b: 255 });
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('should return null for non-string input', () => {
|
|
44
|
+
expect(hexToRgb(123)).toBeNull();
|
|
45
|
+
expect(hexToRgb({})).toBeNull();
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
// ---- rgbToHex ----
|
|
50
|
+
describe('rgbToHex', () => {
|
|
51
|
+
it('should convert a color to hex', () => {
|
|
52
|
+
expect(rgbToHex({ r: 255, g: 136, b: 0 })).toBe('#ff8800');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('should convert black', () => {
|
|
56
|
+
expect(rgbToHex({ r: 0, g: 0, b: 0 })).toBe('#000000');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('should convert white', () => {
|
|
60
|
+
expect(rgbToHex({ r: 255, g: 255, b: 255 })).toBe('#ffffff');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('should return null for invalid input', () => {
|
|
64
|
+
expect(rgbToHex(null)).toBeNull();
|
|
65
|
+
expect(rgbToHex({})).toBeNull();
|
|
66
|
+
expect(rgbToHex({ r: 'a', g: 0, b: 0 })).toBeNull();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('should clamp values to 0-255 range', () => {
|
|
70
|
+
expect(rgbToHex({ r: 300, g: -10, b: 128 })).toBe('#ff0080');
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
// ---- rgbToHsl ----
|
|
75
|
+
describe('rgbToHsl', () => {
|
|
76
|
+
it('should convert pure red', () => {
|
|
77
|
+
expect(rgbToHsl({ r: 255, g: 0, b: 0 })).toEqual({ h: 0, s: 100, l: 50 });
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('should convert pure white', () => {
|
|
81
|
+
expect(rgbToHsl({ r: 255, g: 255, b: 255 })).toEqual({ h: 0, s: 0, l: 100 });
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('should convert pure black', () => {
|
|
85
|
+
expect(rgbToHsl({ r: 0, g: 0, b: 0 })).toEqual({ h: 0, s: 0, l: 0 });
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('should convert mid-gray', () => {
|
|
89
|
+
const hsl = rgbToHsl({ r: 128, g: 128, b: 128 });
|
|
90
|
+
expect(hsl.h).toBe(0);
|
|
91
|
+
expect(hsl.s).toBe(0);
|
|
92
|
+
expect(hsl.l).toBeCloseTo(50, 0);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('should convert pure green', () => {
|
|
96
|
+
expect(rgbToHsl({ r: 0, g: 255, b: 0 })).toEqual({ h: 120, s: 100, l: 50 });
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('should convert pure blue', () => {
|
|
100
|
+
expect(rgbToHsl({ r: 0, g: 0, b: 255 })).toEqual({ h: 240, s: 100, l: 50 });
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('should return null for invalid input', () => {
|
|
104
|
+
expect(rgbToHsl(null)).toBeNull();
|
|
105
|
+
expect(rgbToHsl({ r: 'a', g: 0, b: 0 })).toBeNull();
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
// ---- hslToRgb ----
|
|
110
|
+
describe('hslToRgb', () => {
|
|
111
|
+
it('should convert pure red hsl', () => {
|
|
112
|
+
expect(hslToRgb({ h: 0, s: 100, l: 50 })).toEqual({ r: 255, g: 0, b: 0 });
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('should convert pure white hsl', () => {
|
|
116
|
+
expect(hslToRgb({ h: 0, s: 0, l: 100 })).toEqual({ r: 255, g: 255, b: 255 });
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('should convert pure green hsl', () => {
|
|
120
|
+
expect(hslToRgb({ h: 120, s: 100, l: 50 })).toEqual({ r: 0, g: 255, b: 0 });
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('should convert pure black hsl', () => {
|
|
124
|
+
expect(hslToRgb({ h: 0, s: 0, l: 0 })).toEqual({ r: 0, g: 0, b: 0 });
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('should handle achromatic gray', () => {
|
|
128
|
+
const rgb = hslToRgb({ h: 0, s: 0, l: 50 });
|
|
129
|
+
expect(rgb.r).toBe(128);
|
|
130
|
+
expect(rgb.g).toBe(128);
|
|
131
|
+
expect(rgb.b).toBe(128);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('should return null for invalid input', () => {
|
|
135
|
+
expect(hslToRgb(null)).toBeNull();
|
|
136
|
+
expect(hslToRgb({ h: 'a', s: 0, l: 0 })).toBeNull();
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
// ---- round-trip ----
|
|
141
|
+
describe('RGB <-> HSL round-trip', () => {
|
|
142
|
+
const testColors = [
|
|
143
|
+
{ r: 255, g: 0, b: 0 },
|
|
144
|
+
{ r: 0, g: 255, b: 0 },
|
|
145
|
+
{ r: 0, g: 0, b: 255 },
|
|
146
|
+
{ r: 255, g: 255, b: 0 },
|
|
147
|
+
{ r: 128, g: 64, b: 32 },
|
|
148
|
+
];
|
|
149
|
+
|
|
150
|
+
testColors.forEach(rgb => {
|
|
151
|
+
it(`should round-trip rgb(${rgb.r}, ${rgb.g}, ${rgb.b})`, () => {
|
|
152
|
+
const hsl = rgbToHsl(rgb);
|
|
153
|
+
const backToRgb = hslToRgb(hsl);
|
|
154
|
+
// Allow ±2 rounding tolerance (double rounding through integer HSL)
|
|
155
|
+
expect(Math.abs(backToRgb.r - rgb.r)).toBeLessThanOrEqual(2);
|
|
156
|
+
expect(Math.abs(backToRgb.g - rgb.g)).toBeLessThanOrEqual(2);
|
|
157
|
+
expect(Math.abs(backToRgb.b - rgb.b)).toBeLessThanOrEqual(2);
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
// ---- parseAnyColor ----
|
|
163
|
+
describe('parseAnyColor', () => {
|
|
164
|
+
it('should parse hex color', () => {
|
|
165
|
+
expect(parseAnyColor('#FF0000')).toEqual({ r: 255, g: 0, b: 0 });
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('should parse rgb() string', () => {
|
|
169
|
+
expect(parseAnyColor('rgb(128, 64, 32)')).toEqual({ r: 128, g: 64, b: 32 });
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('should parse hsl() string', () => {
|
|
173
|
+
const rgb = parseAnyColor('hsl(120, 100%, 50%)');
|
|
174
|
+
expect(rgb).toEqual({ r: 0, g: 255, b: 0 });
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('should return null for null/undefined/empty', () => {
|
|
178
|
+
expect(parseAnyColor(null)).toBeNull();
|
|
179
|
+
expect(parseAnyColor(undefined)).toBeNull();
|
|
180
|
+
expect(parseAnyColor('')).toBeNull();
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('should return null for non-string input', () => {
|
|
184
|
+
expect(parseAnyColor(123)).toBeNull();
|
|
185
|
+
expect(parseAnyColor({})).toBeNull();
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it('should parse 3-digit hex', () => {
|
|
189
|
+
expect(parseAnyColor('#F00')).toEqual({ r: 255, g: 0, b: 0 });
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('should parse rgba() string', () => {
|
|
193
|
+
const result = parseAnyColor('rgba(100, 200, 50, 0.5)');
|
|
194
|
+
expect(result.r).toBe(100);
|
|
195
|
+
expect(result.g).toBe(200);
|
|
196
|
+
expect(result.b).toBe(50);
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
// ---- hslToString ----
|
|
201
|
+
describe('hslToString', () => {
|
|
202
|
+
it('should format HSL object as CSS string', () => {
|
|
203
|
+
expect(hslToString({ h: 200, s: 50, l: 30 })).toBe('hsl(200, 50%, 30%)');
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it('should return null for invalid input', () => {
|
|
207
|
+
expect(hslToString(null)).toBeNull();
|
|
208
|
+
expect(hslToString({})).toBeNull();
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
// ---- rgbToString ----
|
|
213
|
+
describe('rgbToString', () => {
|
|
214
|
+
it('should format RGB object as CSS string', () => {
|
|
215
|
+
expect(rgbToString({ r: 128, g: 64, b: 32 })).toBe('rgb(128, 64, 32)');
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it('should return null for invalid input', () => {
|
|
219
|
+
expect(rgbToString(null)).toBeNull();
|
|
220
|
+
expect(rgbToString({})).toBeNull();
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
});
|
package/test/stringUtils.test.js
CHANGED
|
@@ -436,6 +436,66 @@ describe('stringUtils', () => {
|
|
|
436
436
|
});
|
|
437
437
|
});
|
|
438
438
|
|
|
439
|
+
describe('containsVisibleText', () => {
|
|
440
|
+
it('should return true when accessible name exactly matches visible text', () => {
|
|
441
|
+
expect(stringUtils.containsVisibleText('Home', 'Home')).toBe(true);
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
it('should return true when accessible name is a superset of visible text', () => {
|
|
445
|
+
expect(
|
|
446
|
+
stringUtils.containsVisibleText(
|
|
447
|
+
'Report a Concern Opens in new window',
|
|
448
|
+
'Report a Concern'
|
|
449
|
+
)
|
|
450
|
+
).toBe(true);
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
it('should be case-insensitive', () => {
|
|
454
|
+
expect(stringUtils.containsVisibleText('REPORT A CONCERN', 'Report a Concern')).toBe(
|
|
455
|
+
true
|
|
456
|
+
);
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
it('should return false when visible text is a partial word match', () => {
|
|
460
|
+
expect(stringUtils.containsVisibleText('Homepage', 'Home')).toBe(false);
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
it('should return false when accessible name does not contain visible text', () => {
|
|
464
|
+
expect(
|
|
465
|
+
stringUtils.containsVisibleText('Click here for more information', 'Learn more')
|
|
466
|
+
).toBe(false);
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
it('should return false for null/undefined inputs', () => {
|
|
470
|
+
expect(stringUtils.containsVisibleText(null, 'text')).toBe(false);
|
|
471
|
+
expect(stringUtils.containsVisibleText('text', null)).toBe(false);
|
|
472
|
+
expect(stringUtils.containsVisibleText(undefined, undefined)).toBe(false);
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
it('should return false for empty visible text', () => {
|
|
476
|
+
expect(stringUtils.containsVisibleText('some name', '')).toBe(false);
|
|
477
|
+
expect(stringUtils.containsVisibleText('some name', ' ')).toBe(false);
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
it('should normalize whitespace before matching', () => {
|
|
481
|
+
expect(stringUtils.containsVisibleText('Report a Concern', 'Report a Concern')).toBe(
|
|
482
|
+
true
|
|
483
|
+
);
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
it('should match visible text at the end of accessible name', () => {
|
|
487
|
+
expect(stringUtils.containsVisibleText('Click here to Search', 'Search')).toBe(true);
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
it('should not match across word boundaries at end', () => {
|
|
491
|
+
expect(stringUtils.containsVisibleText('Searching', 'Search')).toBe(false);
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
it('should handle visible text that is the entire accessible name', () => {
|
|
495
|
+
expect(stringUtils.containsVisibleText('Buy now', 'Buy now')).toBe(true);
|
|
496
|
+
});
|
|
497
|
+
});
|
|
498
|
+
|
|
439
499
|
describe('hasNewWindowWarning', () => {
|
|
440
500
|
it('should return true for text containing "new window"', () => {
|
|
441
501
|
expect(stringUtils.hasNewWindowWarning('Opens in a new window')).toBe(true);
|