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')}
+
+
+
+
+
+
+ }
+ disabled={!fgColor}
+ onClick={() => {
+ navigator.clipboard.writeText(fgColor || '').then(
+ () => {
+ setToastMessage(t('common:copiedToClipboard'));
+ setToastSeverity('success');
+ setToastOpen(true);
+ },
+ () => {
+ setToastMessage(
+ t('common:failedToCopyToClipboard'),
+ );
+ setToastSeverity('error');
+ setToastOpen(true);
+ },
+ );
+ }}
+ >
+ {t('common:copy')}
+
+ {!!supportsClipboardRead && (
+ }
+ onClick={async () => {
+ const text = await navigator.clipboard.readText();
+ if (text) {
+ setFgColor(text);
+ updateColors({
+ target: { name: 'fg', value: text },
+ });
+ }
+ }}
+ >
+ {t('common:paste')}
+
+ )}
+
+
+
+ }
+ onClick={handleColorSwap}
+ >
+ {t('colors:swap')}
+
+
+
+
+
+ }
+ disabled={!bgColor}
+ onClick={() => {
+ navigator.clipboard.writeText(bgColor || '').then(
+ () => {
+ setToastMessage(t('common:copiedToClipboard'));
+ setToastSeverity('success');
+ setToastOpen(true);
+ },
+ () => {
+ setToastMessage(
+ t('common:failedToCopyToClipboard'),
+ );
+ setToastSeverity('error');
+ setToastOpen(true);
+ },
+ );
+ }}
+ >
+ {t('common:copy')}
+
+ {!!supportsClipboardRead && (
+ }
+ onClick={async () => {
+ const text = await navigator.clipboard.readText();
+ if (text) {
+ setBgColor(text);
+ updateColors({
+ target: { name: 'bg', value: text },
+ });
+ }
+ }}
+ >
+ {t('common:paste')}
+
+ )}
+
+
+
+ {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}
/>
+