From 276443f85525a166fc77ef569bd132c583162879 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dante=20=C3=81lvarez?= <89805481+danalvrz@users.noreply.github.com> Date: Thu, 30 Jan 2025 17:10:00 -0600 Subject: [PATCH] add color contrast checker component --- .../Widgets/ColorContrastChecker.tsx | 106 ++++++++++++++++++ .../components/Widgets/ThemeColorPicker.tsx | 70 ++++++------ .../volto-light-theme/src/config/settings.ts | 9 ++ .../volto-light-theme/src/theme/_widgets.scss | 18 +++ 4 files changed, 170 insertions(+), 33 deletions(-) create mode 100644 packages/volto-light-theme/src/components/Widgets/ColorContrastChecker.tsx diff --git a/packages/volto-light-theme/src/components/Widgets/ColorContrastChecker.tsx b/packages/volto-light-theme/src/components/Widgets/ColorContrastChecker.tsx new file mode 100644 index 00000000..d29360a7 --- /dev/null +++ b/packages/volto-light-theme/src/components/Widgets/ColorContrastChecker.tsx @@ -0,0 +1,106 @@ +import { useState, useEffect } from 'react'; +import { useSelector } from 'react-redux'; +import type { Content } from '@plone/types'; +import cx from 'classnames'; +import config from '@plone/volto/registry'; + +type FormState = { + content: { + data: Content; + }; + form: { + global: Content; + }; +}; + +const ColorContrastChecker = (props: { id: string; value: string }) => { + const { id, value } = props; + const [backgroundColor, setBackgroundColor] = useState('#ffffff'); + const [foregroundColor, setForegroundColor] = useState('#000000'); + const [contrastRatio, setContrastRatio] = useState(21); + + const formData = useSelector( + (state) => state.form.global, + ); + const colorPairMap = config.settings.colorContrastPairMap; + + // Convert hex to RGB + const hexToRgb = (hex) => { + const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); + return result + ? { + r: parseInt(result[1], 16), + g: parseInt(result[2], 16), + b: parseInt(result[3], 16), + } + : null; + }; + + // Calculate relative luminance + const getLuminance = (r, g, b) => { + const [rs, gs, bs] = [r, g, b].map((c) => { + c = c / 255; + return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); + }); + return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs; + }; + + // Calculate contrast ratio + const getContrastRatio = (l1, l2) => { + const lighter = Math.max(l1, l2); + const darker = Math.min(l1, l2); + return (lighter + 0.05) / (darker + 0.05); + }; + + useEffect(() => { + const bg = hexToRgb(backgroundColor); + const fg = hexToRgb(foregroundColor); + if (bg && fg) { + const bgLum = getLuminance(bg.r, bg.g, bg.b); + const fgLum = getLuminance(fg.r, fg.g, fg.b); + const ratio = getContrastRatio(bgLum, fgLum); + setContrastRatio(ratio); + } + + const colorPair = formData[colorPairMap[id]]?.toString(); + const newColorHex = value?.toString(); + + if (id.includes('foreground')) { + setForegroundColor(newColorHex); + setBackgroundColor(colorPair ?? backgroundColor); + } else { + setForegroundColor(colorPair ?? foregroundColor); + setBackgroundColor(newColorHex); + } + }, [backgroundColor, colorPairMap, foregroundColor, formData, id, value]); + + // Get WCAG compliance levels + const getComplianceLevel = (ratio) => { + if (ratio >= 3) return 'AA Large'; + return 'Failed'; + }; + + return ( + <> + {formData[id] && contrastRatio < 4.5 && ( + + The color contrast ratio ({contrastRatio.toFixed(2)}:1) might not be + accesible for all. WCAG Level: {getComplianceLevel(contrastRatio)} + + ? + + + )} + + ); +}; + +export default ColorContrastChecker; diff --git a/packages/volto-light-theme/src/components/Widgets/ThemeColorPicker.tsx b/packages/volto-light-theme/src/components/Widgets/ThemeColorPicker.tsx index dd62d06a..06a60d39 100644 --- a/packages/volto-light-theme/src/components/Widgets/ThemeColorPicker.tsx +++ b/packages/volto-light-theme/src/components/Widgets/ThemeColorPicker.tsx @@ -2,6 +2,7 @@ import FormFieldWrapper from '@plone/volto/components/manage/Widgets/FormFieldWr import { HexColorPicker, HexColorInput } from 'react-colorful'; import { Button, Dialog, DialogTrigger, Popover } from 'react-aria-components'; import { ColorSwatch, CloseIcon } from '@plone/components'; +import ColorContrastChecker from './ColorContrastChecker'; const ColorPickerWidget = (props: { id: string; @@ -13,40 +14,43 @@ const ColorPickerWidget = (props: { const { id, onChange, value } = props; return ( - - - + <> + + + - - - { - // edge case for Batman value - if (value !== '#NaNNaNNaN') { - onChange(id, value); - } - }} - /> - - - - onChange(id, value)} - prefixed - /> - - + + + { + // edge case for Batman value + if (value !== '#NaNNaNNaN') { + onChange(id, value); + } + }} + /> + + + + onChange(id, value)} + prefixed + /> + + + + ); }; diff --git a/packages/volto-light-theme/src/config/settings.ts b/packages/volto-light-theme/src/config/settings.ts index ce2aad9e..2864bcd4 100644 --- a/packages/volto-light-theme/src/config/settings.ts +++ b/packages/volto-light-theme/src/config/settings.ts @@ -31,5 +31,14 @@ export default function install(config: ConfigType) { 'secondary_foreground_color', ]; + config.settings.colorContrastPairMap = { + primary_color: 'primary_foreground_color', + primary_foreground_color: 'primary_color', + secondary_color: 'secondary_foreground_color', + secondary_foreground_color: 'secondary_color', + accent_color: 'accent_foreground_color', + accent_foreground_color: 'accent_color', + }; + return config; } diff --git a/packages/volto-light-theme/src/theme/_widgets.scss b/packages/volto-light-theme/src/theme/_widgets.scss index 0ec8b2f5..c7f4177d 100644 --- a/packages/volto-light-theme/src/theme/_widgets.scss +++ b/packages/volto-light-theme/src/theme/_widgets.scss @@ -60,6 +60,24 @@ } } +span.color-contrast-label { + color: #ed6500; + font-size: 13px; + display: block; + position: relative; + padding-left: 20%; + a { + padding: 0.1rem 0.25rem; + border: 1px solid var(--link-foreground-color); + border-radius: 100%; + margin: 0; + margin-left: 4px; + margin-left: 4px; + font-size: 0.4rem; + vertical-align: super; + } +} + .react-aria-ColorSwatch { box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.3); }