diff --git a/.changeset/little-colts-happen.md b/.changeset/little-colts-happen.md new file mode 100644 index 000000000..015a28f38 --- /dev/null +++ b/.changeset/little-colts-happen.md @@ -0,0 +1,8 @@ +--- +'@cloudfour/patterns': minor +--- + +- Add Subscribe component +- Update CI workflow to use Node 16 +- Add a `button_class` template prop to the Button Swap component +- Add a `class` template prop to the Input Group component diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cfeb5243f..87ac0e83c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,10 +12,10 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - - name: Use Node.js 12 + - name: Use Node.js 16 uses: actions/setup-node@v3 with: - node-version: 12 + node-version: 16 - name: Cache node modules uses: actions/cache@v2 with: @@ -35,10 +35,10 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - - name: Use Node.js 12 + - name: Use Node.js 16 uses: actions/setup-node@v3 with: - node-version: 12 + node-version: 16 - name: Cache node modules uses: actions/cache@v2 with: @@ -58,10 +58,10 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - - name: Use Node.js 12 + - name: Use Node.js 16 uses: actions/setup-node@v3 with: - node-version: 12 + node-version: 16 - name: Cache node modules uses: actions/cache@v2 with: @@ -80,10 +80,10 @@ jobs: steps: - name: Checkout uses: actions/checkout@v3 - - name: Use Node.js 12 + - name: Use Node.js 16 uses: actions/setup-node@v3 with: - node-version: 12 + node-version: 16 - name: Cache node modules uses: actions/cache@v2 with: diff --git a/src/components/button-swap/button-swap.stories.mdx b/src/components/button-swap/button-swap.stories.mdx index fdca9eb46..0cd11f983 100644 --- a/src/components/button-swap/button-swap.stories.mdx +++ b/src/components/button-swap/button-swap.stories.mdx @@ -73,7 +73,8 @@ You have the ability to pass `initialCallback` and `swappedCallback` callback fu ## Template Properties -- `class` (string): Appends a CSS class(es) to the root element. +- `class` (string): Appends to the CSS class of the root element. +- `button_class` (string): Appends to the CSS class of the button element. - `content_start_icon` (string, default `'bell'`): The button [icon](/docs/design-icons--page) (the same icon is applied to both buttons). - `initial_label` (string, default `'Unsubscribed from notifications'`): The first button visually hidden text. - `initial_visual_label` (string, default `'Get notifications'`): The first button label. diff --git a/src/components/button-swap/button-swap.twig b/src/components/button-swap/button-swap.twig index 5518bc747..a236594db 100644 --- a/src/components/button-swap/button-swap.twig +++ b/src/components/button-swap/button-swap.twig @@ -9,7 +9,7 @@ {% include '@cloudfour/components/button/button.twig' with { - class: 'js-button-swap__button', + class: ['js-button-swap__button', button_class]|join(' '), content_start_icon: content_start_icon|default('bell'), label: initial_visual_label|default('Get notifications') } only %} @@ -20,7 +20,12 @@ {% include '@cloudfour/components/button/button.twig' with { - class: 'c-button--secondary is-slashed js-button-swap__button', + class: [ + 'c-button--secondary', + 'is-slashed', + 'js-button-swap__button', + button_class + ]|join(' '), content_start_icon: content_start_icon|default('bell'), label: swapped_visual_label|default('Turn off notifications') } only %} diff --git a/src/components/subscribe/demo/demo.twig b/src/components/subscribe/demo/demo.twig new file mode 100644 index 000000000..f953cf61d --- /dev/null +++ b/src/components/subscribe/demo/demo.twig @@ -0,0 +1,5 @@ +{% embed '@cloudfour/components/subscribe/subscribe.twig' with { + form_id: 'test' +} only %}{% endembed %} + +

This is only for demo purposes. I am a link

