From a6b4d8e91dbf8fafe78a42e0009077dc2fca3a79 Mon Sep 17 00:00:00 2001 From: Suzanne Aitchison Date: Mon, 16 Aug 2021 08:41:26 +0100 Subject: [PATCH] Create crayons mobile drawer components (#14495) * add MobileDrawer component * WIP begin navigation component * rough working version complete * update story to allow for actual navigation * add component tests * add docs * add HTML variants * add a max width to the drawer --- .../components/drawer-navigation.scss | 26 ++++ .../stylesheets/components/drawers.scss | 41 ++++++ app/assets/stylesheets/crayons.scss | 2 + .../crayons/MobileDrawer/MobileDrawer.jsx | 58 ++++++++ .../__stories__/MobileDrawer.html.stories.jsx | 62 ++++++++ .../__stories__/MobileDrawer.stories.jsx | 32 ++++ .../MobileDrawer/__stories__/drawers.md | 25 ++++ .../__tests__/MobileDrawer.test.jsx | 82 +++++++++++ .../__snapshots__/MobileDrawer.test.jsx.snap | 3 + app/javascript/crayons/MobileDrawer/index.js | 1 + app/javascript/crayons/index.js | 2 + .../MobileDrawerNavigation.jsx | 115 +++++++++++++++ .../MobileDrawerNavigation.stories.jsx | 54 +++++++ .../mobileDrawerNavigation.html.stories.jsx | 139 ++++++++++++++++++ .../__stories__/mobileDrawerNavigation.md | 29 ++++ .../__tests__/MobileDrawerNavigation.spec.js | 85 +++++++++++ .../MobileDrawerNavigation.spec.js.snap | 5 + app/javascript/crayons/navigation/index.js | 1 + 18 files changed, 762 insertions(+) create mode 100644 app/assets/stylesheets/components/drawer-navigation.scss create mode 100644 app/assets/stylesheets/components/drawers.scss create mode 100644 app/javascript/crayons/MobileDrawer/MobileDrawer.jsx create mode 100644 app/javascript/crayons/MobileDrawer/__stories__/MobileDrawer.html.stories.jsx create mode 100644 app/javascript/crayons/MobileDrawer/__stories__/MobileDrawer.stories.jsx create mode 100644 app/javascript/crayons/MobileDrawer/__stories__/drawers.md create mode 100644 app/javascript/crayons/MobileDrawer/__tests__/MobileDrawer.test.jsx create mode 100644 app/javascript/crayons/MobileDrawer/__tests__/__snapshots__/MobileDrawer.test.jsx.snap create mode 100644 app/javascript/crayons/MobileDrawer/index.js create mode 100644 app/javascript/crayons/navigation/MobileDrawerNavigation/MobileDrawerNavigation.jsx create mode 100644 app/javascript/crayons/navigation/MobileDrawerNavigation/__stories__/MobileDrawerNavigation.stories.jsx create mode 100644 app/javascript/crayons/navigation/MobileDrawerNavigation/__stories__/mobileDrawerNavigation.html.stories.jsx create mode 100644 app/javascript/crayons/navigation/MobileDrawerNavigation/__stories__/mobileDrawerNavigation.md create mode 100644 app/javascript/crayons/navigation/MobileDrawerNavigation/__tests__/MobileDrawerNavigation.spec.js create mode 100644 app/javascript/crayons/navigation/MobileDrawerNavigation/__tests__/__snapshots__/MobileDrawerNavigation.spec.js.snap create mode 100644 app/javascript/crayons/navigation/index.js diff --git a/app/assets/stylesheets/components/drawer-navigation.scss b/app/assets/stylesheets/components/drawer-navigation.scss new file mode 100644 index 000000000..879492318 --- /dev/null +++ b/app/assets/stylesheets/components/drawer-navigation.scss @@ -0,0 +1,26 @@ +@import '../config/import'; + +.drawer-navigation { + &__item { + display: flex; + justify-content: space-between; + + a { + color: var(--body-color); + } + + a[aria-current='page'] { + color: var(--link-brand-color); + } + + .check-icon { + vertical-align: middle; + display: none; + color: var(--link-brand-color); + } + + a[aria-current='page'] + .check-icon { + display: block; + } + } +} diff --git a/app/assets/stylesheets/components/drawers.scss b/app/assets/stylesheets/components/drawers.scss new file mode 100644 index 000000000..eef5bbb2a --- /dev/null +++ b/app/assets/stylesheets/components/drawers.scss @@ -0,0 +1,41 @@ +@import '../config/import'; + +.crayons-mobile-drawer { + position: fixed; + left: 0; + right: 0; + top: 0; + bottom: 0; + z-index: var(--z-modal); + + &__overlay { + background: var(--base-100); + opacity: 0.5; + position: fixed; + left: 0; + right: 0; + top: 0; + bottom: 0; + } + + &__content { + width: 100%; + max-width: 500px; + background: var(--card-bg); + opacity: 1; + position: absolute; + bottom: 0; + left: 50%; + right: 0; + padding: var(--su-4); + border-radius: var(--radius) var(--radius) 0 0; + transform: translateY(100%) translateX(-50%); + animation: slideIn 0.3s ease-in-out forwards; + + @keyframes slideIn { + 100% { + transform: translateY(0) translateX(-50%); + } + } + } +} diff --git a/app/assets/stylesheets/crayons.scss b/app/assets/stylesheets/crayons.scss index eb964b7d9..c9ab28f4d 100644 --- a/app/assets/stylesheets/crayons.scss +++ b/app/assets/stylesheets/crayons.scss @@ -33,5 +33,7 @@ @import 'components/tabs'; @import 'components/tags'; @import 'components/tooltips'; +@import 'components/drawers'; +@import 'components/drawer-navigation'; @import 'config/generator'; diff --git a/app/javascript/crayons/MobileDrawer/MobileDrawer.jsx b/app/javascript/crayons/MobileDrawer/MobileDrawer.jsx new file mode 100644 index 000000000..8194b98b9 --- /dev/null +++ b/app/javascript/crayons/MobileDrawer/MobileDrawer.jsx @@ -0,0 +1,58 @@ +import { h } from 'preact'; +import PropTypes from 'prop-types'; +import { defaultChildrenPropTypes } from '../../common-prop-types'; +import { FocusTrap } from '../../shared/components/focusTrap'; + +/** + * A component that creates a full-width modal that slides in from the bottom of viewport. + * + * + * @param {object} props + * @param {Array} props.children + * @param {string} props.title The title to be applied to the dialog, surfaced to screen reader users + * @param {Function} props.onClose Action to complete when user opts to close the drawer + * + * @example + * const [isDrawerOpen, setIsDrawerOpen] = useState(false); + * return ( + *
+ * + * {isDrawerOpen && ( + * setIsDrawerOpen(false)} + * > + *

Lorem ipsum

+ * + *
+ * )} + *
+ * ); + */ +export const MobileDrawer = ({ children, title, onClose = () => {} }) => { + return ( +
+
+ +
+ {children} +
+
+
+ ); +}; + +MobileDrawer.propTypes = { + children: defaultChildrenPropTypes.isRequired, + title: PropTypes.string.isRequired, + onClose: PropTypes.func, +}; diff --git a/app/javascript/crayons/MobileDrawer/__stories__/MobileDrawer.html.stories.jsx b/app/javascript/crayons/MobileDrawer/__stories__/MobileDrawer.html.stories.jsx new file mode 100644 index 000000000..64673ff4f --- /dev/null +++ b/app/javascript/crayons/MobileDrawer/__stories__/MobileDrawer.html.stories.jsx @@ -0,0 +1,62 @@ +/* eslint-disable jsx-a11y/no-static-element-interactions */ +/* eslint-disable jsx-a11y/click-events-have-key-events */ +// Disabled for the file due to issues disabling for individual JSX lines. +// These are disabled to allow the "click outside to close" functionality +import { h } from 'preact'; +import { useState, useEffect } from 'preact/hooks'; +import notes from './drawers.md'; + +import '../../storybook-utilities/designSystem.scss'; + +export default { + title: 'Components/MobileDrawer/HTML', + parameters: { notes }, +}; + +export const Default = () => { + const [isDrawerOpen, setIsDrawerOpen] = useState(false); + + useEffect(() => { + const keyupListener = (e) => { + if (e.key === 'Escape') { + setIsDrawerOpen(false); + } + }; + document.addEventListener('keyup', keyupListener); + return () => document.removeEventListener('keyup', keyupListener); + }, []); + + return ( +
+ + {isDrawerOpen && ( +
+
setIsDrawerOpen(false)} + /> + +
+ )} +
+ ); +}; + +Default.story = { + name: 'default', +}; diff --git a/app/javascript/crayons/MobileDrawer/__stories__/MobileDrawer.stories.jsx b/app/javascript/crayons/MobileDrawer/__stories__/MobileDrawer.stories.jsx new file mode 100644 index 000000000..76f1f45a5 --- /dev/null +++ b/app/javascript/crayons/MobileDrawer/__stories__/MobileDrawer.stories.jsx @@ -0,0 +1,32 @@ +import { h } from 'preact'; +import { useState } from 'preact/hooks'; +import notes from './drawers.md'; +import { MobileDrawer, Button } from '@crayons'; + +export default { + title: 'Components/MobileDrawer', + parameters: { notes }, +}; + +export const Default = () => { + const [isDrawerOpen, setIsDrawerOpen] = useState(false); + + return ( +
+ + {isDrawerOpen && ( + setIsDrawerOpen(false)} + > +

Lorem ipsum

+ +
+ )} +
+ ); +}; + +Default.story = { + name: 'MobileDrawer', +}; diff --git a/app/javascript/crayons/MobileDrawer/__stories__/drawers.md b/app/javascript/crayons/MobileDrawer/__stories__/drawers.md new file mode 100644 index 000000000..6387c5feb --- /dev/null +++ b/app/javascript/crayons/MobileDrawer/__stories__/drawers.md @@ -0,0 +1,25 @@ +## MobileDrawers + +MobileDrawers are intended to be used in small screen sizes only (i.e. for +mobile UI variants), and always appear from the bottom of the viewport. The +button which triggers the MobileDrawer may be located anywhere on the page. + +MobileDrawer content should always include at least one interactive item (e.g. +button, link) to make sure focus may be transferred to the new content. + +### MobileDrawer accessibility + +The MobileDrawer is essentially a +[modal dialog](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/dialog_role). +A `title` should be passed in props to properly label the dialog, and at least +one interactive item should be present in the inner content. + +The MobileDrawer component utilises the `focus-trap` library to ensure that: + +- When the drawer is opened, focus is transferred to the first interactive item + in that drawer +- When the drawer is closed, focus is transferred back to the button that + activated the drawer +- While the drawer is open, focus is trapped inside so that when a user presses + the Tab key, interactive items behind the drawer are not focused +- When the drawer is open, pressing the Escape key will close it diff --git a/app/javascript/crayons/MobileDrawer/__tests__/MobileDrawer.test.jsx b/app/javascript/crayons/MobileDrawer/__tests__/MobileDrawer.test.jsx new file mode 100644 index 000000000..a54e70886 --- /dev/null +++ b/app/javascript/crayons/MobileDrawer/__tests__/MobileDrawer.test.jsx @@ -0,0 +1,82 @@ +import { h } from 'preact'; +import { axe } from 'jest-axe'; +import '@testing-library/jest-dom'; +import { render, waitFor } from '@testing-library/preact'; +import userEvent from '@testing-library/user-event'; +import { MobileDrawer } from '../MobileDrawer'; + +describe('', () => { + it('should have no a11y violations', async () => { + const { container } = render( + + + , + ); + const results = await axe(container); + + expect(results).toHaveNoViolations(); + }); + + it('should render correctly', () => { + const { container } = render( + + + , + ); + + expect(container.innerHTML).toMatchSnapshot(); + }); + + it('should trap focus inside the drawer by default', async () => { + const { getByRole } = render( +
+ + + + + +
, + ); + + const innerDrawerButton = getByRole('button', { + name: 'Inside drawer button', + }); + await waitFor(() => expect(innerDrawerButton).toHaveFocus()); + + userEvent.tab(); + expect( + getByRole('button', { name: 'Inside drawer button 2' }), + ).toHaveFocus(); + + userEvent.tab(); + expect(innerDrawerButton).toHaveFocus(); + }); + + it('should close when Escape is pressed', () => { + const onClose = jest.fn(); + const { container } = render( + + + , + ); + + userEvent.type(container, '{esc}'); + expect(onClose).toHaveBeenCalled(); + }); + + it('should close on click outside', () => { + const onClose = jest.fn(); + const { getByText } = render( +
+

Outside content

+ + + + , +
, + ); + + userEvent.click(getByText('Outside content')); + expect(onClose).toHaveBeenCalled(); + }); +}); diff --git a/app/javascript/crayons/MobileDrawer/__tests__/__snapshots__/MobileDrawer.test.jsx.snap b/app/javascript/crayons/MobileDrawer/__tests__/__snapshots__/MobileDrawer.test.jsx.snap new file mode 100644 index 000000000..0e53ed451 --- /dev/null +++ b/app/javascript/crayons/MobileDrawer/__tests__/__snapshots__/MobileDrawer.test.jsx.snap @@ -0,0 +1,3 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` should render correctly 1`] = `"
"`; diff --git a/app/javascript/crayons/MobileDrawer/index.js b/app/javascript/crayons/MobileDrawer/index.js new file mode 100644 index 000000000..b0c0a9768 --- /dev/null +++ b/app/javascript/crayons/MobileDrawer/index.js @@ -0,0 +1 @@ +export * from './MobileDrawer'; diff --git a/app/javascript/crayons/index.js b/app/javascript/crayons/index.js index 1f8885db5..fadc655a7 100644 --- a/app/javascript/crayons/index.js +++ b/app/javascript/crayons/index.js @@ -4,3 +4,5 @@ export * from '@crayons/Dropdown'; export * from '@crayons/formElements'; export * from '@crayons/Modal'; export * from '@crayons/Spinner'; +export * from '@crayons/MobileDrawer'; +export * from '@crayons/navigation'; diff --git a/app/javascript/crayons/navigation/MobileDrawerNavigation/MobileDrawerNavigation.jsx b/app/javascript/crayons/navigation/MobileDrawerNavigation/MobileDrawerNavigation.jsx new file mode 100644 index 000000000..b8d678177 --- /dev/null +++ b/app/javascript/crayons/navigation/MobileDrawerNavigation/MobileDrawerNavigation.jsx @@ -0,0 +1,115 @@ +import { h, Fragment } from 'preact'; +import { useState } from 'preact/hooks'; +import PropTypes from 'prop-types'; +import { MobileDrawer } from '@crayons/MobileDrawer'; +import { Button } from '@crayons/Button'; + +const OverflowIcon = () => ( + + + +); + +const CheckIcon = () => ( + +); + +/** + * Renders a page heading with a button which activates a with a list of the given navigation links. + * + * @param {object} props + * @param {number} headingLevel The level of heading to render as the page title (e.g. 1-6) + * @param {string} navigationTitle The title to be used for the navigation element (e.g. 'Feed timeframes') + * @param {Array} navigationLinks An array of navigationLink objects to display + * + * @example + * + */ +export const MobileDrawerNavigation = ({ + headingLevel, + navigationTitle, + navigationLinks, +}) => { + const [isDrawerOpen, setIsDrawerOpen] = useState(false); + const currentPage = navigationLinks.find((item) => item.isCurrentPage); + + const Heading = `h${headingLevel}`; + + return ( + +
+ {currentPage.displayName} +
+ + {isDrawerOpen && ( + setIsDrawerOpen(false)} + > + + + + )} +
+ ); +}; + +MobileDrawerNavigation.propTypes = { + headingLevel: PropTypes.oneOf([1, 2, 3, 4, 5, 6]).isRequired, + navigationTitle: PropTypes.string.isRequired, + navigationLinks: PropTypes.arrayOf( + PropTypes.shape({ + url: PropTypes.string, + isCurrentPage: PropTypes.bool, + displayName: PropTypes.string, + }), + ).isRequired, +}; diff --git a/app/javascript/crayons/navigation/MobileDrawerNavigation/__stories__/MobileDrawerNavigation.stories.jsx b/app/javascript/crayons/navigation/MobileDrawerNavigation/__stories__/MobileDrawerNavigation.stories.jsx new file mode 100644 index 000000000..64f62baa4 --- /dev/null +++ b/app/javascript/crayons/navigation/MobileDrawerNavigation/__stories__/MobileDrawerNavigation.stories.jsx @@ -0,0 +1,54 @@ +import { h, Fragment } from 'preact'; +import notes from './mobileDrawerNavigation.md'; +import { MobileDrawerNavigation } from '@crayons'; + +export default { + title: 'App Components/MobileDrawerNavigation', + parameters: { notes }, +}; + +export const Default = () => { + const { href, hash } = window.location; + const indexOfHash = href.indexOf(hash) || href.length; + const baseStoryUrl = href.substr(0, indexOfHash); + + const links = [ + { + url: baseStoryUrl, + displayName: 'Drawer Navigation', + isCurrentPage: href === baseStoryUrl, + }, + { + url: `${baseStoryUrl}/#2`, + displayName: 'Example link 2', + isCurrentPage: `#2` === hash, + }, + { + url: `${baseStoryUrl}/#3`, + displayName: 'Example link 3', + isCurrentPage: `#3` === hash, + }, + { + url: `${baseStoryUrl}/#4`, + displayName: 'Example link 4', + isCurrentPage: `#4` === hash, + }, + ]; + + return ( + + +

+ Click on the button to view and select navigation links. +

+
+ ); +}; + +Default.story = { + name: 'MobileDrawerNavigation', +}; diff --git a/app/javascript/crayons/navigation/MobileDrawerNavigation/__stories__/mobileDrawerNavigation.html.stories.jsx b/app/javascript/crayons/navigation/MobileDrawerNavigation/__stories__/mobileDrawerNavigation.html.stories.jsx new file mode 100644 index 000000000..8c446509a --- /dev/null +++ b/app/javascript/crayons/navigation/MobileDrawerNavigation/__stories__/mobileDrawerNavigation.html.stories.jsx @@ -0,0 +1,139 @@ +/* eslint-disable jsx-a11y/no-static-element-interactions */ +/* eslint-disable jsx-a11y/click-events-have-key-events */ +// Disabled for the file due to issues disabling for individual JSX lines. +// These are disabled to allow the "click outside to close" functionality +import { h } from 'preact'; +import { useState, useEffect } from 'preact/hooks'; +import notes from './mobileDrawerNavigation.md'; + +export default { + title: 'App Components/MobileDrawerNavigation/HTML', + parameters: { notes }, +}; + +export const Default = () => { + const [isNavOpen, setIsNavOpen] = useState(false); + + const { href, hash } = window.location; + const indexOfHash = href.indexOf(hash) || href.length; + const baseStoryUrl = href.substr(0, indexOfHash); + + useEffect(() => { + const keyupListener = (e) => { + if (e.key === 'Escape') { + setIsNavOpen(false); + } + }; + document.addEventListener('keyup', keyupListener); + return () => document.removeEventListener('keyup', keyupListener); + }, []); + + return ( +
+
+

