Commit 3336dfc4 authored by Robert Knight's avatar Robert Knight

Import Dialog component from LMS frontend

Import the Dialog component from the LMS frontend, adjusted to avoid
dependencies on some shared variables that were specific to that
application.
parent f879ec7e
import { useElementShouldClose } from '@hypothesis/frontend-shared';
import { Fragment } from 'preact';
import { useEffect, useLayoutEffect, useRef, useState } from 'preact/hooks';
import classNames from 'classnames';
import { LabeledButton } from './buttons';
let idCounter = 0;
/**
* @param {string} prefix
*/
function useUniqueId(prefix) {
const [id] = useState(() => {
++idCounter;
return `${prefix}-${idCounter}`;
});
return id;
}
/**
* @typedef {import("preact").ComponentChildren} Children
*
* @typedef DialogProps
* @prop {Children} children - The content of the dialog.
* @prop {import("preact/hooks").Ref<HTMLElement>} [initialFocus] -
* Child element to focus when the dialog is rendered.
* @prop {Children} [buttons] -
* Additional `Button` elements to display at the bottom of the dialog.
* A "Cancel" button is added automatically if the `onCancel` prop is set.
* @prop {string} [contentClass] - e.g. <button>
* @prop {'dialog'|'alertdialog'} [role] - The aria role for the dialog (defaults to" dialog")
* @prop {string} title - The title of the dialog.
* @prop {() => any} [onCancel] -
* A callback to invoke when the user cancels the dialog. If provided, a
* "Cancel" button will be displayed.
* @prop {string} [cancelLabel] - Label for the cancel button
*/
/**
* HTML control that can be disabled.
*
* @typedef {HTMLElement & { disabled: boolean }} InputElement
*/
/**
* A modal dialog wrapper with a title. The wrapper sets initial focus to itself
* unless an element inside of it is specified with the `initialFocus` ref.
* Optional action buttons may be passed in with the `buttons` prop but the
* cancel button is automatically generated when the on `onCancel` function is
* passed.
*
* Canonical resources:
*
* https://www.w3.org/TR/wai-aria-practices/examples/dialog-modal/dialog.html
* https://www.w3.org/TR/wai-aria-practices/examples/dialog-modal/alertdialog.html
*
* If the dialog's content, specified by the `children` prop, contains a paragraph
* (`<p>`) element, that element will be identified as the dialog's accessible
* description.
*
* @param {DialogProps} props
*/
export default function Dialog({
children,
contentClass,
initialFocus,
onCancel,
cancelLabel = 'Cancel',
role = 'dialog',
title,
buttons,
}) {
const dialogTitleId = useUniqueId('dialog-title');
const dialogDescriptionId = useUniqueId('dialog-description');
const rootEl = useRef(/** @type {HTMLDivElement | null} */ (null));
useElementShouldClose(rootEl, true, () => {
if (onCancel) {
onCancel();
}
});
useEffect(() => {
const focusEl = /** @type {InputElement|undefined} */ (initialFocus?.current);
if (focusEl && !focusEl.disabled) {
focusEl.focus();
} else {
// Modern accessibility guidance is to focus the dialog itself rather than
// trying to be smart about focusing a particular control within the
// dialog. See resources above.
rootEl.current.focus();
}
// We only want to run this effect once when the dialog is mounted.
//
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// If the content of the dialog contains a paragraph of text, mark it as the
// dialog's accessible description.
//
// A limitation of this approach is that it doesn't update if the dialog's
// content changes after the initial render.
useLayoutEffect(() => {
const description = rootEl.current.querySelector('p');
if (description) {
description.id = dialogDescriptionId;
rootEl.current.setAttribute('aria-describedby', dialogDescriptionId);
}
}, [dialogDescriptionId]);
return (
<Fragment>
<div className="Dialog__background" />
<div className="Dialog__container">
<div
tabIndex={-1}
ref={rootEl}
role={role}
aria-labelledby={dialogTitleId}
aria-modal={true}
className={classNames('Dialog__content', contentClass)}
>
<h1 className="Dialog__title" id={dialogTitleId}>
{title}
<span className="u-stretch" />
{onCancel && (
<button
aria-label="Close"
className="Dialog__cancel-btn"
onClick={onCancel}
>
</button>
)}
</h1>
{children}
<div className="u-stretch" />
<div className="Dialog__actions">
{onCancel && (
<LabeledButton icon="cancel" onClick={onCancel}>
{cancelLabel}
</LabeledButton>
)}
{buttons}
</div>
</div>
</div>
</Fragment>
);
}
import { mount } from 'enzyme';
import { createRef } from 'preact';
import Dialog from '../Dialog';
import { checkAccessibility } from '../../../test-util/accessibility';
describe('Dialog', () => {
it('renders content', () => {
const wrapper = mount(
<Dialog>
<span>content</span>
</Dialog>
);
assert.isTrue(wrapper.contains(<span>content</span>));
});
it('adds `contentClass` value to class list', () => {
const wrapper = mount(
<Dialog contentClass="foo">
<span>content</span>
</Dialog>
);
assert.isTrue(wrapper.find('.Dialog__content').hasClass('foo'));
});
it('renders buttons', () => {
const wrapper = mount(
<Dialog
buttons={[
<button key="foo" name="foo" />,
<button key="bar" name="bar" />,
]}
/>
);
assert.isTrue(wrapper.contains(<button key="foo" name="foo" />));
assert.isTrue(wrapper.contains(<button key="bar" name="bar" />));
});
it('renders the title', () => {
const wrapper = mount(<Dialog title="Test dialog" />);
const header = wrapper.find('h1');
assert.equal(header.text().indexOf('Test dialog'), 0);
});
it('closes when Escape key is pressed', () => {
const onCancel = sinon.stub();
const container = document.createElement('div');
document.body.appendChild(container);
mount(<Dialog title="Test dialog" onCancel={onCancel} />, {
attachTo: container,
});
const event = new Event('keydown');
event.key = 'Escape';
document.body.dispatchEvent(event);
assert.called(onCancel);
container.remove();
});
it('closes when close button is clicked', () => {
const onCancel = sinon.stub();
const wrapper = mount(<Dialog title="Test dialog" onCancel={onCancel} />);
wrapper.find('.Dialog__cancel-btn').simulate('click');
assert.called(onCancel);
});
it(`defaults cancel button's label to "Cancel"`, () => {
const wrapper = mount(<Dialog onCancel={sinon.stub()} />);
assert.equal(wrapper.find('LabeledButton').text().trim(), 'Cancel');
});
it('adds a custom label to the cancel button', () => {
const wrapper = mount(
<Dialog onCancel={sinon.stub()} cancelLabel="hello" />
);
assert.equal(wrapper.find('LabeledButton').text().trim(), 'hello');
});
describe('initial focus', () => {
let container;
beforeEach(() => {
container = document.createElement('div');
document.body.appendChild(container);
});
afterEach(() => {
container.remove();
});
it('focuses the `initialFocus` element', () => {
const inputRef = createRef();
mount(
<Dialog initialFocus={inputRef}>
<input ref={inputRef} />
</Dialog>,
{ attachTo: container }
);
assert.equal(document.activeElement, inputRef.current);
});
it('focuses the dialog if `initialFocus` prop is missing', () => {
const wrapper = mount(
<Dialog>
<div>Test</div>
</Dialog>,
{ attachTo: container }
);
assert.equal(
document.activeElement,
wrapper.find('[role="dialog"]').getDOMNode()
);
});
it('focuses the dialog if `initialFocus` ref is `null`', () => {
const wrapper = mount(
<Dialog initialFocus={{ current: null }}>
<div>Test</div>
</Dialog>,
{ attachTo: container }
);
assert.equal(
document.activeElement,
wrapper.find('[role="dialog"]').getDOMNode()
);
});
it('focuses the dialog if `initialFocus` element is disabled', () => {
const inputRef = createRef();
const wrapper = mount(
<Dialog initialFocus={inputRef}>
<button ref={inputRef} disabled={true} />
</Dialog>,
{ attachTo: container }
);
assert.equal(
document.activeElement,
wrapper.find('[role="dialog"]').getDOMNode()
);
});
});
it("marks the first `<p>` in the dialog's content as the accessible description", () => {
const wrapper = mount(
<Dialog>
<p>Enter a URL</p>
</Dialog>
);
const content = wrapper.find('[role="dialog"]').getDOMNode();
const paragraphEl = wrapper.find('p').getDOMNode();
assert.ok(content.getAttribute('aria-describedby'));
assert.equal(content.getAttribute('aria-describedby'), paragraphEl.id);
});
it("does not set an accessible description if the dialog's content does not have a `<p>`", () => {
const wrapper = mount(
<Dialog>
<button>Click me</button>
</Dialog>
);
const content = wrapper.find('[role="dialog"]').getDOMNode();
assert.isNull(content.getAttribute('aria-describedby'));
});
it(
'should pass a11y checks',
checkAccessibility({
// eslint-disable-next-line react/display-name
content: () => (
<Dialog title="Test dialog">
<div>test</div>
</Dialog>
),
})
);
});
@use './components/buttons/styles';
@use './components/Dialog';
@use "@hypothesis/frontend-shared/styles/mixins/focus";
@use "../variables" as var;
$title-font-size: 19px;
.Dialog__container {
display: flex;
position: fixed;
left: 0;
top: 0;
width: 100%;
height: 100%;
}
.Dialog__background {
background-color: black;
bottom: 0;
left: 0;
opacity: 0.5;
position: fixed;
right: 0;
top: 0;
}
.Dialog__content {
@include focus.outline-on-keyboard-focus;
display: flex;
flex-direction: column;
max-height: 90vh;
max-width: 700px; // default for older browsers
max-width: min(700px, 90vw);
background-color: white;
border-radius: 3px;
margin: auto;
padding: 20px;
}
.Dialog__title {
display: flex;
align-items: center;
font-size: $title-font-size;
}
.Dialog__cancel-btn {
@include focus.outline-on-keyboard-focus;
border: 0;
background: none;
color: var.$color-grey-6;
// Given the button a large hit target and ensure the 'X' label is large
// enough to see easily and aligned with the right edge of the dialog.
// Add negative margins so that the button does not force the dialog to
// grow in height.
font-size: $title-font-size;
padding: 5px;
margin: -10px 0px;
&:hover {
cursor: pointer;
color: var.$color-brand;
}
}
.Dialog__actions {
display: flex;
flex-direction: row;
justify-content: flex-end;
margin-top: 10px;
& > *:not(:first-child) {
margin-left: 5px;
}
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment