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..6ad9642a8 --- /dev/null +++ b/src/components/Form/fields/FieldContrastColorPicker.mdx @@ -0,0 +1,81 @@ +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 +- 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 +- Keyboard navigation support for better accessibility +- Documentation link for learning more about contrast requirements + +## 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 + +### 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 +- `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..834e21fda --- /dev/null +++ b/src/components/Form/fields/FieldContrastColorPicker.stories.js @@ -0,0 +1,105 @@ +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: ` + + `, + }), + 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 = { + 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.', + }, +}; + +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 new file mode 100644 index 000000000..d222aa60a --- /dev/null +++ b/src/components/Form/fields/FieldContrastColorPicker.vue @@ -0,0 +1,1212 @@ + + + + + {{ localeLabel }} + {{ multilingualLabel }} + + + {{ label }} + + + * + {{ componentKeys.required }} + + + + {{ tooltip }} + + + + + {{ description }} + + + + + + {{ componentKeys.color1 }} + + + + + + + + + + + + + + + + + + + ▲ + + + ▼ + + + + + + {{ componentKeys[colorFormats.color1] }} + + + + {{ getColorFormatAnnouncement('color1') }} + + + + + + + {{ componentKeys.color2 }} + + + + + + + + + + + + + + + + + + + ▲ + + + ▼ + + + + + + {{ componentKeys[colorFormats.color2] }} + + + + {{ getColorFormatAnnouncement('color2') }} + + + + + + + {{ componentKeys.contrastRatio }} + + + Text + + + + {{ currentContrast.toFixed(2) }} + + + + + + {{ category.label }} + + + + + + + + + {{ getContrastAnnouncement() }} + + + + + + + + + {{ componentKeys.contrastGuideText }} + + + + {{ componentKeys.opensInNewWindowText }} + + + + + + + + + + + + + + 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..a9477a450 --- /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. Use keyboard navigation or mouse to adjust colors.', + value: JSON.stringify({ + color1: '#1E6292', + color2: '#FFFFFF', + }), +}; 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..b615861bd --- /dev/null +++ b/src/styles/components/field-contrast-color-picker.less @@ -0,0 +1,401 @@ +.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; + display: flex; + justify-content: space-between; + align-items: center; + + &--pass { + background-color: rgba(0, 178, 141, 0.15); + color: #007a60; + } + + &--fail { + background-color: rgba(208, 10, 108, 0.15); + color: #b00058; + } + } + + &__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; + 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; + } + } +}