diff --git a/__TESTS__/colors.spec.tsx b/__TESTS__/colors.spec.tsx index 0db5dcf..d67d7ed 100644 --- a/__TESTS__/colors.spec.tsx +++ b/__TESTS__/colors.spec.tsx @@ -51,4 +51,43 @@ describe('Colors', () => { expect(hsl).toHaveValue('333, 55, 51'); expect(hex).toHaveValue('C9397B'); }); + + it('reacts to foreground color change correctly', async () => { + const user = userEvent.setup(); + render(); + + const fg = screen.getByLabelText('colors:foreground'); + + await user.clear(fg); + await user.type(fg, '232c34'); + expect(fg).toHaveValue('232c34'); + }); + + it('reacts to background color change correctly', async () => { + const user = userEvent.setup(); + render(); + + const bg = screen.getByLabelText('colors:background'); + + await user.clear(bg); + await user.type(bg, '232c34'); + expect(bg).toHaveValue('232c34'); + }); + + it('reacts to color swap correctly', async () => { + const user = userEvent.setup(); + render(); + + const bg = screen.getByLabelText('colors:background'); + const fg = screen.getByLabelText('colors:foreground'); + const swap = screen.getByLabelText('colors:swapColors'); + + await user.clear(fg); + await user.clear(bg); + await user.type(fg, '232c34'); + await user.type(bg, 'c9397b'); + await userEvent.click(swap); + expect(fg).toHaveValue('c9397b'); + expect(bg).toHaveValue('232c34'); + }); }); diff --git a/components/colors/ContrastPreview.tsx b/components/colors/ContrastPreview.tsx new file mode 100644 index 0000000..9dca245 --- /dev/null +++ b/components/colors/ContrastPreview.tsx @@ -0,0 +1,267 @@ +import DoneIcon from '@mui/icons-material/Done'; +import { Stack, Typography } from '@mui/material'; +import Box from '@mui/material/Box'; +import Chip from '@mui/material/Chip'; +import Grid from '@mui/material/Grid'; +import { styled } from '@mui/material/styles'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; + +export default function ContrastPreview({ + contrast, + foreground, + background, +}: { + contrast: string; + foreground: string; + background: string; +}) { + const { t } = useTranslation(['colors', 'common']); + + const contrastValue = parseFloat(contrast) || 0; + + const AANormal = contrastValue >= 4.5; + const AALarge = contrastValue >= 3; + const AAGui = contrastValue >= 3; + const AAANormal = contrastValue >= 7; + const AAALarge = contrastValue >= 4.5; + + const TextInput = styled('input')({ + border: '2px solid', + borderColor: foreground, + color: '#fff', + padding: '5px', + backgroundColor: '#121212', + }); + + return ( + + + + + {t('colors:normalText')} + + + + + + {t('colors:wcagAA')} + + + + + {t('colors:wcagAAA')} + + + + + + + + {t('colors:contrastTestString')} + + + + + {t('colors:largeText')} + + + + + + {t('colors:wcagAA')} + + + + + {t('colors:wcagAAA')} + + + + + + + + + {t('colors:contrastTestString')} + + + + + + {t('colors:gui')} + + + + + + {t('colors:wcagAA')} + + + + + + + + + + + + + ); +} diff --git a/components/colors/TextContrastChecker.tsx b/components/colors/TextContrastChecker.tsx new file mode 100644 index 0000000..54395f0 --- /dev/null +++ b/components/colors/TextContrastChecker.tsx @@ -0,0 +1,354 @@ +import ContentCopyIcon from '@mui/icons-material/ContentCopy'; +import ContentPasteGoIcon from '@mui/icons-material/ContentPasteGo'; +import SwapVertIcon from '@mui/icons-material/SwapVert'; +import { TextField, Typography } from '@mui/material'; +import Box from '@mui/material/Box'; +import Button from '@mui/material/Button'; +import * as convert from 'colors-convert'; +import { HEX, HSL, RGB } from 'colors-convert/dist/cjs/lib/types/types'; +import React, { ChangeEvent, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; + +import useLocalState from '../../hooks/useLocalState'; +import useSupportsClipboardRead from '../../hooks/useSupportsClipboardRead'; +import ContrastPreview from './ContrastPreview'; + +export default function TextContrastChecker({ + serializeColor, + setToastMessage, + setToastOpen, + setToastSeverity, +}: { + serializeColor: (value: HEX | HSL | RGB) => string; + setToastMessage: (message: string) => void; + setToastOpen: (open: boolean) => void; + setToastSeverity: (severity: 'success' | 'error') => void; +}) { + const { t } = useTranslation(['colors', 'common']); + + const supportsClipboardRead = useSupportsClipboardRead(); + + const [fgColor, setFgColor] = useLocalState({ + key: 'contrastChecker_fgColor', + defaultValue: 'ffffff', + }); + const [fgErr, setFgErr] = useLocalState({ + key: 'colorPicker_fgErr', + defaultValue: false, + }); + const [bgColor, setBgColor] = useLocalState({ + key: 'contrastChecker_bgColor', + defaultValue: '000000', + }); + const [bgErr, setBgErr] = useLocalState({ + key: 'colorPicker_bgErr', + defaultValue: false, + }); + const [textContrast, setTextContrast] = useLocalState({ + key: 'contastChecker_textContrast', + defaultValue: '21', + }); + const [contrastErr, setContrastErr] = useLocalState({ + key: 'contastChecker_err', + defaultValue: '', + }); + + const HEXColorRegExp = /^#?([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/; + const isValid = (color: string): boolean => + HEXColorRegExp.test(color); + + function HandleFgChange(hex: string) { + setFgColor(serializeColor(hex)); + if (isValid(hex)) { + setFgErr(false); + } else { + setContrastErr(t('colors:invalidHex')); + setFgErr(true); + } + } + + function HandleBgChange(hex: string) { + setBgColor(serializeColor(hex)); + if (isValid(hex)) { + setBgErr(false); + } else { + setContrastErr(t('colors:invalidHex')); + setBgErr(true); + } + } + + function handleColorSwap() { + if (isValid(fgColor) && isValid(bgColor)) { + HandleBgChange(fgColor); + HandleFgChange(bgColor); + } + } + + const updateColors = ( + eventData: + | ChangeEvent + | { target: { name: string; value: string } }, + ) => { + try { + setContrastErr(''); + const { name, value } = eventData.target; + switch (name) { + case 'foreground': + HandleFgChange(value); + break; + case 'background': + HandleBgChange(value); + break; + default: + break; + } + } catch (e: unknown) { + if (e instanceof Error) { + setContrastErr(e.message); + } + } + }; + + function lineariseRGB(rgb: number[]): number[] { + return rgb.map((channel) => + channel <= 0.04045 + ? channel / 12.92 + : ((channel + 0.055) / 1.055) ** 2.4, + ); + } + + function calculateLuminance(value: RGB): number { + const gammaEncoded = Object.values(value).map((v) => v / 255); + const [r, g, b] = lineariseRGB(gammaEncoded); + return 0.2126 * r + 0.7152 * g + 0.0722 * b; + } + + function calculateContrast(hexValues: string[]) { + if (!hexValues.every(isValid)) { + return; + } + + const [L1, L2] = hexValues + .map((value) => calculateLuminance(convert.hexToRgb(`#${value}`))) + .sort((l1, l2) => l2 - l1); + const contrast = (L1 + 0.05) / (L2 + 0.05); + setTextContrast(contrast.toFixed(2)); + } + + useEffect(() => { + calculateContrast([fgColor, bgColor]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [fgColor, bgColor]); + + return ( + <> + + {t('colors:contrastChecker')} + + + {t('colors:contrastCheckerDescription')} + + + + + + + + {!!supportsClipboardRead && ( + + )} + + + + + + + + + + {!!supportsClipboardRead && ( + + )} + + + + {contrastErr} + + + + + {t('colors:contrastRatio')} + + {`${textContrast}:1`} + + + + + + + ); +} diff --git a/data/changelog.yml b/data/changelog.yml index bdfdf92..dffbb63 100644 --- a/data/changelog.yml +++ b/data/changelog.yml @@ -4,6 +4,9 @@ # | Date | Time | Timezone (GMT -7 hours in this example) # 2022-10-26T00:00:00-0700 +- date: 2023-10-26T00:00:00+0530 + note: Added color contrast checker tool + - date: 2022-12-10T00:00:00-0700 note: Added CSS color transform filter generator to color tool diff --git a/i18n/en/colors.json b/i18n/en/colors.json index 7f121ea..693cd1e 100644 --- a/i18n/en/colors.json +++ b/i18n/en/colors.json @@ -12,5 +12,21 @@ "loss": "Loss", "regenerate": "Regenerate", "selectedColor": "Selected Color", - "transformedColor": "Transformed Color" + "transformedColor": "Transformed Color", + "contrastChecker": "Contrast Checker", + "contrastCheckerDescription": "Use this to test accessibility of text colors over background colors.", + "foreground": "Foreground", + "background": "Background", + "swap": "Swap", + "swapColors": "Swap colors", + "contrastRatio": "Contrast Ratio:", + "wcagAA": "WCAG AA:", + "wcagAAA": "WCAG AAA:", + "normalText": "Normal Text", + "largeText": "Large Text", + "gui": "Graphical Objects and User Interface Components", + "contrastTestString": "The five boxing wizards jump quickly.", + "textInput": "Text Input", + "pass": "Pass", + "fail": "Fail" } \ No newline at end of file diff --git a/pages/colors.tsx b/pages/colors.tsx index 51b8329..dd14423 100644 --- a/pages/colors.tsx +++ b/pages/colors.tsx @@ -16,6 +16,7 @@ import useEyeDropper from 'use-eye-dropper'; import FilterGenerator from '../components/colors/FilterGenerator'; import PreviewPane from '../components/colors/PreviewPane'; +import TextContrastChecker from '../components/colors/TextContrastChecker'; import Layout from '../components/Layout'; import Toast, { ToastProps } from '../components/Toast'; import useLocalState from '../hooks/useLocalState'; @@ -444,6 +445,12 @@ export default function Colors() { setToastSeverity={setToastSeverity} setToastOpen={setToastOpen} /> +