drawer-stack
Version:
Drawer stack for React
226 lines (225 loc) • 13.3 kB
JavaScript
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));
}) }));
}