Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 15 additions & 12 deletions src/lib/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,35 +4,41 @@ const LIGHT_TEXT = '#EAEAEA';
const DARK_TEXT = '#000000';

describe('getContrastText', () => {
it('returns textPrimary on dark backgrounds when textPrimary is light', () => {
it('returns light text on dark backgrounds', () => {
expect(getContrastText('#000000', LIGHT_TEXT, DARK_TEXT)).toBe(LIGHT_TEXT);
expect(getContrastText('#1A1A1A', LIGHT_TEXT, DARK_TEXT)).toBe(LIGHT_TEXT);
expect(getContrastText('#121219', LIGHT_TEXT, DARK_TEXT)).toBe(LIGHT_TEXT);
expect(getContrastText('#2F4185', LIGHT_TEXT, DARK_TEXT)).toBe(LIGHT_TEXT);
});

it('returns textReverse on light backgrounds when textPrimary is light', () => {
it('returns dark text on light backgrounds', () => {
expect(getContrastText('#FFFFFF', LIGHT_TEXT, DARK_TEXT)).toBe(DARK_TEXT);
expect(getContrastText('#F5F5F5', LIGHT_TEXT, DARK_TEXT)).toBe(DARK_TEXT);
expect(getContrastText('#FCFCFC', LIGHT_TEXT, DARK_TEXT)).toBe(DARK_TEXT);
});

it('picks the text color with better contrast against a vivid background', () => {
expect(getContrastText('#E9041E', LIGHT_TEXT, DARK_TEXT)).toBe(DARK_TEXT);
expect(getContrastText('#E9041E', '#FFFFFF', '#000000')).toBe('#FFFFFF');
it('returns light text on saturated colors where WCAG luminance is ambiguous', () => {
expect(getContrastText('#E60028', LIGHT_TEXT, DARK_TEXT)).toBe(LIGHT_TEXT);
expect(getContrastText('#E9041E', LIGHT_TEXT, DARK_TEXT)).toBe(LIGHT_TEXT);
expect(getContrastText('#E60028', '#0D0D0D', '#EAEAEA')).toBe('#EAEAEA');
});

it('works regardless of which token is lighter', () => {
expect(getContrastText('#000000', DARK_TEXT, LIGHT_TEXT)).toBe(LIGHT_TEXT);
expect(getContrastText('#FFFFFF', DARK_TEXT, LIGHT_TEXT)).toBe(DARK_TEXT);
});

it('handles 3-character hex shorthand', () => {
expect(getContrastText('#FFF', LIGHT_TEXT, DARK_TEXT)).toBe(DARK_TEXT);
expect(getContrastText('#000', LIGHT_TEXT, DARK_TEXT)).toBe(LIGHT_TEXT);
});

it('handles hex without # prefix', () => {
expect(getContrastText('000000', LIGHT_TEXT, DARK_TEXT)).toBe(LIGHT_TEXT);
expect(getContrastText('FFFFFF', LIGHT_TEXT, DARK_TEXT)).toBe(DARK_TEXT);
it('handles rgb color format', () => {
expect(getContrastText('rgb(0, 0, 0)', LIGHT_TEXT, DARK_TEXT)).toBe(LIGHT_TEXT);
expect(getContrastText('rgb(255, 255, 255)', LIGHT_TEXT, DARK_TEXT)).toBe(DARK_TEXT);
});

it('returns null for non-hex values', () => {
it('returns null for unparseable values', () => {
expect(
getContrastText(
'linear-gradient(130deg, #9355E7 0%, #2E4AA3 60%)',
Expand All @@ -41,8 +47,5 @@ describe('getContrastText', () => {
),
).toBeNull();
expect(getContrastText('not-a-color', LIGHT_TEXT, DARK_TEXT)).toBeNull();
expect(
getContrastText('rgb(255, 0, 0)', LIGHT_TEXT, DARK_TEXT),
).toBeNull();
});
});
32 changes: 15 additions & 17 deletions src/lib/utils.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { getLuminance } from 'polished';

const RGB_HEX = /^#?(?:([\da-f]{3})[\da-f]?|([\da-f]{6})(?:[\da-f]{2})?)$/i;

/** Ensure the consistency of colors between old and new colors */
Expand Down Expand Up @@ -45,33 +47,29 @@ export const hex2RGB = (str: string): [number, number, number] => {
throw new Error('Invalid hex string provided');
};

// WCAG 2.0 relative luminance
const relativeLuminance = (r: number, g: number, b: number): number => {
const [rs, gs, bs] = [r, g, b].map((c) => {
const s = c / 255;
return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
});
return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs;
};

const wcagContrastRatio = (l1: number, l2: number): number =>
(Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);

const luminanceOf = (hex: string): number => {
const [r, g, b] = hex2RGB(hex);
return relativeLuminance(r, g, b);
};
// Minimum WCAG contrast ratio to consider a text color readable on a background.
// 3.0 corresponds to WCAG AA for large text — same threshold used by MUI.
const CONTRAST_THRESHOLD = 3;

export const getContrastText = (
bgColor: string,
textPrimary: string,
textReverse: string,
): string | null => {
try {
const bgLum = luminanceOf(bgColor);
const primaryContrast = wcagContrastRatio(luminanceOf(textPrimary), bgLum);
const reverseContrast = wcagContrastRatio(luminanceOf(textReverse), bgLum);
return reverseContrast > primaryContrast ? textReverse : textPrimary;
const bgLum = getLuminance(bgColor);
const primaryLum = getLuminance(textPrimary);
const reverseLum = getLuminance(textReverse);

const lighterText = primaryLum >= reverseLum ? textPrimary : textReverse;
const darkerText = primaryLum >= reverseLum ? textReverse : textPrimary;

const lighterContrast = wcagContrastRatio(primaryLum >= reverseLum ? primaryLum : reverseLum, bgLum);

return lighterContrast >= CONTRAST_THRESHOLD ? lighterText : darkerText;
} catch {
return null;
}
Expand Down
Loading