drawer-stack
Version:
Drawer stack for React
330 lines (329 loc) • 14.1 kB
JavaScript
import { jsx as _jsx } from "react/jsx-runtime";
import React, { useEffect, useRef, useState, useCallback, } from "react";
import { createPortal } from "react-dom";
// Context to pass drawer state down to child components
const DrawerContext = React.createContext(null);
function useDrawerContext() {
const context = React.useContext(DrawerContext);
if (!context) {
throw new Error("Drawer components must be used within Drawer.Root");
}
return context;
}
// Main drawer root component
function DrawerRoot({ children, ...props }) {
const [isDragging, setIsDragging] = useState(false);
const [dragOffset, setDragOffset] = useState(0);
return (_jsx(DrawerContext.Provider, { value: {
...props,
isDragging,
setIsDragging,
dragOffset,
setDragOffset,
}, children: children }));
}
// Portal wrapper for rendering drawer outside DOM hierarchy
function DrawerPortal({ children }) {
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
return () => setMounted(false);
}, []);
if (!mounted)
return null;
return createPortal(children, document.body);
}
// Overlay component (background)
function DrawerOverlay({ className, style }) {
const { open } = useDrawerContext();
return (_jsx("div", { className: `${className} ${open ? "opacity-100" : "opacity-0"} ${!open ? "pointer-events-none" : ""}`, style: {
...style,
transition: "opacity 250ms cubic-bezier(0.68, 0, 0.265, 1)",
}, "data-drawer-overlay": "true" }));
}
// Main content container with drag support
function DrawerContent({ children, className, style, onPointerDownOutside, onInteractOutside, }) {
const { open, onOpenChange, handleOnly, onDrag, onRelease, isDragging, setIsDragging, dragOffset, setDragOffset, } = useDrawerContext();
const contentRef = useRef(null);
const [isVisible, setIsVisible] = useState(false);
const dragStartY = useRef(null);
const initialHeight = useRef(0);
const lastEventRef = useRef(null);
// Always render the drawer (just positioned offscreen when closed)
// This allows smooth animations controlled by the parent
useEffect(() => {
setIsVisible(true);
if (!open) {
setDragOffset(0);
}
}, [open, setDragOffset]);
// Prevent body scroll when drawer is open
useEffect(() => {
if (open) {
document.body.style.overflow = "hidden";
}
else {
document.body.style.overflow = "unset";
}
return () => {
document.body.style.overflow = "unset";
};
}, [open]);
// Handle drag start
const handleDragStart = useCallback((event) => {
if (handleOnly)
return; // Only allow dragging from handle if handleOnly is true
const nativeEvent = event.nativeEvent;
const clientY = "touches" in nativeEvent
? nativeEvent.touches[0].clientY
: nativeEvent.clientY;
dragStartY.current = clientY;
if (contentRef.current) {
initialHeight.current = contentRef.current.offsetHeight;
}
setIsDragging(true);
lastEventRef.current = nativeEvent;
if (onDrag) {
onDrag(nativeEvent, 0);
}
event.preventDefault();
}, [handleOnly, setIsDragging, onDrag]);
// Handle drag move
const handleDragMove = useCallback((event) => {
if (!isDragging || dragStartY.current === null)
return;
const clientY = "touches" in event ? event.touches[0].clientY : event.clientY;
const deltaY = clientY - dragStartY.current;
// Only allow dragging down (positive deltaY)
const clampedDelta = Math.max(0, deltaY);
setDragOffset(clampedDelta);
lastEventRef.current = event;
if (onDrag && initialHeight.current > 0) {
const percentageDragged = clampedDelta / initialHeight.current;
onDrag(event, percentageDragged);
}
event.preventDefault();
}, [isDragging, setDragOffset, onDrag]);
// Handle drag end
const handleDragEnd = useCallback((event) => {
if (!isDragging)
return;
const finalEvent = event || lastEventRef.current;
const threshold = initialHeight.current * 0.3; // Close if dragged more than 30% of height
const shouldClose = dragOffset > threshold;
setIsDragging(false);
dragStartY.current = null;
lastEventRef.current = null;
if (shouldClose) {
onOpenChange(false);
}
else {
// Animate back to original position
setDragOffset(0);
}
if (onRelease && finalEvent) {
onRelease(finalEvent, !shouldClose);
}
}, [
isDragging,
dragOffset,
setIsDragging,
setDragOffset,
onOpenChange,
onRelease,
]);
// Set up global drag listeners
useEffect(() => {
if (!isDragging)
return;
const handleGlobalMove = (e) => handleDragMove(e);
const handleGlobalEnd = (e) => handleDragEnd(e);
// Add both mouse and touch listeners
document.addEventListener("mousemove", handleGlobalMove);
document.addEventListener("mouseup", handleGlobalEnd);
document.addEventListener("touchmove", handleGlobalMove, { passive: false });
document.addEventListener("touchend", handleGlobalEnd);
return () => {
document.removeEventListener("mousemove", handleGlobalMove);
document.removeEventListener("mouseup", handleGlobalEnd);
document.removeEventListener("touchmove", handleGlobalMove);
document.removeEventListener("touchend", handleGlobalEnd);
};
}, [isDragging, handleDragMove, handleDragEnd]);
// Handle click outside
useEffect(() => {
const handlePointerDown = (event) => {
if (!open || !contentRef.current)
return;
const target = event.target;
// Check if the click is on another drawer's content
const allDrawerContents = document.querySelectorAll('[data-drawer-content="true"]');
let clickedOnAnotherDrawer = false;
allDrawerContents.forEach((drawer) => {
if (drawer !== contentRef.current && drawer.contains(target)) {
clickedOnAnotherDrawer = true;
}
});
if (clickedOnAnotherDrawer) {
return;
}
// Check if this is an overlay click (any overlay means we're clicking outside)
const isOverlayClick = target.hasAttribute("data-drawer-overlay");
if (!contentRef.current.contains(target) || isOverlayClick) {
// Check if clicking on a dialog (Radix UI dialogs or any element with role="dialog")
// This prevents closing the drawer when interacting with dialogs
let element = target;
while (element) {
// Check for Radix UI dialog indicators
if (element.getAttribute("role") === "dialog" ||
element.hasAttribute("data-radix-dialog-content") ||
element.hasAttribute("data-radix-dialog-overlay") ||
// Check for our custom dialog classes
element.classList.contains("z-[100]") || // Dialog overlay z-index
element.classList.contains("z-[101]") // Dialog content z-index
) {
return; // Don't close drawer when clicking on dialogs
}
element = element.parentElement;
}
// First call the callbacks to allow parent to handle it
if (onPointerDownOutside) {
onPointerDownOutside(event);
}
if (onInteractOutside) {
onInteractOutside(event);
}
// Check if we should prevent close (e.g., for toast interactions)
const toastContainer = document.querySelector("[data-toast-container]");
if (toastContainer && toastContainer.contains(target)) {
return; // Don't close if clicking on toast
}
// Only close if parent didn't prevent it
if (!event.defaultPrevented) {
onOpenChange(false);
}
}
};
if (open) {
// Small delay to avoid immediate close on open
const timeout = setTimeout(() => {
document.addEventListener("pointerdown", handlePointerDown);
}, 100);
return () => {
clearTimeout(timeout);
document.removeEventListener("pointerdown", handlePointerDown);
};
}
}, [open, onOpenChange, onPointerDownOutside, onInteractOutside]);
if (!isVisible)
return null;
// The parent (DrawerStack) handles all positioning and animations
// We just need to handle drag offset
const computedStyle = {
...style,
transform: isDragging && dragOffset > 0
? `${style?.transform || ""} translateY(${dragOffset}px)`
: style?.transform,
};
return (_jsx("div", { ref: contentRef, className: className, style: computedStyle, "data-drawer-content": "true", onMouseDown: !handleOnly ? handleDragStart : undefined, onTouchStart: !handleOnly ? handleDragStart : undefined, children: children }));
}
// Handle component for dragging
function DrawerHandle({ className }) {
const { handleOnly, onDrag, onRelease, setIsDragging, setDragOffset } = useDrawerContext();
const handleRef = useRef(null);
const dragStartY = useRef(null);
const parentHeight = useRef(0);
const lastEventRef = useRef(null);
const handleDragStart = useCallback((event) => {
// Handle both React and native events
const nativeEvent = "nativeEvent" in event ? event.nativeEvent : event;
const clientY = "touches" in nativeEvent
? nativeEvent.touches[0].clientY
: nativeEvent.clientY;
dragStartY.current = clientY;
// Get parent content height
const contentEl = handleRef.current?.closest('[class*="flex flex-col"]');
if (contentEl) {
parentHeight.current = contentEl.getBoundingClientRect().height;
}
setIsDragging(true);
lastEventRef.current = nativeEvent;
if (onDrag) {
onDrag(nativeEvent, 0);
}
event.preventDefault();
if ("stopPropagation" in event) {
event.stopPropagation();
}
}, [setIsDragging, onDrag]);
const handleDragMove = useCallback((event) => {
if (dragStartY.current === null)
return;
const clientY = "touches" in event ? event.touches[0].clientY : event.clientY;
const deltaY = clientY - dragStartY.current;
// Only allow dragging down (positive deltaY)
const clampedDelta = Math.max(0, deltaY);
setDragOffset(clampedDelta);
lastEventRef.current = event;
if (onDrag && parentHeight.current > 0) {
const percentageDragged = clampedDelta / parentHeight.current;
onDrag(event, percentageDragged);
}
event.preventDefault();
}, [setDragOffset, onDrag]);
const handleDragEnd = useCallback((event) => {
const finalEvent = event || lastEventRef.current;
setIsDragging(false);
dragStartY.current = null;
lastEventRef.current = null;
// Let the content component handle the actual close logic
if (onRelease && finalEvent) {
onRelease(finalEvent, true);
}
}, [setIsDragging, onRelease]);
// Set up drag listeners when handleOnly is true
useEffect(() => {
if (!handleOnly)
return;
const handle = handleRef.current;
if (!handle)
return;
const onStart = (e) => handleDragStart(e);
handle.addEventListener("mousedown", onStart);
handle.addEventListener("touchstart", onStart, { passive: false });
return () => {
handle.removeEventListener("mousedown", onStart);
handle.removeEventListener("touchstart", onStart);
};
}, [handleOnly, handleDragStart]);
// Global move and end listeners when dragging
const { isDragging } = useDrawerContext();
useEffect(() => {
if (!isDragging || !handleOnly)
return;
const handleGlobalMove = (e) => handleDragMove(e);
const handleGlobalEnd = (e) => handleDragEnd(e);
document.addEventListener("mousemove", handleGlobalMove);
document.addEventListener("mouseup", handleGlobalEnd);
document.addEventListener("touchmove", handleGlobalMove, { passive: false });
document.addEventListener("touchend", handleGlobalEnd);
return () => {
document.removeEventListener("mousemove", handleGlobalMove);
document.removeEventListener("mouseup", handleGlobalEnd);
document.removeEventListener("touchmove", handleGlobalMove);
document.removeEventListener("touchend", handleGlobalEnd);
};
}, [isDragging, handleOnly, handleDragMove, handleDragEnd]);
return (_jsx("div", { ref: handleRef, className: className ||
"w-12 h-1.5 bg-gray-400 rounded-full mx-auto my-3 cursor-grab active:cursor-grabbing" }));
}
// Export as namespace similar to Vaul
export const Drawer = {
Root: DrawerRoot,
Portal: DrawerPortal,
Overlay: DrawerOverlay,
Content: DrawerContent,
Handle: DrawerHandle,
};
// Also export individual components for direct import if needed
export { DrawerRoot, DrawerPortal, DrawerOverlay, DrawerContent, DrawerHandle };