From 5f237dcc4c208c0e6d635c2f4888eb70133850ac Mon Sep 17 00:00:00 2001 From: Guilherme Godoy Date: Mon, 24 Mar 2025 15:11:08 +0000 Subject: [PATCH 1/6] Add the FieldContrastColorPicker as a form field --- src/components/Form/FormGroup.vue | 2 + .../Form/fields/FieldContrastColorPicker.mdx | 57 +++ .../FieldContrastColorPicker.stories.js | 53 +++ .../Form/fields/FieldContrastColorPicker.vue | 369 ++++++++++++++++++ .../Form/mocks/field-contrast-color-picker.js | 11 + 5 files changed, 492 insertions(+) create mode 100644 src/components/Form/fields/FieldContrastColorPicker.mdx create mode 100644 src/components/Form/fields/FieldContrastColorPicker.stories.js create mode 100644 src/components/Form/fields/FieldContrastColorPicker.vue create mode 100644 src/components/Form/mocks/field-contrast-color-picker.js diff --git a/src/components/Form/FormGroup.vue b/src/components/Form/FormGroup.vue index 2050e1730..1e4fd0039 100644 --- a/src/components/Form/FormGroup.vue +++ b/src/components/Form/FormGroup.vue @@ -60,6 +60,7 @@ import FieldArchivingPn from './fields/FieldArchivingPn.vue'; import FieldAutosuggestPreset from './fields/FieldAutosuggestPreset.vue'; import FieldBaseAutosuggest from './fields/FieldBaseAutosuggest.vue'; import FieldColor from './fields/FieldColor.vue'; +import FieldContrastColorPicker from './fields/FieldContrastColorPicker.vue'; import FieldControlledVocab from './fields/FieldControlledVocab.vue'; import FieldPubId from './fields/FieldPubId.vue'; import FieldHtml from './fields/FieldHtml.vue'; @@ -92,6 +93,7 @@ export default { FieldAutosuggestPreset, FieldBaseAutosuggest, FieldColor, + FieldContrastColorPicker, FieldControlledVocab, FieldPubId, FieldHtml, diff --git a/src/components/Form/fields/FieldContrastColorPicker.mdx b/src/components/Form/fields/FieldContrastColorPicker.mdx new file mode 100644 index 000000000..a2c95089f --- /dev/null +++ b/src/components/Form/fields/FieldContrastColorPicker.mdx @@ -0,0 +1,57 @@ +import {Primary, Controls, Stories, Meta} from '@storybook/blocks'; +import * as FieldContrastColorPickerStories from './FieldContrastColorPicker.stories.js'; + + + +# FieldContrastColorPicker + +This component provides a dual color picker interface for selecting a color pair with optimal contrast. It's specifically designed for theme configuration where text readability is important. + +## Usage + +Use this component when you need users to select two colors that work well together for accessibility purposes, such as text color against a background. The component: + +- Allows selection of two colors (typically primary/background and secondary/text colors) +- Displays the calculated contrast ratio between the selected colors +- Shows visual indicators for WCAG accessibility compliance levels + +## Features + +- Two color pickers with hex color selection +- Live preview of text appearance with the selected colors +- Real-time contrast ratio calculation +- Visual indicators for WCAG accessibility compliance levels: + - AA Large Text (3:1) + - AA Normal Text (4.5:1) + - AAA Normal Text (7:1) + - Enhanced (14:1) + - Maximum (21:1) + +## Technical Details + +### Data Structure + +The component stores both colors in a JSON string format: + +```json +{ + "color1": "#1E6292", + "color2": "#FFFFFF" +} +``` + +### WCAG Compliance + +The component uses the WCAG formula to calculate luminance and contrast between colors: + +- Relative luminance is calculated using the formula specified in WCAG 2.0 +- Contrast ratio is calculated as (L1 + 0.05) / (L2 + 0.05), where L1 is the lighter color's luminance and L2 is the darker color's luminance + +### Events + +- `change`: Emitted when either color changes, with the stringified JSON value +- `update:contrastColor`: Emitted when the second color is updated + + + + diff --git a/src/components/Form/fields/FieldContrastColorPicker.stories.js b/src/components/Form/fields/FieldContrastColorPicker.stories.js new file mode 100644 index 000000000..45e49f7bb --- /dev/null +++ b/src/components/Form/fields/FieldContrastColorPicker.stories.js @@ -0,0 +1,53 @@ +import FieldContrastColorPicker from './FieldContrastColorPicker.vue'; +import FieldBaseMock from '../mocks/field-base'; +import FieldContrastColorPickerMock from '../mocks/field-contrast-color-picker'; + +export default { + title: 'Forms/FieldContrastColorPicker', + component: FieldContrastColorPicker, + render: (args) => ({ + components: {FieldContrastColorPicker}, + setup() { + function change(name, prop, newValue, localeKey) { + if (localeKey) { + args[prop][localeKey] = newValue; + } else { + args[prop] = newValue; + } + console.log('Value changed:', newValue); + } + + function updateContrastColor(color) { + console.log('Contrast color selected:', color); + } + + return {args, change, updateContrastColor}; + }, + template: ` + + `, + }), +}; + +export const Base = { + args: {...FieldBaseMock, ...FieldContrastColorPickerMock}, +}; + +export const Required = { + args: { + ...Base.args, + isRequired: true, + }, +}; + +export const WithTooltip = { + args: { + ...Base.args, + tooltip: + 'Select colors with a contrast ratio of at least 4.5:1 for normal text to ensure readability.', + }, +}; diff --git a/src/components/Form/fields/FieldContrastColorPicker.vue b/src/components/Form/fields/FieldContrastColorPicker.vue new file mode 100644 index 000000000..fe2240d3d --- /dev/null +++ b/src/components/Form/fields/FieldContrastColorPicker.vue @@ -0,0 +1,369 @@ + + + + + diff --git a/src/components/Form/mocks/field-contrast-color-picker.js b/src/components/Form/mocks/field-contrast-color-picker.js new file mode 100644 index 000000000..39bf19930 --- /dev/null +++ b/src/components/Form/mocks/field-contrast-color-picker.js @@ -0,0 +1,11 @@ +export default { + name: 'contrastColorPicker', + component: 'field-contrast-color-picker', + label: 'Choose Color Combination', + description: + 'Select two colors with good contrast for accessibility. The contrast ratio should meet WCAG guidelines for text readability.', + value: JSON.stringify({ + color1: '#1E6292', + color2: '#FFFFFF', + }), +}; From db005b678341788dba317a17b5e10b30a46d8aa9 Mon Sep 17 00:00:00 2001 From: Guilherme Godoy Date: Thu, 27 Mar 2025 16:15:41 +0000 Subject: [PATCH 2/6] Improve component and add more accessibility features --- .../Form/fields/FieldContrastColorPicker.mdx | 36 +- .../FieldContrastColorPicker.stories.js | 52 ++ .../Form/fields/FieldContrastColorPicker.vue | 634 +++++++++++++++++- .../Form/mocks/field-contrast-color-picker.js | 2 +- 4 files changed, 684 insertions(+), 40 deletions(-) diff --git a/src/components/Form/fields/FieldContrastColorPicker.mdx b/src/components/Form/fields/FieldContrastColorPicker.mdx index a2c95089f..6ad9642a8 100644 --- a/src/components/Form/fields/FieldContrastColorPicker.mdx +++ b/src/components/Form/fields/FieldContrastColorPicker.mdx @@ -14,18 +14,34 @@ Use this component when you need users to select two colors that work well toget - Allows selection of two colors (typically primary/background and secondary/text colors) - Displays the calculated contrast ratio between the selected colors - Shows visual indicators for WCAG accessibility compliance levels +- Provides keyboard accessibility for color selection +- Includes a link to documentation about contrast ratios and accessibility + +## Why Contrast Matters + +Color contrast is a critical aspect of web accessibility. Insufficient contrast between text and background colors can make content difficult or impossible to read for many users, including those with: + +- Low vision +- Color blindness +- Age-related vision changes +- Situational limitations (e.g., bright sunlight on screens) + +The Web Content Accessibility Guidelines (WCAG) establish minimum contrast ratios to ensure readability: + +- **3:1** - Minimum for large text (18pt+) at Level AA +- **4.5:1** - Minimum for normal text at Level AA +- **7:1** - Enhanced contrast for normal text at Level AAA + +The component visually indicates whether selected colors meet these standards, helping content creators ensure their designs are accessible to all users. ## Features - Two color pickers with hex color selection - Live preview of text appearance with the selected colors - Real-time contrast ratio calculation -- Visual indicators for WCAG accessibility compliance levels: - - AA Large Text (3:1) - - AA Normal Text (4.5:1) - - AAA Normal Text (7:1) - - Enhanced (14:1) - - Maximum (21:1) +- Visual indicators for WCAG accessibility compliance levels +- Keyboard navigation support for better accessibility +- Documentation link for learning more about contrast requirements ## Technical Details @@ -47,6 +63,14 @@ The component uses the WCAG formula to calculate luminance and contrast between - Relative luminance is calculated using the formula specified in WCAG 2.0 - Contrast ratio is calculated as (L1 + 0.05) / (L2 + 0.05), where L1 is the lighter color's luminance and L2 is the darker color's luminance +### Keyboard Accessibility + +The component supports keyboard navigation for color selection: + +- Arrow keys: Adjust saturation and brightness +- Page Up/Down: Adjust hue +- Tab: Navigate between color pickers and other interactive elements + ### Events - `change`: Emitted when either color changes, with the stringified JSON value diff --git a/src/components/Form/fields/FieldContrastColorPicker.stories.js b/src/components/Form/fields/FieldContrastColorPicker.stories.js index 45e49f7bb..834e21fda 100644 --- a/src/components/Form/fields/FieldContrastColorPicker.stories.js +++ b/src/components/Form/fields/FieldContrastColorPicker.stories.js @@ -31,6 +31,14 @@ export default { /> `, }), + parameters: { + docs: { + description: { + component: + 'A color picker component that helps users select color combinations with sufficient contrast for accessibility compliance. Includes accessibility features like keyboard navigation and WCAG contrast validation.', + }, + }, + }, }; export const Base = { @@ -51,3 +59,47 @@ export const WithTooltip = { 'Select colors with a contrast ratio of at least 4.5:1 for normal text to ensure readability.', }, }; + +export const WithDescription = { + args: { + ...Base.args, + description: + 'This color picker helps ensure your text is readable by checking contrast against WCAG standards. For more information, click the link below the contrast categories.', + }, +}; + +export const WithHighContrast = { + args: { + ...Base.args, + value: JSON.stringify({ + color1: '#000000', + color2: '#ffffff', + }), + }, + parameters: { + docs: { + description: { + story: + 'Example with maximum contrast (black and white), which passes all WCAG accessibility levels.', + }, + }, + }, +}; + +export const WithLowContrast = { + args: { + ...Base.args, + value: JSON.stringify({ + color1: '#777777', + color2: '#999999', + }), + }, + parameters: { + docs: { + description: { + story: + 'Example with insufficient contrast that fails to meet WCAG accessibility levels, showing the visual indicators for failed tests.', + }, + }, + }, +}; diff --git a/src/components/Form/fields/FieldContrastColorPicker.vue b/src/components/Form/fields/FieldContrastColorPicker.vue index fe2240d3d..66aaa1072 100644 --- a/src/components/Form/fields/FieldContrastColorPicker.vue +++ b/src/components/Form/fields/FieldContrastColorPicker.vue @@ -41,30 +41,40 @@
-
Color 1
+
+ {{ t('common.colorPicker.color1') }} +
-
Color 2
+
+ {{ t('common.colorPicker.color2') }} +
-
Contrast Ratio
- +
+ {{ t('common.colorPicker.contrastRatio') }} +
Text
@@ -73,22 +83,36 @@ {{ currentContrast.toFixed(2) }}
+ +
+
+ {{ category.label }} +
+
-
-
- {{ category.label }} + @@ -126,11 +150,14 @@ export default { {value: 3, label: 'AA Large Text (3:1)'}, {value: 4.5, label: 'AA Normal Text (4.5:1)'}, {value: 7, label: 'AAA Normal Text (7:1)'}, - {value: 14, label: 'Enhanced (14:1)'}, - {value: 21, label: 'Maximum (21:1)'}, ], + keyboardStep: 1, + keyboardHueStep: 2, }; }, + mounted() { + this.setupKeyboardControls(); + }, created() { if (this.currentValue) { try { @@ -166,6 +193,366 @@ export default { }); }, methods: { + /** + * Setup keyboard controls for the color pickers + * This adds event listeners to the specific interactive elements within the color picker + */ + setupKeyboardControls() { + this.$nextTick(() => { + if (this.$refs.color1Picker) { + const saturation1 = + this.$refs.color1Picker.$el.querySelector('.vc-saturation'); + const satPointer1 = saturation1 + ? saturation1.querySelector('.vc-saturation-circle') + : null; + + if (saturation1 && satPointer1) { + saturation1.setAttribute('tabindex', '0'); + saturation1.setAttribute( + 'aria-label', + 'Color 1 saturation and brightness selector', + ); + saturation1.addEventListener('keydown', (e) => + this.handleSaturationKeydown(e, 'color1'), + ); + + satPointer1.style.transition = + 'box-shadow 0.2s ease, transform 0.1s ease'; + + saturation1.addEventListener('focus', () => { + satPointer1.style.boxShadow = + '0 0 0 2px white, 0 0 0 4px #1976d2'; + satPointer1.style.transform = 'scale(1.2)'; + }); + + saturation1.addEventListener('blur', () => { + satPointer1.style.boxShadow = + '0 0 0 1.5px #fff, inset 0 0 1px 1px rgba(0,0,0,.3)'; + satPointer1.style.transform = 'scale(1)'; + }); + } + + const hueSlider1 = + this.$refs.color1Picker.$el.querySelector('.vc-hue-container'); + const huePointer1 = hueSlider1 + ? hueSlider1.querySelector('.vc-hue-picker') + : null; + + if (hueSlider1 && huePointer1) { + hueSlider1.setAttribute('tabindex', '0'); + hueSlider1.setAttribute('aria-label', 'Color 1 hue slider'); + hueSlider1.addEventListener('keydown', (e) => + this.handleHueKeydown(e, 'color1'), + ); + + huePointer1.style.transition = + 'box-shadow 0.2s ease, transform 0.1s ease'; + + hueSlider1.addEventListener('focus', () => { + huePointer1.style.boxShadow = + '0 0 0 2px white, 0 0 0 4px #1976d2'; + }); + + hueSlider1.addEventListener('blur', () => { + huePointer1.style.boxShadow = + '0 0 0 1.5px #fff, inset 0 0 1px 1px rgba(0,0,0,.3)'; + }); + } + + const hexInput = this.$refs.color1Picker.$el.querySelector( + '.vc-chrome-hex input', + ); + if (hexInput) { + hexInput.setAttribute('aria-label', 'Color 1 hex value'); + } + } + + if (this.$refs.color2Picker) { + const saturation2 = + this.$refs.color2Picker.$el.querySelector('.vc-saturation'); + const satPointer2 = saturation2 + ? saturation2.querySelector('.vc-saturation-circle') + : null; + + if (saturation2 && satPointer2) { + saturation2.setAttribute('tabindex', '0'); + saturation2.setAttribute( + 'aria-label', + 'Color 2 saturation and brightness selector', + ); + saturation2.addEventListener('keydown', (e) => + this.handleSaturationKeydown(e, 'color2'), + ); + + satPointer2.style.transition = + 'box-shadow 0.2s ease, transform 0.1s ease'; + + saturation2.addEventListener('focus', () => { + satPointer2.style.boxShadow = + '0 0 0 2px white, 0 0 0 4px #1976d2'; + satPointer2.style.transform = 'scale(1.2)'; + }); + + saturation2.addEventListener('blur', () => { + satPointer2.style.boxShadow = + '0 0 0 1.5px #fff, inset 0 0 1px 1px rgba(0,0,0,.3)'; + satPointer2.style.transform = 'scale(1)'; + }); + } + + const hueSlider2 = + this.$refs.color2Picker.$el.querySelector('.vc-hue-container'); + const huePointer2 = hueSlider2 + ? hueSlider2.querySelector('.vc-hue-picker') + : null; + + if (hueSlider2 && huePointer2) { + hueSlider2.setAttribute('tabindex', '0'); + hueSlider2.setAttribute('aria-label', 'Color 2 hue slider'); + hueSlider2.addEventListener('keydown', (e) => + this.handleHueKeydown(e, 'color2'), + ); + + huePointer2.style.transition = + 'box-shadow 0.2s ease, transform 0.1s ease'; + + hueSlider2.addEventListener('focus', () => { + huePointer2.style.boxShadow = + '0 0 0 2px white, 0 0 0 4px #1976d2'; + }); + + hueSlider2.addEventListener('blur', () => { + huePointer2.style.boxShadow = + '0 0 0 1.5px #fff, inset 0 0 1px 1px rgba(0,0,0,.3)'; + }); + } + + const hexInput = this.$refs.color2Picker.$el.querySelector( + '.vc-chrome-hex input', + ); + if (hexInput) { + hexInput.setAttribute('aria-label', 'Color 2 hex value'); + } + } + }); + }, + + /** + * Handle keyboard navigation for the saturation/brightness square + * + * @param {Event} event - Keyboard event + * @param {String} colorId - Which color picker is being used (color1 or color2) + */ + handleSaturationKeydown(event, colorId) { + const color = colorId === 'color1' ? this.color1 : this.color2; + const pickerRef = + colorId === 'color1' + ? this.$refs.color1Picker + : this.$refs.color2Picker; + if (!pickerRef || !pickerRef.$el) return; + + const hsv = this.hexToHsv(color.hex); + let modified = false; + + const hueContainer = pickerRef.$el.querySelector('.vc-hue-container'); + const huePointer = hueContainer + ? hueContainer.querySelector('.vc-hue-pointer') + : null; + let currentHuePosition = '0%'; + + if (huePointer) { + currentHuePosition = huePointer.style.left || '0%'; + if (currentHuePosition !== '0%') { + const hueValue = Math.round( + (parseFloat(currentHuePosition) / 100) * 360, + ); + hsv.h = hueValue; + } + } + + switch (event.key) { + case 'ArrowUp': + hsv.v = Math.min(100, hsv.v + this.keyboardStep); + modified = true; + break; + case 'ArrowDown': + hsv.v = Math.max(0, hsv.v - this.keyboardStep); + modified = true; + break; + case 'ArrowRight': + hsv.s = Math.min(100, hsv.s + this.keyboardStep); + modified = true; + break; + case 'ArrowLeft': + hsv.s = Math.max(0, hsv.s - this.keyboardStep); + modified = true; + break; + case 'Home': + hsv.s = 0; + hsv.v = 100; + modified = true; + break; + case 'End': + hsv.s = 100; + hsv.v = 0; + modified = true; + break; + case 'PageUp': + hsv.s = 100; + hsv.v = 100; + modified = true; + break; + case 'PageDown': + hsv.s = 0; + hsv.v = 0; + modified = true; + break; + } + + if (modified) { + event.preventDefault(); + const newHex = this.hsvToHex(hsv); + + if (colorId === 'color1') { + this.setColor1({hex: newHex}); + } else { + this.setColor2({hex: newHex}); + } + + setTimeout(() => { + const saturation = pickerRef.$el.querySelector('.vc-saturation'); + if (saturation) { + saturation.style.background = `hsl(${hsv.h}, 100%, 50%)`; + } + + if (huePointer) { + huePointer.style.left = currentHuePosition; + } + }, 10); + } + }, + + /** + * Handle keyboard navigation for the hue slider + * + * @param {Event} event - Keyboard event + * @param {String} colorId - Which color picker is being used (color1 or color2) + */ + handleHueKeydown(event, colorId) { + const pickerRef = + colorId === 'color1' + ? this.$refs.color1Picker + : this.$refs.color2Picker; + if (!pickerRef || !pickerRef.$el) return; + + const satPointer = pickerRef.$el.querySelector('.vc-saturation-pointer'); + if (!satPointer) return; + const currentTop = satPointer.style.top; + const currentLeft = satPointer.style.left; + + const hueContainer = pickerRef.$el.querySelector('.vc-hue-container'); + const huePointer = hueContainer + ? hueContainer.querySelector('.vc-hue-pointer') + : null; + if (!hueContainer || !huePointer) return; + + const color = colorId === 'color1' ? this.color1 : this.color2; + const currentHsv = this.hexToHsv(color.hex); + + let currentHueLeft; + + if (currentHsv.s === 0) { + const huePositionStr = huePointer.style.left; + if (!huePositionStr || huePositionStr === '') { + currentHueLeft = 0; + } else { + currentHueLeft = parseFloat(huePositionStr); + } + } else { + currentHueLeft = (currentHsv.h / 360) * 100; + } + + let newHuePosition = currentHueLeft; + const smallStep = (this.keyboardHueStep / 360) * 100; + const largeStep = (60 / 360) * 100; + let modified = false; + + switch (event.key) { + case 'ArrowRight': + case 'ArrowUp': + newHuePosition = Math.min(100, currentHueLeft + smallStep); + modified = true; + break; + case 'ArrowLeft': + case 'ArrowDown': + newHuePosition = Math.max(0, currentHueLeft - smallStep); + modified = true; + break; + case 'Home': + newHuePosition = 0; + modified = true; + break; + case 'End': + newHuePosition = 100; + modified = true; + break; + case 'PageUp': + newHuePosition = Math.min(100, currentHueLeft + largeStep); + modified = true; + break; + case 'PageDown': + newHuePosition = Math.max(0, currentHueLeft - largeStep); + modified = true; + break; + } + + if (modified) { + event.preventDefault(); + event.stopPropagation(); + + const hue = Math.round((newHuePosition / 100) * 360); + + huePointer.style.left = `${newHuePosition}%`; + satPointer.style.top = currentTop; + satPointer.style.left = currentLeft; + + const saturation = pickerRef.$el.querySelector('.vc-saturation'); + if (saturation) { + saturation.style.background = `hsl(${hue}, 100%, 50%)`; + } + + const hsv = { + h: hue, + s: currentHsv.s, + v: currentHsv.v, + }; + + if (currentHsv.s !== 0) { + const newHex = this.hsvToHex(hsv); + if (colorId === 'color1') { + this.setColor1({hex: newHex}); + } else { + this.setColor2({hex: newHex}); + } + } + + setTimeout(() => { + if (satPointer) { + satPointer.style.top = currentTop; + satPointer.style.left = currentLeft; + } + + if (huePointer) { + huePointer.style.left = `${newHuePosition}%`; + } + + if (saturation) { + saturation.style.background = `hsl(${hue}, 100%, 50%)`; + } + }, 10); + } + }, + /** * Update color 1 * @@ -261,6 +648,108 @@ export default { const darker = Math.min(l1, l2); return (lighter + 0.05) / (darker + 0.05); }, + + /** + * Convert hex color to HSV + * + * @param {String} hex - Hex color value + * @return {Object} HSV object with h, s, v properties + */ + hexToHsv(hex) { + const rgb = this.hexToRgb(hex); + if (!rgb) return {h: 0, s: 0, v: 0}; + + const r = rgb.r; + const g = rgb.g; + const b = rgb.b; + + const max = Math.max(r, g, b); + const min = Math.min(r, g, b); + const delta = max - min; + + let h = 0; + let s = max === 0 ? 0 : delta / max; + let v = max; + + if (delta > 0) { + if (max === r) { + h = ((g - b) / delta) % 6; + } else if (max === g) { + h = (b - r) / delta + 2; + } else { + h = (r - g) / delta + 4; + } + + h = Math.round(h * 60); + if (h < 0) h += 360; + } + + return { + h: h, + s: Math.round(s * 100), + v: Math.round(v * 100), + }; + }, + + /** + * Convert HSV color to hex + * + * @param {Object} hsv - HSV color object + * @return {String} Hex color string + */ + hsvToHex(hsv) { + const h = hsv.h; + const s = hsv.s / 100; + const v = hsv.v / 100; + + let r, g, b; + + const i = Math.floor(h / 60) % 6; + const f = h / 60 - Math.floor(h / 60); + const p = v * (1 - s); + const q = v * (1 - f * s); + const t = v * (1 - (1 - f) * s); + + switch (i) { + case 0: + r = v; + g = t; + b = p; + break; + case 1: + r = q; + g = v; + b = p; + break; + case 2: + r = p; + g = v; + b = t; + break; + case 3: + r = p; + g = q; + b = v; + break; + case 4: + r = t; + g = p; + b = v; + break; + default: + r = v; + g = p; + b = q; + break; + } + + const toHex = (x) => { + const hex = Math.round(x * 255).toString(16); + return hex.length === 1 ? '0' + hex : hex; + }; + + return `#${toHex(r)}${toHex(g)}${toHex(b)}`; + }, }, }; @@ -281,29 +770,84 @@ export default { border-radius: 2px; margin-bottom: 12px; width: 100%; + height: calc(100% - 44px); +} + +.pkpFormField--contrastColor .vc-saturation { + height: 170px !important; +} + +.pkpFormField--contrastColor .vc-saturation:focus, +.pkpFormField--contrastColor + .vc-chrome-controls + .vc-chrome-sliders + .vc-chrome-hue-wrap + .vc-hue-picker:focus { + outline: 2px solid #1976d2; + outline-offset: 2px; + box-shadow: 0 0 5px rgba(25, 118, 210, 0.5); +} + +.pkpFormField--contrastColor .vc-hue-container { + position: relative; + cursor: pointer; +} + +.pkpFormField--contrastColor .vc-hue { + border-radius: 4px; +} + +.pkpFormField--contrastColor .vc-hue-picker { + transition: + box-shadow 0.2s ease, + transform 0.1s ease; + height: 14px !important; + transform-origin: center; +} + +.pkpFormField--contrastColor .vc-saturation-circle { + transition: + box-shadow 0.2s ease, + transform 0.1s ease; + transform-origin: center; +} + +.pkpFormField--contrastColor .vc-chrome-fields input:focus { + outline: 2px solid #1976d2; + outline-offset: 0; } .pkpFormField__threeColumnsRow { display: flex; gap: 24px; - margin-bottom: 20px; } .pkpFormField__colorPickerColumn { flex: 1; max-width: calc(50% - 24px); + display: flex; + flex-direction: column; + height: 340px; } .pkpFormField__contrastValueColumn { display: flex; flex-direction: column; - flex: 0 0 120px; + flex: 0 0 140px; gap: 8px; + height: 340px; + justify-content: space-between; } .pkpFormField__colorPickerLabel { font-weight: 700; - margin-bottom: 8px; +} + +.pkpFormField__keyboardInstructions { + margin-top: 8px; + font-size: 11px; + color: #666; + font-style: italic; } .pkpFormField__textSample { @@ -311,8 +855,7 @@ export default { display: flex; align-items: center; justify-content: center; - height: 60px; - margin-bottom: 8px; + height: 50px; transition: background-color 0.2s, color 0.2s; @@ -332,7 +875,8 @@ export default { background-color: #f8f8f8; border-radius: 4px; padding: 12px; - height: 40px; + height: 50px; + margin-bottom: 16px; } .pkpFormField__contrastValueNumber { @@ -340,21 +884,20 @@ export default { font-size: 20px; } -.pkpFormField__contrastCategories { +.pkpFormField__contrastCategoriesInColumn { display: flex; - justify-content: space-between; - gap: 8px; - margin-bottom: 16px; + flex-direction: column; + gap: 6px; + margin-bottom: 0; + flex-grow: 1; } .pkpFormField__contrastCategory { - flex: 1; - text-align: center; - padding: 8px 4px; + text-align: left; + padding: 8px; border-radius: 4px; font-weight: 600; font-size: 11px; - white-space: nowrap; &--pass { background-color: rgba(0, 178, 141, 0.15); @@ -366,4 +909,29 @@ export default { color: #b00058; } } + +.pkpFormField__accessibilityInfoRow { + display: flex; + justify-content: flex-start; + margin-bottom: 16px; +} + +.pkpFormField__accessibilityInfo { + font-size: 12px; + + a { + color: #007bff; + text-decoration: none; + + &:hover, + &:focus { + text-decoration: underline; + } + + &:focus { + outline: 2px solid #1976d2; + outline-offset: 2px; + } + } +} diff --git a/src/components/Form/mocks/field-contrast-color-picker.js b/src/components/Form/mocks/field-contrast-color-picker.js index 39bf19930..a9477a450 100644 --- a/src/components/Form/mocks/field-contrast-color-picker.js +++ b/src/components/Form/mocks/field-contrast-color-picker.js @@ -3,7 +3,7 @@ export default { component: 'field-contrast-color-picker', label: 'Choose Color Combination', description: - 'Select two colors with good contrast for accessibility. The contrast ratio should meet WCAG guidelines for text readability.', + 'Select two colors with good contrast for accessibility. The contrast ratio should meet WCAG guidelines for text readability. Use keyboard navigation or mouse to adjust colors.', value: JSON.stringify({ color1: '#1E6292', color2: '#FFFFFF', From 99f6fbfe76d3b001d24090dcabec4dcf4ba1e7fd Mon Sep 17 00:00:00 2001 From: Guilherme Godoy Date: Fri, 25 Apr 2025 16:35:25 +0100 Subject: [PATCH 3/6] Major updates on the ContrastColorPicker component --- .../Form/fields/FieldContrastColorPicker.vue | 1321 ++++++++++++----- 1 file changed, 938 insertions(+), 383 deletions(-) diff --git a/src/components/Form/fields/FieldContrastColorPicker.vue b/src/components/Form/fields/FieldContrastColorPicker.vue index 66aaa1072..e9d44d68f 100644 --- a/src/components/Form/fields/FieldContrastColorPicker.vue +++ b/src/components/Form/fields/FieldContrastColorPicker.vue @@ -10,7 +10,7 @@ * - {{ t('common.required') }} + {{ componentKeys.required }} + > + {{ tooltip }} +
+ > + {{ description }} +
- {{ t('common.colorPicker.color1') }} + {{ componentKeys.color1 }} +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + +
+ +
+
+ {{ componentKeys[colorFormats.color1] }} +
+
+ + {{ getColorFormatAnnouncement('color1') }} + +
-
- {{ t('common.colorPicker.color2') }} + {{ componentKeys.color2 }} +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + +
+ +
+
+ {{ componentKeys[colorFormats.color2] }} +
+
+ + {{ getColorFormatAnnouncement('color2') }} + +
-
- {{ t('common.colorPicker.contrastRatio') }} + {{ componentKeys.contrastRatio }}
Text @@ -100,6 +251,13 @@ {{ category.label }}
+
+ {{ getContrastAnnouncement() }} +
@@ -109,9 +267,9 @@ href="https://docs.pkp.sfu.ca/accessible-content/en/principles#contrast-ratio" target="_blank" rel="noopener" - :aria-label="t('common.colorPicker.contrastGuideText')" + :aria-label="componentKeys.contrastGuideText" > - {{ t('common.colorPicker.contrastGuideText') }} + {{ componentKeys.contrastGuideText }}
@@ -127,7 +285,6 @@ @@ -758,73 +1111,28 @@ export default { .pkpFormField--contrastColor { padding: 0; border: none; + max-width: 100%; + box-sizing: border-box; + overflow: hidden; } .pkpFormField__heading--legend { font-weight: 700; } -.pkpFormField--contrastColor .vc-chrome { - box-shadow: none; - border: 1px solid #ddd; - border-radius: 2px; - margin-bottom: 12px; - width: 100%; - height: calc(100% - 44px); -} - -.pkpFormField--contrastColor .vc-saturation { - height: 170px !important; -} - -.pkpFormField--contrastColor .vc-saturation:focus, -.pkpFormField--contrastColor - .vc-chrome-controls - .vc-chrome-sliders - .vc-chrome-hue-wrap - .vc-hue-picker:focus { - outline: 2px solid #1976d2; - outline-offset: 2px; - box-shadow: 0 0 5px rgba(25, 118, 210, 0.5); -} - -.pkpFormField--contrastColor .vc-hue-container { - position: relative; - cursor: pointer; -} - -.pkpFormField--contrastColor .vc-hue { - border-radius: 4px; -} - -.pkpFormField--contrastColor .vc-hue-picker { - transition: - box-shadow 0.2s ease, - transform 0.1s ease; - height: 14px !important; - transform-origin: center; -} - -.pkpFormField--contrastColor .vc-saturation-circle { - transition: - box-shadow 0.2s ease, - transform 0.1s ease; - transform-origin: center; -} - -.pkpFormField--contrastColor .vc-chrome-fields input:focus { - outline: 2px solid #1976d2; - outline-offset: 0; -} - .pkpFormField__threeColumnsRow { display: flex; - gap: 24px; + gap: 12px; + width: 100%; + max-width: 100%; + overflow: hidden; + flex-wrap: wrap; } .pkpFormField__colorPickerColumn { flex: 1; - max-width: calc(50% - 24px); + max-width: calc(37% - 12px); + min-width: 160px; display: flex; flex-direction: column; height: 340px; @@ -833,7 +1141,7 @@ export default { .pkpFormField__contrastValueColumn { display: flex; flex-direction: column; - flex: 0 0 140px; + flex: 0 0 180px; gap: 8px; height: 340px; justify-content: space-between; @@ -841,6 +1149,9 @@ export default { .pkpFormField__colorPickerLabel { font-weight: 700; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } .pkpFormField__keyboardInstructions { @@ -934,4 +1245,248 @@ export default { } } } + +.pkpFormField__colorPickerWrapper { + position: relative; + display: flex; + flex-direction: column; + width: 100%; + box-sizing: border-box; +} + +.pkpFormField__customSaturationBox { + position: relative; + width: 100%; + height: 100px; + margin: 10px auto 15px; + border-radius: 4px; + overflow: hidden; + cursor: pointer; + outline: none; + box-sizing: border-box; + + &:focus { + box-shadow: 0 0 0 2px #1976d2; + } +} + +.pkpFormField__huePreviewRow { + display: flex; + align-items: center; + width: 100%; + gap: 10px; + margin-bottom: 10px; +} + +.pkpFormField__customHueSlider { + position: relative; + height: 14px; + z-index: 10; + cursor: pointer; + background: linear-gradient( + to right, + #f00 0%, + #ff0 17%, + #0f0 33%, + #0ff 50%, + #00f 67%, + #f0f 83%, + #f00 100% + ); + border-radius: 4px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2); + outline: none; + box-sizing: border-box; + flex: 1; + max-width: calc(100% - 30px); +} + +.pkpFormField__colorPreview { + width: 22px; + height: 22px; + border-radius: 50%; + border: 1px solid #ddd; + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.2); + flex-shrink: 0; +} + +.pkpFormField__inputFormatRow { + display: flex; + align-items: center; + margin: 0 auto 5px; + width: 100%; + box-sizing: border-box; + flex-wrap: nowrap; + overflow: hidden; +} + +.pkpFormField__formatArrows { + display: flex; + flex-direction: column; + margin-right: 4px; + height: 28px; +} + +.pkpFormField__formatArrowUp, +.pkpFormField__formatArrowDown { + background: none; + border: none; + padding: 0; + margin: 0; + cursor: pointer; + height: 14px; + width: 20px; + line-height: 8px; + font-size: 10px; + display: flex; + align-items: center; + justify-content: center; + color: #666; + + &:hover { + color: #1976d2; + } + + &:focus { + outline: 1px solid #1976d2; + color: #1976d2; + } +} + +.pkpFormField__formatLabel { + font-size: 11px; + color: #666; + text-align: left; + margin-bottom: 10px; + letter-spacing: 0.5px; + padding-left: 24px; +} + +.pkpFormField__colorValueInput { + flex: 1; + padding: 4px 8px; + border: 1px solid #ddd; + border-radius: 4px; + font-family: monospace; + font-size: 12px; + height: 28px; + box-sizing: border-box; + + &:focus { + outline: 2px solid #1976d2; + outline-offset: -1px; + border-color: #1976d2; + } +} + +.pkpFormField__control { + max-width: 100%; + box-sizing: border-box; + overflow: hidden; +} + +@media (max-width: 767px) { + .pkpFormField__threeColumnsRow { + flex-direction: column; + } + + .pkpFormField__colorPickerColumn { + max-width: 100%; + height: auto; + margin-bottom: 20px; + } + + .pkpFormField__contrastValueColumn { + max-width: 100%; + flex: 1; + height: auto; + } + + .pkpFormField__formatSelector { + flex-wrap: wrap; + } + + .pkpFormField__formatSelect, + .pkpFormField__colorValueInput { + margin-top: 4px; + } + + .pkpFormField__colorValueInput { + width: 100%; + min-width: 0; + } +} + +.pkpFormField__customHueHandle { + position: absolute; + width: 16px; + height: 16px; + border-radius: 50%; + border: 2px solid #fff; + box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.3); + background: #fff; + transform: translate(-50%, -1px); + pointer-events: none; +} + +.pkpFormField__customHueSlider:focus { + box-shadow: 0 0 0 2px #1976d2; +} + +.pkpFormField__customSaturationBox--white, +.pkpFormField__customSaturationBox--black { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + pointer-events: none; +} + +.pkpFormField__customSaturationBox--white { + background: linear-gradient(to right, #fff, rgba(255, 255, 255, 0)); +} + +.pkpFormField__customSaturationBox--black { + background: linear-gradient(to top, #000, rgba(0, 0, 0, 0)); +} + +.pkpFormField__customSaturationPointer { + position: absolute; + cursor: pointer; + transform: translate(-4px, -4px); +} + +.pkpFormField__customSaturationCircle { + width: 8px; + height: 8px; + box-shadow: + 0 0 0 1.5px #fff, + inset 0 0 1px 1px rgba(0, 0, 0, 0.3), + 0 0 1px 2px rgba(0, 0, 0, 0.4); + border-radius: 50%; + cursor: pointer; + transform-origin: center; + transition: transform 0.1s ease; +} + +.pkpFormField__customSaturationBox:focus .pkpFormField__customSaturationCircle { + transform: scale(1.2); + box-shadow: + 0 0 0 1.5px #fff, + 0 0 0 3px #1976d2, + inset 0 0 1px 1px rgba(0, 0, 0, 0.3); +} + +.pkpFormField__a11yAnnouncer { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} From 561b1a349d26952ea1cb62ee0ac636b8fa3e6788 Mon Sep 17 00:00:00 2001 From: Guilherme Godoy Date: Fri, 25 Apr 2025 17:00:03 +0100 Subject: [PATCH 4/6] Refactor component by separating its style into a LESS file --- .../Form/fields/FieldContrastColorPicker.vue | 382 +---------------- .../field-contrast-color-picker.less | 384 ++++++++++++++++++ 2 files changed, 385 insertions(+), 381 deletions(-) create mode 100644 src/styles/components/field-contrast-color-picker.less diff --git a/src/components/Form/fields/FieldContrastColorPicker.vue b/src/components/Form/fields/FieldContrastColorPicker.vue index e9d44d68f..7bd53c54f 100644 --- a/src/components/Form/fields/FieldContrastColorPicker.vue +++ b/src/components/Form/fields/FieldContrastColorPicker.vue @@ -1108,385 +1108,5 @@ export default { diff --git a/src/styles/components/field-contrast-color-picker.less b/src/styles/components/field-contrast-color-picker.less new file mode 100644 index 000000000..9ef8c852b --- /dev/null +++ b/src/styles/components/field-contrast-color-picker.less @@ -0,0 +1,384 @@ +.pkpFormField--contrastColor { + padding: 0; + border: none; + max-width: 100%; + box-sizing: border-box; + overflow: hidden; +} + +.pkpFormField__heading--legend { + font-weight: 700; +} + +.pkpFormField { + &__threeColumnsRow { + display: flex; + gap: 12px; + width: 100%; + max-width: 100%; + overflow: hidden; + flex-wrap: wrap; + } + + &__colorPickerColumn { + flex: 1; + max-width: calc(37% - 12px); + min-width: 160px; + display: flex; + flex-direction: column; + height: 340px; + } + + &__contrastValueColumn { + display: flex; + flex-direction: column; + flex: 0 0 180px; + gap: 8px; + height: 340px; + justify-content: space-between; + } + + &__colorPickerLabel { + font-weight: 700; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + &__keyboardInstructions { + margin-top: 8px; + font-size: 11px; + color: #666; + font-style: italic; + } + + &__textSample { + border-radius: 4px; + display: flex; + align-items: center; + justify-content: center; + height: 50px; + transition: + background-color 0.2s, + color 0.2s; + border: 1px solid #ddd; + } + + &__textSampleContent { + font-size: 16px; + font-weight: 700; + text-align: center; + } + + &__contrastValueDisplay { + display: flex; + align-items: center; + justify-content: center; + background-color: #f8f8f8; + border-radius: 4px; + padding: 12px; + height: 50px; + margin-bottom: 16px; + } + + &__contrastValueNumber { + font-weight: 700; + font-size: 20px; + } + + &__contrastCategoriesInColumn { + display: flex; + flex-direction: column; + gap: 6px; + margin-bottom: 0; + flex-grow: 1; + } + + &__contrastCategory { + text-align: left; + padding: 8px; + border-radius: 4px; + font-weight: 600; + font-size: 11px; + + &--pass { + background-color: rgba(0, 178, 141, 0.15); + color: #007a60; + } + + &--fail { + background-color: rgba(208, 10, 108, 0.15); + color: #b00058; + } + } + + &__accessibilityInfoRow { + display: flex; + justify-content: flex-start; + margin-bottom: 16px; + } + + &__accessibilityInfo { + font-size: 12px; + + a { + color: #007bff; + text-decoration: none; + + &:hover, + &:focus { + text-decoration: underline; + } + + &:focus { + outline: 2px solid #1976d2; + outline-offset: 2px; + } + } + } + + &__colorPickerWrapper { + position: relative; + display: flex; + flex-direction: column; + width: 100%; + box-sizing: border-box; + } + + &__customSaturationBox { + position: relative; + width: 100%; + height: 100px; + margin: 10px auto 15px; + border-radius: 4px; + overflow: hidden; + cursor: pointer; + outline: none; + box-sizing: border-box; + + &:focus { + box-shadow: 0 0 0 2px #1976d2; + } + } + + &__huePreviewRow { + display: flex; + align-items: center; + width: 100%; + gap: 10px; + margin-bottom: 10px; + } + + &__customHueSlider { + position: relative; + height: 14px; + cursor: pointer; + background: linear-gradient( + to right, + #f00 0%, + #ff0 17%, + #0f0 33%, + #0ff 50%, + #00f 67%, + #f0f 83%, + #f00 100% + ); + border-radius: 4px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2); + outline: none; + box-sizing: border-box; + flex: 1; + max-width: calc(100% - 30px); + } + + &__colorPreview { + width: 22px; + height: 22px; + border-radius: 50%; + border: 1px solid #ddd; + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.2); + flex-shrink: 0; + } + + &__inputFormatRow { + display: flex; + align-items: center; + margin: 0 auto 5px; + width: 100%; + box-sizing: border-box; + flex-wrap: nowrap; + overflow: hidden; + } + + &__formatArrows { + display: flex; + flex-direction: column; + margin-right: 4px; + height: 28px; + } + + &__formatArrowUp, + &__formatArrowDown { + background: none; + border: none; + padding: 0; + margin: 0; + cursor: pointer; + height: 14px; + width: 20px; + line-height: 8px; + font-size: 10px; + display: flex; + align-items: center; + justify-content: center; + color: #666; + + &:hover { + color: #1976d2; + } + + &:focus { + outline: 1px solid #1976d2; + color: #1976d2; + } + } + + &__formatLabel { + font-size: 11px; + color: #666; + text-align: left; + margin-bottom: 10px; + letter-spacing: 0.5px; + padding-left: 24px; + } + + &__colorValueInput { + flex: 1; + padding: 4px 8px; + border: 1px solid #ddd; + border-radius: 4px; + font-family: monospace; + font-size: 12px; + height: 28px; + box-sizing: border-box; + + &:focus { + outline: 2px solid #1976d2; + outline-offset: -1px; + border-color: #1976d2; + } + } + + &__control { + max-width: 100%; + box-sizing: border-box; + overflow: hidden; + } + + &__customHueHandle { + position: absolute; + width: 16px; + height: 16px; + border-radius: 50%; + border: 2px solid #fff; + box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.3); + background: #fff; + transform: translate(-50%, -1px); + pointer-events: none; + } + + &__customHueSlider:focus { + box-shadow: 0 0 0 2px #1976d2; + } + + &__customSaturationBox--white, + &__customSaturationBox--black { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + pointer-events: none; + } + + &__customSaturationBox--white { + background: linear-gradient(to right, #fff, rgba(255, 255, 255, 0)); + } + + &__customSaturationBox--black { + background: linear-gradient(to top, #000, rgba(0, 0, 0, 0)); + } + + &__customSaturationPointer { + position: absolute; + cursor: pointer; + transform: translate(-4px, -4px); + } + + &__customSaturationCircle { + width: 8px; + height: 8px; + box-shadow: + 0 0 0 1.5px #fff, + inset 0 0 1px 1px rgba(0, 0, 0, 0.3), + 0 0 1px 2px rgba(0, 0, 0, 0.4); + border-radius: 50%; + cursor: pointer; + transform-origin: center; + transition: transform 0.1s ease; + } + + &__customSaturationBox:focus &__customSaturationCircle { + transform: scale(1.2); + box-shadow: + 0 0 0 1.5px #fff, + 0 0 0 3px #1976d2, + inset 0 0 1px 1px rgba(0, 0, 0, 0.3); + } + + &__a11yAnnouncer { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; + } +} + +@media (max-width: 767px) { + .pkpFormField { + &__threeColumnsRow { + flex-direction: column; + } + + &__colorPickerColumn { + max-width: 100%; + height: auto; + margin-bottom: 20px; + } + + &__contrastValueColumn { + max-width: 100%; + flex: 1; + height: auto; + } + + &__formatSelector { + flex-wrap: wrap; + } + + &__formatSelect, + &__colorValueInput { + margin-top: 4px; + } + + &__colorValueInput { + width: 100%; + min-width: 0; + } + } +} \ No newline at end of file From 6f4bca62b2a0593a6357045ffc4d405baa0100df Mon Sep 17 00:00:00 2001 From: Guilherme Godoy Date: Sat, 26 Apr 2025 10:33:43 +0100 Subject: [PATCH 5/6] Update aria-live for better acessibility behavior --- .../Form/fields/FieldContrastColorPicker.vue | 106 ++++++++++++++++-- 1 file changed, 94 insertions(+), 12 deletions(-) diff --git a/src/components/Form/fields/FieldContrastColorPicker.vue b/src/components/Form/fields/FieldContrastColorPicker.vue index 7bd53c54f..a0deae5cb 100644 --- a/src/components/Form/fields/FieldContrastColorPicker.vue +++ b/src/components/Form/fields/FieldContrastColorPicker.vue @@ -55,6 +55,8 @@ @mousedown="startCustomSatValueDrag($event, 'color1')" @touchstart="startCustomSatValueDrag($event, 'color1')" @keydown="handleCustomSatValueKeydown($event, 'color1')" + @focus="handleFocus('color1')" + @blur="handleBlur('color1')" >
@@ -114,6 +116,8 @@ type="text" :aria-label="componentKeys.format + ' 1'" @input="handleColorValueInput($event, 'color1')" + @focus="handleFocus('color1')" + @blur="handleBlur('color1')" />
@@ -124,7 +128,7 @@ class="pkpFormField__a11yAnnouncer" aria-atomic="true" > - + {{ getColorFormatAnnouncement('color1') }}
@@ -143,6 +147,8 @@ @mousedown="startCustomSatValueDrag($event, 'color2')" @touchstart="startCustomSatValueDrag($event, 'color2')" @keydown="handleCustomSatValueKeydown($event, 'color2')" + @focus="handleFocus('color2')" + @blur="handleBlur('color2')" >
@@ -202,6 +208,8 @@ type="text" :aria-label="componentKeys.format + ' 2'" @input="handleColorValueInput($event, 'color2')" + @focus="handleFocus('color2')" + @blur="handleBlur('color2')" />
@@ -212,7 +220,7 @@ class="pkpFormField__a11yAnnouncer" aria-atomic="true" > - + {{ getColorFormatAnnouncement('color2') }}
@@ -256,7 +264,9 @@ class="pkpFormField__a11yAnnouncer" aria-atomic="true" > - {{ getContrastAnnouncement() }} + + {{ getContrastAnnouncement() }} + @@ -330,6 +340,15 @@ export default { color2: 'hex', }, lastUpdated: null, + focused: { + color1: false, + color2: false, + }, + previousColors: { + color1: '#1E6292', + color2: '#FFFFFF', + }, + debounceTimer: null, // Preloaded translation keys for SSR componentKeys: { required: this.t('common.required'), @@ -546,6 +565,9 @@ export default { } if (newHex) { + const previousColor = + colorId === 'color1' ? this.color1.hex : this.color2.hex; + if (colorId === 'color1') { this.color1.hex = newHex; } else { @@ -558,13 +580,16 @@ export default { this.valueValues[colorId] = hsv.v; this.updateValue(); - this.checkContrast(); + this.debouncedCheckContrast(); + + if (previousColor !== newHex) { + this.lastUpdated = colorId; + this.previousColors[colorId] = newHex; + } } } catch (e) { console.error('Error parsing color input:', e); } - - this.lastUpdated = colorId; }, /** @@ -586,6 +611,7 @@ export default { handleHexInput(colorId) { const color = colorId === 'color1' ? this.color1 : this.color2; + const previousHex = color.hex; let hex = color.hex.trim(); @@ -609,7 +635,12 @@ export default { this.valueValues[colorId] = hsv.v; this.updateValue(); - this.checkContrast(); + this.debouncedCheckContrast(); + + if (previousHex !== hex) { + this.lastUpdated = colorId; + this.previousColors[colorId] = hex; + } }, handleCustomHueClick(event, colorId) { @@ -689,9 +720,14 @@ export default { } this.updateValue(); - this.checkContrast(); + this.debouncedCheckContrast(); - this.lastUpdated = colorId; + const currentColor = + colorId === 'color1' ? this.color1.hex : this.color2.hex; + if (this.previousColors[colorId] !== currentColor) { + this.lastUpdated = colorId; + this.previousColors[colorId] = currentColor; + } }, handleCustomHueKeydown(event, colorId) { @@ -853,6 +889,8 @@ export default { }; const hex = this.hsvToHex(hsv); + const previousHex = + colorId === 'color1' ? this.color1.hex : this.color2.hex; if (colorId === 'color1') { this.color1 = {hex}; @@ -861,9 +899,12 @@ export default { } this.updateValue(); - this.checkContrast(); + this.debouncedCheckContrast(); - this.lastUpdated = colorId; + if (previousHex !== hex) { + this.lastUpdated = colorId; + this.previousColors[colorId] = hex; + } }, updateValue() { @@ -884,11 +925,12 @@ export default { const lum1 = this.calculateLuminance(rgb1.r, rgb1.g, rgb1.b); const lum2 = this.calculateLuminance(rgb2.r, rgb2.g, rgb2.b); + const previousContrast = this.currentContrast; this.currentContrast = this.calculateContrast(lum1, lum2); this.updateValue(); - if (this.lastUpdated) { + if (previousContrast !== this.currentContrast) { this.lastUpdated = 'contrast'; } }, @@ -1103,6 +1145,46 @@ export default { this.colorFormats[colorId] = formats[newIndex]; this.lastUpdated = colorId; }, + + handleFocus(colorId) { + this.focused[colorId] = true; + this.previousColors[colorId] = + colorId === 'color1' ? this.color1.hex : this.color2.hex; + }, + + handleBlur(colorId) { + this.focused[colorId] = false; + }, + + shouldAnnounceColor(colorId) { + const currentColor = + colorId === 'color1' ? this.color1.hex : this.color2.hex; + // On first focus, announce even if no change + if (this.focused[colorId] && this.lastUpdated === null) { + return true; + } + // For subsequent changes, only announce if color actually changed + return ( + this.lastUpdated === colorId && + this.previousColors[colorId] !== currentColor + ); + }, + + shouldAnnounceContrast() { + // Only announce contrast changes after debounce + return this.lastUpdated === 'contrast' && this.debounceTimer === null; + }, + + debouncedCheckContrast() { + if (this.debounceTimer) { + clearTimeout(this.debounceTimer); + } + + this.debounceTimer = setTimeout(() => { + this.checkContrast(); + this.debounceTimer = null; + }, 300); // 300ms debounce + }, }, }; From b4aa5a8ce779f32ad3852635439411a672e6b6a4 Mon Sep 17 00:00:00 2001 From: Guilherme Godoy Date: Thu, 1 May 2025 18:02:24 +0100 Subject: [PATCH 6/6] Improve acessibility for the component --- .../Form/fields/FieldContrastColorPicker.vue | 20 ++++++++++++++++++- .../field-contrast-color-picker.less | 19 +++++++++++++++++- 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/src/components/Form/fields/FieldContrastColorPicker.vue b/src/components/Form/fields/FieldContrastColorPicker.vue index a0deae5cb..d222aa60a 100644 --- a/src/components/Form/fields/FieldContrastColorPicker.vue +++ b/src/components/Form/fields/FieldContrastColorPicker.vue @@ -257,6 +257,13 @@ :aria-label="`${category.label}: ${currentContrast >= category.value ? 'Pass' : 'Fail'}`" > {{ category.label }} + + + +
{{ componentKeys.contrastGuideText }} + + + + {{ componentKeys.opensInNewWindowText }} + +
@@ -372,6 +389,7 @@ export default { ), levelAccessible: this.t('common.colorPicker.levelAccessible'), levelNotAccessible: this.t('common.colorPicker.levelNotAccessible'), + opensInNewWindowText: this.t('common.colorPicker.opensInNewWindowText'), }, }; }, diff --git a/src/styles/components/field-contrast-color-picker.less b/src/styles/components/field-contrast-color-picker.less index 9ef8c852b..b615861bd 100644 --- a/src/styles/components/field-contrast-color-picker.less +++ b/src/styles/components/field-contrast-color-picker.less @@ -100,6 +100,9 @@ border-radius: 4px; font-weight: 600; font-size: 11px; + display: flex; + justify-content: space-between; + align-items: center; &--pass { background-color: rgba(0, 178, 141, 0.15); @@ -112,6 +115,20 @@ } } + &__contrastCategoryIcon { + display: inline-flex; + align-items: center; + justify-content: center; + + .fa-check { + color: #007a60; + } + + .fa-times { + color: #b00058; + } + } + &__accessibilityInfoRow { display: flex; justify-content: flex-start; @@ -381,4 +398,4 @@ min-width: 0; } } -} \ No newline at end of file +}