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
This commit is contained in:
parent
c16f94f8da
commit
a6b4d8e91d
18 changed files with 762 additions and 0 deletions
26
app/assets/stylesheets/components/drawer-navigation.scss
Normal file
26
app/assets/stylesheets/components/drawer-navigation.scss
Normal file
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
41
app/assets/stylesheets/components/drawers.scss
Normal file
41
app/assets/stylesheets/components/drawers.scss
Normal file
|
|
@ -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%);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -33,5 +33,7 @@
|
|||
@import 'components/tabs';
|
||||
@import 'components/tags';
|
||||
@import 'components/tooltips';
|
||||
@import 'components/drawers';
|
||||
@import 'components/drawer-navigation';
|
||||
|
||||
@import 'config/generator';
|
||||
|
|
|
|||
58
app/javascript/crayons/MobileDrawer/MobileDrawer.jsx
Normal file
58
app/javascript/crayons/MobileDrawer/MobileDrawer.jsx
Normal file
|
|
@ -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 (
|
||||
* <div>
|
||||
* <Button onClick={() => setIsDrawerOpen(true)}>Open drawer</Button>
|
||||
* {isDrawerOpen && (
|
||||
* <MobileDrawer
|
||||
* title="Example MobileDrawer"
|
||||
* onClose={() => setIsDrawerOpen(false)}
|
||||
* >
|
||||
* <h2>Lorem ipsum</h2>
|
||||
* <Button onClick={() => setIsDrawerOpen(false)}>OK</Button>
|
||||
* </MobileDrawer>
|
||||
* )}
|
||||
* </div>
|
||||
* );
|
||||
*/
|
||||
export const MobileDrawer = ({ children, title, onClose = () => {} }) => {
|
||||
return (
|
||||
<div className="crayons-mobile-drawer">
|
||||
<div className="crayons-mobile-drawer__overlay" />
|
||||
<FocusTrap
|
||||
clickOutsideDeactivates
|
||||
selector=".crayons-mobile-drawer__content"
|
||||
onDeactivate={onClose}
|
||||
>
|
||||
<div
|
||||
className="crayons-mobile-drawer__content"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={title}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</FocusTrap>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
MobileDrawer.propTypes = {
|
||||
children: defaultChildrenPropTypes.isRequired,
|
||||
title: PropTypes.string.isRequired,
|
||||
onClose: PropTypes.func,
|
||||
};
|
||||
|
|
@ -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 (
|
||||
<div>
|
||||
<button className="crayons-btn" onClick={() => setIsDrawerOpen(true)}>
|
||||
Open drawer
|
||||
</button>
|
||||
{isDrawerOpen && (
|
||||
<div class="crayons-mobile-drawer">
|
||||
<div
|
||||
class="crayons-mobile-drawer__overlay"
|
||||
onClick={() => setIsDrawerOpen(false)}
|
||||
/>
|
||||
<div
|
||||
aria-label="Example MobileDrawer"
|
||||
aria-modal="true"
|
||||
class="crayons-mobile-drawer__content"
|
||||
role="dialog"
|
||||
>
|
||||
<h2 className="mb-4">Lorem ipsum</h2>
|
||||
<button
|
||||
className="crayons-btn"
|
||||
onClick={() => setIsDrawerOpen(false)}
|
||||
>
|
||||
OK
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Default.story = {
|
||||
name: 'default',
|
||||
};
|
||||
|
|
@ -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 (
|
||||
<div>
|
||||
<Button onClick={() => setIsDrawerOpen(true)}>Open drawer</Button>
|
||||
{isDrawerOpen && (
|
||||
<MobileDrawer
|
||||
title="Example MobileDrawer"
|
||||
onClose={() => setIsDrawerOpen(false)}
|
||||
>
|
||||
<h2 className="mb-4">Lorem ipsum</h2>
|
||||
<Button onClick={() => setIsDrawerOpen(false)}>OK</Button>
|
||||
</MobileDrawer>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Default.story = {
|
||||
name: 'MobileDrawer',
|
||||
};
|
||||
25
app/javascript/crayons/MobileDrawer/__stories__/drawers.md
Normal file
25
app/javascript/crayons/MobileDrawer/__stories__/drawers.md
Normal file
|
|
@ -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
|
||||
|
|
@ -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('<MobileDrawer />', () => {
|
||||
it('should have no a11y violations', async () => {
|
||||
const { container } = render(
|
||||
<MobileDrawer title="Example MobileDrawer">
|
||||
<button>Click me</button>
|
||||
</MobileDrawer>,
|
||||
);
|
||||
const results = await axe(container);
|
||||
|
||||
expect(results).toHaveNoViolations();
|
||||
});
|
||||
|
||||
it('should render correctly', () => {
|
||||
const { container } = render(
|
||||
<MobileDrawer title="Example MobileDrawer">
|
||||
<button>Click me</button>
|
||||
</MobileDrawer>,
|
||||
);
|
||||
|
||||
expect(container.innerHTML).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should trap focus inside the drawer by default', async () => {
|
||||
const { getByRole } = render(
|
||||
<div>
|
||||
<button>Outside drawer button</button>
|
||||
<MobileDrawer title="Example MobileDrawer">
|
||||
<button>Inside drawer button</button>
|
||||
<button>Inside drawer button 2</button>
|
||||
</MobileDrawer>
|
||||
</div>,
|
||||
);
|
||||
|
||||
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(
|
||||
<MobileDrawer title="Example MobileDrawer" onClose={onClose}>
|
||||
<button>Inner button</button>
|
||||
</MobileDrawer>,
|
||||
);
|
||||
|
||||
userEvent.type(container, '{esc}');
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should close on click outside', () => {
|
||||
const onClose = jest.fn();
|
||||
const { getByText } = render(
|
||||
<div>
|
||||
<p>Outside content</p>
|
||||
<MobileDrawer title="Example MobileDrawer" onClose={onClose}>
|
||||
<button>Inner button</button>
|
||||
</MobileDrawer>
|
||||
,
|
||||
</div>,
|
||||
);
|
||||
|
||||
userEvent.click(getByText('Outside content'));
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`<MobileDrawer /> should render correctly 1`] = `"<div class=\\"crayons-mobile-drawer\\"><div class=\\"crayons-mobile-drawer__overlay\\"></div><div class=\\"crayons-mobile-drawer__content\\" role=\\"dialog\\" aria-modal=\\"true\\" aria-label=\\"Example MobileDrawer\\"><button>Click me</button></div></div>"`;
|
||||
1
app/javascript/crayons/MobileDrawer/index.js
Normal file
1
app/javascript/crayons/MobileDrawer/index.js
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from './MobileDrawer';
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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 = () => (
|
||||
<svg width="24" height="24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M7 12a2 2 0 11-4 0 2 2 0 014 0zm7 0a2 2 0 11-4 0 2 2 0 014 0zm5 2a2 2 0 100-4 2 2 0 000 4z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const CheckIcon = () => (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="check-icon"
|
||||
fill="currentColor"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d="M10 15.172l9.192-9.193 1.415 1.414L10 18l-6.364-6.364 1.414-1.414 4.95 4.95z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
/**
|
||||
* Renders a page heading with a button which activates a <MobileDrawer /> 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
|
||||
* <MobileDrawerNavigation
|
||||
* headingLevel={2}
|
||||
* navigationTitle="Feed options"
|
||||
* navigationLinks={links} />
|
||||
*/
|
||||
export const MobileDrawerNavigation = ({
|
||||
headingLevel,
|
||||
navigationTitle,
|
||||
navigationLinks,
|
||||
}) => {
|
||||
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
|
||||
const currentPage = navigationLinks.find((item) => item.isCurrentPage);
|
||||
|
||||
const Heading = `h${headingLevel}`;
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<div className="flex justify-between">
|
||||
<Heading>{currentPage.displayName}</Heading>
|
||||
<Button
|
||||
aria-label={navigationTitle}
|
||||
icon={OverflowIcon}
|
||||
size="s"
|
||||
contentType="icon"
|
||||
variant="ghost"
|
||||
onClick={() => setIsDrawerOpen(true)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isDrawerOpen && (
|
||||
<MobileDrawer
|
||||
title={navigationTitle}
|
||||
onClose={() => setIsDrawerOpen(false)}
|
||||
>
|
||||
<nav aria-label={navigationTitle} className="drawer-navigation">
|
||||
<ul className="list-none">
|
||||
{navigationLinks.map((linkDetails) => {
|
||||
const { url, isCurrentPage, displayName } = linkDetails;
|
||||
return (
|
||||
<li
|
||||
key={`link-${url}`}
|
||||
className="drawer-navigation__item py-2"
|
||||
>
|
||||
<a href={url} aria-current={isCurrentPage ? 'page' : null}>
|
||||
{displayName}
|
||||
</a>
|
||||
<CheckIcon />
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</nav>
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="w-100 mt-4"
|
||||
onClick={() => setIsDrawerOpen(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</MobileDrawer>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
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,
|
||||
};
|
||||
|
|
@ -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 (
|
||||
<Fragment>
|
||||
<MobileDrawerNavigation
|
||||
headingLevel={2}
|
||||
navigationTitle="Example MobileDrawerNavigation"
|
||||
navigationLinks={links}
|
||||
/>
|
||||
<p className="my-4">
|
||||
Click on the button to view and select navigation links.
|
||||
</p>
|
||||
</Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
Default.story = {
|
||||
name: 'MobileDrawerNavigation',
|
||||
};
|
||||
|
|
@ -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 (
|
||||
<div>
|
||||
<div class="flex justify-between">
|
||||
<h1>Link 1</h1>
|
||||
<button
|
||||
onClick={() => setIsNavOpen(true)}
|
||||
aria-label="Test navigation"
|
||||
class="crayons-btn crayons-btn--ghost crayons-btn--s crayons-btn--icon"
|
||||
type="button"
|
||||
>
|
||||
<svg height="24" width="24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
clip-rule="evenodd"
|
||||
d="M7 12a2 2 0 11-4 0 2 2 0 014 0zm7 0a2 2 0 11-4 0 2 2 0 014 0zm5 2a2 2 0 100-4 2 2 0 000 4z"
|
||||
fill-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{isNavOpen && (
|
||||
<div class="crayons-mobile-drawer">
|
||||
<div
|
||||
class="crayons-mobile-drawer__overlay"
|
||||
onClick={() => setIsNavOpen(false)}
|
||||
/>
|
||||
<div
|
||||
aria-label="Test navigation"
|
||||
aria-modal="true"
|
||||
class="crayons-mobile-drawer__content"
|
||||
role="dialog"
|
||||
>
|
||||
<nav aria-label="Test navigation" class="drawer-navigation">
|
||||
<ul class="list-none">
|
||||
<li class="drawer-navigation__item py-2">
|
||||
<a
|
||||
aria-current={href === baseStoryUrl ? 'page' : null}
|
||||
href={baseStoryUrl}
|
||||
>
|
||||
Link 1
|
||||
</a>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class="check-icon"
|
||||
fill="currentColor"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d="M10 15.172l9.192-9.193 1.415 1.414L10 18l-6.364-6.364 1.414-1.414 4.95 4.95z" />
|
||||
</svg>
|
||||
</li>
|
||||
<li class="drawer-navigation__item py-2">
|
||||
<a
|
||||
href={`${baseStoryUrl}/#2`}
|
||||
aria-current={`#2` === hash ? 'page' : null}
|
||||
>
|
||||
Link 2
|
||||
</a>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class="check-icon"
|
||||
fill="currentColor"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d="M10 15.172l9.192-9.193 1.415 1.414L10 18l-6.364-6.364 1.414-1.414 4.95 4.95z" />
|
||||
</svg>
|
||||
</li>
|
||||
<li class="drawer-navigation__item py-2">
|
||||
<a
|
||||
href={`${baseStoryUrl}/#3`}
|
||||
aria-current={`#3` === hash ? 'page' : null}
|
||||
>
|
||||
Link 3
|
||||
</a>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class="check-icon"
|
||||
fill="currentColor"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d="M10 15.172l9.192-9.193 1.415 1.414L10 18l-6.364-6.364 1.414-1.414 4.95 4.95z" />
|
||||
</svg>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
<button
|
||||
class="crayons-btn crayons-btn--secondary w-100 mt-4"
|
||||
type="button"
|
||||
onClick={() => setIsNavOpen(false)}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Default.story = {
|
||||
name: 'MobileDrawerNavigation',
|
||||
};
|
||||
|
|
@ -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
|
||||
`<MobileDrawer />`, 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.
|
||||
|
|
@ -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('<MobileDrawerNavigation />', () => {
|
||||
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(
|
||||
<MobileDrawerNavigation
|
||||
navigationTitle="Test navigation"
|
||||
headingLevel={1}
|
||||
navigationLinks={testLinks}
|
||||
/>,
|
||||
);
|
||||
|
||||
const results = await axe(container);
|
||||
expect(results).toHaveNoViolations();
|
||||
});
|
||||
|
||||
it('should have no a11y violations when open', async () => {
|
||||
const { container, getByRole } = render(
|
||||
<MobileDrawerNavigation
|
||||
navigationTitle="Test navigation"
|
||||
headingLevel={1}
|
||||
navigationLinks={testLinks}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<MobileDrawerNavigation
|
||||
navigationTitle="Test navigation"
|
||||
headingLevel={1}
|
||||
navigationLinks={testLinks}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(container.innerHTML).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should render a navigation with a checkmark for current page when open', async () => {
|
||||
const { container, getByRole } = render(
|
||||
<MobileDrawerNavigation
|
||||
navigationTitle="Test navigation"
|
||||
headingLevel={1}
|
||||
navigationLinks={testLinks}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<MobileDrawerNavigation
|
||||
navigationTitle="Test navigation"
|
||||
headingLevel={1}
|
||||
navigationLinks={testLinks}
|
||||
/>,
|
||||
);
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`<MobileDrawerNavigation /> should render a heading with a button when closed 1`] = `"<div class=\\"flex justify-between\\"><h1>Link 1</h1><button class=\\"crayons-btn crayons-btn--ghost crayons-btn--s crayons-btn--icon\\" type=\\"button\\" aria-label=\\"Test navigation\\"><svg width=\\"24\\" height=\\"24\\" xmlns=\\"http://www.w3.org/2000/svg\\"><path fill-rule=\\"evenodd\\" clip-rule=\\"evenodd\\" d=\\"M7 12a2 2 0 11-4 0 2 2 0 014 0zm7 0a2 2 0 11-4 0 2 2 0 014 0zm5 2a2 2 0 100-4 2 2 0 000 4z\\"></path></svg></button></div>"`;
|
||||
|
||||
exports[`<MobileDrawerNavigation /> should render a navigation with a checkmark for current page when open 1`] = `"<div class=\\"flex justify-between\\"><h1>Link 1</h1><button class=\\"crayons-btn crayons-btn--ghost crayons-btn--s crayons-btn--icon\\" type=\\"button\\" aria-label=\\"Test navigation\\"><svg width=\\"24\\" height=\\"24\\" xmlns=\\"http://www.w3.org/2000/svg\\"><path fill-rule=\\"evenodd\\" clip-rule=\\"evenodd\\" d=\\"M7 12a2 2 0 11-4 0 2 2 0 014 0zm7 0a2 2 0 11-4 0 2 2 0 014 0zm5 2a2 2 0 100-4 2 2 0 000 4z\\"></path></svg></button></div><div class=\\"crayons-mobile-drawer\\"><div class=\\"crayons-mobile-drawer__overlay\\"></div><div class=\\"crayons-mobile-drawer__content\\" role=\\"dialog\\" aria-modal=\\"true\\" aria-label=\\"Test navigation\\"><nav aria-label=\\"Test navigation\\" class=\\"drawer-navigation\\"><ul class=\\"list-none\\"><li class=\\"drawer-navigation__item py-2\\"><a href=\\"/#1\\" aria-current=\\"page\\">Link 1</a><svg aria-hidden=\\"true\\" class=\\"check-icon\\" fill=\\"currentColor\\" width=\\"24\\" height=\\"24\\" viewBox=\\"0 0 24 24\\" xmlns=\\"http://www.w3.org/2000/svg\\"><path d=\\"M10 15.172l9.192-9.193 1.415 1.414L10 18l-6.364-6.364 1.414-1.414 4.95 4.95z\\"></path></svg></li><li class=\\"drawer-navigation__item py-2\\"><a href=\\"/#2\\">Link 2</a><svg aria-hidden=\\"true\\" class=\\"check-icon\\" fill=\\"currentColor\\" width=\\"24\\" height=\\"24\\" viewBox=\\"0 0 24 24\\" xmlns=\\"http://www.w3.org/2000/svg\\"><path d=\\"M10 15.172l9.192-9.193 1.415 1.414L10 18l-6.364-6.364 1.414-1.414 4.95 4.95z\\"></path></svg></li><li class=\\"drawer-navigation__item py-2\\"><a href=\\"/#3\\">Link 3</a><svg aria-hidden=\\"true\\" class=\\"check-icon\\" fill=\\"currentColor\\" width=\\"24\\" height=\\"24\\" viewBox=\\"0 0 24 24\\" xmlns=\\"http://www.w3.org/2000/svg\\"><path d=\\"M10 15.172l9.192-9.193 1.415 1.414L10 18l-6.364-6.364 1.414-1.414 4.95 4.95z\\"></path></svg></li></ul></nav><button class=\\"crayons-btn crayons-btn--secondary w-100 mt-4\\" type=\\"button\\">Cancel</button></div></div>"`;
|
||||
1
app/javascript/crayons/navigation/index.js
Normal file
1
app/javascript/crayons/navigation/index.js
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from './MobileDrawerNavigation/MobileDrawerNavigation';
|
||||
Loading…
Add table
Reference in a new issue