import React, { useCallback, useEffect, useState } from 'react'; import { createPortal } from 'react-dom'; import { AnimatePresence, motion } from 'framer-motion'; import { useNavigate, useSearch } from '@tanstack/react-router'; import { __ } from '@wordpress/i18n'; import Icon from '@/utils/Icon'; import { PageFilter } from '@/components/Filters/PageFilter'; import DateRange from '@/components/Statistics/DateRange'; import ErrorBoundary from '@/components/Common/ErrorBoundary'; import { FILTER_KEYS } from '@/config/filterConfig'; import { SHEET_OVERLAY_PROPS, SHEET_PANEL_PROPS } from '@/components/Common/sheetMotionProps'; // Duration in ms, matched to the exit spring animation. const EXIT_DURATION_MS = 0; type SheetOverlayProps = { title: React.ReactNode; overlayId?: string; children: React.ReactNode; }; /** * SheetOverlay — shared full-screen bottom sheet shell. * * Provides backdrop motion, header chrome (title slot, filters, date range, * close button), escape/backdrop close, body scroll lock, and navigation * back to the source route with filter/date context preserved. * * @param {Object} props - Component props. * @param {React.ReactNode} props.title - Title content rendered in the header. * @param {string} props.overlayId - Optional DOM id for the overlay root. * @param {React.ReactNode} props.children - Scrollable body content. * @return {JSX.Element} The sheet overlay component. */ export const SheetOverlay: React.FC = ({ title, overlayId = 'sheet-overlay', children }) => { const navigate = useNavigate(); // eslint-disable-next-line @typescript-eslint/no-explicit-any const search = useSearch({ strict: false }) as Record; const from = ( search.from as string ) || '/'; // Controls the exit animation before the actual navigation fires. const [ isVisible, setIsVisible ] = useState( true ); /** * Close the overlay and navigate back to the source page, carrying the * currently active filters and date range forward. */ const handleClose = useCallback( () => { setIsVisible( false ); const closeSearch: Record = {}; // Carry active filter values forward. FILTER_KEYS.forEach( ( key ) => { const value = search[ key ]; if ( value && '' !== value ) { closeSearch[ key ] = value; } }); // Carry date range forward. if ( search.startDate ) { closeSearch.startDate = search.startDate; } if ( search.endDate ) { closeSearch.endDate = search.endDate; } if ( search.range ) { closeSearch.range = search.range; } setTimeout( () => { navigate({ // eslint-disable-next-line @typescript-eslint/no-explicit-any to: from as any, // eslint-disable-next-line @typescript-eslint/no-explicit-any search: closeSearch as any }); }, EXIT_DURATION_MS ); }, [ navigate, from, search ]); const handleBackdropClick = useCallback( ( e: React.MouseEvent ) => { if ( e.target === e.currentTarget ) { handleClose(); } }, [ handleClose ]); // Close on Escape key. useEffect( () => { const handleKeyDown = ( e: KeyboardEvent ) => { if ( 'Escape' === e.key ) { handleClose(); } }; document.addEventListener( 'keydown', handleKeyDown ); return () => document.removeEventListener( 'keydown', handleKeyDown ); }, [ handleClose ]); // Prevent scroll on the body while overlay is open. useEffect( () => { document.body.style.overflow = 'hidden'; return () => { document.body.style.overflow = ''; }; }, []); // The overlay is portaled to document.body (to escape stacking contexts of // the admin page), so the main app's dark mode class must be mirrored on // the portal wrapper. const [ isDark, setIsDark ] = useState( false ); useEffect( () => { if ( ! isVisible ) { return; } const mainAppContainer = Array.from( document.querySelectorAll( '#burst-statistics' ) ) .find( ( el ) => ! el.classList.contains( 'overlay-portal-wrapper' ) ); if ( mainAppContainer ) { setIsDark( mainAppContainer.classList.contains( 'dark' ) ); } }, [ isVisible ]); return createPortal(
{ isVisible && (
{/* Header: title, filters + date range, close button. */}
{ title }
{ children }
) }
, document.body ); };