a11y: add runtime contrast validation, justify override, line-height floor
This commit is contained in:
71
src/utils/contrastUtils.ts
Normal file
71
src/utils/contrastUtils.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* WCAG 2.2 contrast ratio utilities for runtime color validation
|
||||
*/
|
||||
|
||||
export function hexToRgb(hex: string): [number, number, number] {
|
||||
const clean = hex.replace('#', '');
|
||||
const full = clean.length === 3
|
||||
? clean.split('').map(c => c + c).join('')
|
||||
: clean;
|
||||
const num = parseInt(full, 16);
|
||||
return [(num >> 16) & 255, (num >> 8) & 255, num & 255];
|
||||
}
|
||||
|
||||
export function relativeLuminance([r, g, b]: [number, number, number]): number {
|
||||
const [rs, gs, bs] = [r, g, b].map(c => {
|
||||
const s = c / 255;
|
||||
return s <= 0.04045 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
|
||||
});
|
||||
return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs;
|
||||
}
|
||||
|
||||
export function contrastRatio(color1: string, color2: string): number {
|
||||
const l1 = relativeLuminance(hexToRgb(color1));
|
||||
const l2 = relativeLuminance(hexToRgb(color2));
|
||||
const lighter = Math.max(l1, l2);
|
||||
const darker = Math.min(l1, l2);
|
||||
return (lighter + 0.05) / (darker + 0.05);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adjusts foreground color to meet minimum contrast ratio against background.
|
||||
* Lightens or darkens the foreground as needed.
|
||||
*/
|
||||
export function ensureContrast(fg: string, bg: string, minRatio: number): string {
|
||||
if (contrastRatio(fg, bg) >= minRatio) return fg;
|
||||
|
||||
const bgLum = relativeLuminance(hexToRgb(bg));
|
||||
const [r, g, b] = hexToRgb(fg);
|
||||
|
||||
// Determine direction: lighten if bg is dark, darken if bg is light
|
||||
const lighten = bgLum < 0.5;
|
||||
|
||||
for (let step = 1; step <= 50; step++) {
|
||||
const factor = step * 0.02;
|
||||
let nr: number, ng: number, nb: number;
|
||||
|
||||
if (lighten) {
|
||||
nr = Math.min(255, Math.round(r + (255 - r) * factor));
|
||||
ng = Math.min(255, Math.round(g + (255 - g) * factor));
|
||||
nb = Math.min(255, Math.round(b + (255 - b) * factor));
|
||||
} else {
|
||||
nr = Math.max(0, Math.round(r * (1 - factor)));
|
||||
ng = Math.max(0, Math.round(g * (1 - factor)));
|
||||
nb = Math.max(0, Math.round(b * (1 - factor)));
|
||||
}
|
||||
|
||||
const adjusted = '#' + [nr, ng, nb].map(c => c.toString(16).padStart(2, '0')).join('');
|
||||
if (contrastRatio(adjusted, bg) >= minRatio) return adjusted;
|
||||
}
|
||||
|
||||
// Fallback: return white or black
|
||||
return lighten ? '#FFFFFF' : '#000000';
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if text at given size/weight qualifies as "large text" per WCAG.
|
||||
* Large text = 18pt+ (24px+) or 14pt+ bold (18.66px+)
|
||||
*/
|
||||
export function isLargeText(sizePt: number, bold: boolean): boolean {
|
||||
return sizePt >= 18 || (bold && sizePt >= 14);
|
||||
}
|
||||
Reference in New Issue
Block a user