diff --git a/src/components/subscribe/subscribe.scss b/src/components/subscribe/subscribe.scss new file mode 100644 index 000000000..339071183 --- /dev/null +++ b/src/components/subscribe/subscribe.scss @@ -0,0 +1,55 @@ +@use '../../compiled/tokens/scss/breakpoint'; +@use '../../compiled/tokens/scss/color'; +@use '../../compiled/tokens/scss/size'; +@use '../../mixins/a11y'; +@use '../../mixins/theme'; + +/// +/// Subscribe component +/// + +.c-subscribe { + position: relative; +} + +.c-subscribe__controls { + display: grid; + gap: 1em; + margin: size.$rhythm-default 0; + + @media (min-width: breakpoint.$s) { + grid-template-columns: 1fr 1fr; + } +} + +.c-subscribe__control { + inline-size: 100%; +} + +/// +/// 1. Ensures the form background color matches the current theme +/// + +.c-subscribe__form { + background-color: var(--theme-color-background-base); // 1 + block-size: 100%; + inline-size: 100%; + inset-block-start: 0; + inset-inline-start: 0; + position: absolute; + + /// + /// 1. Allows for the visually hidden form to be keyboard accessible, will be + /// visually hidden when elements within the form are not in focus. + /// + + .c-subscribe__form-input-label, + .c-subscribe:not(.activate-form) // 1 + &:not(:target):not(:focus-within) { + @include a11y.sr-only; + } +} + +.c-subscribe__form-input-group { + margin: size.$rhythm-default 0; +} diff --git a/src/components/subscribe/subscribe.stories.mdx b/src/components/subscribe/subscribe.stories.mdx new file mode 100644 index 000000000..7ac3aeed4 --- /dev/null +++ b/src/components/subscribe/subscribe.stories.mdx @@ -0,0 +1,254 @@ +import { Story, Canvas, Meta, ArgsTable } from '@storybook/addon-docs'; +import { useEffect } from '@storybook/client-api'; +import { makeTwigInclude } from '../../make-twig-include'; +import template from './subscribe.twig'; +import { initSubscribe } from './subscribe.ts'; +// Helper function to initialize toggling button JS +const templateStory = (args) => { + useEffect(() => { + const templateEls = [...document.querySelectorAll('.js-subscribe')].map( + (templateEl) => initSubscribe(templateEl) + ); + return () => { + for (const templateEl of templateEls) templateEl.destroy(); + }; + }); + return template(args); +}; + +` value', + table: { + defaultValue: { summary: 'Email' }, + }, + }, + email_input_placeholder: { + type: 'string', + description: 'The email input placeholder text', + table: { + defaultValue: { summary: 'Your Email Address' }, + }, + }, + submit_btn_label: { + type: 'string', + description: 'The subscription form submit button label', + table: { + defaultValue: { summary: 'Subscribe' }, + }, + }, + }} +/> + +# Subscribe + +The Subscribe component provides the UI to subscribe to notifications and/or email weekly digests. + +This component embeds the [Button Swap component](/?path=/docs/components-button-swap--default-story) for the "Get Notifications" button. + + + + {(args) => templateStory(args)} + + + +## Template Properties + + + +## JavaScript + +The Subscribe component UX can be progressively enhanced using JavaScript. Enhancements include: + +- A one second form hide delay is added when `:focus` is moved away from the Subscribe component elements +- The form can be hidden by pressing the Escape key + +### Syntax + +```js +initSubscribe(subscribeEl); +``` + +### Parameters + +#### `subscribeEl` + +The Subscribe component `.js-subscribe` root element. + +### Return value + +An object with a `destroy` function that removes all event listeners added by this component. + +```js +{ + destroy: () => void +} +``` + +### Examples + +#### Single Subscribe component on the page + +```js +// Initialize +const subscribeEl = initSubscribe(document.querySelector('.js-subscribe')); + +// Remove all event listeners +subscribeEl.destroy(); +``` + +#### Multiple Subscribe components on the page + +```js +// Initialize +const subscribeEls = [...document.querySelectorAll('.js-subscribe')].map( + (subscribeEl) => initSubscribe(subscribeEl) +); + +// Remove all event listeners +for (const subscribeEl of subscribeEls) { + subscribeEl.destroy(); +} +``` + +### Note + +You will need to initialize the "Get Notifications" button as well, see the +[Button Swap component](/?path=/docs/components-button-swap--default-story#javascript) +for initialization docs. diff --git a/src/components/subscribe/subscribe.test.ts b/src/components/subscribe/subscribe.test.ts new file mode 100644 index 000000000..744af568b --- /dev/null +++ b/src/components/subscribe/subscribe.test.ts @@ -0,0 +1,264 @@ +import path from 'path'; +import type { ElementHandle, PleasantestUtils } from 'pleasantest'; +import { withBrowser, getAccessibilityTree } from 'pleasantest'; +import { loadTwigTemplate, loadGlobalCSS } from '../../../test-utils'; + +// Helper to load the Twig template file +const componentMarkup = loadTwigTemplate( + path.join(__dirname, './subscribe.twig') +); +// Helper to load the demo Twig template file +const demoMarkup = loadTwigTemplate(path.join(__dirname, './demo/demo.twig')); + +/** + * Helper function that checks the `clientHeight` and `clientWidth` of + * a given element, expects dimensions to be smaller than or equal to `1` + * meaning the element is visually hidden. + */ +const expectElementToBeVisuallyHidden = async ( + element: ElementHandle +) => { + const { elHeight, elWidth } = await element.evaluate((el: HTMLElement) => ({ + elHeight: el.clientHeight, + elWidth: el.clientWidth, + })); + expect(elHeight).toBeLessThanOrEqual(1); + expect(elWidth).toBeLessThanOrEqual(1); +}; + +/** + * Helper function that checks the `clientHeight` and `clientWidth` of + * given element, expects dimensions to be greater than or equal to `1` + * meaning the element is not visually hidden. + */ +const expectElementNotToBeVisuallyHidden = async ( + form: ElementHandle +) => { + const { elHeight, elWidth } = await form.evaluate((el: HTMLElement) => ({ + elHeight: el.clientHeight, + elWidth: el.clientWidth, + })); + + expect(elHeight).toBeGreaterThan(1); + expect(elWidth).toBeGreaterThan(1); +}; + +// Helper to initialize the component JS +const initJS = (utils: PleasantestUtils) => + utils.runJS(` + import { initSubscribe } from './subscribe' + export default () => initSubscribe( + document.querySelector('.js-subscribe') + ) + `); + +describe('Subscription', () => { + test( + 'should use semantic markup', + withBrowser(async ({ utils, page }) => { + await loadGlobalCSS(utils); + await utils.loadCSS('./subscribe.scss'); + await utils.injectHTML(await componentMarkup()); + await initJS(utils); + + expect(await getAccessibilityTree(page)).toMatchInlineSnapshot(` + document + heading "Never miss an article!" + text "Never miss an article!" + status + text "Notifications have been turned off." + button "Get notifications" + link "Get Weekly Digests" + text "Get Weekly Digests" + form "Get Weekly Digests" + heading "Get Weekly Digests" + text "Get Weekly Digests" + text "Email" + textbox + button "Subscribe" + `); + }) + ); + + test( + 'should be keyboard accessible', + withBrowser(async ({ utils, screen, waitFor, page }) => { + await loadGlobalCSS(utils); + await utils.loadCSS('./subscribe.scss'); + await utils.injectHTML(await demoMarkup()); + await initJS(utils); + + // Confirm the form is visually hidden by default + const form = await screen.getByRole('form', { + name: 'Get Weekly Digests', + }); + await expectElementToBeVisuallyHidden(form); + + // Tab all the way to the form email input + await page.keyboard.press('Tab'); // Notifications button + await page.keyboard.press('Tab'); // Weekly Digests link + await page.keyboard.press('Tab'); // Email input + + // Confirm the form is now "active" (not visually hidden) + await expectElementNotToBeVisuallyHidden(form); + + // Email input should be in focus + const emailInput = await screen.getByRole('textbox', { name: 'Email' }); + await expect(emailInput).toHaveFocus(); + + // Tab again to get to the Submit button + await page.keyboard.press('Tab'); + + // Submit button should be in focus + const subscribeBtn = await screen.getByRole('button', { + name: 'Subscribe', + }); + await expect(subscribeBtn).toHaveFocus(); + + // Confirm the form is still "active" (not visually hidden) + await expectElementNotToBeVisuallyHidden(form); + + // Navigate back up to the Weekly Digests link + await page.keyboard.down('Shift'); // Navigate backwards + await page.keyboard.press('Tab'); // Email input + await page.keyboard.press('Tab'); // Weekly Digests link + await page.keyboard.up('Shift'); // Release Shift key + + // Confirm the focus has moved to the Weekly Digests link + const weeklyDigestsBtn = await screen.getByRole('link', { + name: 'Get Weekly Digests', + }); + await expect(weeklyDigestsBtn).toHaveFocus(); + + // The form should now be visually hidden again + await expectElementToBeVisuallyHidden(form); + + // Navigate forward past the Submit to activate the form hide timeout + await page.keyboard.press('Tab'); // Email input + await page.keyboard.press('Tab'); // Submit button + await page.keyboard.press('Tab'); // Out of the form + + // Confirm the form is still "active" (not visually hidden) + await expectElementNotToBeVisuallyHidden(form); + + // Navigate back quickly to confirm timeout getting cancelled + await page.keyboard.down('Shift'); // Navigate backwards + await page.keyboard.press('Tab'); // Submit button + await page.keyboard.up('Shift'); // Release Shift key + + // Confirm the form is still "active" (not visually hidden) + await expectElementNotToBeVisuallyHidden(form); + + await page.keyboard.press('Tab'); // Out of the form + + // Confirm the form is still "active" (not visually hidden) + await expectElementNotToBeVisuallyHidden(form); + + // After a timeout, the form eventually visually hides + await waitFor( + async () => { + await expectElementToBeVisuallyHidden(form); + }, + { + timeout: 2000, + interval: 1000, + } + ); + + // Navigate back into the form + await page.keyboard.down('Shift'); // Navigate backwards + await page.keyboard.press('Tab'); // Submit button + await page.keyboard.up('Shift'); // Release Shift key + + // Confirm the form is "active" again (not visually hidden) + await expectElementNotToBeVisuallyHidden(form); + + // Should hide the form + await page.keyboard.press('Escape'); + + // Confirm the form should is visually hidden + await expectElementToBeVisuallyHidden(form); + + // The focus should reset back to the "weekly digests" link + await expect(weeklyDigestsBtn).toHaveFocus(); + }) + ); + + test( + 'should be customizable', + withBrowser(async ({ utils, screen }) => { + // Set up CSS + await loadGlobalCSS(utils); + await utils.loadCSS('./subscribe.scss'); + + // No customization + await utils.injectHTML(await componentMarkup({ form_id: 'test' })); + + // Confirm default heading tag + await screen.getByRole('heading', { + name: 'Never miss an article!', + level: 2, + }); + await screen.getByRole('heading', { + name: 'Get Weekly Digests', + level: 2, + }); + + // Confirm default form values + let emailInput = await screen.getByRole('textbox', { name: 'Email' }); + expect(emailInput).toHaveAttribute('placeholder', 'Your Email Address'); + + // Customize the component + await utils.injectHTML( + await componentMarkup({ + form_id: 'test', + form_action: 'test-action.com', + heading_tag: 'h3', + weekly_digests_heading: 'Weekly digests available', + never_miss_article_heading: "Don't miss out!", + notifications_btn_class: 'hello', + notifications_btn_initial_visual_label: 'Yes to notifications', + weekly_digests_btn_class: 'world', + weekly_digests_btn_label: 'I want weekly digests', + email_input_placeholder: 'Gimme email', + submit_btn_label: 'Sign up', + }) + ); + + // Confirm custom headings + await screen.getByRole('heading', { + name: 'Weekly digests available', + level: 3, + }); + await screen.getByRole('heading', { + name: "Don't miss out!", + level: 3, + }); + + // Confirm custom form values + const form = await screen.getByRole('form', { + name: 'Weekly digests available', + }); + expect(form).toHaveAttribute('action', 'test-action.com'); + emailInput = await screen.getByRole('textbox', { name: 'Email' }); + expect(emailInput).toHaveAttribute('placeholder', 'Gimme email'); + + // Confirm custom notifications button + const notificationsBtn = await screen.getByRole('button', { + name: 'Yes to notifications', + }); + await expect(notificationsBtn).toHaveClass('hello'); + + // Confirm custom weekly digests link + const weeklyDigestsLink = await screen.getByRole('link', { + name: 'I want weekly digests', + }); + await expect(weeklyDigestsLink).toHaveClass('world'); + + // Confirm custom weekly digests submit button + await screen.getByRole('button', { + name: 'Sign up', + }); + }) + ); +}); diff --git a/src/components/subscribe/subscribe.ts b/src/components/subscribe/subscribe.ts new file mode 100644 index 000000000..788b9b2ca --- /dev/null +++ b/src/components/subscribe/subscribe.ts @@ -0,0 +1,105 @@ +/** + * Subscribe + * + * Progressively enhances the UX for the Subscribe component. + * + * Enhancements include: + * - A one second form hide delay is added when `:focus` is moved away from the + * Subscribe component elements + * - The form can be hidden by pressing the `Escape` key + */ +export const initSubscribe = (containerEl: HTMLElement) => { + const SHOW_FORM_CLASS = 'activate-form'; + const BLUR_TIMEOUT = 1000; // Milliseconds + + // Keeps track of active setTimeouts + let blurTimeoutId: number; + // Keeps the current state of the form + let isFormOpen = false; + + // Query all the required elements + const getWeeklyDigestsBtn = containerEl.querySelector( + '.js-subscribe__get-weekly-digests-btn' + ); + const formEl = containerEl.querySelector('form'); + const formFocusableEls = containerEl.querySelectorAll('label, input, button'); + const controlEls = containerEl.querySelectorAll('.js-subscribe__control'); + + // Confirm we have what we need to proceed + if (!getWeeklyDigestsBtn || !formEl) { + return; + } + + // Hide the form anytime a `js-subscribe__control` gets focus + const onControlFocus = () => { + clearTimeout(blurTimeoutId); + containerEl.classList.remove(SHOW_FORM_CLASS); + isFormOpen = false; + }; + + // Show the form anytime any form element gets focus + // The form is always accessible by keyboard, it's only visually hidden + const onFormFocus = () => { + clearTimeout(blurTimeoutId); + containerEl.classList.add(SHOW_FORM_CLASS); + isFormOpen = true; + }; + + // Hide the form after a delay anytime focus is removed from a form element + const onFormBlur = () => { + clearTimeout(blurTimeoutId); + blurTimeoutId = window.setTimeout(() => { + containerEl.classList.remove(SHOW_FORM_CLASS); + isFormOpen = false; + }, BLUR_TIMEOUT); + }; + + // Handler for the button click + const onGetWeeklyDigestsClick = (event: Event) => { + event.preventDefault(); + containerEl.classList.add(SHOW_FORM_CLASS); + isFormOpen = true; + // Jump the focus to the first input element + formEl.querySelector('input')?.focus(); + }; + + // Handler for the Escape keydown event + const onKeydown = (event: KeyboardEvent) => { + // We need to hide the form and reset the focus, we can get both by setting + // the focus back to the "Get Weekly Digests" link. + if (event.key === 'Escape' && isFormOpen) { + (getWeeklyDigestsBtn as HTMLElement).focus(); + } + }; + + // Clean up event listeners + const destroy = () => { + getWeeklyDigestsBtn.removeEventListener('click', onGetWeeklyDigestsClick); + for (const formFocusableEl of formFocusableEls) { + formFocusableEl.removeEventListener('blur', onFormBlur); + formFocusableEl.removeEventListener('focus', onFormFocus); + } + for (const controlEl of controlEls) { + controlEl.removeEventListener('focus', onControlFocus); + } + document.removeEventListener('keydown', onKeydown); + }; + + // Set up all event listeners + const init = () => { + getWeeklyDigestsBtn.addEventListener('click', onGetWeeklyDigestsClick); + for (const formFocusableEl of formFocusableEls) { + formFocusableEl.addEventListener('blur', onFormBlur); + formFocusableEl.addEventListener('focus', onFormFocus); + } + for (const controlEl of controlEls) { + controlEl.addEventListener('focus', onControlFocus); + } + document.addEventListener('keydown', onKeydown); + }; + + init(); + + // Return a public API for consumers of this component + return { destroy }; +}; diff --git a/src/components/subscribe/subscribe.twig b/src/components/subscribe/subscribe.twig new file mode 100644 index 000000000..559ece996 --- /dev/null +++ b/src/components/subscribe/subscribe.twig @@ -0,0 +1,74 @@ +{% set _heading_tag = heading_tag|default('h2') %} + + diff --git a/src/objects/input-group/input-group.stories.mdx b/src/objects/input-group/input-group.stories.mdx index 9643d3503..3dc578247 100644 --- a/src/objects/input-group/input-group.stories.mdx +++ b/src/objects/input-group/input-group.stories.mdx @@ -1,4 +1,4 @@ -import { Story, Canvas, Meta } from '@storybook/addon-docs'; +import { Story, Canvas, Meta, ArgsTable } from '@storybook/addon-docs'; // The '!!raw-loader!' syntax is a non-standard, Webpack-specific, syntax. // See: https://github.com/webpack-contrib/raw-loader#examples // For now, it seems likely Storybook is pretty tied to Webpack, therefore, we are @@ -9,9 +9,21 @@ import { Story, Canvas, Meta } from '@storybook/addon-docs'; // eslint-disable-next-line @cloudfour/import/no-webpack-loader-syntax import inputGroupDemoSource from '!!raw-loader!./demo/input-group-demo.twig'; import inputGroupDemo from './demo/input-group-demo.twig'; -import './input-group.scss'; - + # Input Group @@ -30,6 +42,10 @@ Buttons and inputs can be put in whatever order you choose. }, }} > - {inputGroupDemo} + {(args) => inputGroupDemo(args)} + +## Template Properties + + diff --git a/src/objects/input-group/input-group.twig b/src/objects/input-group/input-group.twig index 802652fa8..01a8da79b 100644 --- a/src/objects/input-group/input-group.twig +++ b/src/objects/input-group/input-group.twig @@ -1,3 +1,3 @@ -
+
{% block content %} {% endblock %}