import React, { type ReactPortal, useCallback, useEffect, useLayoutEffect, useRef, useState, } from 'react' import * as ReactDOM from 'react-dom' import { cn } from '../../services/cn' import { type ConfirmationPopoverContentProps } from '../ConfirmationPopover/ConfirmationPopoverContent' import isClient from '../../services/isClient' import PopoverHeader from '../PopoverHeader/PopoverHeader' import Stepper from '../Stepper/Stepper' import { useMediaQuery } from '../../hooks/responsiveHooks' import { useWindowSize } from '../../hooks/useWindowSize/useWindowSize' import { type StepperProps } from '../Stepper/Stepper' import ScrollingContainer from '../ScrollingContainer/ScrollingContainer' import BackgroundOverlay from '../BackgroundOverlay/BackgroundOverlay' import { c } from '../../translations/LibraryTranslationService' const { createPortal } = ReactDOM type SideDrawerWithSize = SideDrawerBase & { /** Size of the drawer */ size?: 'sm' | 'md' | 'lg' fullWidth?: never } type SideDrawerWithFullWidth = SideDrawerBase & { /** Optionally render `SideDrawer` in a full width view. */ fullWidth?: boolean size?: never } type DetermineSideDrawerSize = SideDrawerWithSize | SideDrawerWithFullWidth type SideDrawerWithText = DetermineSideDrawerSize & { /** The content to be rendered within the header. */ headerContent: React.ReactNode logoUrl?: never } type SideDrawerWithLogo = DetermineSideDrawerSize & { headerContent?: never /** Optional logo to be passed in place of `headerContent` */ logoUrl: string } export type SideDrawerBase = { /** The content to be rendered within the component. */ children: | React.ReactNode | (({ height, close, }: { height: number close: () => void }) => React.JSX.Element) /** The open state of `SideDrawer`. */ isOpen: boolean /** Callout function to close the `SideDrawer`. */ closeCallout: React.MouseEventHandler & ((isOutsideClick?: boolean) => void) /** Optional className for the `SideDrawer` container */ containerClassName?: string /** Optional className for the content */ contentClassName?: string /** The content to be rendered within the footer. */ footerContent?: | React.ReactNode | (({ close }: { close: () => void }) => React.JSX.Element) /** Optionally render `SideDrawer` in a mobile view only. */ onlyMobile?: boolean /** Optionally add the `Stepper` component. */ stepperProps?: StepperProps /** Optionally remove the gradient from the `SideDrawer`. This is useful when the background of * the content is something other than white. */ noGradient?: boolean /** Optionally remove content padding */ noContentPadding?: boolean /** Optionally remove footer padding */ noFooterPadding?: boolean /** Optionally add a layer position to the drawer to allow layered drawers. The number here will * be the position in the stack of `SideDrawer` layers if there are multiple SideDrawers that * need to be displayed. NOTE: Omit this prop if there is only 1 `SideDrawer` layer. This is * important for styling. */ layerPosition?: number /** Optionally use the confirmation header style. Note: this only applies to the header of the drawer and for styling the mobile version of the `Modal` component. */ confirmation?: ConfirmationPopoverContentProps /** Optional prop to force the background overlay to be shown. * By default, the background overlay is displayed. However, it is * turned off for layered drawers. Setting this prop ensures that * the overlay is always shown. */ showBackgroundOverlay?: boolean /** Optional prop to add a test id for QA testing */ qaTestId?: string } export type SideDrawerProps = SideDrawerWithText | SideDrawerWithLogo const sizeVariants = { sm: 'max-w-[448px]', // 448px width defined in the Design System md: 'max-w-[600px]', // 600px width defined in the Design System lg: 'max-w-[768px]', // 768px width defined in the Design System fullWidth: 'max-w-screen', } as const export function SideDrawer({ children, closeCallout, containerClassName = '', contentClassName = '', footerContent, headerContent, logoUrl, isOpen, onlyMobile, size = 'sm', fullWidth, stepperProps, noGradient, noContentPadding, noFooterPadding, layerPosition, confirmation, qaTestId = 'side-drawer', showBackgroundOverlay = false, }: SideDrawerProps): React.JSX.Element { const zIndex = 1100 + (layerPosition ?? 0) if (typeof isOpen !== 'boolean') { throw new Error(c('errorSideDrawerIsOpen')) } if (!closeCallout || typeof closeCallout !== 'function') { throw new Error( !closeCallout ? c('errorSideDrawerCloseCalloutRequired') : c('errorSideDrawerCloseCalloutMustBeFunction'), ) } const screenLargerThanMd = useMediaQuery({ type: 'min', breakpoint: 'md' }) const hide = screenLargerThanMd && onlyMobile const sectionRef = useRef(null) const sectionContentRef = useRef(null) const [contentHeight, setContentHeight] = useState('auto') const [footerRef, setFooterRef] = useState(null) const openDrawer = useRef(false) const windowHeight = useWindowSize(true)?.height const [isTransitioning, setIsTransitioning] = useState(false) const timeoutRef = useRef>(undefined) useEffect(() => { setTimeout(() => (openDrawer.current = isOpen)) // wait for SideDrawer to be rendered on screen }, [isOpen]) useLayoutEffect(() => { if (sectionRef.current) { const sectionEl = sectionRef.current const transitionStart = (evt: TransitionEvent) => { // we need to make sure we only pay attention to transition events that the section element fires and nothing from the children if (evt.target === sectionEl) { sectionEl.dataset.drawerState = 'transitioning' } } const transitionEnd = (evt?: TransitionEvent) => { // evt could be undefined, in the case below where we call it manually. // otherwise, on all events, we need to make sure we only pay attention to transition events that the section element fires, and nothing from the children if (!evt || evt.target === sectionEl) { if (sectionEl.dataset.drawerEndStatus === 'open') { sectionEl.dataset.drawerState = 'open' } else { sectionEl.dataset.drawerState = 'closed' } } } sectionEl.addEventListener('transitionstart', transitionStart) sectionEl.addEventListener('transitionend', transitionEnd) // execute one time to set the initial data-drawer-state transitionEnd() return () => { sectionEl.removeEventListener('transitionstart', transitionStart) sectionEl.removeEventListener('transitionend', transitionEnd) } } }, []) useEffect(() => { const headerHeight = 49 // 49px accounts for the height of `PopoverHeader` const footerHeight = footerRef ? footerRef.clientHeight + 1 : 0 // 1px accounts for the border of the footer const heightToSubtract = headerHeight + footerHeight + (stepperProps ? 56 /** Height of the Stepper */ : 0) const height = (windowHeight ?? 0) - heightToSubtract setContentHeight(height) }, [footerRef, stepperProps, windowHeight]) const close = useCallback( (isOutsideClick?: boolean) => { setIsTransitioning(true) timeoutRef.current = setTimeout(() => { closeCallout(isOutsideClick) setIsTransitioning(false) }, 250) }, [closeCallout], ) useEffect(() => { return () => { clearTimeout(timeoutRef.current) } }, []) // when onlyMobile, we won't use this at all on > md devices if (hide) return <> return isClient && isOpen ? ( (createPortal( <> close(true)} // Passing in true to indicate that we are clicking outside of the drawer isTransitioning={isTransitioning} style={{ zIndex: zIndex - 1, background: layerPosition && !showBackgroundOverlay ? 'rgba(0, 0, 0, 0)' /** Overriding the opacity for the background so that SideDrawers that have `layerPosition` defined will not have the background overlay visible. */ : 'rgba(0, 0, 0, 0.25)' /** This is the style defined in _background-overlay.module.scss */, }} />
close(false)} // Explicitly passing in false to indicate that we are clicking inside of the drawer. noBorderRadius hasBackButton={!!layerPosition} {...(confirmation?.type ? { styleType: confirmation.type } : {})} /> {stepperProps ? : null}
{typeof children === 'function' ? children({ height: typeof contentHeight === 'number' ? contentHeight : 0, close, }) : children}
{footerContent ? (
{typeof footerContent === 'function' ? footerContent({ close, }) : footerContent}
) : null}
, document.querySelector('body') as HTMLBodyElement, ) as ReactPortal) ) : ( <> ) }