Use the Preact ColorPicker in creator onboarding (#16731)

* use Preact ColorPicker in creator onboarding

* update tests

* update comments

* Update app/javascript/packs/admin/creatorOnboarding.jsx

Co-authored-by: Julianna Tetreault <32834804+juliannatetreault@users.noreply.github.com>

Co-authored-by: Julianna Tetreault <32834804+juliannatetreault@users.noreply.github.com>
This commit is contained in:
Suzanne Aitchison 2022-03-03 08:22:10 +00:00 committed by GitHub
parent 4e76771867
commit db288c0b4b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 174 additions and 111 deletions

View file

@ -1,67 +0,0 @@
import { Controller } from '@hotwired/stimulus';
import { isLowContrast } from '@utilities/color/contrastValidator';
import { brightness } from '@utilities/color/accentCalculator';
/**
* Manages interactions on the Creator Settings page.
*/
export class CreatorSettingsController extends Controller {
static targets = ['colorContrastError', 'brandColor'];
/**
* Validates the color contrast for accessibility,
* if the contrast is okay, it updates the branding,
* else it displays the error.
* @param {Event} 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 overriding the accent-color in the :root object
*
* @param {String} color
*/
updateBranding(color) {
if (!new RegExp(event.target.getAttribute('pattern')).test(color)) {
return;
}
document.documentElement.style.setProperty('--accent-brand', color);
// We need to recalculate '--accent-brand-darker' in javascript as it's
// currently being calculated in ruby. It is used for the hover effect
// over the button.
// 0.85 represents the brightness value set in Ruby to calculate
// '--accent-brand-darker'
document.documentElement.style.setProperty(
'--accent-brand-darker',
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

@ -1,11 +1,18 @@
import { h, render } from 'preact';
import { ColorPicker } from '@crayons';
// Find any color picker inputs on the page and replace them with the Preact enhanced component
const colorInputs = document.querySelectorAll('[data-color-picker]');
for (const input of colorInputs) {
const { labelText } = input.dataset;
/**
* Takes a text input, and replaces it with the richer Preact component
*
* @param {HTMLElement} input The input to replace
* @param {string} labelText The label to apply to the new form controls
* @param {function} onChange Any onChange callback
*/
export function replaceTextInputWithColorPicker({
input,
labelText,
onChange,
}) {
const inputProps = {};
// Copy any specific attributes to the new input
@ -15,13 +22,14 @@ for (const input of colorInputs) {
}
// The third `replaceNode` argument here makes sure that the new picker is rendered in the correct position within the parentElement
// However, Preact is unable to do a VDOM diff that allows a straight replacement of one input for the other, so we also need to remove it manually on line 29
// However, Preact is unable to do a VDOM diff that allows a straight replacement of one input for the other, so we also need to remove it manually below
render(
<ColorPicker
id={inputProps.id}
defaultValue={input.value}
inputProps={inputProps}
buttonLabelText={labelText}
onChange={onChange}
/>,
input.parentElement,
input,

View file

@ -6,11 +6,21 @@ import { HexColorPicker, HexColorInput } from 'react-colorful';
import { initializeDropdown } from '@utilities/dropdownUtils';
import { ButtonNew as Button } from '@crayons';
const convertThreeCharHexToSix = (hex) => {
const r = hex.charAt(1);
const g = hex.charAt(2);
const b = hex.charAt(3);
return `#${r}${r}${g}${g}${b}${b}`;
};
export const ColorPicker = ({
id,
buttonLabelText,
defaultValue,
inputProps,
onChange,
onBlur,
}) => {
// Ternary has been used here to guard against an empty string being passed as default value
const [color, setColor] = useState(defaultValue ? defaultValue : '#000');
@ -25,9 +35,21 @@ export const ColorPicker = ({
});
}, [buttonId, popoverId]);
// Hex codes may validly be represented by three characters, where r, g, b are all repeated,
// e.g. #0D6 === #00DD66. To make sure that all color codes can be handled consistently through our app,
// we convert any shorthand hex codes to their full 6 char representation.
const handleBlur = () => {
// Color always includes a leading '#', hence a length of 4
if (color.length === 4) {
const fullHexCode = convertThreeCharHexToSix(color);
setColor(fullHexCode);
onChange?.(fullHexCode);
}
};
return (
<Fragment>
<div className="c-color-picker relative">
<div className="c-color-picker relative w-100">
<Button
id={buttonId}
className="c-btn c-color-picker__swatch absolute"
@ -35,21 +57,34 @@ export const ColorPicker = ({
aria-label={buttonLabelText}
/>
<HexColorInput
{...inputProps}
id={id}
className={classNames(
'c-color-picker__input crayons-textfield',
inputProps?.class,
)}
color={color}
onChange={setColor}
onChange={(color) => {
onChange?.(color);
setColor(color);
}}
onBlur={(e) => {
onBlur?.(e);
handleBlur();
}}
prefixed
{...inputProps}
/>
<div
id={popoverId}
className="c-color-picker__popover crayons-dropdown absolute p-0"
>
<HexColorPicker color={color} onChange={setColor} />
<HexColorPicker
color={color}
onChange={(color) => {
onChange?.(color);
setColor(color);
}}
/>
</div>
</div>
</Fragment>

View file

@ -1,5 +1,5 @@
import { h } from 'preact';
import { render } from '@testing-library/preact';
import { render, waitFor } from '@testing-library/preact';
import { axe } from 'jest-axe';
import { ColorPicker } from '@crayons';
@ -46,4 +46,24 @@ describe('<ColorPicker />', () => {
);
expect(container.innerHTML).toMatchSnapshot();
});
it('converts 3 char hex codes to full 6 chars on blur', async () => {
const changeHandler = jest.fn();
const { getByRole } = render(
<ColorPicker
id="color-picker"
buttonLabelText="Choose a color"
defaultValue="#0B6"
inputProps={{ 'aria-label': 'Choose a color' }}
onChange={changeHandler}
/>,
);
const input = getByRole('textbox', { name: 'Choose a color' });
input.focus();
input.blur();
await waitFor(() => expect(changeHandler).toHaveBeenCalledWith('#00BB66'));
});
});

View file

@ -1,5 +1,5 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<ColorPicker /> should render 1`] = `"<div class=\\"c-color-picker relative\\"><button type=\\"button\\" class=\\"c-btn c-btn c-color-picker__swatch absolute\\" id=\\"color-popover-btn-color-picker\\" style=\\"background-color: rgb(0, 0, 0);\\" aria-label=\\"Choose a color\\" aria-expanded=\\"false\\" aria-controls=\\"color-popover-color-picker\\" aria-haspopup=\\"true\\"></button><input id=\\"color-picker\\" class=\\"c-color-picker__input crayons-textfield\\" aria-label=\\"Choose a color\\" spellcheck=\\"false\\"><div id=\\"color-popover-color-picker\\" class=\\"c-color-picker__popover crayons-dropdown absolute p-0\\"><div class=\\"react-colorful\\"><div class=\\"react-colorful__saturation\\" style=\\"background-color: rgb(255, 0, 0);\\"><div aria-label=\\"Color\\" aria-valuetext=\\"Saturation 0%, Brightness 0%\\" class=\\"react-colorful__interactive\\" tabindex=\\"0\\" role=\\"slider\\"><div class=\\"react-colorful__pointer react-colorful__saturation-pointer\\" style=\\"top: 100%; left: 0%;\\"><div class=\\"react-colorful__pointer-fill\\" style=\\"background-color: rgb(0, 0, 0);\\"></div></div></div></div><div class=\\"react-colorful__hue react-colorful__last-control\\"><div aria-label=\\"Hue\\" aria-valuetext=\\"0\\" class=\\"react-colorful__interactive\\" tabindex=\\"0\\" role=\\"slider\\"><div class=\\"react-colorful__pointer react-colorful__hue-pointer\\" style=\\"top: 50%; left: 0%;\\"><div class=\\"react-colorful__pointer-fill\\" style=\\"background-color: rgb(255, 0, 0);\\"></div></div></div></div></div></div></div>"`;
exports[`<ColorPicker /> should render 1`] = `"<div class=\\"c-color-picker relative w-100\\"><button type=\\"button\\" class=\\"c-btn c-btn c-color-picker__swatch absolute\\" id=\\"color-popover-btn-color-picker\\" style=\\"background-color: rgb(0, 0, 0);\\" aria-label=\\"Choose a color\\" aria-expanded=\\"false\\" aria-controls=\\"color-popover-color-picker\\" aria-haspopup=\\"true\\"></button><input aria-label=\\"Choose a color\\" id=\\"color-picker\\" class=\\"c-color-picker__input crayons-textfield\\" spellcheck=\\"false\\"><div id=\\"color-popover-color-picker\\" class=\\"c-color-picker__popover crayons-dropdown absolute p-0\\"><div class=\\"react-colorful\\"><div class=\\"react-colorful__saturation\\" style=\\"background-color: rgb(255, 0, 0);\\"><div aria-label=\\"Color\\" aria-valuetext=\\"Saturation 0%, Brightness 0%\\" class=\\"react-colorful__interactive\\" tabindex=\\"0\\" role=\\"slider\\"><div class=\\"react-colorful__pointer react-colorful__saturation-pointer\\" style=\\"top: 100%; left: 0%;\\"><div class=\\"react-colorful__pointer-fill\\" style=\\"background-color: rgb(0, 0, 0);\\"></div></div></div></div><div class=\\"react-colorful__hue react-colorful__last-control\\"><div aria-label=\\"Hue\\" aria-valuetext=\\"0\\" class=\\"react-colorful__interactive\\" tabindex=\\"0\\" role=\\"slider\\"><div class=\\"react-colorful__pointer react-colorful__hue-pointer\\" style=\\"top: 50%; left: 0%;\\"><div class=\\"react-colorful__pointer-fill\\" style=\\"background-color: rgb(255, 0, 0);\\"></div></div></div></div></div></div></div>"`;
exports[`<ColorPicker /> should render with a default value 1`] = `"<div class=\\"c-color-picker relative\\"><button type=\\"button\\" class=\\"c-btn c-btn c-color-picker__swatch absolute\\" id=\\"color-popover-btn-color-picker\\" style=\\"background-color: rgb(171, 171, 171);\\" aria-label=\\"Choose a color\\" aria-expanded=\\"false\\" aria-controls=\\"color-popover-color-picker\\" aria-haspopup=\\"true\\"></button><input id=\\"color-picker\\" class=\\"c-color-picker__input crayons-textfield\\" aria-label=\\"Choose a color\\" spellcheck=\\"false\\"><div id=\\"color-popover-color-picker\\" class=\\"c-color-picker__popover crayons-dropdown absolute p-0\\"><div class=\\"react-colorful\\"><div class=\\"react-colorful__saturation\\" style=\\"background-color: rgb(255, 0, 0);\\"><div aria-label=\\"Color\\" aria-valuetext=\\"Saturation 0%, Brightness 67%\\" class=\\"react-colorful__interactive\\" tabindex=\\"0\\" role=\\"slider\\"><div class=\\"react-colorful__pointer react-colorful__saturation-pointer\\" style=\\"top: 32.99999999999999%; left: 0%;\\"><div class=\\"react-colorful__pointer-fill\\" style=\\"background-color: rgb(171, 171, 171);\\"></div></div></div></div><div class=\\"react-colorful__hue react-colorful__last-control\\"><div aria-label=\\"Hue\\" aria-valuetext=\\"0\\" class=\\"react-colorful__interactive\\" tabindex=\\"0\\" role=\\"slider\\"><div class=\\"react-colorful__pointer react-colorful__hue-pointer\\" style=\\"top: 50%; left: 0%;\\"><div class=\\"react-colorful__pointer-fill\\" style=\\"background-color: rgb(255, 0, 0);\\"></div></div></div></div></div></div></div>"`;
exports[`<ColorPicker /> should render with a default value 1`] = `"<div class=\\"c-color-picker relative w-100\\"><button type=\\"button\\" class=\\"c-btn c-btn c-color-picker__swatch absolute\\" id=\\"color-popover-btn-color-picker\\" style=\\"background-color: rgb(171, 171, 171);\\" aria-label=\\"Choose a color\\" aria-expanded=\\"false\\" aria-controls=\\"color-popover-color-picker\\" aria-haspopup=\\"true\\"></button><input aria-label=\\"Choose a color\\" id=\\"color-picker\\" class=\\"c-color-picker__input crayons-textfield\\" spellcheck=\\"false\\"><div id=\\"color-popover-color-picker\\" class=\\"c-color-picker__popover crayons-dropdown absolute p-0\\"><div class=\\"react-colorful\\"><div class=\\"react-colorful__saturation\\" style=\\"background-color: rgb(255, 0, 0);\\"><div aria-label=\\"Color\\" aria-valuetext=\\"Saturation 0%, Brightness 67%\\" class=\\"react-colorful__interactive\\" tabindex=\\"0\\" role=\\"slider\\"><div class=\\"react-colorful__pointer react-colorful__saturation-pointer\\" style=\\"top: 32.99999999999999%; left: 0%;\\"><div class=\\"react-colorful__pointer-fill\\" style=\\"background-color: rgb(171, 171, 171);\\"></div></div></div></div><div class=\\"react-colorful__hue react-colorful__last-control\\"><div aria-label=\\"Hue\\" aria-valuetext=\\"0\\" class=\\"react-colorful__interactive\\" tabindex=\\"0\\" role=\\"slider\\"><div class=\\"react-colorful__pointer react-colorful__hue-pointer\\" style=\\"top: 50%; left: 0%;\\"><div class=\\"react-colorful__pointer-fill\\" style=\\"background-color: rgb(255, 0, 0);\\"></div></div></div></div></div></div></div>"`;

View file

@ -0,0 +1,65 @@
import { replaceTextInputWithColorPicker } from '../../colorPickers/replaceTextInputWithColorPicker';
import { isLowContrast } from '@utilities/color/contrastValidator';
import { brightness } from '@utilities/color/accentCalculator';
const vanillaPicker = document.getElementById(
'creator_settings_form_primary_brand_color_hex',
);
const contrastErrorMessage = document.getElementById('color-contrast-error');
const finishButton = document.getElementById('finish-button');
if (vanillaPicker) {
replaceTextInputWithColorPicker({
input: vanillaPicker,
labelText: 'Brand color',
onChange: handleValidationsAndUpdates,
});
// We don't want the form to submit if the contrast is too low
finishButton.addEventListener('click', (event) => {
const { value: color } = document.getElementById(
'creator_settings_form_primary_brand_color_hex',
);
if (isLowContrast(color)) {
event.preventDefault();
}
});
}
/**
* Validates the color contrast for accessibility,
* if the contrast is okay, it updates the branding,
* else it displays the error.
*
* @param {string} color The color hex code
*/
function handleValidationsAndUpdates(color) {
if (isLowContrast(color)) {
contrastErrorMessage.innerText =
'The selected color must be darker for accessibility purposes.';
} else {
updateBranding(color);
contrastErrorMessage.innerText = '';
}
}
/**
* Updates the branding/colors on the Creator Settings Page
* by overriding the accent-color in the :root object
*
* @param {String} color The color hex code
*/
function updateBranding(color) {
document.documentElement.style.setProperty('--accent-brand', color);
// We need to recalculate '--accent-brand-darker' in javascript as it's
// currently being calculated in ruby. It is used for the hover effect
// over the button.
// 0.85 represents the brightness value set in Ruby to calculate
// '--accent-brand-darker'
document.documentElement.style.setProperty(
'--accent-brand-darker',
brightness(color, 0.85),
);
}

View file

@ -156,18 +156,12 @@ initializeNav();
async function loadCreatorSettings() {
try {
const [
{ CreatorSettingsController },
{ LogoUploadController },
{ Application },
] = await Promise.all([
import('@admin/controllers/creator_settings_controller'),
const [{ LogoUploadController }, { Application }] = await Promise.all([
import('@admin/controllers/logo_upload_controller'),
import('@hotwired/stimulus'),
]);
const application = Application.start();
application.register('creator-settings', CreatorSettingsController);
application.register('logo-upload', LogoUploadController);
} catch (error) {
Honeybadger.notify(

View file

@ -0,0 +1,9 @@
import { replaceTextInputWithColorPicker } from '../colorPickers/replaceTextInputWithColorPicker';
// Find any color picker inputs on the page and replace them with the Preact enhanced component
const colorInputs = document.querySelectorAll('[data-color-picker]');
for (const input of colorInputs) {
const { labelText } = input.dataset;
replaceTextInputWithColorPicker({ input, labelText });
}

View file

@ -7,6 +7,11 @@
*/
export function brightness(color, amount = 1) {
const rgbObj = hexToRgb(color);
if (!rgbObj) {
return null;
}
Object.keys(rgbObj).forEach((key) => {
rgbObj[key] = Math.round(rgbObj[key] * amount);
});

View file

@ -22,19 +22,12 @@
pattern: "^#+([a-fA-F0-9]{6})$",
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#handleValidationsAndUpdates",
"data-creator-settings-target": "brandColor",
class: "crayons-textfield",
"aria-describedby": "color-contrast-error" %>
<%= f.color_field :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",
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 id="color-contrast-error" aria-live="polite" class="mt-1 color-accent-danger"></div>
</div>
<div class="crayons-field mt-6 align-left">
@ -89,3 +82,4 @@
</div>
</fieldset>
</div>
<%= javascript_packs_with_chunks_tag "admin/creatorOnboarding", defer: true %>

View file

@ -22,7 +22,7 @@
</div>
<br>
<%= render "form", creator_settings_form: @creator_settings_form, f: f %>
<div class="crayons-field mt-6 align-left">
<div id="finish-button" class="crayons-field mt-6 align-left">
<%= f.submit "Finish", class: "crayons-btn btn--primary" %>
</div>
<% if @help_url %>

View file

@ -34,4 +34,4 @@
<%= inline_svg_tag("preview-logo.svg", id: "color-select-preview-logo", class: "p-4 radius-default", title: t("views.logo.preview.icon")) %>
</div>
</div>
<%= javascript_packs_with_chunks_tag "colorPicker", defer: true %>
<%= javascript_packs_with_chunks_tag "enhanceColorPickers", defer: true %>

View file

@ -56,4 +56,4 @@
</div>
<% end %>
</main>
<%= javascript_packs_with_chunks_tag "colorPicker", defer: true %>
<%= javascript_packs_with_chunks_tag "enhanceColorPickers", defer: true %>

View file

@ -1,4 +1,4 @@
<%= javascript_packs_with_chunks_tag "colorPicker", "stickySaveFooter", "userProfileSettings", "validateFileInputs", defer: true %>
<%= javascript_packs_with_chunks_tag "enhanceColorPickers", "stickySaveFooter", "userProfileSettings", "validateFileInputs", defer: true %>
<%= render "users/additional_authentication" %>

View file

@ -33,9 +33,11 @@ describe('Creator Settings Page', () => {
'be.visible',
);
// should contain a brand color field
cy.findByText(/^Brand color/).should('be.visible');
cy.findByText(/^Brand color/).invoke('attr', 'value', '#ff0000');
// should contain a brand color field, enhanced with popover picker
cy.findByRole('button', { name: /^Brand color/ }).should('be.visible');
cy.findByRole('textbox', { name: /^Brand color/ })
.clear()
.type('#BC1A90');
// should contain a 'Who can join this community?' radio selector field and allow selection upon click
cy.findByRole('group', { name: /^Who can join this community/i })
@ -100,7 +102,7 @@ describe('Creator Settings Page', () => {
it('should show an error when the contrast ratio of a brand color is too low', () => {
const lowContrastColor = '#a6e8a6';
cy.findByLabelText(/^Brand color/)
cy.findByRole('textbox', { name: /^Brand color/ })
.clear()
.type(lowContrastColor)
.blur();
@ -113,7 +115,7 @@ describe('Creator Settings Page', () => {
it('should not show an error when the contrast ratio of a brand color is good', () => {
const adequateContrastColor = '#25544b';
cy.findByLabelText(/^Brand color/)
cy.findByRole('textbox', { name: /^Brand color/ })
.clear()
.type(adequateContrastColor)
.blur();
@ -129,7 +131,7 @@ describe('Creator Settings Page', () => {
const lowContrastColor = '#a6e8a6';
const lowContrastRgbColor = 'rgb(166, 232, 166)';
cy.findByLabelText(/^Brand color/)
cy.findByRole('textbox', { name: /^Brand color/ })
.clear()
.type(lowContrastColor)
.blur();
@ -149,7 +151,7 @@ describe('Creator Settings Page', () => {
const color = '#25544b';
const rgbColor = 'rgb(37, 84, 75)';
cy.findByLabelText(/^Brand color/)
cy.findByRole('textbox', { name: /^Brand color/ })
.clear()
.type(color)
.blur();
@ -234,9 +236,7 @@ describe('Admin -> Customization -> Config -> Images', () => {
'not.exist',
);
cy.findAllByRole('img', { name: /DEV\(local\)/i }).should(
'not.exist',
)
cy.findAllByRole('img', { name: /DEV\(local\)/i }).should('not.exist');
// we should see the community name instead of a logo
cy.get('.site-logo__community-name')