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:
parent
d0c6088253
commit
25dd42704e
8 changed files with 319 additions and 42 deletions
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
92
app/javascript/utilities/color/WCAGColorContrast.js
Normal file
92
app/javascript/utilities/color/WCAGColorContrast.js
Normal 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));
|
||||
},
|
||||
};
|
||||
32
app/javascript/utilities/color/contrastValidator.js
Normal file
32
app/javascript/utilities/color/contrastValidator.js
Normal 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('#', '');
|
||||
}
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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 %>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue