import React, {
cloneElement,
forwardRef,
HTMLAttributes,
isValidElement,
ReactNode,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import classNames from "classnames";
import FocusLock from "react-focus-lock";
import { RemoveScroll } from "react-remove-scroll";
import mergeRefs from "react-merge-refs";
import { Box, BoxProps } from "../Box";
import { Flex } from "../Flex";
import { Portal, PortalProps, PortalRootElementType } from "../Portal";
import { Description, DescriptionProps } from "../Description";
import { CloseButton, CloseButtonProps } from "../CloseButton";
import { Stack, StackProps } from "../Stack";
import { KEY_CODE } from "../../types";
import { useKeyPress, useOutsideClick } from "../../hooks";
import { bem } from "../../utilities/bem";
import { Tab } from "../Tab";
import { Heading, HeadingProps } from "../Heading";
import { ModalContext, useModalContext } from "./context";
const cn = "Modal";
export enum MODAL_SIZE {
DEFAULT = "default",
LARGE = "large",
FULL = "full",
}
export interface ModalProps extends BoxProps {
/**
* The title of the modal is placed in the modal header.
*/
title?: string;
/**
* The secondary title is the same font size and weight of the title,
* but a subdued gray and it's placed on the right side if the modal
* header opposite of the title.
*
* When using the secondary title, `hideCloseButton` should be `true`
* and then the modal should be closed another way. The use case is
* using the modal to build a set up wizard.
*/
secondaryTitle?: string;
/**
* The description is smaller text placed under the title in the modal
* header.
*
* Deprecated; this prop will be removed in the next major release.
* Place any supporting description copy within the modal body.
*/
description?: string;
/**
* A boolean to control whether or not the modal is open. This is
* useful if the modal needs to be open when the page loads.
*
* Generally, this value is set via the `useDisclosure` hook.
*/
isOpen?: boolean;
/**
* A method to close the modal window. Generally, this function is set
* via the `useDisclosure` hook.
*/
onClose?: () => void;
/**
* The modal component uses react-focus-lock to trap the keyboard focus within
* the modal when it's opened. This is useful so that elements under the modal
* cannot be focused with the keyboard. This is considered good practice for
* usability and accessibility.
*
* By default, FocusLock gives focus to the first focusable element, which in
* many cases is the close button. This isn't always ideal, so the default
* value for autoFocus is false. Enable autoFocus when the modal has no other
* focusable elements.
*
* If autoFocus is not set on the modal, then you'll need to manually set
* focus to another element. This can be done by putting `autoFocus` on the
* element.
*/
autoFocus?: boolean;
/**
* Determine whether the focus should be returned to the element that had
* focus before the modal was opened.
*/
returnFocus?: boolean;
/**
* Determine whether the content underneath the modal overlay is prevented
* from scrolling; uses react-remove-scroll.
*/
lockScroll?: boolean;
/**
* Clicking outside of the modal (on the overlay) dismisses it.
*/
disableOutsideClick?: boolean;
/**
* Pressing the escape key on the keyboard dismisses the modal. This
* is the default behavior, but it can be disabled by setting this
* prop to `true`.
*/
disableEscClose?: boolean;
/**
* If the close button in the modal header needs to be hidden, it can
* be done so by setting this prop to `true`.
*/
hideCloseButton?: boolean;
/**
* Size controls the width of the modal.
*
* `MODAL_SIZE` enum can be one of `DEFAULT` | `LARGE` | `FULL`
*/
size?: MODAL_SIZE;
/**
* Whether or not to center the modal vertically within the viewport.
*/
center?: boolean;
/**
* When true, the prop prevents the header from rendering and the
* following props will be ignored: `title`, `secondaryTitle`,
* `hideCloseButton`. Use when you want a custom layout.
*/
noHeader?: boolean;
/**
* Root element where the modal portal should be attached
*/
portalRoot?: PortalRootElementType;
/**
* Class name to be appended to the overlay; useful to apply a custom z-index
* value to the outermost element of the modal
*/
overlayClassName?: string;
/**
* Whether or not to hide the screen behind the modal. The screen is the dark
* background which blurs the content behind the modal. This is useful when
* opening a modal on top of another modal.
*/
hideScreen?: boolean;
}
export interface ModalBodyProps extends BoxProps {
/**
* Adds padding around the modal body content.
*/
padded?: boolean;
}
export interface ModalFooterMultiStepProps extends BoxProps {
/**
* The total number of steps within the multi-step flow; this will be used to
* create the correct number of dots in the progress indicator.
*/
steps: number;
/**
* The number of the current step within the multi-step flow; this will be
* used to mark the dot representing the current step active in the progress
* indicator.
*/
currentStep: number;
/**
* Text label to describe the left-hand side button, or the previous button.
*/
leftButtonLabel?: string;
/**
* Text label to describe the right-hand side button, or the next button.
*/
rightButtonLabel?: string;
}
export const ModalHeader = (props: BoxProps) => {
const { className, ...rest } = props;
return (
);
};
export const ModalTitle = (props: HeadingProps) => {
return ;
};
export const ModalSecondaryTitle = ({ className, ...rest }: HeadingProps) => {
return (
);
};
export const ModalDescription = ({ className, ...rest }: DescriptionProps) => {
return (
);
};
export const ModalBody = (props: ModalBodyProps) => {
const { padded = true, className, ...rest } = props;
return (
);
};
export const ModalTabs = (props: StackProps) => {
const { className, ...rest } = props;
return (
);
};
export const ModalFooter = (props: BoxProps) => {
const { className, ...rest } = props;
return (
);
};
export const ModalFooterMultiStep = ({
steps,
currentStep,
leftButtonLabel,
rightButtonLabel,
children,
className,
...rest
}: ModalFooterMultiStepProps) => {
return (
{leftButtonLabel}
{rightButtonLabel}
{children}
{[...new Array(steps).keys()].map((dot, index) => (
))}
);
};
export const ModalCloseButton = ({ className, ...rest }: CloseButtonProps) => {
const { onClose } = useModalContext();
return (
);
};
interface ModalCloseProps {
children: ReactNode;
asChild?: boolean;
}
export const ModalClose = forwardRef<
HTMLElement,
HTMLAttributes & ModalCloseProps
>(function ModalClose(
{ children, onClick, asChild = false, ...rest },
forwardedRef,
) {
const { onClose } = useModalContext();
const childrenRef: any = (children as any).ref;
const ref = useMemo(() => mergeRefs([forwardedRef, childrenRef]), [
forwardedRef,
childrenRef,
]);
const mergedOnClick = (event: React.MouseEvent) => {
if (onClose) {
onClose();
}
if (onClick) {
onClick(event);
}
};
if (asChild && isValidElement(children)) {
return cloneElement(children, {
ref,
...rest,
...children.props,
onClick: mergedOnClick,
});
}
return (
);
});
export interface ModalRootProps {
children: ReactNode;
isOpen?: boolean;
onClose?: () => void;
disableOutsideClick?: boolean;
disableEscClose?: boolean;
}
export const ModalRoot = ({
children,
isOpen,
onClose,
// @TODO [breaking change] change to enableOutsideClick = false
disableOutsideClick = true,
disableEscClose = false,
}: ModalRootProps) => {
const [isSelectOpen, setIsSelectOpen] = useState(false);
const [esc, setEsc] = useState(disableEscClose);
useEffect(() => {
setEsc(disableEscClose);
}, [disableEscClose]);
useKeyPress(KEY_CODE.ESC, () => {
if (!esc && onClose && isOpen) {
onClose();
}
});
const context = useMemo(() => {
return {
isOpen,
onClose,
isSelectOpen,
setIsSelectOpen,
disableOutsideClick,
};
}, [isOpen, onClose, isSelectOpen, disableOutsideClick]);
return (
{children}
);
};
export type ModalPortalProps = PortalProps;
export const ModalPortal = ({ children, root, id }: ModalPortalProps) => {
return (
{children}
);
};
export interface ModalOverlayProps {
children: ReactNode;
className?: string;
lockScroll?: boolean;
autoFocus?: boolean;
returnFocus?: boolean;
}
export const ModalOverlay = ({
children,
className,
lockScroll = true,
autoFocus = false,
returnFocus = true,
}: ModalOverlayProps) => {
const { isOpen } = useModalContext();
useEffect(() => {
if (isOpen) {
document.body.classList.add("modal-open");
} else {
document.body.classList.remove("modal-open");
}
return () => document.body.classList.remove("modal-open");
}, [isOpen]);
return (
<>
{isOpen && (
<>{children}>
)}
>
);
};
export interface ModalContainerProps extends BoxProps {
children: ReactNode;
className?: string;
}
export const ModalContainer = ({
children,
className,
...rest
}: ModalContainerProps) => {
return (
{children}
);
};
export interface ModalContentProps extends BoxProps {
children: ReactNode;
}
export const ModalContent = forwardRef(
function ModalContent({ children, className, ...rest }, forwardedRef) {
const { onClose, disableOutsideClick } = useModalContext();
const modalElement = useRef(null);
useOutsideClick(modalElement, () => {
if (!disableOutsideClick && onClose) {
onClose();
}
});
const ref = useMemo(() => mergeRefs([modalElement, forwardedRef]), [
modalElement,
forwardedRef,
]);
return (
{children}
);
},
);
export const ModalScreen = ({
className,
style,
...rest
}: HTMLAttributes) => {
return (
);
};
/**
* Modals are overlays that prevent users from interacting with the rest
* of the application until a specific action is taken.
*
* The modal contains a portal by default, so there is no need to wrap
* it in one in the application.
*
* [Skip to examples](#stories)
*/
export const Modal = (props: ModalProps) => {
const {
title,
secondaryTitle,
description,
children,
isOpen,
onClose,
autoFocus = false,
returnFocus = true,
lockScroll = true,
disableOutsideClick = true, // @TODO [breaking change] change to enableOutsideClick = false
disableEscClose = false,
hideCloseButton = false,
size = MODAL_SIZE.DEFAULT,
center = false,
noHeader = false,
portalRoot,
className,
overlayClassName,
hideScreen,
...rest
} = props;
const showHeader = title || secondaryTitle || !hideCloseButton;
return (
{!noHeader && showHeader && (
{title && {title}}
{secondaryTitle && (
{secondaryTitle}
)}
{!hideCloseButton && }
)}
{description && (
{description}
)}
{children}
{!hideScreen && }
);
};
Modal.Root = ModalRoot;
Modal.Portal = ModalPortal;
Modal.Overlay = ModalOverlay;
Modal.Container = ModalContainer;
Modal.Content = ModalContent;
Modal.Screen = ModalScreen;
Modal.Tabs = ModalTabs;
Modal.Header = ModalHeader;
Modal.Title = ModalTitle;
Modal.SecondaryTitle = ModalSecondaryTitle;
Modal.Description = ModalDescription;
Modal.Body = ModalBody;
Modal.Footer = ModalFooter;
Modal.FooterMultiStep = ModalFooterMultiStep;
Modal.CloseButton = ModalCloseButton;
Modal.Close = ModalClose;