diff --git a/app/javascript/admin/controllers/creator_settings_controller.js b/app/javascript/admin/controllers/creator_settings_controller.js index c6e5d28ff..64cadaf88 100644 --- a/app/javascript/admin/controllers/creator_settings_controller.js +++ b/app/javascript/admin/controllers/creator_settings_controller.js @@ -1,5 +1,6 @@ import { Controller } from '@hotwired/stimulus'; -import { brightness } from '../../utilities/color/accentCalculator'; +import { isLowContrast } from '@utilities/color/contrastValidator'; +import { brightness } from '@utilities/color/accentCalculator'; const MAX_LOGO_PREVIEW_HEIGHT = 80; const MAX_LOGO_PREVIEW_WIDTH = 220; @@ -8,7 +9,7 @@ const MAX_LOGO_PREVIEW_WIDTH = 220; * Manages interactions on the Creator Settings page. */ export class CreatorSettingsController extends Controller { - static targets = ['previewLogo']; + static targets = ['previewLogo', 'colorContrastError', 'brandColor']; /** * Displays a preview of the image selected by the user. @@ -70,13 +71,30 @@ export class CreatorSettingsController extends Controller { } /** - * Updates ths branding/colors on the Creator Settings Page. - * + * Validates the color contrast for accessibility, + * if the contrast is okay, it updates the branding, + * else it displays the error. * @param {Event} event */ - updateBranding(event) { + handleValidationsAndUpdates(event) { const { value: color } = event.target; + if (isLowContrast(color)) { + this.colorContrastErrorTarget.innerText = + 'The selected color must be darker for accessibility purposes.'; + } else { + this.updateBranding(color); + this.colorContrastErrorTarget.innerText = ''; + } + } + + /** + * Updates ths branding/colors on the Creator Settings Page. + * by overridding the accent-color in the :root object + * + * @param {String} color + */ + updateBranding(color) { if (!new RegExp(event.target.getAttribute('pattern')).test(color)) { return; } @@ -93,4 +111,19 @@ export class CreatorSettingsController extends Controller { brightness(color, 0.85), ); } + + /** + * Prevents a submission of the form if the + * color contrast is low. + * + * @param {Event} event + */ + formValidations(event) { + const { value: color } = this.brandColorTarget; + if (isLowContrast(color)) { + event.preventDefault(); + this.colorContrastErrorTarget.classList.remove('hidden'); + // we don't want the form to submit if the contrast is low + } + } } diff --git a/app/javascript/utilities/__tests__/color/WCAGColorContrast.test.js b/app/javascript/utilities/__tests__/color/WCAGColorContrast.test.js new file mode 100644 index 000000000..a94b4a753 --- /dev/null +++ b/app/javascript/utilities/__tests__/color/WCAGColorContrast.test.js @@ -0,0 +1,55 @@ +import { WCAGColorContrast } from '@utilities/color/WCAGColorContrast'; + +// Tests have been extracted from the original library https://github.com/doochik/wcag-color-contrast/blob/master/index.html +describe('WCAGColorContrast.validRGB ', () => { + it('valid #FFFFFF', () => { + expect(WCAGColorContrast.validRGB('FFFFFF')).toBeTruthy(); + }); + it('valid #FFF', () => { + expect(WCAGColorContrast.validRGB('FFF')).toBeTruthy(); + }); + it('valid #111', () => { + expect(WCAGColorContrast.validRGB('111')).toBeTruthy(); + }); + it('valid #f11', () => { + expect(WCAGColorContrast.validRGB('f11')).toBeTruthy(); + }); + it('invalid #11', () => { + expect(WCAGColorContrast.validRGB('11')).toBeFalsy(); + }); + it('invalid #11123', () => { + expect(WCAGColorContrast.validRGB('11123')).toBeFalsy(); + }); + it('invalid #x12345', () => { + expect(WCAGColorContrast.validRGB('x12345')).toBeFalsy(); + }); +}); + +describe('WCAGColorContrast.ratio', () => { + it('#FFFFFF and #000000 must be 21', () => { + expect(WCAGColorContrast.ratio('FFFFFF', '000000')).toBe(21); + }); + + it('#000000 and #FFFFFF must be 21', () => { + expect(WCAGColorContrast.ratio('000000', 'FFFFFF')).toBe(21); + }); + + it('#000 and #FFF must be 21', () => { + expect(WCAGColorContrast.ratio('000', 'FFF')).toBe(21); + }); + + it('#123 and #FFF must be 16.15', () => { + expect(WCAGColorContrast.ratio('123', 'FFF').toFixed(2)).toBe('16.15'); + }); + + it('#8883C4 and #1169FF must be 1.36', () => { + expect(WCAGColorContrast.ratio('8883C4', '1169FF').toFixed(2)).toBe('1.36'); + }); + + it('#x123 and #1169FF must throw Exception', () => { + const test = function () { + WCAGColorContrast.ratio('x123', '1169FF'); + }; + expect(test).toThrow(); + }); +}); diff --git a/app/javascript/utilities/__tests__/color/contrastValidator.test.js b/app/javascript/utilities/__tests__/color/contrastValidator.test.js new file mode 100644 index 000000000..19e843241 --- /dev/null +++ b/app/javascript/utilities/__tests__/color/contrastValidator.test.js @@ -0,0 +1,11 @@ +import { isLowContrast } from '@utilities/color/contrastValidator'; + +describe('Color: Contrast Validator Utilities', () => { + it('should return a boolean indicating whether the contrast is low or not', () => { + expect(isLowContrast('#41c3ab')).toBe(true); + expect(isLowContrast('#4341c3')).toBe(false); + expect(isLowContrast('#c9c5c5', '000000')).toBe(false); + expect(isLowContrast('#544f4f', '000000')).toBe(true); + expect(isLowContrast('#ffffff', '000000', 2)).toBe(false); + }); +}); diff --git a/app/javascript/utilities/color/WCAGColorContrast.js b/app/javascript/utilities/color/WCAGColorContrast.js new file mode 100644 index 000000000..01b69a1fb --- /dev/null +++ b/app/javascript/utilities/color/WCAGColorContrast.js @@ -0,0 +1,92 @@ +/** + * Check color contrast according to WCAG 2.0 spec + * @see http://www.w3.org/TR/WCAG20-TECHS/G17.html + * Based on original implementation + * https://github.com/doochik/wcag-color-contrast + */ +export const WCAGColorContrast = { + /** + * Calculate contast ratio beetween rgb1 and rgb2 + * @param {String} rgb1 6-letter RGB color. + * @param {String} rgb2 6-letter RGB color. + * @return {Number} + */ + ratio(rgb1, rgb2) { + if (this.validRGB(rgb1)) { + var sRGB1 = this.RGBtosRGB(rgb1); + } else { + throw `Invalid color ${rgb1}`; + } + if (this.validRGB(rgb2)) { + var sRGB2 = this.RGBtosRGB(rgb2); + } else { + throw `Invalid color ${rgb2}`; + } + + const L1 = this.sRGBLightness(sRGB1); + const L2 = this.sRGBLightness(sRGB2); + + /* + Calculate the contrast ratio using the following formula. + (L1 + 0.05) / (L2 + 0.05), where + L1 is the relative luminance of the lighter of the foreground or background colors, and + L2 is the relative luminance of the darker of the foreground or background colors. + */ + return L1 > L2 ? (L1 + 0.05) / (L2 + 0.05) : (L2 + 0.05) / (L1 + 0.05); + }, + + /** + * Convert RGB color to sRGB + * @param {String} rgb 6-letter RGB color. + * @return {Number[]} [R, G, B] + */ + RGBtosRGB(rgb) { + if (rgb.length === 3) { + rgb = rgb[0] + rgb[0] + rgb[1] + rgb[1] + rgb[2] + rgb[2]; + } + return [ + parseInt(rgb.slice(0, 2), 16) / 255, + parseInt(rgb.slice(2, 4), 16) / 255, + parseInt(rgb.slice(4, 6), 16) / 255, + ]; + }, + + /** + * Calculate lightness for sRGB color. + * @param {Number[]} sRGB sRGB color [R, G, B] + * @return {Number} + */ + sRGBLightness(sRGB) { + // L = 0.2126 * R + 0.7152 * G + 0.0722 * B where R, G and B are defined as + // if R <= 0.03928 then R = R sRGB /12.92 else R = ((R sRGB +0.055)/1.055) ^ 2.4 + // if G <= 0.03928 then G = G sRGB /12.92 else G = ((G sRGB +0.055)/1.055) ^ 2.4 + // if B <= 0.03928 then B = B sRGB /12.92 else B = ((B sRGB +0.055)/1.055) ^ 2.4 + const RsRGB = sRGB[0]; + const GsRGB = sRGB[1]; + const BsRGB = sRGB[2]; + + return ( + 0.2126 * + (RsRGB <= 0.03928 + ? RsRGB / 12.92 + : Math.pow((RsRGB + 0.055) / 1.055, 2.4)) + + 0.7152 * + (GsRGB <= 0.03928 + ? GsRGB / 12.92 + : Math.pow((GsRGB + 0.055) / 1.055, 2.4)) + + 0.0722 * + (BsRGB <= 0.03928 + ? BsRGB / 12.92 + : Math.pow((BsRGB + 0.055) / 1.055, 2.4)) + ); + }, + + /** + * Validate RGB string. + * @param {String} rgb Color. + * @return {Boolean} + */ + validRGB(rgb) { + return rgb && (/^[a-f0-9]{3}$/i.test(rgb) || /^[a-f0-9]{6}$/i.test(rgb)); + }, +}; diff --git a/app/javascript/utilities/color/contrastValidator.js b/app/javascript/utilities/color/contrastValidator.js new file mode 100644 index 000000000..b116654d7 --- /dev/null +++ b/app/javascript/utilities/color/contrastValidator.js @@ -0,0 +1,32 @@ +import { WCAGColorContrast } from './WCAGColorContrast'; + +/** + * Determine if the contast ratio is low. + * Uses the WCAGColorContrast utility library. + * + * @param {String} rgb1 6-letter RGB color. + * @param {String} rgb2 6-letter RGB color. + * + * @return {Boolean} + */ +export function isLowContrast( + color, + comparedColor = 'ffffff', + minContrast = 4.5, +) { + return ( + WCAGColorContrast.ratio(strippedHex(color), strippedHex(comparedColor)) < + minContrast + ); +} + +/** + * Removes the # in a string. + * + * @param {String} a hex color in this case. + * + * @return {String} without the # + */ +function strippedHex(hex) { + return hex.replace('#', ''); +} diff --git a/app/views/admin/creator_settings/_form.html.erb b/app/views/admin/creator_settings/_form.html.erb index a92dd0155..75dac8b43 100644 --- a/app/views/admin/creator_settings/_form.html.erb +++ b/app/views/admin/creator_settings/_form.html.erb @@ -38,16 +38,19 @@ title: "Provide a valid HEX Color or pick your color from the color picker.", placeholder: ::Settings::UserExperience.primary_brand_color_hex, class: "crayons-textfield js-color-field", - "data-action": "change->creator-settings#updateBranding" %> + "data-action": "change->creator-settings#handleValidationsAndUpdates", + "data-creator-settings-target": "brandColor", + "aria-describedby": "color-contrast-error" %> <%= color_field_tag :primary_brand_color_hex, ::Settings::UserExperience.primary_brand_color_hex, pattern: "^#+([a-fA-F0-9]{6})$", placeholder: ::Settings::UserExperience.primary_brand_color_hex, class: "crayons-color-selector js-color-field ml-2", - "data-action": "change->creator-settings#updateBranding", - required: true %> + required: true, + "data-action": "change->creator-settings#handleValidationsAndUpdates" %> +