import React, { ComponentPropsWithoutRef, TouchEvent as ReactTouchEvent, forwardRef, useContext, useEffect, useRef, useState, } from "react"; import { AsChild, Slot, VariantProps, vcn } from "../shared"; import { createPortal } from "react-dom"; interface IDrawerContext { opened: boolean; closeThreshold: number; movePercentage: number; isDragging: boolean; leaveWhileDragging: boolean; } const DrawerContextInitial: IDrawerContext = { opened: false, closeThreshold: 0.3, movePercentage: 0, isDragging: false, leaveWhileDragging: false, }; const DrawerContext = React.createContext< [IDrawerContext, React.Dispatch>] >([ DrawerContextInitial, () => { if (process.env.NODE_ENV && process.env.NODE_ENV === "development") { console.warn( "It seems like you're using DrawerContext outside of a provider." ); } }, ]); interface DrawerRootProps { children: React.ReactNode; closeThreshold?: number; opened?: boolean; } const DrawerRoot = ({ children, closeThreshold, opened }: DrawerRootProps) => { const state = useState({ ...DrawerContextInitial, opened: opened ?? DrawerContextInitial.opened, closeThreshold: closeThreshold ?? DrawerContextInitial.closeThreshold, }); useEffect(() => { state[1]((prev) => ({ ...prev, opened: opened ?? prev.opened, closeThreshold: closeThreshold ?? prev.closeThreshold, })); }, [closeThreshold, opened]); return ( {children} ); }; const DrawerTrigger = ({ children }: { children: React.ReactNode }) => { const [_, setState] = useContext(DrawerContext); function onClick() { setState((prev) => ({ ...prev, opened: true })); } return {children}; }; const [drawerOverlayVariant, resolveDrawerOverlayVariantProps] = vcn({ base: "fixed inset-0 transition-[backdrop-filter] duration-75", variants: { opened: { true: "pointer-events-auto select-auto", false: "pointer-events-none select-none", }, }, defaults: { opened: false, }, }); const DRAWER_OVERLAY_BACKDROP_FILTER_BRIGHTNESS = 0.3; interface DrawerOverlayProps extends Omit, "opened">, AsChild, ComponentPropsWithoutRef<"div"> {} const DrawerOverlay = forwardRef( (props, ref) => { const [state, setState] = useContext(DrawerContext); const [variantProps, restPropsCompressed] = resolveDrawerOverlayVariantProps(props); const { asChild, ...restPropsExtracted } = restPropsCompressed; function onOutsideClick() { if (state.leaveWhileDragging) { setState((prev) => ({ ...prev, leaveWhileDragging: false })); return; } setState((prev) => ({ ...prev, opened: false })); } const Comp = asChild ? Slot : "div"; return createPortal( , document.body ); } ); const drawerContentColors = { background: "bg-white dark:bg-black", border: "border-neutral-200 dark:border-neutral-800", }; const [drawerContentVariant, resolveDrawerContentVariantProps] = vcn({ base: `fixed ${drawerContentColors.background} ${drawerContentColors.border} transition-all p-4 flex flex-col justify-between gap-8`, variants: { position: { top: "top-0 inset-x-0 w-full max-w-screen rounded-t-lg border-b-2", bottom: "bottom-0 inset-x-0 w-full max-w-screen rounded-b-lg border-t-2", left: "left-0 inset-y-0 h-screen rounded-l-lg border-r-2", right: "right-0 inset-y-0 h-screen rounded-r-lg border-l-2", }, opened: { true: "touch-none", false: "-[&.top-0]:translate-y-full [&.bottom-0]:translate-y-full [&.left-0]:-translate-x-full [&.right-0]:translate-x-full", }, }, defaults: { position: "left", opened: false, }, }); interface DrawerContentProps extends Omit, "opened">, AsChild, ComponentPropsWithoutRef<"div"> {} const DrawerContent = forwardRef( (props, ref) => { const [state, setState] = useContext(DrawerContext); const [dragState, setDragState] = useState({ isDragging: false, prevTouch: { x: 0, y: 0 }, delta: 0, }); const [variantProps, restPropsCompressed] = resolveDrawerContentVariantProps(props); const { position = "left" } = variantProps; const { asChild, onClick, ...restPropsExtracted } = restPropsCompressed; const Comp = asChild ? Slot : "div"; const internalRef = useRef(null); function onMouseDown() { setState((prev) => ({ ...prev, isDragging: true })); setDragState({ isDragging: true, delta: 0, prevTouch: { x: 0, y: 0 }, }); } function onTouchStart(e: ReactTouchEvent) { setState((prev) => ({ ...prev, isDragging: true })); setDragState({ isDragging: true, delta: 0, prevTouch: { x: e.touches[0].pageX, y: e.touches[0].pageY }, }); } useEffect(() => { function onMouseUp(e: TouchEvent): void; function onMouseUp(e: MouseEvent): void; function onMouseUp(e: TouchEvent | MouseEvent) { if ( e.target instanceof Element && internalRef.current && internalRef.current.contains(e.target) ) { const size = ["top", "bottom"].includes(position) ? e.target.getBoundingClientRect().height : e.target.getBoundingClientRect().width; setState((prev) => ({ ...prev, isDragging: false, opened: Math.abs(dragState.delta) > state.closeThreshold * size ? false : prev.opened, movePercentage: 0, })); } else { setState((prev) => ({ ...prev, isDragging: false, movePercentage: 0, })); } setDragState({ isDragging: false, delta: 0, prevTouch: { x: 0, y: 0 }, }); } function onMouseMove(e: TouchEvent): void; function onMouseMove(e: MouseEvent): void; function onMouseMove(e: MouseEvent | TouchEvent) { if (dragState.isDragging) { setDragState((prev) => { let movement = ["top", "bottom"].includes(position) ? "movementY" in e ? e.movementY : e.touches[0].pageY - prev.prevTouch.y : "movementX" in e ? e.movementX : e.touches[0].pageX - prev.prevTouch.x; if ( (["top", "left"].includes(position) && dragState.delta >= 0 && movement > 0) || (["bottom", "right"].includes(position) && dragState.delta <= 0 && movement < 0) ) { movement = movement / (dragState.delta === 0 ? 1 : dragState.delta); } return { ...prev, delta: prev.delta + movement, ...("touches" in e ? { prevTouch: { x: e.touches[0].pageX, y: e.touches[0].pageY }, } : {}), }; }); if ( e.target instanceof Element && internalRef.current && internalRef.current.contains(e.target) ) { const size = ["top", "bottom"].includes(position) ? e.target.getBoundingClientRect().height : e.target.getBoundingClientRect().width; const movePercentage = dragState.delta / size; setState((prev) => ({ ...prev, movePercentage: ["top", "left"].includes(position) ? -movePercentage : movePercentage, })); } } } window.addEventListener("mousemove", onMouseMove); window.addEventListener("mouseup", onMouseUp); window.addEventListener("touchmove", onMouseMove); window.addEventListener("touchend", onMouseUp); return () => { window.removeEventListener("mousemove", onMouseMove); window.removeEventListener("mouseup", onMouseUp); window.removeEventListener("touchmove", onMouseMove); window.removeEventListener("touchend", onMouseUp); }; }, [state, dragState]); return (
{ internalRef.current = el; if (typeof ref === "function") { ref(el); } else if (ref) { ref.current = el; } }} onClick={(e) => { e.stopPropagation(); onClick?.(e); }} onMouseDown={onMouseDown} onMouseLeave={() => dragState.isDragging && setState((prev) => ({ ...prev, leaveWhileDragging: true })) } onMouseEnter={() => dragState.isDragging && setState((prev) => ({ ...prev, leaveWhileDragging: false })) } onTouchStart={onTouchStart} />
); } ); const DrawerClose = forwardRef< HTMLButtonElement, ComponentPropsWithoutRef<"button"> >((props, ref) => { const [_, setState] = useContext(DrawerContext); return ( setState((prev) => ({ ...prev, opened: false }))} /> ); }); const [drawerHeaderVariant, resolveDrawerHeaderVariantProps] = vcn({ base: "flex flex-col gap-2", variants: {}, defaults: {}, }); interface DrawerHeaderProps extends ComponentPropsWithoutRef<"div">, VariantProps, AsChild {} const DrawerHeader = forwardRef( (props, ref) => { const [variantProps, restPropsCompressed] = resolveDrawerHeaderVariantProps(props); const { asChild, ...restPropsExtracted } = restPropsCompressed; return (
); } ); const [drawerBodyVariant, resolveDrawerBodyVariantProps] = vcn({ base: "flex-grow", variants: {}, defaults: {}, }); interface DrawerBodyProps extends ComponentPropsWithoutRef<"div">, VariantProps, AsChild {} const DrawerBody = forwardRef((props, ref) => { const [variantProps, restPropsCompressed] = resolveDrawerBodyVariantProps(props); const { asChild, ...restPropsExtracted } = restPropsCompressed; return (
); }); const [drawerFooterVariant, resolveDrawerFooterVariantProps] = vcn({ base: "flex flex-row justify-end gap-2", variants: {}, defaults: {}, }); interface DrawerFooterProps extends ComponentPropsWithoutRef<"div">, VariantProps, AsChild {} const DrawerFooter = forwardRef( (props, ref) => { const [variantProps, restPropsCompressed] = resolveDrawerFooterVariantProps(props); const { asChild, ...restPropsExtracted } = restPropsCompressed; return (
); } ); export { DrawerRoot, DrawerTrigger, DrawerOverlay, DrawerContent, DrawerClose, DrawerHeader, DrawerBody, DrawerFooter, };