import { createFocusTrap, onEscapeKey, animate, cancelAnimations } from '@inertiaui/vanilla' import clsx from 'clsx' import { useState, useEffect, useRef, useCallback, useMemo, ReactNode, SyntheticEvent, MouseEvent } from 'react' import CloseButton from './CloseButton' import { getMaxWidthClass } from './constants' import type { Modal } from './types' interface SlideoverContentConfig { maxWidth: string paddingClasses: string panelClasses: string position: string closeButton: boolean closeExplicitly?: boolean closeOnClickOutside?: boolean } interface SlideoverContentProps { modalContext: Modal config: SlideoverContentConfig useNativeDialog: boolean isFirstModal: boolean onAfterLeave?: () => void children: ReactNode | ((props: { modalContext: Modal; config: SlideoverContentConfig }) => ReactNode) } const SlideoverContent = ({ modalContext, config, useNativeDialog, isFirstModal, onAfterLeave, children }: SlideoverContentProps) => { const [isRendered, setIsRendered] = useState(false) const [isVisible, setIsVisible] = useState(false) // For backdrop sync const [entered, setEntered] = useState(false) // After animation completes const wrapperRef = useRef(null) const dialogRef = useRef(null) const nativeWrapperRef = useRef(null) const cleanupFocusTrapRef = useRef<(() => void) | null>(null) const cleanupEscapeKeyRef = useRef<(() => void) | null>(null) const isLeft = config.position === 'left' const maxWidthClass = useMemo(() => getMaxWidthClass(config.maxWidth), [config.maxWidth]) // Get translate value based on position const getTranslateX = useCallback(() => (isLeft ? '-100%' : '100%'), [isLeft]) // ============ Animation handlers using Web Animations API ============ const animateIn = useCallback( async (element: HTMLElement | null) => { if (!element) return setIsVisible(true) // Trigger backdrop immediately const translateX = getTranslateX() await animate(element, [ { transform: `translate3d(${translateX}, 0, 0)`, opacity: 0 }, { transform: 'translate3d(0, 0, 0)', opacity: 1 }, ]) setEntered(true) }, [getTranslateX], ) const animateOut = useCallback( async (element: HTMLElement | null) => { if (!element) return setIsVisible(false) // Trigger backdrop fade out immediately const translateX = getTranslateX() await animate(element, [ { transform: 'translate3d(0, 0, 0)', opacity: 1 }, { transform: `translate3d(${translateX}, 0, 0)`, opacity: 0 }, ]) setIsRendered(false) if (useNativeDialog && dialogRef.current) { dialogRef.current.close() } onAfterLeave?.() modalContext.afterLeave() }, [getTranslateX, useNativeDialog, onAfterLeave, modalContext], ) // ============ Non-native dialog handlers ============ const setupFocusTrap = useCallback(() => { if (useNativeDialog) return if (!wrapperRef.current || !modalContext.onTopOfStack) return if (cleanupFocusTrapRef.current) return cleanupFocusTrapRef.current = createFocusTrap(wrapperRef.current, { initialFocus: true, returnFocus: false, }) }, [modalContext.onTopOfStack, useNativeDialog]) const cleanupFocusTrap = useCallback(() => { if (cleanupFocusTrapRef.current) { cleanupFocusTrapRef.current() cleanupFocusTrapRef.current = null } }, []) const setupEscapeKey = useCallback(() => { if (useNativeDialog) return if (cleanupEscapeKeyRef.current) return if (config?.closeExplicitly) return cleanupEscapeKeyRef.current = onEscapeKey(() => { if (modalContext.onTopOfStack) { modalContext.close() } }) }, [config?.closeExplicitly, modalContext, useNativeDialog]) const cleanupEscapeKey = useCallback(() => { if (cleanupEscapeKeyRef.current) { cleanupEscapeKeyRef.current() cleanupEscapeKeyRef.current = null } }, []) const handleClickOutside = useCallback( (event: MouseEvent) => { if (useNativeDialog) return if (!modalContext.onTopOfStack) return if (config?.closeExplicitly) return if (config?.closeOnClickOutside === false) return if (!wrapperRef.current) return if (!wrapperRef.current.contains(event.target as Node)) { modalContext.close() } }, [modalContext, config?.closeExplicitly, config?.closeOnClickOutside, useNativeDialog], ) // ============ Native dialog handlers ============ const handleCancel = useCallback( (event: SyntheticEvent) => { event.preventDefault() if (modalContext.onTopOfStack && !config?.closeExplicitly) { modalContext.close() } }, [modalContext, config?.closeExplicitly], ) const handleDialogClick = useCallback( (event: MouseEvent) => { if (event.target === dialogRef.current) { if (modalContext.onTopOfStack && !config?.closeExplicitly && config?.closeOnClickOutside !== false) { modalContext.close() } } }, [modalContext, config?.closeExplicitly, config?.closeOnClickOutside], ) // ============ Lifecycle ============ // Track previous isOpen state for detecting close const prevIsOpenRef = useRef(modalContext.isOpen) // Initial mount and open state changes useEffect(() => { if (useNativeDialog) { if (modalContext.isOpen && !dialogRef.current?.open) { dialogRef.current?.showModal() animateIn(nativeWrapperRef.current) } else if (!modalContext.isOpen && prevIsOpenRef.current) { setEntered(false) animateOut(nativeWrapperRef.current) } } else { if (modalContext.isOpen && !isRendered) { setIsRendered(true) } else if (!modalContext.isOpen && prevIsOpenRef.current) { setEntered(false) animateOut(wrapperRef.current) } } prevIsOpenRef.current = modalContext.isOpen }, [modalContext.isOpen, useNativeDialog, animateIn, animateOut, isRendered]) // Trigger animation after render (non-native) useEffect(() => { if (!useNativeDialog && isRendered && !entered && modalContext.isOpen) { animateIn(wrapperRef.current).then(() => { setupFocusTrap() }) } }, [isRendered, useNativeDialog, entered, modalContext.isOpen, animateIn, setupFocusTrap]) // Setup escape key (non-native) useEffect(() => { if (!useNativeDialog) { setupEscapeKey() } return () => { cleanupEscapeKey() } }, [useNativeDialog, setupEscapeKey, cleanupEscapeKey]) // Handle becoming top of stack / losing top of stack (non-native only) useEffect(() => { if (useNativeDialog) return if (modalContext.onTopOfStack) { setupEscapeKey() if (entered) { setupFocusTrap() } } else { cleanupFocusTrap() cleanupEscapeKey() } }, [modalContext.onTopOfStack, entered, setupEscapeKey, setupFocusTrap, cleanupFocusTrap, cleanupEscapeKey, useNativeDialog]) // Cleanup on unmount useEffect(() => { return () => { const wrapper = useNativeDialog ? nativeWrapperRef.current : wrapperRef.current if (wrapper) { cancelAnimations(wrapper) } if (useNativeDialog) { if (dialogRef.current?.open) { dialogRef.current.close() } } else { cleanupFocusTrap() cleanupEscapeKey() } } }, [useNativeDialog, cleanupFocusTrap, cleanupEscapeKey]) // ============ Render ============ const renderContent = () => (
{config.closeButton && (
)} {typeof children === 'function' ? children({ modalContext, config }) : children}
) // Native dialog mode if (useNativeDialog) { return (
{renderContent()}
) } // Non-native dialog mode if (!isRendered) return null return (
Dialog {renderContent()}
) } export default SlideoverContent