UNPKG

@primer/react

Version:

An implementation of GitHub's Primer Design System using React

399 lines (393 loc) • 13.9 kB
import React, { useRef, useState, useCallback, useEffect } from 'react'; import { IconButton } from '../Button/IconButton.js'; import { ButtonComponent } from '../Button/Button.js'; import { useFocusTrap } from '../hooks/useFocusTrap.js'; import { XIcon } from '@primer/octicons-react'; import { useFocusZone } from '../hooks/useFocusZone.js'; import { FocusKeys } from '@primer/behaviors'; import { Portal } from '../Portal/Portal.js'; import { useId } from '../hooks/useId.js'; import classes from './Dialog.module.css.js'; import { clsx } from 'clsx'; import { useSlots } from '../hooks/useSlots.js'; import { useResizeObserver } from '../hooks/useResizeObserver.js'; import { jsx, jsxs, Fragment } from 'react/jsx-runtime'; import { useMergedRefs } from '../hooks/useMergedRefs.js'; import { useOnEscapePress } from '../hooks/useOnEscapePress.js'; import { ScrollableRegion } from '../ScrollableRegion/ScrollableRegion.js'; import { useProvidedRefOrCreate } from '../hooks/useProvidedRefOrCreate.js'; let dialogScrollDisabledCount = 0; const widthMap = { small: '296px', medium: '320px', large: '480px', xlarge: '640px' }; const isWidthMapKey = width => typeof width === 'string' && Object.hasOwn(widthMap, width); const normalizeWidth = width => typeof width === 'number' ? `${width}px` : width; const DefaultHeader = ({ dialogLabelId, title, subtitle, dialogDescriptionId, onClose }) => { const onCloseClick = useCallback(() => { onClose('close-button'); }, [onClose]); return /*#__PURE__*/jsx(Dialog.Header, { children: /*#__PURE__*/jsxs("div", { className: classes.HeaderInner, children: [/*#__PURE__*/jsxs("div", { className: classes.HeaderContent, children: [/*#__PURE__*/jsx(Dialog.Title, { id: dialogLabelId, children: title !== null && title !== void 0 ? title : 'Dialog' }), subtitle && /*#__PURE__*/jsx(Dialog.Subtitle, { id: dialogDescriptionId, children: subtitle })] }), /*#__PURE__*/jsx(Dialog.CloseButton, { onClose: onCloseClick })] }) }); }; DefaultHeader.displayName = "DefaultHeader"; const DefaultBody = ({ children }) => { return /*#__PURE__*/jsx(Dialog.Body, { children: children }); }; DefaultBody.displayName = "DefaultBody"; const DefaultFooter = ({ footerButtons }) => { const { containerRef: footerRef } = useFocusZone({ bindKeys: FocusKeys.ArrowHorizontal | FocusKeys.Tab, focusInStrategy: 'closest' }); return footerButtons ? /*#__PURE__*/jsx(Dialog.Footer, { ref: footerRef, children: /*#__PURE__*/jsx(Dialog.Buttons, { buttons: footerButtons }) }) : null; }; const defaultPosition = { narrow: 'center', regular: 'center' }; const defaultFooterButtons = []; // Minimum room needed for body content before forcing footer buttons into horizontal scroll. const MIN_BODY_HEIGHT = 48; // useful to determine whether we're inside a Dialog from a nested component const DialogContext = /*#__PURE__*/React.createContext(undefined); const DIALOG_CONTEXT_VALUE = Object.freeze({}); const _Dialog = /*#__PURE__*/React.forwardRef((props, forwardedRef) => { var _slots$header, _slots$body, _slots$footer; const { 'data-component': dataComponentProp, title = 'Dialog', subtitle = '', renderHeader, renderBody, renderFooter, onClose, role = 'dialog', width = 'xlarge', height = 'auto', footerButtons = defaultFooterButtons, position = defaultPosition, align, returnFocusRef, initialFocusRef, className, style } = props; const dialogLabelId = useId(); const dialogDescriptionId = useId(); const autoFocusedFooterButtonRef = useRef(null); for (const footerButton of footerButtons) { if (footerButton.autoFocus) { // eslint-disable-next-line react-hooks/immutability footerButton.ref = autoFocusedFooterButtonRef; } } const [lastMouseDownIsBackdrop, setLastMouseDownIsBackdrop] = useState(false); const [footerButtonLayout, setFooterButtonLayout] = useState('wrap'); const defaultedProps = { ...props, title, subtitle, role, dialogLabelId, dialogDescriptionId }; const onBackdropClick = useCallback(e => { if (e.target === e.currentTarget && lastMouseDownIsBackdrop) { onClose('escape'); } }, [onClose, lastMouseDownIsBackdrop]); const [slots, childrenWithoutSlots] = useSlots(props.children, { body: Dialog.Body, header: Dialog.Header, footer: Dialog.Footer }); const dialogRef = useRef(null); const mergedDialogRef = useMergedRefs(forwardedRef, dialogRef); const backdropRef = useRef(null); useFocusTrap({ containerRef: dialogRef, initialFocusRef: initialFocusRef !== null && initialFocusRef !== void 0 ? initialFocusRef : autoFocusedFooterButtonRef, // eslint-disable-next-line react-hooks/refs restoreFocusOnCleanUp: returnFocusRef !== null && returnFocusRef !== void 0 && returnFocusRef.current ? false : true, returnFocusRef }); useOnEscapePress(event => { onClose('escape'); event.preventDefault(); }, [onClose]); React.useEffect(() => { const scrollbarWidth = window.innerWidth - document.body.clientWidth; dialogScrollDisabledCount++; document.body.style.setProperty('--prc-dialog-scrollgutter', `${scrollbarWidth}px`); document.body.setAttribute('data-dialog-scroll-disabled', ''); return () => { dialogScrollDisabledCount--; if (dialogScrollDisabledCount === 0) { document.body.style.removeProperty('--prc-dialog-scrollgutter'); document.body.removeAttribute('data-dialog-scroll-disabled'); } }; }, []); const header = (_slots$header = slots.header) !== null && _slots$header !== void 0 ? _slots$header : (renderHeader !== null && renderHeader !== void 0 ? renderHeader : DefaultHeader)(defaultedProps); const body = (_slots$body = slots.body) !== null && _slots$body !== void 0 ? _slots$body : (renderBody !== null && renderBody !== void 0 ? renderBody : DefaultBody)({ ...defaultedProps, children: childrenWithoutSlots }); const footer = (_slots$footer = slots.footer) !== null && _slots$footer !== void 0 ? _slots$footer : (renderFooter !== null && renderFooter !== void 0 ? renderFooter : DefaultFooter)(defaultedProps); const hasFooter = footer != null; const updateFooterButtonLayout = useCallback(() => { if (!hasFooter) { return; } const dialogElement = dialogRef.current; if (!(dialogElement instanceof HTMLElement)) { return; } const bodyWrapper = dialogElement.querySelector(`.${classes.DialogOverflowWrapper}`); if (!(bodyWrapper instanceof HTMLElement)) { return; } // We temporarily force "wrap" the footer layout so that the browser can calculate the body height - // when the footer is wrapping. This is instantaneous with what we set below (`dialogElement.setAttribute('data-footer-button-layout', newLayout)`). dialogElement.setAttribute('data-footer-button-layout', 'wrap'); const bodyHeight = bodyWrapper.clientHeight; const newLayout = bodyHeight >= MIN_BODY_HEIGHT ? 'wrap' : 'scroll'; dialogElement.setAttribute('data-footer-button-layout', newLayout); setFooterButtonLayout(newLayout); }, [hasFooter]); useResizeObserver(updateFooterButtonLayout, backdropRef); const positionDataAttributes = typeof position === 'string' ? { 'data-position-regular': position } : Object.fromEntries(Object.entries(position).map(([key, value]) => { return [`data-position-${key}`, value]; })); const dataComponent = dataComponentProp !== null && dataComponentProp !== void 0 ? dataComponentProp : 'Dialog'; return /*#__PURE__*/jsx(DialogContext.Provider, { value: DIALOG_CONTEXT_VALUE, children: /*#__PURE__*/jsx(Portal, { children: /*#__PURE__*/jsx("div", { ref: backdropRef, className: classes.Backdrop, ...positionDataAttributes, ...(align && { 'data-align': align }), onClick: onBackdropClick, onMouseDown: e => { setLastMouseDownIsBackdrop(e.target === e.currentTarget); }, children: /*#__PURE__*/jsxs("div", { ref: mergedDialogRef, role: role, "aria-labelledby": dialogLabelId, "aria-describedby": dialogDescriptionId, "aria-modal": true, ...positionDataAttributes, ...(align && { 'data-align': align }), "data-width": isWidthMapKey(width) ? width : undefined, "data-height": height, "data-has-footer": hasFooter ? '' : undefined, "data-footer-button-layout": hasFooter ? footerButtonLayout : undefined, className: clsx(className, classes.Dialog), style: { ...style, ...(!isWidthMapKey(width) ? { '--dialog-width': normalizeWidth(width) } : {}) }, "data-component": dataComponent, children: [header, /*#__PURE__*/jsx(ScrollableRegion, { "aria-labelledby": dialogLabelId, className: classes.DialogOverflowWrapper, children: body }), footer] }) }) }) }); }); _Dialog.displayName = 'Dialog'; const Header = /*#__PURE__*/React.forwardRef(function Header({ className, ...rest }, forwardRef) { return /*#__PURE__*/jsx("div", { ref: forwardRef, className: clsx(className, classes.Header), ...rest, "data-component": "Dialog.Header" }); }); Header.displayName = 'Dialog.Header'; const Title = /*#__PURE__*/React.forwardRef(function Title({ className, ...rest }, forwardRef) { return /*#__PURE__*/jsx("h1", { ref: forwardRef, className: clsx(className, classes.Title), ...rest, "data-component": "Dialog.Title" }); }); Title.displayName = 'Dialog.Title'; const Subtitle = /*#__PURE__*/React.forwardRef(function Subtitle({ className, ...rest }, forwardRef) { return /*#__PURE__*/jsx("h2", { ref: forwardRef, className: clsx(className, classes.Subtitle), ...rest, "data-component": "Dialog.Subtitle" }); }); Subtitle.displayName = 'Dialog.Subtitle'; const Body = /*#__PURE__*/React.forwardRef(function Body({ className, ...rest }, forwardRef) { return /*#__PURE__*/jsx("div", { ref: forwardRef, className: clsx(className, classes.Body), ...rest, "data-component": "Dialog.Body" }); }); Body.displayName = 'Dialog.Body'; const Footer = /*#__PURE__*/React.forwardRef(function Footer({ className, ...rest }, forwardRef) { return /*#__PURE__*/jsx("div", { ref: forwardRef, className: clsx(className, classes.Footer), ...rest, "data-component": "Dialog.Footer" }); }); Footer.displayName = 'Dialog.Footer'; const Buttons = ({ buttons }) => { var _buttons$find; const autoFocusRef = useProvidedRefOrCreate((_buttons$find = buttons.find(button => button.autoFocus)) === null || _buttons$find === void 0 ? void 0 : _buttons$find.ref); let autoFocusCount = 0; const [hasRendered, setHasRendered] = useState(0); useEffect(() => { // hack to work around dialogs originating from other focus traps. if (hasRendered === 1) { var _autoFocusRef$current; (_autoFocusRef$current = autoFocusRef.current) === null || _autoFocusRef$current === void 0 ? void 0 : _autoFocusRef$current.focus(); } else { // eslint-disable-next-line react-hooks/set-state-in-effect setHasRendered(hasRendered + 1); } }, [autoFocusRef, hasRendered]); return /*#__PURE__*/jsx(Fragment, { children: buttons.map((dialogButtonProps, index) => { const { content, buttonType = 'default', autoFocus = false, ...buttonProps } = dialogButtonProps; return /*#__PURE__*/jsx(ButtonComponent, { "data-component": "Dialog.FooterButton", ...buttonProps, // 'normal' value is equivalent to 'default', this is used for backwards compatibility variant: buttonType === 'normal' ? 'default' : buttonType // @ts-expect-error it needs a non nullable ref , ref: autoFocus && autoFocusCount === 0 ? (autoFocusCount++, autoFocusRef) : null, children: content }, index); }) }); }; const CloseButton = ({ onClose }) => { return /*#__PURE__*/jsx(IconButton, { icon: XIcon, "aria-label": "Close", onClick: onClose, variant: "invisible", "data-component": "Dialog.CloseButton" }); }; CloseButton.displayName = "CloseButton"; /** * A dialog is a type of overlay that can be used for confirming actions, asking * for disambiguation, and presenting small forms. They generally allow the user * to focus on a quick task without having to navigate to a different page. * * Dialogs appear in the page after a direct user interaction. Don't show dialogs * on page load or as system alerts. * * Dialogs appear centered in the page, with a visible backdrop that dims the rest * of the window for focus. * * All dialogs have a title and a close button. * * Dialogs are modal. Dialogs can be dismissed by clicking on the close button, * pressing the escape key, or by interacting with another button in the dialog. * To avoid losing information and missing important messages, clicking outside * of the dialog will not close it. * * The sub components provided (e.g. Header, Title, etc.) are available for custom * renderers only. They are not intended to be used otherwise. */ Header.__SLOT__ = Symbol('Dialog.Header'); Footer.__SLOT__ = Symbol('Dialog.Footer'); Body.__SLOT__ = Symbol('Dialog.Body'); const Dialog = Object.assign(_Dialog, { __SLOT__: Symbol('Dialog'), Header, Title, Subtitle, Body, Footer, Buttons, CloseButton }); export { Dialog, DialogContext };