diff --git a/assets/css/udoit4-theme.css b/assets/css/udoit4-theme.css
index d3ff6d2a1..d358ad003 100644
--- a/assets/css/udoit4-theme.css
+++ b/assets/css/udoit4-theme.css
@@ -814,6 +814,7 @@ input[type="color"] {
height: 100%;
border-radius: 8px;
border: 2px solid var(--primary-color);
+ background-color: var(--background-color);
&:focus {
outline: 2px solid var(--focus-color);
@@ -828,6 +829,15 @@ input[type="color"] {
}
}
+.gradient-row {
+ margin-bottom: 0.75em;
+}
+
+.gradient-note {
+ font-style: italic;
+ color: #666;
+}
+
.icon-sm {
width: 16px;
height: 16px;
diff --git a/assets/js/Components/Forms/ContrastForm.js b/assets/js/Components/Forms/ContrastForm.js
index 064211839..bfbc39cb0 100644
--- a/assets/js/Components/Forms/ContrastForm.js
+++ b/assets/js/Components/Forms/ContrastForm.js
@@ -1,6 +1,8 @@
-import React, { useState, useEffect } from 'react'
+import React, { useState, useEffect, useRef } from 'react'
import DarkIcon from '../Icons/DarkIcon'
import LightIcon from '../Icons/LightIcon'
+import CheckIcon from '../Icons/CheckIcon'
+import CloseIcon from '../Icons/CloseIcon'
import SeverityIssueIcon from '../Icons/SeverityIssueIcon'
import FixedIcon from '../Icons/FixedIcon'
import ResolvedIcon from '../Icons/ResolvedIcon'
@@ -9,145 +11,266 @@ import * as Html from '../../Services/Html'
import * as Contrast from '../../Services/Contrast'
export default function ContrastForm({
- t,
+ t,
settings,
activeIssue,
isDisabled,
handleActiveIssue,
handleIssueSave,
markAsReviewed,
- setMarkAsReviewed
}) {
+ // Extract color strings from gradients
+ const extractColors = (gradientString) => {
+ const colorRegex = /#(?:[0-9a-fA-F]{3,8})\b|(?:rgba?|hsla?)\([^)]*\)/g
+ return gradientString.match(colorRegex) || []
+ }
- const getBackgroundColor = () => {
- const issue = activeIssue
- const metadata = (issue.metadata) ? JSON.parse(issue.metadata) : {}
+ // Get all background colors (including gradients)
+ const getBackgroundColors = () => {
const html = Html.getIssueHtml(activeIssue)
- const element = Html.toElement(html)
-
- if (element?.style?.backgroundColor) {
- return Contrast.standardizeColor(element.style.backgroundColor)
+ let element = Html.toElement(html)
+ if (!element) return []
+
+ let tempBackgroundColors = []
+ let current = element
+ let rawStyle = ''
+ let bgMatch = null
+
+ // Traverse up the DOM to find the first background(-color|-image)
+ while (current) {
+ rawStyle = current.getAttribute && current.getAttribute('style') || ''
+ bgMatch = rawStyle.match(/background(-color|-image)?:\s*([^;]+);?/i)
+ if (bgMatch) break
+ current = current.parentElement
}
- else {
- return (metadata.backgroundColor) ? Contrast.standardizeColor(metadata.backgroundColor) : settings.backgroundColor
+
+ if (bgMatch) {
+ const styleValue = bgMatch[2]
+ const colors = extractColors(styleValue)
+ colors.forEach(color => {
+ const hsl = Contrast.toHSL(color)
+ if (hsl) {
+ tempBackgroundColors.push({
+ originalString: styleValue,
+ originalColorString: color,
+ hsl
+ })
+ }
+ })
+ }
+ if (tempBackgroundColors.length === 0) {
+ tempBackgroundColors.push({
+ originalString: '',
+ originalColorString: settings.backgroundColor,
+ hsl: Contrast.toHSL(settings.backgroundColor)
+ })
}
+ return tempBackgroundColors
}
+ // Get initial text color
const getTextColor = () => {
- const issue = activeIssue
- const metadata = (issue.metadata) ? JSON.parse(issue.metadata) : {}
- const html = Html.getIssueHtml(activeIssue)
- const element = Html.toElement(html)
+ const metadata = activeIssue.metadata ? JSON.parse(activeIssue.metadata) : {};
+ const html = Html.getIssueHtml(activeIssue);
+ const element = Html.toElement(html);
- if (element.style.color) {
- return Contrast.standardizeColor(element.style.color)
+ let colorEl = element;
+ if (metadata.textColorXpath && Html.findElementWithXpath) {
+ const found = Html.findElementWithXpath(element, metadata.textColorXpath);
+ if (found) colorEl = found;
}
- else {
- return (metadata.color) ? Contrast.standardizeColor(metadata.color) : settings.textColor
+
+ if (colorEl && colorEl.style && colorEl.style.color) {
+ return Contrast.toHSL(colorEl.style.color);
}
+ return Contrast.toHSL(settings.textColor);
}
- const initialBackgroundColor = getBackgroundColor()
- const initialTextColor = getTextColor()
+ // Heading tags for contrast threshold
const headingTags = ["H1", "H2", "H3", "H4", "H5", "H6"]
- const [backgroundColor, setBackgroundColor] = useState(initialBackgroundColor)
- const [textColor, setTextColor] = useState(initialTextColor)
+ // State
+ const [originalBgColors, setOriginalBgColors] = useState([])
+ const [currentBgColors, setCurrentBgColors] = useState([])
+ const [textColor, setTextColor] = useState(null)
const [contrastRatio, setContrastRatio] = useState(null)
const [ratioIsValid, setRatioIsValid] = useState(false)
-
- const isValidHexColor = (color) => {
- const hexColorPattern = /^#?([a-fA-F0-9]{6}|[a-fA-F0-9]{3})$/
- let outcome = hexColorPattern.test(color)
- return outcome
- }
+ const [autoAdjustError, setAutoAdjustError] = useState(false)
+ const [showAllColors, setShowAllColors] = useState(false)
- const processHtml = (html) => {
- let element = Html.toElement(html)
+ // Generate updated HTML with new colors
+ const processHtml = (html, bgColors) => {
+ let element = Html.toElement(html);
+ if (bgColors.length > 1) {
+ let gradientHtml = originalBgColors[0].originalString;
+ originalBgColors.forEach((bg, idx) => {
+ gradientHtml = gradientHtml.replace(bg.originalColorString, Contrast.hslToHex(bgColors[idx]));
+ });
+ element.style.background = gradientHtml;
+ element.style.backgroundColor = '';
+ } else if (bgColors.length === 1) {
+ element.style.backgroundColor = Contrast.hslToHex(bgColors[0]);
+ } else {
+ element.style.background = '';
+ }
- element.style.backgroundColor = Contrast.convertShortenedHex(backgroundColor)
- element.style.color = Contrast.convertShortenedHex(textColor)
+ // Set text color on the correct element
+ let textColorXpath = null;
+ try {
+ const metadata = activeIssue.metadata ? JSON.parse(activeIssue.metadata) : {};
+ textColorXpath = metadata.textColorXpath;
+ } catch (e) {}
+ let textEl = element;
+ try {
+ const metadata = activeIssue.metadata ? JSON.parse(activeIssue.metadata) : {};
+ if (metadata.textColorXpath && Html.findElementWithXpath) {
+ const found = Html.findElementWithXpath(element, metadata.textColorXpath);
+ if (found) textEl = found;
+ }
+ } catch (e) {}
+ textEl.style.color = Contrast.hslToHex(textColor);
return Html.toString(element)
}
+ // Update preview and contrast ratio
const updatePreview = () => {
- let issue = activeIssue
const html = Html.getIssueHtml(activeIssue)
- let contrastRatio = Contrast.contrastRatio(backgroundColor, textColor)
- let tagName = Html.toElement(html).tagName
- let ratioIsValid = ratioIsValid
-
- if(headingTags.includes(tagName)) {
- ratioIsValid = (contrastRatio >= 3)
- } else {
- ratioIsValid = (contrastRatio >= 4.5)
+ let ratio = 1
+ if (currentBgColors.length > 0 && textColor) {
+ const ratios = currentBgColors.map(bg => Contrast.contrastRatio(
+ Contrast.hslToHex(bg), Contrast.hslToHex(textColor)
+ ))
+ ratio = Math.min(...ratios)
+ }
+ setContrastRatio(ratio)
+ setRatioIsValid(ratio >= minRatio)
+
+ const newHtml = processHtml(html, currentBgColors)
+ if (activeIssue.newHtml !== newHtml) {
+ activeIssue.newHtml = newHtml
+ handleActiveIssue(activeIssue)
}
-
- setContrastRatio(contrastRatio)
- setRatioIsValid(ratioIsValid)
-
- issue.newHtml = processHtml(html)
- handleActiveIssue(issue)
}
+ // Handlers
const updateText = (event) => {
const value = event.target.value
- if(!isValidHexColor(value)) {
- return
- }
- setTextColor(value)
+ const hsl = Contrast.toHSL(value)
+ if (hsl) setTextColor(hsl)
}
- const updateBackground = (event) => {
- const value = event.target.value
- if(!isValidHexColor(value)) {
- return
- }
- setBackgroundColor(value)
- }
+ // On issue change, extract from original HTML
+ useEffect(() => {
+ const info = getBackgroundColors()
+ setOriginalBgColors(info)
+ setCurrentBgColors(info.map(bg => bg.hsl))
+ setTextColor(getTextColor())
+ setAutoAdjustError(false)
+ setShowAllColors(false)
+ }, [activeIssue])
- const handleLightenText = () => {
- const newColor = Contrast.changehue(textColor, 'lighten')
- setTextColor(newColor)
+ const updateBackgroundColor = (idx, value) => {
+ const hsl = Contrast.toHSL(value)
+ setCurrentBgColors(colors =>
+ colors.map((c, i) => i === idx ? hsl : c)
+ )
}
- const handleDarkenText = () => {
- const newColor = Contrast.changehue(textColor, 'darken')
- setTextColor(newColor)
- }
+ const handleLightenText = () => setTextColor(Contrast.changeLuminance(textColor, 'lighten'))
+ const handleDarkenText = () => setTextColor(Contrast.changeLuminance(textColor, 'darken'))
- const handleLightenBackground = () => {
- const newColor = Contrast.changehue(backgroundColor, 'lighten')
- setBackgroundColor(newColor)
+ const handleLightenBackground = idx => {
+ setCurrentBgColors(colors =>
+ colors.map((c, i) => i === idx ? Contrast.changeLuminance(c, 'lighten') : c)
+ )
}
-
- const handleDarkenBackground = () => {
- const newColor = Contrast.changehue(backgroundColor, 'darken')
- setBackgroundColor(newColor)
+ const handleDarkenBackground = idx => {
+ setCurrentBgColors(colors =>
+ colors.map((c, i) => i === idx ? Contrast.changeLuminance(c, 'darken') : c)
+ )
}
const handleSubmit = () => {
- let issue = activeIssue
- if(ratioIsValid || markAsReviewed) {
- issue.newHtml = Contrast.convertHtmlRgb2Hex(issue.newHtml)
+ if (ratioIsValid || markAsReviewed) {
+ const issue = { ...activeIssue, newHtml: Contrast.convertHtmlRgb2Hex(activeIssue.newHtml) }
handleIssueSave(issue)
}
}
+ const debounceTimer = useRef(null)
+ // Debounced updatePreview
useEffect(() => {
- updatePreview()
- }, [textColor, backgroundColor])
+ if (debounceTimer.current) clearTimeout(debounceTimer.current)
+ debounceTimer.current = setTimeout(() => {
+ updatePreview()
+ }, 150)
+ return () => clearTimeout(debounceTimer.current)
+ }, [textColor, currentBgColors])
- useEffect(() => {
- setBackgroundColor(getBackgroundColor())
- setTextColor(getTextColor())
- }, [activeIssue])
+ const handleAutoAdjustAll = () => {
+ let newBgColors = [...currentBgColors];
+ let changed = false;
+ let failed = false;
+ const element = Html.toElement(Html.getIssueHtml(activeIssue));
+ const minRatio = isLargeText(element) ? 3 : 4.5;
+ for (let i = 0; i < newBgColors.length; i++) {
+ let bg = newBgColors[i];
+ let ratio = Contrast.contrastRatio(bg, textColor);
+ let attempts = 0;
+ while (ratio < minRatio && attempts < 20) {
+ const lighter = Contrast.changeLuminance(bg, 'lighten');
+ const darker = Contrast.changeLuminance(bg, 'darken');
+ const lighterRatio = Contrast.contrastRatio(lighter, textColor);
+ const darkerRatio = Contrast.contrastRatio(darker, textColor);
+ if (lighterRatio > darkerRatio) {
+ bg = lighter;
+ ratio = lighterRatio;
+ } else {
+ bg = darker;
+ ratio = darkerRatio;
+ }
+ attempts++;
+ }
+ if (ratio < minRatio) {
+ failed = true;
+ break;
+ }
+ if (attempts > 0) changed = true;
+ newBgColors[i] = bg;
+ }
+ if (failed) {
+ setCurrentBgColors(originalBgColors.map(bg => bg.hsl));
+ setAutoAdjustError(true);
+ } else if (changed) {
+ setCurrentBgColors(newBgColors);
+ setAutoAdjustError(false);
+ }
+ };
+
+ function isLargeText(element) {
+ if (!element) return false;
+ const style = window.getComputedStyle(element);
+ const fontSizePx = parseFloat(style.fontSize);
+ const fontWeight = style.fontWeight;
+
+ // Convert px to pt (1pt = 1.333px)
+ const fontSizePt = fontSizePx / 1.333;
+
+ // WCAG: large text is >= 18pt (24px) regular or >= 14pt (18.67px) bold
+ const isBold = parseInt(fontWeight, 10) >= 700 || style.fontWeight === 'bold';
+ return (fontSizePt >= 18) || (isBold && fontSizePt >= 14);
+ }
+
+ const maxColorsToShow = 4;
+ const shouldShowExpand = currentBgColors.length > maxColorsToShow;
+ const visibleBgColors = showAllColors ? currentBgColors : currentBgColors.slice(0, maxColorsToShow);
+ const tagName = Html.toElement(Html.getIssueHtml(activeIssue)).tagName;
+ const minRatio = headingTags.includes(tagName) ? 3 : 4.5;
return (
<>
{t('form.contrast.label.adjust')}
-
@@ -155,13 +278,14 @@ export default function ContrastForm({
+ />
@@ -170,7 +294,7 @@ export default function ContrastForm({
tabIndex="0"
disabled={isDisabled}
onClick={handleLightenText}>
-
+
{t('form.contrast.label.lighten')}
@@ -180,7 +304,7 @@ export default function ContrastForm({
tabIndex="0"
disabled={isDisabled}
onClick={handleDarkenText}>
-
+
{t('form.contrast.label.darken')}
@@ -188,43 +312,97 @@ export default function ContrastForm({
-
+
-
-
-
{
+ let showStatus = currentBgColors.length > 1;
+ let ratio = Contrast.contrastRatio(
+ Contrast.hslToHex(color), Contrast.hslToHex(textColor)
+ );
+ let isValid = ratio >= minRatio;
+
+ return (
+
+
+ updateBackgroundColor(idx, e.target.value)}
+ aria-label={
+ t('form.contrast.label.background.show_color_picker') +
+ ' ' +
+ (isValid
+ ? t('form.contrast.feedback.valid')
+ : t('form.contrast.feedback.invalid'))
+ }
+ title={t('form.contrast.label.background.show_color_picker')}
+ disabled={isDisabled}
+ />
+ {showStatus && (
+ isValid ? (
+
+ ) : (
+
+ )
+ )}
+
+
+
+
+
+
+ );
+ })}
+
+ {shouldShowExpand && (
+
+
+
+ )}
+
+ {currentBgColors.length > 1 && (
+
+
+ onClick={handleAutoAdjustAll}
+ >
+ {t('form.contrast.label.auto_adjust_all')}
+
-
-
-
-
-
-
+ )}
+
+ {autoAdjustError && (
+
+
+ {t('form.contrast.auto_adjust_error')}
-
+ )}
@@ -241,28 +419,50 @@ export default function ContrastForm({
)}
-
{contrastRatio}
+
{contrastRatio?.toFixed(2)}
-
{ratioIsValid ? t('form.contrast.feedback.valid') : t('form.contrast.feedback.invalid')}
+
+ {ratioIsValid
+ ? t('form.contrast.feedback.valid')
+ : (
+
+ {t('form.contrast.feedback.minimum', {
+ required: minRatio
+ })}
+
+ )
+ }
+
+ {currentBgColors.length > 1 && (
+
+
+ {t('form.contrast.ratio_note')}
+
+
+ )}
- { (activeIssue.status === 1 || activeIssue.status === 3) ? (
-
-
-
{t('filter.label.resolution.fixed_single')}
+ {(activeIssue.status === 1 || activeIssue.status === 3) ? (
+
+
+
+ {t('filter.label.resolution.fixed_single')}
- ) : activeIssue.status === 2 ? (
-
-
-
{t('filter.label.resolution.resolved_single')}
+
+ ) : activeIssue.status === 2 ? (
+
+
+
+ {t('filter.label.resolution.resolved_single')}
- ) : ''}
+
+ ) : ''}