@primer/react
Version:
An implementation of GitHub's Primer Design System using React
313 lines (312 loc) • 12.7 kB
JavaScript
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 };