UNPKG

@primer/react

Version:

An implementation of GitHub's Primer Design System using React

313 lines (312 loc) • 12.7 kB
import { useMergedRefs } from "../hooks/useMergedRefs.js"; import { useProvidedRefOrCreate } from "../hooks/useProvidedRefOrCreate.js"; import { useOnEscapePress } from "../hooks/useOnEscapePress.js"; import { useResizeObserver } from "../hooks/useResizeObserver.js"; import { useId as useId$1 } from "../hooks/useId.js"; import { IconButton } from "../Button/IconButton.js"; import { ButtonComponent } from "../Button/Button.js"; import { useSlots } from "../hooks/useSlots.js"; import { useFocusTrap } from "../hooks/useFocusTrap.js"; import { useFocusZone } from "../hooks/useFocusZone.js"; import Portal_default from "../Portal/index.js"; import { ScrollableRegion } from "../ScrollableRegion/ScrollableRegion.js"; import Dialog_module_css_default from "./Dialog.module.css.js"; import { DialogContext } from "./DialogContext.js"; import { clsx } from "clsx"; import { Fragment, jsx, jsxs } from "react/jsx-runtime"; import React, { useCallback, useEffect, useRef, useState } from "react"; import { FocusKeys } from "@primer/behaviors"; import { XIcon } from "@primer/octicons-react"; //#region src/Dialog/Dialog.tsx /** * Ref count for data-dialog-scroll-disabled attribute management. * Tracks how many dialogs are currently open to know when to remove the attribute. * This is client-only: it is only accessed inside useEffect, which never runs on the server. */ 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]); const onCloseKeyDown = useCallback((event) => { if (event.key === "Escape") { event.stopPropagation(); onClose("escape"); } }, [onClose]); return /*#__PURE__*/ jsx(Dialog.Header, { children: /*#__PURE__*/ jsxs("div", { className: Dialog_module_css_default.HeaderInner, children: [/*#__PURE__*/ jsxs("div", { className: Dialog_module_css_default.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, onKeyDown: onCloseKeyDown })] }) }); }; DefaultHeader.displayName = "DefaultHeader"; const DefaultBody = ({ children }) => { return /*#__PURE__*/ jsx(Dialog.Body, { 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 = []; const MIN_BODY_HEIGHT = 48; 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$1(); const dialogDescriptionId = useId$1(); const autoFocusedFooterButtonRef = useRef(null); for (const footerButton of footerButtons) if (footerButton.autoFocus) 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, 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; useResizeObserver(useCallback(() => { if (!hasFooter) return; const dialogElement = dialogRef.current; if (!(dialogElement instanceof HTMLElement)) return; const bodyWrapper = dialogElement.querySelector(`.${Dialog_module_css_default.DialogOverflowWrapper}`); if (!(bodyWrapper instanceof HTMLElement)) return; dialogElement.setAttribute("data-footer-button-layout", "wrap"); const newLayout = bodyWrapper.clientHeight >= MIN_BODY_HEIGHT ? "wrap" : "scroll"; dialogElement.setAttribute("data-footer-button-layout", newLayout); setFooterButtonLayout(newLayout); }, [hasFooter]), 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_default, { children: /*#__PURE__*/ jsx("div", { ref: backdropRef, className: Dialog_module_css_default.Backdrop, ...positionDataAttributes, ...align && { "data-align": align }, onClick: onBackdropClick, onMouseDown: (e) => { setLastMouseDownIsBackdrop(e.target === e.currentTarget); }, children: /*#__PURE__*/ jsxs("div", { ref: mergedDialogRef, role, "aria-labelledby": dialogLabelId, "aria-describedby": dialogDescriptionId, "aria-modal": true, ...positionDataAttributes, ...align && { "data-align": align }, "data-width": isWidthMapKey(width) ? width : void 0, "data-height": height, "data-has-footer": hasFooter ? "" : void 0, "data-footer-button-layout": hasFooter ? footerButtonLayout : void 0, className: clsx(className, Dialog_module_css_default.Dialog), style: { ...style, ...!isWidthMapKey(width) ? { "--dialog-width": normalizeWidth(width) } : {} }, "data-component": dataComponent, children: [ header, /*#__PURE__*/ jsx(ScrollableRegion, { "aria-labelledby": dialogLabelId, className: Dialog_module_css_default.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, Dialog_module_css_default.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, Dialog_module_css_default.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, Dialog_module_css_default.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, Dialog_module_css_default.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, Dialog_module_css_default.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(() => { if (hasRendered === 1) { var _autoFocusRef$current; (_autoFocusRef$current = autoFocusRef.current) === null || _autoFocusRef$current === void 0 || _autoFocusRef$current.focus(); } else 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, variant: buttonType === "normal" ? "default" : buttonType, ref: autoFocus && autoFocusCount === 0 ? (autoFocusCount++, autoFocusRef) : null, children: content }, index); }) }); }; const CloseButton = ({ onClose, onKeyDown }) => { return /*#__PURE__*/ jsx(IconButton, { icon: XIcon, "aria-label": "Close", onClick: onClose, onKeyDown, 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, { Header, Title, Subtitle, Body, Footer, Buttons, CloseButton }); //#endregion export { Dialog };