UNPKG

drawer-stack

Version:
226 lines (225 loc) 13.3 kB
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime"; import { Drawer } from "./CustomVerticalDrawer"; import { useDrawerStack } from "./useDrawerStack"; import { useEffect, useState, createContext, useContext } from "react"; import { findRouteAndMatch, flattenRoutes } from "./routeUtils"; import { UNSAFE_RouteContext as RouteContext, } from "react-router"; // Context for drawer search parameters const DrawerSearchParamsContext = createContext(null); // Context to expose the scrollable container element for the drawer content const DrawerScrollContainerContext = createContext(null); export const useDrawerScrollContainerRef = () => useContext(DrawerScrollContainerContext); // Hook to access drawer search parameters export const useDrawerSearchParams = () => { const drawerSearchParams = useContext(DrawerSearchParamsContext); return drawerSearchParams || new URLSearchParams(); }; // This component renders route components inside drawers function DrawerContent({ path, // level, onClose, // onNavigateInDrawer, routes, closeButton: CloseButton, borderRadius, }) { const [scrollContainerEl, setScrollContainerEl] = useState(null); // Ignore root route to prevent recursive rendering if (path === "/") { return (_jsxs("div", { className: "flex flex-col h-full", children: [_jsxs("div", { className: "flex items-center justify-between p-4", children: [_jsx("div", { className: "flex-1 text-center", children: _jsx("h1", { className: "font-medium", children: "Root Route" }) }), CloseButton && _jsx(CloseButton, { onClick: onClose })] }), _jsxs("div", { className: "flex-1 overflow-auto p-4", children: [_jsx("p", { children: "The root route cannot be displayed in a drawer." }), _jsx("p", { className: "text-sm text-gray-600 mt-2", children: "Try opening other routes instead." })] })] })); } const flatRoutes = flattenRoutes(routes); const result = findRouteAndMatch(path, flatRoutes); if (!result || !result.route.element) { return (_jsxs("div", { className: "flex flex-col h-full", children: [_jsxs("div", { className: "flex items-center justify-between p-4", children: [_jsx("div", { className: "flex-1 text-center", children: _jsx("h1", { className: "font-medium", children: "Not Found" }) }), CloseButton && _jsx(CloseButton, { onClick: onClose })] }), _jsx("div", { className: "flex-1 overflow-auto p-4", children: _jsxs("p", { children: ["No route found for path: ", path] }) })] })); } const { route, match } = result; // Parse search parameters from the drawer path const [, drawerSearch] = path.split("?"); const drawerSearchParams = new URLSearchParams(drawerSearch || ""); const routeContextValue = { outlet: null, matches: [ { params: match.params, pathname: match.pathname, pathnameBase: match.pathname, route: route, }, ], isDataRoute: false, }; return (_jsxs("div", { className: "flex flex-col h-full flex-1 min-h-0 relative", children: [CloseButton && _jsx(CloseButton, { onClick: onClose }), _jsx("div", { className: "overflow-x-auto overflow-y-auto flex-1", style: { borderTopLeftRadius: borderRadius, borderTopRightRadius: borderRadius, }, ref: setScrollContainerEl, children: _jsx(DrawerSearchParamsContext.Provider, { value: drawerSearchParams, children: _jsx(DrawerScrollContainerContext.Provider, { value: scrollContainerEl, children: _jsx(RouteContext.Provider, { value: routeContextValue, children: route.element }) }) }) })] })); } export function DrawerStack({ routes, STACK_GAP = 40, STACK_SQUEEZE = 0.04, closeButton, height = "85%", backgroundColor = "white", borderRadius = "10px", handleClassName, }) { const { drawerStack, hasDrawers, popDrawerInternal, closeAllDrawers, pushDrawer, } = useDrawerStack(); const [openDrawers, setOpenDrawers] = useState([]); const [closingDrawers, setClosingDrawers] = useState(new Set()); const [draggingDrawers, setDraggingDrawers] = useState(new Set()); // Sync open state with drawer stack useEffect(() => { setOpenDrawers((prev) => { // If we're adding drawers, keep existing states and add new ones as closed initially if (drawerStack.length > prev.length) { const newStates = [...prev]; for (let i = prev.length; i < drawerStack.length; i++) { newStates[i] = false; // Start closed, will animate open } // Schedule opening animation after a frame setTimeout(() => { setOpenDrawers((current) => { const opened = [...current]; for (let i = prev.length; i < drawerStack.length; i++) { opened[i] = true; } return opened; }); }, 10); return newStates; } // If we're removing drawers, truncate the array else if (drawerStack.length < prev.length) { const newStates = prev.slice(0, drawerStack.length); return newStates; } // No change in length return prev; }); }, [drawerStack.length]); // Handle drawer close with animation delay const handleDrawerClose = (level) => { // Mark this drawer as closing immediately so other drawers can start moving setClosingDrawers((prev) => { const newSet = new Set([...prev, level]); return newSet; }); // Set the drawer as closed to trigger exit animation setOpenDrawers((prev) => { const newOpen = [...prev]; newOpen[level] = false; return newOpen; }); // Remove from URL after animation completes // Add extra buffer for Safari iOS to prevent animation glitches const isSafariIOS = /iPad|iPhone|iPod/.test(navigator.userAgent); const animationDuration = isSafariIOS ? 300 : 250; setTimeout(() => { if (level === drawerStack.length - 1) { // Closing the top drawer popDrawerInternal(); } else { // Closing a drawer in the middle - close all above it closeAllDrawers(); } // Clean up closing state after URL update to prevent position jiggle setTimeout(() => { setClosingDrawers((prev) => { const newSet = new Set(prev); newSet.delete(level); return newSet; }); }, 50); }, animationDuration); }; // Listen for animated close events useEffect(() => { const handlePopDrawerAnimated = (event) => { const { level } = event.detail; if (level >= 0 && level < drawerStack.length) { handleDrawerClose(level); } }; window.addEventListener("popDrawerAnimated", handlePopDrawerAnimated); return () => { window.removeEventListener("popDrawerAnimated", handlePopDrawerAnimated); }; }, [drawerStack.length, handleDrawerClose]); // Handle navigation within drawers const handleNavigateInDrawer = (path) => { pushDrawer(path); }; if (!hasDrawers) { return null; } return (_jsx(_Fragment, { children: drawerStack.map((drawer, index) => { const isDragging = draggingDrawers.has(index); // Calculate effective stack excluding closing drawers const effectiveStack = drawerStack.filter((_, i) => !closingDrawers.has(i)); const effectiveIndex = effectiveStack.findIndex((d) => d.id === drawer.id); const zIndex = 50 + index; // Ensure proper stacking return (_jsx(Drawer.Root, { open: openDrawers[index] === true, onOpenChange: (open) => { if (!open) { handleDrawerClose(index); } }, handleOnly: true, onDrag: () => { // event, percentageDragged // Mark as dragging when drag starts setDraggingDrawers((prev) => new Set([...prev, index])); }, onRelease: () => { // event, open // Remove from dragging state when released setDraggingDrawers((prev) => { const newSet = new Set(prev); newSet.delete(index); return newSet; }); }, children: _jsxs(Drawer.Portal, { children: [_jsx(Drawer.Overlay, { className: "fixed inset-0 bg-black/40", style: { zIndex: zIndex } }), _jsxs(Drawer.Content, { className: "flex flex-col mt-24 fixed bottom-0 left-0 right-0", style: { borderTopRightRadius: borderRadius, borderTopLeftRadius: borderRadius, backgroundColor: backgroundColor, height: height, zIndex: zIndex + 1, // Remove focus ring outline: "none", // Enable hardware acceleration for Safari iOS willChange: "transform", // Control positioning: offscreen when not open, stack position when open // Safari iOS needs translateZ to enable hardware acceleration transform: isDragging ? "none" : !openDrawers[index] ? "translate3d(0, 100%, 0)" // Offscreen when closed (use 3d for Safari) : `translate3d(0, ${(effectiveStack.length - 1 - effectiveIndex) * -STACK_GAP}px, 0) scale(${1 - (effectiveStack.length - 1 - effectiveIndex) * STACK_SQUEEZE})`, // Apply transition unless dragging // Spring animation: snappy start with momentum, smooth settle (like framer-motion spring) transition: isDragging ? "none" : "transform 250ms cubic-bezier(0.68, 0, 0.265, 1)", }, onPointerDownOutside: (event) => { // Always prevent default to stop the drawer from closing itself event.preventDefault(); const toastContainer = document.querySelector("[data-toast-container]"); const originalTarget = event?.target || event?.originalEvent?.target || event?.detail?.originalEvent?.target; if (toastContainer && originalTarget && toastContainer.contains(originalTarget)) { return; } else if (index === drawerStack.length - 1) { // Only the top drawer should respond to outside clicks handleDrawerClose(index); } // Lower drawers do nothing }, onInteractOutside: (event) => { const toastContainer = document.querySelector("[data-toast-container]"); const originalTarget = event?.target || event?.originalEvent?.target || event?.detail?.originalEvent?.target; if (toastContainer && originalTarget && toastContainer.contains(originalTarget)) { event.preventDefault(); } else { // Always prevent default to avoid the drawer closing itself event.preventDefault(); } }, children: [handleClassName && (_jsx(Drawer.Handle, { className: handleClassName })), _jsx("div", { className: "flex-1 min-h-0", children: _jsx(DrawerContent, { path: drawer.path, level: drawer.level, onClose: () => handleDrawerClose(index), onNavigateInDrawer: handleNavigateInDrawer, routes: routes, closeButton: closeButton, borderRadius: borderRadius }) })] })] }) }, drawer.id)); }) })); }