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 && ( +
+
-
-
- -
-
- + )} + + {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')}
- ) : ''} +
+ ) : ''}