@hypothesis/frontend-shared
Version:
Shared components, styles and utilities for Hypothesis projects
200 lines (189 loc) • 6.75 kB
JavaScript
var _jsxFileName = "/home/runner/work/frontend-shared/frontend-shared/src/components/Dialog.js";
import classnames from 'classnames';
import { useEffect, useLayoutEffect, useRef, useState } from 'preact/hooks'; // @ts-ignore
import cancelSVG from '../../images/icons/cancel.svg';
import { IconButton, LabeledButton } from './buttons';
import { registerIcon, SvgIcon } from './SvgIcon'; // Register the checkbox icon for use
import { jsxDEV as _jsxDEV } from "preact/jsx-dev-runtime";
const cancelIcon = registerIcon('cancel', cancelSVG);
let idCounter = 0;
/**
* Return an element ID beginning with `prefix` that is unique per component instance.
*
* This avoids different instances of a component re-using the same ID.
*
* @param {string} prefix
*/
function useUniqueId(prefix) {
const [id] = useState(() => {
++idCounter;
return `${prefix}-${idCounter}`;
});
return id;
}
/**
* @typedef {import('preact').ComponentChildren} Children
*
* @typedef DialogProps
* @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} [cancelLabel] - Label for the cancel button
* @prop {Children} children
* @prop {string} [contentClass] - CSS class to apply to the dialog's content
* @prop {string|symbol} [icon] - Name of optional icon to render in header
* @prop {import("preact/hooks").Ref<HTMLElement>|null} [initialFocus] -
* Child element to focus when the dialog is rendered. If not provided,
* the Dialog's container will be automatically focused on opening. Set to
* `null` to opt out of automatic focus control.
* @prop {() => void} [onCancel] -
* A callback to invoke when the user cancels the dialog. If provided, a
* "Cancel" button will be displayed.
* @prop {'dialog'|'alertdialog'} [role] - The aria role for the dialog (defaults to" dialog")
* @prop {string} title
* @prop {boolean} [withCancelButton=true] - If `onCancel` is provided, render
* a Cancel button as one of the Dialog's buttons (along with any other
* `buttons`)
* @prop {boolean} [withCloseButton=true] - If `onCancel` is provided, render
* a close button (X icon) in the Dialog's header
*/
/**
* HTML control that can be disabled.
*
* @typedef {HTMLElement & { disabled: boolean }} InputElement
*/
/**
* Render a "panel"-like interface with a title and optional icon and/or
* close button. Grabs focus on initial render, defaulting to the entire
* Dialog container element, or `initialFocus` HTMLElement if provided.
*
* @param {DialogProps} props
*/
export function Dialog({
buttons,
cancelLabel = 'Cancel',
children,
contentClass,
icon,
initialFocus,
onCancel,
role = 'dialog',
title,
withCancelButton = true,
withCloseButton = true
}) {
const dialogDescriptionId = useUniqueId('dialog-description');
const dialogTitleId = useUniqueId('dialog-title');
const rootEl =
/** @type {{ current: HTMLDivElement }} */
useRef();
useEffect(() => {
// Setting `initialFocus` to `null` opts out of focus handling
if (initialFocus !== null) {
const focusEl =
/** @type {InputElement|null} */
initialFocus === null || initialFocus === void 0 ? void 0 : initialFocus.current;
if (focusEl && !focusEl.disabled) {
focusEl.focus();
} else {
// The `initialFocus` prop has not been set, so use automatic focus handling.
// Modern accessibility guidance is to focus the dialog itself rather than
// trying to be smart about focusing a particular control within the
// dialog.
rootEl.current.focus();
}
} // We only want to run this effect once when the dialog is mounted.
//
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); // Try to assign the dialog an accessible description, using the content of
// the first paragraph of text in it.
//
// 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]);
const hasCancelButton = onCancel && withCancelButton;
const hasCloseButton = onCancel && withCloseButton;
const hasButtons = buttons || hasCancelButton;
return _jsxDEV("div", {
"aria-labelledby": dialogTitleId,
className: classnames('Hyp-Dialog', {
'Hyp-Dialog--closeable': hasCloseButton
}, contentClass),
ref: rootEl,
role: role,
tabIndex: -1,
children: [_jsxDEV("header", {
className: "Hyp-Dialog__header",
children: [icon && _jsxDEV("div", {
className: "Hyp-Dialog__header-icon",
children: _jsxDEV(SvgIcon, {
name: icon,
title: title,
"data-testid": "header-icon"
}, void 0, false, {
fileName: _jsxFileName,
lineNumber: 139,
columnNumber: 13
}, this)
}, void 0, false, {
fileName: _jsxFileName,
lineNumber: 138,
columnNumber: 11
}, this), _jsxDEV("h2", {
className: "Hyp-Dialog__title",
id: dialogTitleId,
children: title
}, void 0, false, {
fileName: _jsxFileName,
lineNumber: 142,
columnNumber: 9
}, this), onCancel && withCloseButton && _jsxDEV("div", {
className: "Hyp-Dialog__close",
children: _jsxDEV(IconButton, {
"data-testid": "close-button",
icon: cancelIcon,
title: "Close",
onClick: onCancel
}, void 0, false, {
fileName: _jsxFileName,
lineNumber: 147,
columnNumber: 13
}, this)
}, void 0, false, {
fileName: _jsxFileName,
lineNumber: 146,
columnNumber: 11
}, this)]
}, void 0, true, {
fileName: _jsxFileName,
lineNumber: 136,
columnNumber: 7
}, this), children, hasButtons && _jsxDEV("div", {
className: "Hyp-Dialog__actions",
children: [hasCancelButton && _jsxDEV(LabeledButton, {
"data-testid": "cancel-button",
onClick: onCancel,
children: cancelLabel
}, void 0, false, {
fileName: _jsxFileName,
lineNumber: 160,
columnNumber: 13
}, this), buttons]
}, void 0, true, {
fileName: _jsxFileName,
lineNumber: 158,
columnNumber: 9
}, this)]
}, void 0, true, {
fileName: _jsxFileName,
lineNumber: 125,
columnNumber: 5
}, this);
}
//# sourceMappingURL=Dialog.js.map