import * as React from 'react'; import { FocusScope } from 'react-aria'; import { createPortal } from 'react-dom'; import classNames from 'classnames'; import { Group, HeadingLevelProvider, useTranslations } from '@shoptet/ui-core-web'; import type { Level } from '../../types'; import { onlyAllowedComponentsFactory } from '../../utils/onlyAllowedComponents'; import { Anchor } from '../Anchor/Anchor'; import type { ButtonProps } from '../Button/Button'; import { Button } from '../Button/Button'; import { Icon } from '../Icon/Icon'; import type { IconName } from '../Icon/IconName'; import { IconButton } from '../IconButton/IconButton'; import { Spacer } from '../Spacer/Spacer'; import { Stack } from '../Stack/Stack'; import { H1 } from '../Typography/H1/H1'; import { dictionary } from './dictionary'; type AnimationStates = 'open' | 'closing' | 'closed'; export interface ModalProps { children?: React.ReactNode; /** * Whether the modal can be dismissed. * If set to false, the modal will not close when pressing the Escape key or outside of the modal. * @default true */ canBeDismissed?: boolean; /** * If set to true, the modal will have a fixed max-width of 600px. * @default 'hug' */ maxWidth?: true | 'hug' | 'full'; /** * Whether the modal is currently open. * This prop controls the visibility of the modal. * If false, the modal will not be rendered. * @default false */ isOpen: boolean; /** * Sets the attention level and displays icon before the title. */ level?: Level; /** * Callback triggered when the close button, Escape key, or outside of the modal is pressed. * If `canBeDismissed` is false, this function will not be called. */ onClose: () => void; /** * Callback triggered when the modal finished closing. * This is called after the closing animation ends. * Useful for cleanup or state updates. */ onClosed?: () => void; /** * Callback triggered when the modal starts opening. */ onOpen?: () => void; /** * Callback triggered when the modal finished opening. * This is called after the opening animation ends. * Useful for analytics or state updates. */ onOpened?: () => void; } export interface ModalHeaderProps { children?: React.ReactNode; subheader?: React.ReactNode; } export interface ModalContentProps { children: React.ReactNode; } export interface ModalFooterProps { children: (state: { actionButtonProps: ButtonProps; primaryButtonProps: ButtonProps; level: Level | undefined; secondaryButtonProps: ButtonProps; }) => React.ReactNode; } const mapLevelToIcon: Record, IconName> = { notice: 'shp-info', alert: 'shp-tip', error: 'shp-alert', success: 'shp-checkmark', }; interface ModalContextType { level?: Level; titleId: string; } const ModalContext = React.createContext({ titleId: '', }); // HEADER const Header: React.FC = ({ children, subheader, ...rest }) => { const { level, titleId } = React.useContext(ModalContext); return (
{level && (
)}

{children}

{subheader &&
{subheader}
}
); }; // CONTENT const Content: React.FC = ({ children, ...rest }) => { return (
{children}
); }; const allowedAsFooterChildren = onlyAllowedComponentsFactory(new Set([Button, Anchor, Spacer, Stack])); const primaryButtonProps = { variant: 'primary' } satisfies ButtonProps; const secondaryButtonProps = { variant: 'secondary' } satisfies ButtonProps; const actionButtonPropsFactory = (level: Level | undefined) => ({ variant: level ? 'primary' : 'action' }) satisfies ButtonProps; // FOOTER const Footer: React.FC = ({ children, ...rest }) => { const { level } = React.useContext(ModalContext); const actionButtonProps = React.useMemo(() => actionButtonPropsFactory(level), [level]); const translations = useTranslations(dictionary); return ( {allowedAsFooterChildren( children({ actionButtonProps, level, primaryButtonProps, secondaryButtonProps, }) )} ); }; // MODAL export const Modal = Object.assign( React.forwardRef(function Modal( { children, canBeDismissed = true, maxWidth = false, isOpen, level, onClose, onClosed, onOpen, onOpened, ...rest }, ref ) { rest satisfies Record; const [animationState, setAnimationState] = React.useState(() => (isOpen ? 'open' : 'closed')); const [modalWrapper, setModalWrapper] = React.useState(null); const titleId = React.useId(); React.useEffect(() => { if (process.env.NODE_ENV === 'production' || !isOpen || animationState !== 'open') { return; } const titleElement = document.getElementById(titleId); if (!titleElement?.textContent?.trim()) { console.warn( `[Modal] aria-labelledby="${titleId}" does not reference a visible title. Use Modal.Header or apply titleId from Modal context to your title element.` ); } }, [animationState, isOpen, titleId]); React.useEffect(() => { if (isOpen && animationState !== 'open') { onOpen?.(); setAnimationState('open'); } if (!isOpen && animationState === 'open') { setAnimationState('closing'); } }, [animationState, isOpen, onOpen]); React.useEffect(() => { if (isOpen && modalWrapper) { modalWrapper.focus(); } }, [isOpen, modalWrapper]); const handleAnimationEnd = () => { if (animationState === 'open') { onOpened?.(); } if (animationState === 'closing') { setAnimationState('closed'); onClosed?.(); } }; const handleKeyDown: React.KeyboardEventHandler = e => { if (isOpen && e.key === 'Escape') { e.preventDefault(); if (canBeDismissed) { onClose(); } } }; if (animationState === 'closed') { return null; } return createPortal( , document.body ); }), { Header, Content, Footer } );