From 80efffadceae0edb36866743011cbcbf52d45ba6 Mon Sep 17 00:00:00 2001 From: Elias Wainberg Date: Tue, 7 Oct 2025 15:01:41 -0400 Subject: [PATCH 01/12] Initial commit - Adds initial gradient support - Begins work on getting / modifying backgrounds from parent elements --- assets/js/Components/Forms/ContrastForm.js | 287 +++++++++++-------- assets/js/Components/Widgets/UfixitWidget.js | 29 +- assets/js/Services/Contrast.js | 13 +- 3 files changed, 212 insertions(+), 117 deletions(-) diff --git a/assets/js/Components/Forms/ContrastForm.js b/assets/js/Components/Forms/ContrastForm.js index 064211839..125dfd92b 100644 --- a/assets/js/Components/Forms/ContrastForm.js +++ b/assets/js/Components/Forms/ContrastForm.js @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from 'react' +import React, { useState, useEffect, useRef } from 'react' import DarkIcon from '../Icons/DarkIcon' import LightIcon from '../Icons/LightIcon' import SeverityIssueIcon from '../Icons/SeverityIssueIcon' @@ -16,138 +16,186 @@ export default function ContrastForm({ handleActiveIssue, handleIssueSave, markAsReviewed, - setMarkAsReviewed + setMarkAsReviewed, + parentBackground = false }) { + console.log("parentBackground", parentBackground) + // 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) + console.log("html", html) + 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 standardColor = Contrast.standardizeColor(color) + if (standardColor) { + tempBackgroundColors.push({ + originalString: styleValue, + originalColorString: color, + standardColor + }) + } + }) + } + + if (tempBackgroundColors.length === 0) { + tempBackgroundColors.push({ + originalString: '', + originalColorString: settings.backgroundColor, + standardColor: settings.backgroundColor + }) } + return tempBackgroundColors } + // Get initial text color const getTextColor = () => { - const issue = activeIssue - const metadata = (issue.metadata) ? JSON.parse(issue.metadata) : {} + const metadata = activeIssue.metadata ? JSON.parse(activeIssue.metadata) : {} const html = Html.getIssueHtml(activeIssue) + console.log(html) const element = Html.toElement(html) - if (element.style.color) { return Contrast.standardizeColor(element.style.color) } - else { - return (metadata.color) ? Contrast.standardizeColor(metadata.color) : settings.textColor - } + return metadata.color ? Contrast.standardizeColor(metadata.color) : 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('') 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 processHtml = (html) => { - let element = Html.toElement(html) + // Validate hex color + const isValidHexColor = (color) => /^#?([a-fA-F0-9]{6}|[a-fA-F0-9]{3})$/.test(color) - element.style.backgroundColor = Contrast.convertShortenedHex(backgroundColor) + // 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, bgColors[idx]) + }) + element.style.background = gradientHtml + element.style.backgroundColor = '' + } else if (bgColors.length === 1) { + element.style.backgroundColor = Contrast.convertShortenedHex(bgColors[0]) + } else { + element.style.background = '' + } element.style.color = Contrast.convertShortenedHex(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(bg, textColor)) + ratio = Math.min(...ratios) } + const tagName = Html.toElement(html).tagName + const valid = headingTags.includes(tagName) ? (ratio >= 3) : (ratio >= 4.5) + setContrastRatio(ratio) + setRatioIsValid(valid) - setContrastRatio(contrastRatio) - setRatioIsValid(ratioIsValid) - - issue.newHtml = processHtml(html) - handleActiveIssue(issue) + const newHtml = processHtml(html, currentBgColors) + if (activeIssue.newHtml !== newHtml) { + const issue = { ...activeIssue, newHtml } + handleActiveIssue(issue) + } } + // Handlers const updateText = (event) => { const value = event.target.value - if(!isValidHexColor(value)) { - return - } - setTextColor(value) + if (isValidHexColor(value)) setTextColor(value) } - const updateBackground = (event) => { - const value = event.target.value - if(!isValidHexColor(value)) { - return - } - setBackgroundColor(value) - } + // On issue change, extract from original HTML + useEffect(() => { + console.log(activeIssue) + const info = getBackgroundColors() // Use sourceHtml inside this function if available + setOriginalBgColors(info) + setCurrentBgColors(info.map(bg => bg.standardColor)) + setTextColor(getTextColor()) + // eslint-disable-next-line + }, [activeIssue]) - const handleLightenText = () => { - const newColor = Contrast.changehue(textColor, 'lighten') - setTextColor(newColor) + // On user interaction, only update state (do NOT call getBackgroundColors again) + const updateBackgroundColor = (idx, value) => { + setCurrentBgColors(colors => + colors.map((c, i) => i === idx ? value : c) + ) } - const handleDarkenText = () => { - const newColor = Contrast.changehue(textColor, 'darken') - setTextColor(newColor) - } + const handleLightenText = () => setTextColor(Contrast.changehue(textColor, 'lighten')) + const handleDarkenText = () => setTextColor(Contrast.changehue(textColor, 'darken')) - const handleLightenBackground = () => { - const newColor = Contrast.changehue(backgroundColor, 'lighten') - setBackgroundColor(newColor) + const handleLightenBackground = idx => { + setCurrentBgColors(colors => + colors.map((c, i) => i === idx ? Contrast.changehue(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.changehue(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) } } - useEffect(() => { - updatePreview() - }, [textColor, backgroundColor]) + // Debounce timer ref + const debounceTimer = useRef(null) + // Debounced updatePreview useEffect(() => { - setBackgroundColor(getBackgroundColor()) - setTextColor(getTextColor()) - }, [activeIssue]) + if (debounceTimer.current) clearTimeout(debounceTimer.current) + debounceTimer.current = setTimeout(() => { + updatePreview() + }, 150) // 150ms debounce, adjust as needed + return () => clearTimeout(debounceTimer.current) + // eslint-disable-next-line + }, [textColor, currentBgColors]) + // Render return ( <>
{t('form.contrast.label.adjust')}
-
@@ -161,7 +209,8 @@ export default function ContrastForm({ tabIndex="0" disabled={isDisabled} value={textColor} - onChange={updateText} /> + onChange={updateText} + />
@@ -170,7 +219,7 @@ export default function ContrastForm({ tabIndex="0" disabled={isDisabled} onClick={handleLightenText}> - + {t('form.contrast.label.lighten')}
@@ -180,7 +229,7 @@ export default function ContrastForm({ tabIndex="0" disabled={isDisabled} onClick={handleDarkenText}> - + {t('form.contrast.label.darken')}
@@ -188,43 +237,42 @@ export default function ContrastForm({
- +
-
-
+ {currentBgColors.map((color, idx) => ( +
-
-
-
+ value={color} + style={{ width: '2.5em', height: '2em' }} + onChange={e => updateBackgroundColor(idx, e.target.value)} + /> +
-
-
-
+ ))}
@@ -241,28 +289,41 @@ export default function ContrastForm({ )}
-
{contrastRatio}
+
{contrastRatio}
-
{ratioIsValid ? t('form.contrast.feedback.valid') : t('form.contrast.feedback.invalid')}
+
+ {ratioIsValid ? t('form.contrast.feedback.valid') : t('form.contrast.feedback.invalid')} +
+ {currentBgColors.length > 1 && ( +
+ + * lowest among gradient colors + +
+ )}
- { (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')}
- ) : ''} +
+ ) : ''}
))} + {currentBgColors.length > 1 && ( +
+ +
+ )} +
diff --git a/translations/en.json b/translations/en.json index 22ffa56a6..7a486ca36 100644 --- a/translations/en.json +++ b/translations/en.json @@ -338,6 +338,7 @@ "form.contrast.replace_text": "Text Color", "form.contrast.replace_background": "Background Color", "form.contrast.label.ratio": "Contrast Ratio", + "form.contrast.label.auto_adjust_all": "Auto-Adjust All Colors", "form.contrast.feedback.invalid": "Invalid Ratio", "form.contrast.feedback.valid": "Valid Ratio", "rule.desc.text_contrast_sufficient": "", diff --git a/translations/es.json b/translations/es.json index f5d1f1aad..908ed38c9 100644 --- a/translations/es.json +++ b/translations/es.json @@ -339,6 +339,7 @@ "form.contrast.feedback.invalid": "Relación Inválida", "form.contrast.feedback.valid": "Relación Válida", "form.contrast.label.bolden_text": "Poner Texto en Negrita", + "form.contrast.label.auto_adjust": "Ajustar Colores Automáticamente", "form.contrast.label.italicize_text": "Poner Texto en Cursiva", "rule.desc.text_contrast_sufficient": "", From 58109686b82db0076bd6b293c6835aeda2f9f490 Mon Sep 17 00:00:00 2001 From: Elias Wainberg Date: Thu, 16 Oct 2025 11:34:48 -0400 Subject: [PATCH 05/12] Fixes background color select sizing and spacing --- assets/css/udoit4-theme.css | 9 ++++++++ assets/js/Components/Forms/ContrastForm.js | 25 +++++++++++----------- 2 files changed, 22 insertions(+), 12 deletions(-) diff --git a/assets/css/udoit4-theme.css b/assets/css/udoit4-theme.css index d3ff6d2a1..408b29c14 100644 --- a/assets/css/udoit4-theme.css +++ b/assets/css/udoit4-theme.css @@ -828,6 +828,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 d1b392115..f4e5b7b81 100644 --- a/assets/js/Components/Forms/ContrastForm.js +++ b/assets/js/Components/Forms/ContrastForm.js @@ -299,17 +299,18 @@ export default function ContrastForm({
{currentBgColors.map((color, idx) => ( -
- updateBackgroundColor(idx, e.target.value)} - /> +
+
+ updateBackgroundColor(idx, e.target.value)} + /> +
- + {currentBgColors.map((color, idx) => { + let showStatus = currentBgColors.length > 1; + let tagName = Html.toElement(Html.getIssueHtml(activeIssue)).tagName; + let minRatio = headingTags.includes(tagName) ? 3 : 4.5; + let ratio = Contrast.contrastRatio(color, textColor); + let isValid = ratio >= minRatio; + + return ( +
+
+ updateBackgroundColor(idx, e.target.value)} + /> + {showStatus && ( + isValid + ? + : + )} +
+
+ + +
-
- ))} + ); + })} {currentBgColors.length > 1 && (
diff --git a/translations/en.json b/translations/en.json index 7a486ca36..a66f71561 100644 --- a/translations/en.json +++ b/translations/en.json @@ -339,8 +339,8 @@ "form.contrast.replace_background": "Background Color", "form.contrast.label.ratio": "Contrast Ratio", "form.contrast.label.auto_adjust_all": "Auto-Adjust All Colors", - "form.contrast.feedback.invalid": "Invalid Ratio", - "form.contrast.feedback.valid": "Valid Ratio", + "form.contrast.feedback.invalid": "Insufficient Contrast", + "form.contrast.feedback.valid": "Sufficient Contrast", "rule.desc.text_contrast_sufficient": "", "form.embedded_content_title.title": "Embedded Content Should Have a Label", diff --git a/translations/es.json b/translations/es.json index 908ed38c9..93d1265e0 100644 --- a/translations/es.json +++ b/translations/es.json @@ -336,8 +336,8 @@ "form.contrast.replace_text": "Color del Texto", "form.contrast.replace_background": "Color de Fondo", "form.contrast.label.ratio": "Relación de Contraste", - "form.contrast.feedback.invalid": "Relación Inválida", - "form.contrast.feedback.valid": "Relación Válida", + "form.contrast.feedback.invalid": "Contraste Insuficiente", + "form.contrast.feedback.valid": "Contraste Suficiente", "form.contrast.label.bolden_text": "Poner Texto en Negrita", "form.contrast.label.auto_adjust": "Ajustar Colores Automáticamente", "form.contrast.label.italicize_text": "Poner Texto en Cursiva", From 90241efc39678e23f0811a8d86ff03f4e47a84d7 Mon Sep 17 00:00:00 2001 From: Elias Wainberg Date: Tue, 28 Oct 2025 14:10:43 -0400 Subject: [PATCH 07/12] Adds fixes for: - Red dotted focus line will now draw around the text relevant to the issue as well as the background - Fixes outdated logic for getTextColor, now supports new framework with textColorXpath NOTE: triggerLiveUpdate() will break the dotted focus box for reasons unknown, will be fixed later somewhere else --- assets/js/Components/Forms/ContrastForm.js | 23 +++++++------------ .../Widgets/FixIssuesContentPreview.js | 11 +++++++++ assets/js/Components/Widgets/UfixitWidget.js | 2 +- assets/js/Services/Html.js | 3 +++ assets/js/Services/Report.js | 2 +- 5 files changed, 24 insertions(+), 17 deletions(-) diff --git a/assets/js/Components/Forms/ContrastForm.js b/assets/js/Components/Forms/ContrastForm.js index 7dec32eff..610a2223d 100644 --- a/assets/js/Components/Forms/ContrastForm.js +++ b/assets/js/Components/Forms/ContrastForm.js @@ -75,23 +75,16 @@ export default function ContrastForm({ const html = Html.getIssueHtml(activeIssue); const element = Html.toElement(html); - // Helper to find first descendant with a color style - function findTextColorEl(el) { - if (!el) return null; - if (el.style && el.style.color && el.style.color !== '') return el; - for (let i = 0; i < el.children.length; i++) { - const found = findTextColorEl(el.children[i]); - if (found) return found; - } - return null; + let colorEl = element; + if (metadata.textColorXpath && Html.findElementWithXpath) { + const found = Html.findElementWithXpath(element, metadata.textColorXpath); + if (found) colorEl = found; } - const colorEl = findTextColorEl(element); - - if (colorEl && colorEl.style.color) { + if (colorEl && colorEl.style && colorEl.style.color) { return Contrast.standardizeColor(colorEl.style.color); } - return metadata.color ? Contrast.standardizeColor(metadata.color) : settings.textColor + return settings.textColor; } // Heading tags for contrast threshold @@ -159,8 +152,8 @@ export default function ContrastForm({ const newHtml = processHtml(html, currentBgColors) if (activeIssue.newHtml !== newHtml) { - const issue = { ...activeIssue, newHtml } - handleActiveIssue(issue) + activeIssue.newHtml = newHtml + handleActiveIssue(activeIssue) } } diff --git a/assets/js/Components/Widgets/FixIssuesContentPreview.js b/assets/js/Components/Widgets/FixIssuesContentPreview.js index 9e7230319..7ce4b4673 100644 --- a/assets/js/Components/Widgets/FixIssuesContentPreview.js +++ b/assets/js/Components/Widgets/FixIssuesContentPreview.js @@ -74,6 +74,10 @@ export default function FixIssuesContentPreview({ formNames.HEADING_STYLE ] + const FOCUS_RELATED = [ + formNames.CONTRAST + ] + const convertErrorHtmlString = (htmlText) => { let tempElement = Html.toElement(htmlText) if(tempElement){ @@ -190,6 +194,13 @@ export default function FixIssuesContentPreview({ } else { errorElement.replaceWith(convertErrorHtmlElement(errorElement)) } + if (FOCUS_RELATED.includes(formNameFromRule(activeIssue.scanRuleId))) { + let metadata = JSON.parse(activeIssue.issueData.metadata) + let focusElement = Html.findElementWithXpath(doc, metadata.focusXpath) + if (focusElement) { + focusElement.replaceWith(convertErrorHtmlElement(focusElement)) + } + } setCanShowPreview(true) setIsErrorFoundInContent(true) } diff --git a/assets/js/Components/Widgets/UfixitWidget.js b/assets/js/Components/Widgets/UfixitWidget.js index 3a5365121..3635b11e6 100644 --- a/assets/js/Components/Widgets/UfixitWidget.js +++ b/assets/js/Components/Widgets/UfixitWidget.js @@ -123,7 +123,7 @@ export default function UfixitWidget({ tempIssue.issueData = newIssue tempIssue.isModified = newIssue?.isModified || false setTempActiveIssue(tempIssue) - triggerLiveUpdate() + //triggerLiveUpdate() } const interceptIssueSave = (issue) => { diff --git a/assets/js/Services/Html.js b/assets/js/Services/Html.js index 57f03d264..94c4ef1df 100644 --- a/assets/js/Services/Html.js +++ b/assets/js/Services/Html.js @@ -428,6 +428,9 @@ export const findXpathFromElement = (element) => { } export const findElementWithXpath = (content, xpath) => { + if (!content || !xpath) { + return null + } if(xpath.startsWith('/html[1]/body[1]/main[1]/')) { xpath = xpath.replace('/html[1]/body[1]/main[1]/', '/html[1]/body[1]/') diff --git a/assets/js/Services/Report.js b/assets/js/Services/Report.js index 2df70e8ad..a4bb69364 100644 --- a/assets/js/Services/Report.js +++ b/assets/js/Services/Report.js @@ -98,7 +98,6 @@ const checkTextContrastSufficient = (issue, element, parsedDocument) => { if (bgAncestor) { issue.xpath = Html.findXpathFromElement(bgAncestor); issue.sourceHtml = Html.toString(bgAncestor); // <-- Set sourceHtml to the correct scope - } else { } // Store the text color element's xpath in metadata @@ -128,6 +127,7 @@ const checkTextContrastSufficient = (issue, element, parsedDocument) => { } issue.metadata = issue.metadata || {}; issue.metadata.textColorXpath = getRelativeXpath(bgAncestor, textAncestor); + issue.metadata.focusXpath = Html.findXpathFromElement(element); issue.metadata = JSON.stringify(issue.metadata); } // Return false to indicate this issue should not be ignored From dafdc95eaf432b2cd5f1001e255967dcada61034 Mon Sep 17 00:00:00 2001 From: Elias Wainberg Date: Thu, 30 Oct 2025 10:32:26 -0400 Subject: [PATCH 08/12] Adds auto-adjust colors error handling and some translations --- assets/js/Components/Forms/ContrastForm.js | 33 ++++++++++++++++------ translations/en.json | 2 ++ translations/es.json | 2 ++ 3 files changed, 29 insertions(+), 8 deletions(-) diff --git a/assets/js/Components/Forms/ContrastForm.js b/assets/js/Components/Forms/ContrastForm.js index 610a2223d..521c116de 100644 --- a/assets/js/Components/Forms/ContrastForm.js +++ b/assets/js/Components/Forms/ContrastForm.js @@ -96,6 +96,7 @@ export default function ContrastForm({ const [textColor, setTextColor] = useState('') const [contrastRatio, setContrastRatio] = useState(null) const [ratioIsValid, setRatioIsValid] = useState(false) + const [autoAdjustError, setAutoAdjustError] = useState(false) // Validate hex color const isValidHexColor = (color) => /^#?([a-fA-F0-9]{6}|[a-fA-F0-9]{3})$/.test(color) @@ -165,10 +166,11 @@ export default function ContrastForm({ // On issue change, extract from original HTML useEffect(() => { - const info = getBackgroundColors() // Use sourceHtml inside this function if available + const info = getBackgroundColors() setOriginalBgColors(info) setCurrentBgColors(info.map(bg => bg.standardColor)) setTextColor(getTextColor()) + setAutoAdjustError(false) // Reset error when switching issues // eslint-disable-next-line }, [activeIssue]) @@ -214,19 +216,16 @@ export default function ContrastForm({ }, [textColor, currentBgColors]) const handleAutoAdjustAll = () => { - // Try to minimally adjust each background color to achieve valid contrast let newBgColors = [...currentBgColors]; let changed = false; + let failed = false; for (let i = 0; i < newBgColors.length; i++) { let bg = newBgColors[i]; let ratio = Contrast.contrastRatio(bg, textColor); - // Use the same threshold logic as your UI const tagName = Html.toElement(Html.getIssueHtml(activeIssue)).tagName; const minRatio = headingTags.includes(tagName) ? 3 : 4.5; let attempts = 0; - // Try to lighten or darken until valid, but don't go infinite while (ratio < minRatio && attempts < 20) { - // Decide direction: lighten or darken based on which is closer to valid const lighter = Contrast.changehue(bg, 'lighten'); const darker = Contrast.changehue(bg, 'darken'); const lighterRatio = Contrast.contrastRatio(lighter, textColor); @@ -240,10 +239,20 @@ export default function ContrastForm({ } attempts++; } + if (ratio < minRatio) { + failed = true; + break; // No need to continue if one fails + } if (attempts > 0) changed = true; newBgColors[i] = bg; } - if (changed) setCurrentBgColors(newBgColors); + if (failed) { + setCurrentBgColors(originalBgColors.map(bg => bg.standardColor)); + setAutoAdjustError(true); + } else if (changed) { + setCurrentBgColors(newBgColors); + setAutoAdjustError(false); + } }; // Render @@ -359,11 +368,19 @@ export default function ContrastForm({ disabled={isDisabled} onClick={handleAutoAdjustAll} > - {t('form.contrast.label.auto_adjust_all') || 'Auto Adjust All'} + {t('form.contrast.label.auto_adjust_all')}
)} + {autoAdjustError && ( +
+
+ {t('form.contrast.auto_adjust_error')} +
+
+ )} +
@@ -390,7 +407,7 @@ export default function ContrastForm({ {currentBgColors.length > 1 && (
- * lowest among gradient colors + {t('form.contrast.ratio_note')}
)} diff --git a/translations/en.json b/translations/en.json index a66f71561..717aa497d 100644 --- a/translations/en.json +++ b/translations/en.json @@ -341,6 +341,8 @@ "form.contrast.label.auto_adjust_all": "Auto-Adjust All Colors", "form.contrast.feedback.invalid": "Insufficient Contrast", "form.contrast.feedback.valid": "Sufficient Contrast", + "form.contrast.auto_adjust_error": "We couldn't auto-adjust your colors, please do so manually.", + "form.contrast.ratio_note": "* lowest among gradient colors", "rule.desc.text_contrast_sufficient": "", "form.embedded_content_title.title": "Embedded Content Should Have a Label", diff --git a/translations/es.json b/translations/es.json index 93d1265e0..a535224e4 100644 --- a/translations/es.json +++ b/translations/es.json @@ -341,6 +341,8 @@ "form.contrast.label.bolden_text": "Poner Texto en Negrita", "form.contrast.label.auto_adjust": "Ajustar Colores Automáticamente", "form.contrast.label.italicize_text": "Poner Texto en Cursiva", + "form.contrast.auto_adjust_error": "No se pudo ajustar automáticamente el color. Intenta ajustar manualmente los colores.", + "form.contrast.ratio_note": "* más bajo entre todos los colores", "rule.desc.text_contrast_sufficient": "", "form.embedded_content_title.title": "El Contenido Incrustado Debe Tener una Etiqueta", From b8ae6058fd12579a4cc8c75211e27dcfd83d7c4e Mon Sep 17 00:00:00 2001 From: Elias Wainberg Date: Tue, 4 Nov 2025 11:24:56 -0500 Subject: [PATCH 09/12] Changes color standard to HSL to prevent color data loss --- assets/js/Components/Forms/ContrastForm.js | 65 +++--- assets/js/Services/Contrast.js | 254 +++------------------ package.json | 1 + 3 files changed, 62 insertions(+), 258 deletions(-) diff --git a/assets/js/Components/Forms/ContrastForm.js b/assets/js/Components/Forms/ContrastForm.js index 521c116de..a7ce135a2 100644 --- a/assets/js/Components/Forms/ContrastForm.js +++ b/assets/js/Components/Forms/ContrastForm.js @@ -48,22 +48,21 @@ export default function ContrastForm({ const styleValue = bgMatch[2] const colors = extractColors(styleValue) colors.forEach(color => { - const standardColor = Contrast.standardizeColor(color) - if (standardColor) { + const hsl = Contrast.toHSL(color) + if (hsl) { tempBackgroundColors.push({ originalString: styleValue, originalColorString: color, - standardColor + hsl }) } }) } - if (tempBackgroundColors.length === 0) { tempBackgroundColors.push({ originalString: '', originalColorString: settings.backgroundColor, - standardColor: settings.backgroundColor + hsl: Contrast.toHSL(settings.backgroundColor) }) } return tempBackgroundColors @@ -82,9 +81,9 @@ export default function ContrastForm({ } if (colorEl && colorEl.style && colorEl.style.color) { - return Contrast.standardizeColor(colorEl.style.color); + return Contrast.toHSL(colorEl.style.color); } - return settings.textColor; + return Contrast.toHSL(settings.textColor); } // Heading tags for contrast threshold @@ -93,7 +92,7 @@ export default function ContrastForm({ // State const [originalBgColors, setOriginalBgColors] = useState([]) const [currentBgColors, setCurrentBgColors] = useState([]) - const [textColor, setTextColor] = useState('') + const [textColor, setTextColor] = useState(null) const [contrastRatio, setContrastRatio] = useState(null) const [ratioIsValid, setRatioIsValid] = useState(false) const [autoAdjustError, setAutoAdjustError] = useState(false) @@ -104,17 +103,15 @@ export default function ContrastForm({ // Generate updated HTML with new colors const processHtml = (html, bgColors) => { let element = Html.toElement(html); - - // Set background as before if (bgColors.length > 1) { let gradientHtml = originalBgColors[0].originalString; originalBgColors.forEach((bg, idx) => { - gradientHtml = gradientHtml.replace(bg.originalColorString, bgColors[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.convertShortenedHex(bgColors[0]); + element.style.backgroundColor = Contrast.hslToHex(bgColors[0]); } else { element.style.background = ''; } @@ -133,7 +130,7 @@ export default function ContrastForm({ if (found) textEl = found; } } catch (e) {} - textEl.style.color = Contrast.convertShortenedHex(textColor); + textEl.style.color = Contrast.hslToHex(textColor); return Html.toString(element) } @@ -143,7 +140,9 @@ export default function ContrastForm({ const html = Html.getIssueHtml(activeIssue) let ratio = 1 if (currentBgColors.length > 0 && textColor) { - const ratios = currentBgColors.map(bg => Contrast.contrastRatio(bg, textColor)) + const ratios = currentBgColors.map(bg => Contrast.contrastRatio( + Contrast.hslToHex(bg), Contrast.hslToHex(textColor) + )) ratio = Math.min(...ratios) } const tagName = Html.toElement(html).tagName @@ -161,14 +160,15 @@ export default function ContrastForm({ // Handlers const updateText = (event) => { const value = event.target.value - if (isValidHexColor(value)) setTextColor(value) + const hsl = Contrast.toHSL(value) + if (hsl) setTextColor(hsl) } // On issue change, extract from original HTML useEffect(() => { const info = getBackgroundColors() setOriginalBgColors(info) - setCurrentBgColors(info.map(bg => bg.standardColor)) + setCurrentBgColors(info.map(bg => bg.hsl)) setTextColor(getTextColor()) setAutoAdjustError(false) // Reset error when switching issues // eslint-disable-next-line @@ -176,22 +176,23 @@ export default function ContrastForm({ // On user interaction, only update state (do NOT call getBackgroundColors again) const updateBackgroundColor = (idx, value) => { + const hsl = Contrast.toHSL(value) setCurrentBgColors(colors => - colors.map((c, i) => i === idx ? value : c) + colors.map((c, i) => i === idx ? hsl : c) ) } - const handleLightenText = () => setTextColor(Contrast.changehue(textColor, 'lighten')) - const handleDarkenText = () => setTextColor(Contrast.changehue(textColor, 'darken')) + const handleLightenText = () => setTextColor(Contrast.changeLuminance(textColor, 'lighten')) + const handleDarkenText = () => setTextColor(Contrast.changeLuminance(textColor, 'darken')) const handleLightenBackground = idx => { setCurrentBgColors(colors => - colors.map((c, i) => i === idx ? Contrast.changehue(c, 'lighten') : c) + colors.map((c, i) => i === idx ? Contrast.changeLuminance(c, 'lighten') : c) ) } const handleDarkenBackground = idx => { setCurrentBgColors(colors => - colors.map((c, i) => i === idx ? Contrast.changehue(c, 'darken') : c) + colors.map((c, i) => i === idx ? Contrast.changeLuminance(c, 'darken') : c) ) } @@ -226,8 +227,8 @@ export default function ContrastForm({ const minRatio = headingTags.includes(tagName) ? 3 : 4.5; let attempts = 0; while (ratio < minRatio && attempts < 20) { - const lighter = Contrast.changehue(bg, 'lighten'); - const darker = Contrast.changehue(bg, 'darken'); + 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) { @@ -247,7 +248,7 @@ export default function ContrastForm({ newBgColors[i] = bg; } if (failed) { - setCurrentBgColors(originalBgColors.map(bg => bg.standardColor)); + setCurrentBgColors(originalBgColors.map(bg => bg.hsl)); setAutoAdjustError(true); } else if (changed) { setCurrentBgColors(newBgColors); @@ -266,13 +267,13 @@ export default function ContrastForm({
@@ -306,7 +307,9 @@ export default function ContrastForm({ let showStatus = currentBgColors.length > 1; let tagName = Html.toElement(Html.getIssueHtml(activeIssue)).tagName; let minRatio = headingTags.includes(tagName) ? 3 : 4.5; - let ratio = Contrast.contrastRatio(color, textColor); + let ratio = Contrast.contrastRatio( + Contrast.hslToHex(color), Contrast.hslToHex(textColor) + ); let isValid = ratio >= minRatio; return ( @@ -314,12 +317,12 @@ export default function ContrastForm({
updateBackgroundColor(idx, e.target.value)} aria-label={t('form.contrast.label.background.show_color_picker')} title={t('form.contrast.label.background.show_color_picker')} - type="color" disabled={isDisabled} - value={color} - onChange={e => updateBackgroundColor(idx, e.target.value)} /> {showStatus && ( isValid diff --git a/assets/js/Services/Contrast.js b/assets/js/Services/Contrast.js index 2bfd236a3..e94eb0608 100644 --- a/assets/js/Services/Contrast.js +++ b/assets/js/Services/Contrast.js @@ -1,242 +1,42 @@ -export function rgb2hex(rgb) { - if (/^#[0-9A-F]{6}$/i.test(rgb)) return rgb; +import chroma from "chroma-js"; - rgb = rgb.match(/^rgb\((\d+),\s*(\d+),\s*(\d+)\)$/); - - if (!rgb) { - return rgb; - } - - function hex(x) { - return ("0" + parseInt(x).toString(16)).slice(-2); - } - return "#" + hex(rgb[1]) + hex(rgb[2]) + hex(rgb[3]); -} - -export function hexToRgb(hex) { - // Expand shorthand form (e.g. "03F") to full form (e.g. "0033FF") - const shorthandRegex = /^#?([a-f\d])([a-f\d])([a-f\d])$/i; - hex = hex.replace(shorthandRegex, (m, r, g, b) => { - return r + r + g + g + b + b; - }); - - 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; -} - -export function changehue(hex, dir) { - const color = hex.substring(1) - let update - let R, G, B - - if (color.length == 3) { - R = color.substring(0, 1) + color.substring(0, 1); - G = color.substring(1, 2) + color.substring(1, 2); - B = color.substring(2, 3) + color.substring(2, 3); - update = true; - } - else if (color.length == 6) { - R = color.substring(0, 2); - G = color.substring(2, 4); - B = color.substring(4, 6); - update = true; - } - else { - return '#' + color - } - R = getRGB(R); - G = getRGB(G); - B = getRGB(B); - - const HSL = RGBtoHSL(R, G, B); - let lightness = HSL[2]; - if (update == true) { - lightness = (dir == "lighten") ? lightness + 6.25 : lightness - 6.25; - if (lightness > 100) { - lightness = 100; - } - if (lightness < 0) { - lightness = 0; - } - const RGB = hslToRgb(HSL[0], HSL[1], lightness); - R = RGB[0]; - G = RGB[1]; - B = RGB[2]; - if (!(R >= 0) && !(R <= 255)) R = 0 - if (!(G >= 0) && !(G <= 255)) G = 0 - if (!(B >= 0) && !(B <= 255)) B = 0 - R = (R >= 16) ? R.toString(16) : "0" + R.toString(16); - G = (G >= 16) ? G.toString(16) : "0" + G.toString(16); - B = (B >= 16) ? B.toString(16) : "0" + B.toString(16); - R = (R.length == 1) ? R + R : R; - G = (G.length == 1) ? G + G : G; - B = (B.length == 1) ? B + B : B; - return ('#' + R + G + B); - } -} - -export function RGBtoHSL(r, g, b) { - let Min = 0; - let Max = 0; - let H, S, L - r = (parseInt(r) / 51) * .2; - g = (parseInt(g) / 51) * .2; - b = (parseInt(b) / 51) * .2; - - if (r >= g) - Max = r; - else - Max = g; - if (b > Max) - Max = b; - - if (r <= g) - Min = r; - else - Min = g; - if (b < Min) - Min = b; - - L = (Max + Min) / 2; - if (Max == Min) { - S = 0; - H = 0; - } - else { - if (L < .5) - S = (Max - Min) / (Max + Min); - if (L >= .5) - S = (Max - Min) / (2 - Max - Min); - if (r == Max) - H = (g - b) / (Max - Min); - if (g == Max) - H = 2 + ((b - r) / (Max - Min)); - if (b == Max) - H = 4 + ((r - g) / (Max - Min)); - } - H = Math.round(H * 60); - if (H < 0) H += 360; - if (H >= 360) H -= 360; - S = Math.round(S * 100); - L = Math.round(L * 100); - return [H, S, L]; -} - -export function hslToRgb(H, S, L) { - let p1, p2; - let R, G, B; - L /= 100; - S /= 100; - if (L <= 0.5) p2 = L * (1 + S); - else p2 = L + S - (L * S); - p1 = 2 * L - p2; - if (S == 0) { - R = L; - G = L; - B = L; - } - else { - R = FindRGB(p1, p2, H + 120); - G = FindRGB(p1, p2, H); - B = FindRGB(p1, p2, H - 120); +export function toHSL(color) { + try { + const [h, s, l] = chroma(color).hsl(); + return { h, s, l }; + } catch { + console.error('Error converting color to HSL:', color); + return { h: 0, s: 0, l: 0 }; } - R *= 255; - G *= 255; - B *= 255; - R = Math.round(R); - G = Math.round(G); - B = Math.round(B); - - return [R, G, B]; } -export function getRGB(color) { +export function hslToHex(hsl) { try { - color = parseInt(color, 16); + return chroma.hsl(hsl.h, hsl.s, hsl.l).hex(); + } catch { + console.error('Error converting HSL to hex:', hsl); + return null; } - catch (err) { - color = false; - } - return color; } -export function FindRGB(q1, q2, hue) { - if (hue > 360) hue = hue - 360; - if (hue < 0) hue = hue + 360; - if (hue < 60) return (q1 + (q2 - q1) * hue / 60); - else if (hue < 180) return (q2); - else if (hue < 240) return (q1 + (q2 - q1) * (240 - hue) / 60); - else return (q1); -} - -export function contrastRatio(back, fore) { - const l1 = relativeLuminance(parseRgb(back)); - const l2 = relativeLuminance(parseRgb(fore)); - let ratio = (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05); - - ratio = Math.round((ratio * 100)) / 100; - - return ratio || 1; -} - -export function relativeLuminance(c) { - const lum = []; - for (let i = 0; i < 3; i++) { - const v = c[i] / 255; - lum.push(v < 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4)); +export function changeLuminance(hsl, dir) { + if (!hsl || typeof hsl.l !== 'number') return hsl; + const step = 0.05; + let newL = hsl.l; + if (dir === "lighten") { + newL = Math.min(1, newL + step); + } else { + newL = Math.max(0, newL - step); } - return 0.2126 * lum[0] + 0.7152 * lum[1] + 0.0722 * lum[2]; + return { h: hsl.h, s: hsl.s, l: newL }; } -export function parseRgb(color) { - color = convertShortenedHex(color) - color = color.substring(1); - - const hex = parseInt(color.toUpperCase(), 16); - - const r = hex >> 16; - const g = hex >> 8 & 0xFF; - const b = hex & 0xFF; - return [r, g, b]; -} - -export function convertShortenedHex(color) { - color = color.substring(1); - - // If the string is too long, cut it off at 6 characters - if(color.length > 6) { - color = color.substring(0,6) - } - // If the length is 3, hex shorthand is being used - else if (color.length == 3) { - color = color[0] + color[0] + color[1] + color[1] + color[2] + color[2]; - } - // If the length is not 3 or 6, pad the end with zeroes - else if(color.length != 6) { - let padding = '0'.repeat(6 - color.length) - color = color + padding; - } - - return '#' + color -} - -// Accepts HSL, RGB, Hex, color name (eg. red, black) and returns the hex value -export function standardizeColor(color){ - const element = document.createElement("canvas").getContext("2d") - element.fillStyle = 'rgba(0, 0, 0, 0)' // This translates 100% transparent - - // If the color is invalid, the fillStyle will not change - element.fillStyle = color - - if(element.fillStyle === 'rgba(0, 0, 0, 0)') { - return null +export function contrastRatio(back, fore) { + try { + return Math.round(chroma.contrast(back, fore) * 100) / 100; + } catch { + return 1; } - - return element.fillStyle } export function convertHtmlRgb2Hex(html) { diff --git a/package.json b/package.json index e446208c8..4db650585 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "dependencies": { "axios": "1.10.0", "chart.js": "^4.5.0", + "chroma-js": "^3.1.2", "react": "^19.1.0", "react-dom": "^19.1.0", "tinymce": "^7.9.1", From 726347f98b4861badcd3140e6772166d7bb4962a Mon Sep 17 00:00:00 2001 From: Elias Wainberg Date: Tue, 4 Nov 2025 11:43:33 -0500 Subject: [PATCH 10/12] Adds font size and weight to ratio valid logic --- assets/js/Components/Forms/ContrastForm.js | 38 +++++++++++++--------- 1 file changed, 23 insertions(+), 15 deletions(-) diff --git a/assets/js/Components/Forms/ContrastForm.js b/assets/js/Components/Forms/ContrastForm.js index a7ce135a2..8b37d52c8 100644 --- a/assets/js/Components/Forms/ContrastForm.js +++ b/assets/js/Components/Forms/ContrastForm.js @@ -97,9 +97,6 @@ export default function ContrastForm({ const [ratioIsValid, setRatioIsValid] = useState(false) const [autoAdjustError, setAutoAdjustError] = useState(false) - // Validate hex color - const isValidHexColor = (color) => /^#?([a-fA-F0-9]{6}|[a-fA-F0-9]{3})$/.test(color) - // Generate updated HTML with new colors const processHtml = (html, bgColors) => { let element = Html.toElement(html); @@ -145,11 +142,11 @@ export default function ContrastForm({ )) ratio = Math.min(...ratios) } - const tagName = Html.toElement(html).tagName - const valid = headingTags.includes(tagName) ? (ratio >= 3) : (ratio >= 4.5) + const element = Html.toElement(html); + const minRatio = isLargeText(element) ? 3 : 4.5; setContrastRatio(ratio) - setRatioIsValid(valid) - + setRatioIsValid(ratio >= minRatio) + const newHtml = processHtml(html, currentBgColors) if (activeIssue.newHtml !== newHtml) { activeIssue.newHtml = newHtml @@ -170,11 +167,10 @@ export default function ContrastForm({ setOriginalBgColors(info) setCurrentBgColors(info.map(bg => bg.hsl)) setTextColor(getTextColor()) - setAutoAdjustError(false) // Reset error when switching issues + setAutoAdjustError(false) // eslint-disable-next-line }, [activeIssue]) - // On user interaction, only update state (do NOT call getBackgroundColors again) const updateBackgroundColor = (idx, value) => { const hsl = Contrast.toHSL(value) setCurrentBgColors(colors => @@ -203,15 +199,13 @@ export default function ContrastForm({ } } - // Debounce timer ref const debounceTimer = useRef(null) - // Debounced updatePreview useEffect(() => { if (debounceTimer.current) clearTimeout(debounceTimer.current) debounceTimer.current = setTimeout(() => { updatePreview() - }, 150) // 150ms debounce, adjust as needed + }, 150) return () => clearTimeout(debounceTimer.current) // eslint-disable-next-line }, [textColor, currentBgColors]) @@ -220,11 +214,11 @@ export default function ContrastForm({ 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); - const tagName = Html.toElement(Html.getIssueHtml(activeIssue)).tagName; - const minRatio = headingTags.includes(tagName) ? 3 : 4.5; let attempts = 0; while (ratio < minRatio && attempts < 20) { const lighter = Contrast.changeLuminance(bg, 'lighten'); @@ -242,7 +236,7 @@ export default function ContrastForm({ } if (ratio < minRatio) { failed = true; - break; // No need to continue if one fails + break; } if (attempts > 0) changed = true; newBgColors[i] = bg; @@ -256,6 +250,20 @@ export default function ContrastForm({ } }; + 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); + } + // Render return ( <> From 5a4f0136224f071ae458df5a2f743c2c39cbe306 Mon Sep 17 00:00:00 2001 From: Elias Wainberg Date: Thu, 13 Nov 2025 16:07:27 -0500 Subject: [PATCH 11/12] Adds background color to color input --- assets/css/udoit4-theme.css | 1 + 1 file changed, 1 insertion(+) diff --git a/assets/css/udoit4-theme.css b/assets/css/udoit4-theme.css index 408b29c14..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); From e366926fa26049dffbb428da9975ca36852a2be0 Mon Sep 17 00:00:00 2001 From: Elias Wainberg Date: Thu, 20 Nov 2025 11:21:18 -0500 Subject: [PATCH 12/12] Adds show/hide button for gradient lists and screen reader description for valid/invalid colors --- assets/js/Components/Forms/ContrastForm.js | 55 +++++++++++++++------- translations/en.json | 3 ++ translations/es.json | 3 ++ 3 files changed, 44 insertions(+), 17 deletions(-) diff --git a/assets/js/Components/Forms/ContrastForm.js b/assets/js/Components/Forms/ContrastForm.js index 8b37d52c8..4564757c5 100644 --- a/assets/js/Components/Forms/ContrastForm.js +++ b/assets/js/Components/Forms/ContrastForm.js @@ -11,7 +11,7 @@ import * as Html from '../../Services/Html' import * as Contrast from '../../Services/Contrast' export default function ContrastForm({ - t, + t, settings, activeIssue, isDisabled, @@ -96,6 +96,7 @@ export default function ContrastForm({ const [contrastRatio, setContrastRatio] = useState(null) const [ratioIsValid, setRatioIsValid] = useState(false) const [autoAdjustError, setAutoAdjustError] = useState(false) + const [showAllColors, setShowAllColors] = useState(false) // Generate updated HTML with new colors const processHtml = (html, bgColors) => { @@ -264,7 +265,11 @@ export default function ContrastForm({ return (fontSizePt >= 18) || (isBold && fontSizePt >= 14); } - // Render + // In your render, before mapping colors: + const maxColorsToShow = 4; + const shouldShowExpand = currentBgColors.length > maxColorsToShow; + const visibleBgColors = showAllColors ? currentBgColors : currentBgColors.slice(0, maxColorsToShow); + return ( <>
{t('form.contrast.label.adjust')}
@@ -311,7 +316,7 @@ export default function ContrastForm({
- {currentBgColors.map((color, idx) => { + {visibleBgColors.map((color, idx) => { let showStatus = currentBgColors.length > 1; let tagName = Html.toElement(Html.getIssueHtml(activeIssue)).tagName; let minRatio = headingTags.includes(tagName) ? 3 : 4.5; @@ -328,24 +333,22 @@ export default function ContrastForm({ type="color" value={Contrast.hslToHex(color) || '#ffffff'} onChange={e => updateBackgroundColor(idx, e.target.value)} - aria-label={t('form.contrast.label.background.show_color_picker')} + 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 - ? - : + isValid ? ( + + ) : ( + + ) )}
@@ -372,6 +375,24 @@ export default function ContrastForm({ ); })} + {shouldShowExpand && ( +
+ {!showAllColors && ( +
+ {t('form.contrast.more_colors', { count: currentBgColors.length - maxColorsToShow })} +
+ )} + +
+ )} + {currentBgColors.length > 1 && (