Link 1

+ +
+ {isNavOpen && ( +
+
setIsNavOpen(false)} + /> + +
+ )} +
+ ); +}; + +Default.story = { + name: 'MobileDrawerNavigation', +}; diff --git a/app/javascript/crayons/navigation/MobileDrawerNavigation/__stories__/mobileDrawerNavigation.md b/app/javascript/crayons/navigation/MobileDrawerNavigation/__stories__/mobileDrawerNavigation.md new file mode 100644 index 000000000..f9e081c2a --- /dev/null +++ b/app/javascript/crayons/navigation/MobileDrawerNavigation/__stories__/mobileDrawerNavigation.md @@ -0,0 +1,29 @@ +## MobileDrawerNavigation + +The MobileDrawerNavigation component is intended to be used on small +(mobile-sized) screens only. It can be used as an alternative to the larger +navigation tab component. + +The component is responsible for showing: + +- The heading of the currently selected page +- A navigation dialog with the given links + +This component is best placed at the top of a page. The dialog utilizes +``, and will appear from the bottom of the screen. + +### MobileDrawerNavigation Accessibility + +The MobileDrawerNavigation component requires a `navigationTitle` prop which +will be used for: + +- The activating button for the navigation dialog +- The label of the navigation dialog +- The label of the navigation element containing the links + +This helps ensure accessible element names are surfaced to all users. + +The component also requires a `headingLevel` prop which is used to determine the +HTML element used for the displayed current page title (e.g. `1` will render an +`h1`). Appropriate consideration should be given to which heading level is +semantically correct for the location the component is rendered. diff --git a/app/javascript/crayons/navigation/MobileDrawerNavigation/__tests__/MobileDrawerNavigation.spec.js b/app/javascript/crayons/navigation/MobileDrawerNavigation/__tests__/MobileDrawerNavigation.spec.js new file mode 100644 index 000000000..fab8b94e6 --- /dev/null +++ b/app/javascript/crayons/navigation/MobileDrawerNavigation/__tests__/MobileDrawerNavigation.spec.js @@ -0,0 +1,85 @@ +import { h } from 'preact'; +import { axe } from 'jest-axe'; +import '@testing-library/jest-dom'; +import { render, waitFor } from '@testing-library/preact'; +import { MobileDrawerNavigation } from '../MobileDrawerNavigation'; + +describe('', () => { + const testLinks = [ + { url: '/#1', displayName: 'Link 1', isCurrentPage: true }, + { url: '/#2', displayName: 'Link 2', isCurrentPage: false }, + { url: '/#3', displayName: 'Link 3', isCurrentPage: false }, + ]; + + it('should have no a11y violations when closed', async () => { + const { container } = render( + , + ); + + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); + + it('should have no a11y violations when open', async () => { + const { container, getByRole } = render( + , + ); + + getByRole('button', { name: 'Test navigation' }).click(); + await waitFor(() => getByRole('navigation', { name: 'Test navigation' })); + + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); + + it('should render a heading with a button when closed', () => { + const { container } = render( + , + ); + + expect(container.innerHTML).toMatchSnapshot(); + }); + + it('should render a navigation with a checkmark for current page when open', async () => { + const { container, getByRole } = render( + , + ); + + getByRole('button', { name: 'Test navigation' }).click(); + await waitFor(() => getByRole('navigation', { name: 'Test navigation' })); + expect(container.innerHTML).toMatchSnapshot(); + }); + + it('should render all links', async () => { + const { getByRole } = render( + , + ); + + getByRole('button', { name: 'Test navigation' }).click(); + await waitFor(() => getByRole('navigation', { name: 'Test navigation' })); + + expect(getByRole('link', { name: 'Link 1' })).toBeInTheDocument(); + expect(getByRole('link', { name: 'Link 2' })).toBeInTheDocument(); + expect(getByRole('link', { name: 'Link 3' })).toBeInTheDocument(); + }); +}); diff --git a/app/javascript/crayons/navigation/MobileDrawerNavigation/__tests__/__snapshots__/MobileDrawerNavigation.spec.js.snap b/app/javascript/crayons/navigation/MobileDrawerNavigation/__tests__/__snapshots__/MobileDrawerNavigation.spec.js.snap new file mode 100644 index 000000000..4522fa1f2 --- /dev/null +++ b/app/javascript/crayons/navigation/MobileDrawerNavigation/__tests__/__snapshots__/MobileDrawerNavigation.spec.js.snap @@ -0,0 +1,5 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` should render a heading with a button when closed 1`] = `"

Link 1

"`; + +exports[` should render a navigation with a checkmark for current page when open 1`] = `"

Link 1

"`; diff --git a/app/javascript/crayons/navigation/index.js b/app/javascript/crayons/navigation/index.js new file mode 100644 index 000000000..f002afbeb --- /dev/null +++ b/app/javascript/crayons/navigation/index.js @@ -0,0 +1 @@ +export * from './MobileDrawerNavigation/MobileDrawerNavigation';