UNPKG

@hypothesis/frontend-shared

Version:

Shared components, styles and utilities for Hypothesis projects

200 lines (189 loc) 6.75 kB
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