Validate the color contrast ratio before submitting the Creator Settings Form (#15444)

* feat: add a color contrast utility

* feat: add an error when the color contrast is low

* feat: add form validations

* refactor: treat WCAGColorContrast as a library that can be intercanged at any time

* fix: styling

* test: add a test for the contrast

* feat: add test for WCAGColorContrast

* feat: update cypress tests for brand color and color contrast ratios

* feat: update the message to read better

* chore: update the styling

* refactor: address all feedback/suggestions

* Update cypress/integration/creatorOnboardingFlows/creatorSettings.spec.js

Co-authored-by: Nick Taylor <nick@iamdeveloper.com>

* Replaced other .trigger('change')s with .blur()

Co-authored-by: Nick Taylor <nick@iamdeveloper.com>
Co-authored-by: Nick Taylor <nick@dev.to>
This commit is contained in:
Ridhwana 2021-12-01 18:49:11 +02:00 committed by GitHub
parent d0c6088253
commit 25dd42704e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 319 additions and 42 deletions

View file

@ -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
}
}
}

View file

@ -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();
});
});

View file

@ -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);
});
});

View file

@ -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));
},
};

View file

@ -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('#', '');
}

View file

@ -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" %>
</div>
</div>
<div id="color-contrast-error" data-creator-settings-target="colorContrastError" aria-live="polite" class="mt-1 color-accent-danger"></div>
</div>
<div class="crayons-field mt-6 align-left">

View file

@ -3,7 +3,7 @@
<%= Rails.application.assets["setup-mode.css"].to_s.html_safe %>
</style>
<main id="main-content" class="flex flex-1 justify-center flex-col crayons-layout crayons-layout--limited-xs" data-controller="creator-settings">
<main id="main-content" class="flex flex-1 justify-center flex-col crayons-layout crayons-layout--limited-s" data-controller="creator-settings">
<div aria-live="assertive">
<% if flash[:error] %>
<div class="crayons-notice crayons-notice--danger mb-6" role="alert">
@ -12,7 +12,7 @@
<% end %>
</div>
<%= form_tag(admin_creator_settings_path, method: "post", class: "relative z-elevate p-4") do %>
<%= form_tag(admin_creator_settings_path, method: "post", class: "relative z-elevate p-4", "data-action": "submit->creator-settings#formValidations") do %>
<% if defined?(resource) && resource&.errors&.any? %>
<div class="crayons-card crayons-card--secondary crayons-notice crayons-notice--danger" role="alert" data-testid="signup-errors">
<div class="crayons-card__header">
@ -44,7 +44,7 @@
<% end %>
<% end %>
<%= inline_svg_tag("forem-background.svg", aria_hidden: true, class: "forem-background absolute bottom-0 right-0 hidden m:block") %>
<%= inline_svg_tag("forem-background.svg", aria_hidden: true, class: "forem-background fixed bottom-0 right-0 hidden m:block") %>
</main>
<% end %>

View file

@ -73,37 +73,6 @@ describe('Creator Settings Page', () => {
cy.url().should('equal', baseUrl);
});
it('should update the colors on the form when a new brand color is selected', () => {
const color = '#25544b';
const rgbColor = 'rgb(37, 84, 75)';
cy.findByLabelText(/^Brand color/)
.clear()
.type(color)
.trigger('change');
cy.findByRole('button', { name: 'Finish' }).should(
'have.css',
'background-color',
rgbColor,
);
cy.findAllByRole('radio', { name: /members only/i })
.check()
.should('have.css', 'background-color', rgbColor)
.should('have.css', 'border-color', rgbColor);
cy.findByRole('textbox', { name: /community name/i })
.focus()
.should('have.css', 'border-color', rgbColor);
cy.findByRole('link', { name: /Forem Admin Guide/i }).should(
'have.css',
'background-color',
rgbColor,
);
});
it('should not submit the creator settings form if any of the fields are not filled out', () => {
// TODO: Circle back around to testing this once the styling for the form is complete
cy.findByRole('textbox', { name: /community name/i }).should(
@ -120,4 +89,86 @@ describe('Creator Settings Page', () => {
cy.findByRole('button', { name: 'Finish' }).click();
cy.url().should('equal', `${baseUrl}admin/creator_settings/new`);
});
context('color contrast ratios', () => {
it('should show an error when the constrast ratio of a brand color is too low', () => {
const lowContrastColor = '#a6e8a6';
cy.findByLabelText(/^Brand color/)
.clear()
.type(lowContrastColor)
.blur();
cy.findByText(
/^The selected color must be darker for accessibility purposes./,
).should('be.visible');
});
it('should not show an error when the constrast ratio of a brand color is good', () => {
const adequateContrastColor = '#25544b';
cy.findByLabelText(/^Brand color/)
.clear()
.type(adequateContrastColor)
.blur();
cy.findByText(
/^The selected color must be darker for accessibility purposes./,
).should('not.exist');
});
});
context('brand color updates', () => {
it('should not update the brand color if the color contrast ratio is low', () => {
const lowContrastColor = '#a6e8a6';
const lowContrastRgbColor = 'rgb(166, 232, 166)';
cy.findByLabelText(/^Brand color/)
.clear()
.type(lowContrastColor)
.blur();
cy.findByText(
/^The selected color must be darker for accessibility purposes./,
).should('be.visible');
cy.findByRole('button', { name: 'Finish' }).should(
'not.have.css',
'background-color',
lowContrastRgbColor,
);
});
it('should update the colors on the form when a new brand color is selected', () => {
const color = '#25544b';
const rgbColor = 'rgb(37, 84, 75)';
cy.findByLabelText(/^Brand color/)
.clear()
.type(color)
.blur();
cy.findByRole('button', { name: 'Finish' }).should(
'have.css',
'background-color',
rgbColor,
);
cy.findAllByRole('radio', { name: /members only/i })
.check()
.should('have.css', 'background-color', rgbColor)
.should('have.css', 'border-color', rgbColor);
cy.findByRole('link', { name: /Forem Admin Guide/i }).should(
'have.css',
'background-color',
rgbColor,
);
cy.findByRole('textbox', { name: /community name/i })
.focus()
.type('Climbing Life')
.should('have.css', 'border-color', rgbColor);
});
});
});