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