From 3e36aed4dedd13d2759e0bb6fd55f92e0974088d Mon Sep 17 00:00:00 2001 From: Nicolas Brugneaux Date: Fri, 3 Jun 2022 16:59:46 +0200 Subject: [PATCH] feat: a11y/colors (#221) * feat: first pass at color checking * test: add color tests --- packages/example/components/theme-button.tsx | 6 +- .../__tests__/react-celo-provider.test.tsx | 28 +-- .../react-celo/__tests__/utils/colors.test.ts | 102 +++++++++ packages/react-celo/src/modals/action.tsx | 2 +- packages/react-celo/src/modals/connect.tsx | 2 +- packages/react-celo/src/use-celo-methods.ts | 28 +-- packages/react-celo/src/utils/colors.ts | 200 ++++++++++++++++++ packages/react-celo/src/utils/helpers.ts | 22 -- 8 files changed, 326 insertions(+), 64 deletions(-) create mode 100644 packages/react-celo/__tests__/utils/colors.test.ts create mode 100644 packages/react-celo/src/utils/colors.ts diff --git a/packages/example/components/theme-button.tsx b/packages/example/components/theme-button.tsx index 5f64ac5e..6cf91b1e 100644 --- a/packages/example/components/theme-button.tsx +++ b/packages/example/components/theme-button.tsx @@ -64,7 +64,7 @@ const defaultDark = { const defaultLight = { primary: '#6366f1', secondary: '#eef2ff', - text: '#000000', + text: '#000', textSecondary: '#1f2937', textTertiary: '#64748b', muted: '#e2e8f0', @@ -74,7 +74,7 @@ const defaultLight = { const greenCustom = { primary: '#34d399', secondary: '#ecfccb', - text: '#fff', + text: 'hsla(81, 88%, 80%)', textSecondary: '#d9f99d', textTertiary: '#bef264', muted: '#3f6212', @@ -89,7 +89,7 @@ const roseCustom = { textTertiary: '#fecdd3', muted: '#3f3f46', background: '#27272a', - error: '#ef4444', + error: 'rgb(255, 0, 0)', }; export const themes = [defaultDark, defaultLight, greenCustom, roseCustom]; diff --git a/packages/react-celo/__tests__/react-celo-provider.test.tsx b/packages/react-celo/__tests__/react-celo-provider.test.tsx index 2ee1d445..862ad224 100644 --- a/packages/react-celo/__tests__/react-celo-provider.test.tsx +++ b/packages/react-celo/__tests__/react-celo-provider.test.tsx @@ -8,6 +8,7 @@ import { Mainnet } from '../src/constants'; import { CeloProvider, CeloProviderProps } from '../src/react-celo-provider'; import { Maybe, Network, Theme } from '../src/types'; import { UseCelo, useCelo, useCeloInternal } from '../src/use-celo'; +import defaultTheme from '../src/theme/default'; interface RenderArgs { providerProps: Partial; @@ -194,29 +195,20 @@ describe('CeloProvider', () => { expect(result.current.network).toEqual(Mainnet); act(() => { - result.current.updateTheme({ - background: '#000', - primary: '#000', - secondary: '#000', - muted: '#000', - error: '#000', - text: '#000', - textSecondary: '#000', - textTertiary: '#000', - }); + result.current.updateTheme(defaultTheme.light); }); rerender(); expect(result.current.theme).toEqual({ - background: '#000', - primary: '#000', - secondary: '#000', - muted: '#000', - error: '#000', - text: '#000', - textSecondary: '#000', - textTertiary: '#000', + background: '#ffffff', + primary: '#6366f1', + secondary: '#eef2ff', + muted: '#e2e8f0', + error: '#ef4444', + text: '#000000', + textSecondary: '#1f2937', + textTertiary: '#64748b', }); }); }); diff --git a/packages/react-celo/__tests__/utils/colors.test.ts b/packages/react-celo/__tests__/utils/colors.test.ts new file mode 100644 index 00000000..87e7e47d --- /dev/null +++ b/packages/react-celo/__tests__/utils/colors.test.ts @@ -0,0 +1,102 @@ +import { + Color, + contrast, + hexToRGB, + luminance, + RGBToHex, +} from '../../src/utils/colors'; + +describe('RGBToHex', () => { + it('converts rgb to hex', () => { + expect(RGBToHex('rgb(0, 0, 0)')).toEqual('#000000'); + expect(RGBToHex('rgb(255, 0, 0)')).toEqual('#ff0000'); + }); + it('converts rgba to hex, with an error, and strip the alpha', () => { + const spy = jest.spyOn(console, 'error'); + expect(RGBToHex('rgba(255, 255, 255, 0.5)')).toEqual('#ffffff80'); + expect(spy).toBeCalled(); + }); +}); + +describe('hexToRGB', () => { + it('converts hex to rgb', () => { + expect(hexToRGB('#000000')).toEqual('rgb(0, 0, 0)'); + expect(hexToRGB('#ff0000')).toEqual('rgb(255, 0, 0)'); + }); + it('converts hex to rgba', () => { + expect(hexToRGB('#000000ff')).toEqual('rgba(0, 0, 0, 1)'); + expect(hexToRGB('#ff0000', 0.5)).toEqual('rgba(255, 0, 0, 0.5)'); + }); +}); + +describe('luminance', () => { + it('calculates the luminance of one color', () => { + expect(luminance(new Color('#fff'))).toBe(1); + expect(luminance(new Color('#000'))).toBe(0); + const randomColor = '#' + Math.floor(Math.random() * 0xffffff).toString(16); + expect(luminance(new Color(randomColor))).toBeGreaterThanOrEqual(0); + expect(luminance(new Color(randomColor))).toBeLessThanOrEqual(1); + }); +}); + +describe('contrast', () => { + it('calculates the constrats between two colors', () => { + expect(contrast(new Color('#fff'), new Color('#000'))).toBe(21); + expect(contrast(new Color('#000'), new Color('#000'))).toBe(1); + expect(contrast(new Color('#fff'), new Color('#fff'))).toBe(1); + expect(contrast(new Color('#fff'), new Color('#444'))).toBe(9.74); + }); +}); + +describe('Color', () => { + it('accepts hex', () => { + const color1 = new Color('#000000'); + expect(color1.r).toEqual(0x00); + expect(color1.g).toEqual(0x00); + expect(color1.b).toEqual(0x00); + expect(color1.a).toEqual(null); + const color2 = new Color('#f00'); + expect(color2.r).toEqual(0xff); + expect(color1.g).toEqual(0x00); + expect(color1.b).toEqual(0x00); + expect(color1.a).toEqual(null); + const color3 = new Color('#ffffff80'); + expect(color3.r).toEqual(0xff); + expect(color3.r).toEqual(0xff); + expect(color3.r).toEqual(0xff); + expect(color3.a).toEqual(0.5); + }); + it('accepts rgb(a)', () => { + const color1 = new Color('rgb(0, 0, 0)'); + expect(color1.r).toEqual(0x00); + expect(color1.g).toEqual(0x00); + expect(color1.b).toEqual(0x00); + expect(color1.a).toEqual(null); + const color2 = new Color('rgb(255, 0, 0)'); + expect(color2.r).toEqual(0xff); + expect(color1.g).toEqual(0x00); + expect(color1.b).toEqual(0x00); + expect(color1.a).toEqual(null); + const color3 = new Color('rgb(255, 255, 255, 0.2)'); + expect(color3.r).toEqual(0xff); + expect(color3.r).toEqual(0xff); + expect(color3.r).toEqual(0xff); + expect(color3.a).toEqual(0.2); + }); + it('accepts hsl(a)', () => { + const color1 = new Color('hsl(0, 0, 0)'); + expect(color1.r).toEqual(0x00); + expect(color1.g).toEqual(0x00); + expect(color1.b).toEqual(0x00); + expect(color1.a).toEqual(null); + const color2 = new Color('hsl(359, 94, 62)'); + expect(color2.r).toEqual(0xf9); + expect(color2.g).toEqual(0x43); + expect(color2.b).toEqual(0x46); + const color3 = new Color('hsl(359, 94, 62, 0.3)'); + expect(color3.r).toEqual(0xf9); + expect(color3.g).toEqual(0x43); + expect(color3.b).toEqual(0x46); + expect(color3.a).toEqual(0.3); + }); +}); diff --git a/packages/react-celo/src/modals/action.tsx b/packages/react-celo/src/modals/action.tsx index 96bd8e4a..c5e69b8e 100644 --- a/packages/react-celo/src/modals/action.tsx +++ b/packages/react-celo/src/modals/action.tsx @@ -5,7 +5,7 @@ import ReactModal from 'react-modal'; import Spinner from '../components/spinner'; import { Theme } from '../types'; import { useCeloInternal } from '../use-celo'; -import { hexToRGB } from '../utils/helpers'; +import { hexToRGB } from '../utils/colors'; import cls from '../utils/tailwind'; import useTheme from '../utils/useTheme'; import { styles as modalStyles } from './connect'; diff --git a/packages/react-celo/src/modals/connect.tsx b/packages/react-celo/src/modals/connect.tsx index 449d8de0..379d3bfe 100644 --- a/packages/react-celo/src/modals/connect.tsx +++ b/packages/react-celo/src/modals/connect.tsx @@ -21,7 +21,7 @@ import { WalletEntry, } from '../types'; import { useCeloInternal } from '../use-celo'; -import { hexToRGB } from '../utils/helpers'; +import { hexToRGB } from '../utils/colors'; import { defaultProviderSort, SortingPredicate } from '../utils/sort'; import cls from '../utils/tailwind'; import useProviders, { walletToProvider } from '../utils/useProviders'; diff --git a/packages/react-celo/src/use-celo-methods.ts b/packages/react-celo/src/use-celo-methods.ts index ff9e2b19..b0a67f79 100644 --- a/packages/react-celo/src/use-celo-methods.ts +++ b/packages/react-celo/src/use-celo-methods.ts @@ -14,9 +14,8 @@ import { useContractsCache, } from './ContractCacheBuilder'; import { Dispatcher } from './react-celo-provider'; -import defaultTheme from './theme/default'; import { Connector, Network, Theme } from './types'; -import { RGBToHex } from './utils/helpers'; +import { contrastCheck, fixTheme } from './utils/colors'; export function useCeloMethods( { @@ -185,23 +184,14 @@ export function useCeloMethods( const updateTheme = useCallback( (theme: Theme | null) => { if (!theme) return dispatch('setTheme', null); - Object.entries(theme).forEach(([key, value]: [string, string]) => { - if (!(key in defaultTheme.light)) { - console.warn(`Theme key ${key} is not valid.`); - } - const _key = key as keyof Theme; - if (value.startsWith('rgb')) { - theme[_key] = RGBToHex(value); - console.warn( - `RGB values not officially supported, but were translated to hex (${value} -> ${theme[_key]})` - ); - } else if (!value.startsWith('#')) { - theme[_key] = `#${value}`; - console.warn( - `Malformed hex value was missing # (${value} -> ${theme[_key]})` - ); - } - }); + + if (process.env.NODE_ENV !== 'production') { + fixTheme(theme); + // minimal recommended contrast ratio is 4. + // or 3 for larger font-sizes + contrastCheck(theme); + } + dispatch('setTheme', theme); }, [dispatch] diff --git a/packages/react-celo/src/utils/colors.ts b/packages/react-celo/src/utils/colors.ts new file mode 100644 index 00000000..3bd766be --- /dev/null +++ b/packages/react-celo/src/utils/colors.ts @@ -0,0 +1,200 @@ +import defaultTheme from '../theme/default'; +import { Theme } from '../types'; + +const minmax = (value: number, lowerBound = 0, higherBound = 1) => + Math.max(lowerBound, Math.min(higherBound, value)); + +const round2 = (num: number) => Math.round((num + Number.EPSILON) * 100) / 100; + +enum Format { + Hex, + Rgb, +} +export class Color { + r: number; + b: number; + g: number; + a: number | null = null; + + constructor(color: string) { + if (color.startsWith('#')) { + if (color.length === 4) { + // eg: #fff, #000 + this.r = parseInt(color.slice(1, 2) + color.slice(1, 2), 16); + this.g = parseInt(color.slice(2, 3) + color.slice(2, 3), 16); + this.b = parseInt(color.slice(3, 4) + color.slice(3, 4), 16); + } else { + // eg: #ffffff, #000000 + this.r = parseInt(color.slice(1, 3), 16); + this.g = parseInt(color.slice(3, 5), 16); + this.b = parseInt(color.slice(5, 7), 16); + + if (color.length === 9) { + // eg: #ffffff80, #000000ff + this.a = round2(minmax(parseInt(color.slice(7, 9), 16) / 255)); + } + } + } else if (color.startsWith('rgb')) { + // eg: rgb(0, 0, 0) + const values = color.split('(')[1].split(')')[0].split(','); + this.r = parseInt(values[0].trim(), 10); + this.g = parseInt(values[1].trim(), 10); + this.b = parseInt(values[2].trim(), 10); + + if (values[3]) { + // eg: rgba(0, 0, 0, 1) + this.a = round2(minmax(parseFloat(values[3]))); + } + + console.error( + `[react-celo] RGB(A) values not officially supported, but were translated to hex (${color} -> ${this.toHex()})` + ); + } else if (color.startsWith('hsl')) { + // eg: hsl(100, 50%, 75%) + const values = color.split('(')[1].split(')')[0].split(','); + const h = parseInt(values[0].trim().replace('deg', ''), 10); + const s = parseInt(values[1].trim().replace('%', ''), 10); + let l = parseFloat(values[2].trim().replace('%', '')); + + if (l > 1) { + l /= 100; + } + + const a = (s * Math.min(l, 1 - l)) / 100; + const f = (n: number) => { + const k = (n + h / 30) % 12; + const color = l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1); + return Math.round(255 * color + Number.EPSILON); + }; + this.r = f(0); + this.g = f(8); + this.b = f(4); + + if (values[3]) { + // eg: hsla(100, 50%, 75%, 0.8) + this.a = round2(minmax(parseFloat(values[3]))); + } + + console.error( + `[react-celo] HSL(A) values not officially supported, but were translated to hex (${color} -> ${this.toHex()})` + ); + } else { + throw new Error(`[react-celo] Malformed color (${color})`); + } + } + + opacity(alpha?: number) { + if (alpha != null) { + this.a = round2(minmax(alpha)); + } + return this; + } + + toRGB() { + if (this.a !== null) { + return `rgba(${this.r}, ${this.g}, ${this.b}, ${this.a})`; + } + return `rgb(${this.r}, ${this.g}, ${this.b})`; + } + + toHex() { + const hex = [ + this.r.toString(16).padStart(2, '0'), + this.g.toString(16).padStart(2, '0'), + this.b.toString(16).padStart(2, '0'), + ].join(''); + + if (this.a !== null) { + // 0 <= this.a <= 1 + const alpha = Math.round(this.a * 256) + .toString(16) + .padStart(2, '0'); + return `#${hex}${alpha}`; + } + + return `#${hex}`; + } + + toString(format: Format) { + switch (format) { + case Format.Hex: + return this.toHex(); + case Format.Rgb: + return this.toRGB(); + } + } +} + +export function hexToRGB(hex: string, alpha?: number): string { + return new Color(hex).opacity(alpha).toRGB(); +} + +export function RGBToHex(rgba: string): string { + return new Color(rgba).toHex(); +} + +// https://en.wikipedia.org/wiki/Relative_luminance#Relative_luminance_and_.22gamma_encoded.22_colorspaces +export function luminance(color: Color) { + const a = [color.r, color.g, color.b].map((v) => { + v /= 255; + return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); + }); + return a[0] * 0.2126 + a[1] * 0.7152 + a[2] * 0.0722; +} + +export function contrast(a: Color, b: Color) { + const lum1 = luminance(a); + const lum2 = luminance(b); + const brightest = Math.max(lum1, lum2); + const darkest = Math.min(lum1, lum2); + + return round2((brightest + 0.05) / (darkest + 0.05)); +} + +export function contrastCheck(theme: Theme) { + // minimal recommended contrast ratio is 4. + // or 3 for larger font-sizes + + const textToBg = contrast(new Color(theme.background), new Color(theme.text)); + if (textToBg <= 4) { + console.error( + `[react-celo] potential accessibility error between text and background colors (${textToBg})` + ); + } + const textSecondaryToBg = contrast( + new Color(theme.background), + new Color(theme.textSecondary) + ); + if (textSecondaryToBg <= 4) { + console.error( + `[react-celo] potential accessibility error between textSecondary and background colors (${textSecondaryToBg})` + ); + } + const primaryToSecondary = contrast( + new Color(theme.background), + new Color(theme.secondary) + ); + if (primaryToSecondary <= 3) { + console.error( + `[react-celo] potential accessibility error between primary and secondary colors (${primaryToSecondary})` + ); + } +} + +export function fixTheme(theme: Theme) { + Object.entries(theme).forEach(([key, value]: [string, string]) => { + if (!(key in defaultTheme.light)) { + console.error(`[react-celo] Theme key ${key} is not valid.`); + } + const _key = key as keyof Theme; + try { + const color = new Color(value); + theme[_key] = color.toHex(); + } catch (e) { + theme[_key] = '#FF0000'; + console.error( + `[react-celo] Could not parse theme.${_key} with value ${value}. Replaced it with red!` + ); + } + }); +} diff --git a/packages/react-celo/src/utils/helpers.ts b/packages/react-celo/src/utils/helpers.ts index 1326e7ca..c8b5e5a5 100644 --- a/packages/react-celo/src/utils/helpers.ts +++ b/packages/react-celo/src/utils/helpers.ts @@ -108,25 +108,3 @@ export function isValidFeeCurrency(currency: Maybe): boolean { return false; } } - -export function hexToRGB(hex: string, alpha?: number): string { - const r = parseInt(hex.slice(1, 3), 16); - const g = parseInt(hex.slice(3, 5), 16); - const b = parseInt(hex.slice(5, 7), 16); - - if (alpha) { - return `rgba(${r}, ${g}, ${b}, ${Math.max(0, Math.min(1, alpha))})`; - } else { - return `rgba(${r}, ${g}, ${b})`; - } -} - -export function RGBToHex(rgba: string): string { - const values = rgba.split('(')[1].split(')')[0]; - const r = parseInt(values[0]).toString(16); - const g = parseInt(values[1]).toString(16); - const b = parseInt(values[2]).toString(16); - const alpha = values[3] ? parseInt(values[3]).toString(16) : ''; - - return `#${r}${g}${b}${alpha}`; -}