import { type AsChild, Slot, type VariantProps, useAnimatedMount, useDocument, vcn, } from "@pswui-lib"; import React, { type ComponentPropsWithoutRef, type TouchEvent as ReactTouchEvent, forwardRef, useContext, useEffect, useRef, useState, } from "react"; import { createPortal } from "react-dom"; interface IDrawerContext { opened: boolean; closeThreshold: number; movePercentage: number; isDragging: boolean; isMounted: boolean; isRendered: boolean; leaveWhileDragging: boolean; } const DrawerContextInitial: IDrawerContext = { opened: false, closeThreshold: 0.3, movePercentage: 0, isDragging: false, isMounted: false, isRendered: 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, }); const setState = state[1]; useEffect(() => { setState((prev) => ({ ...prev, opened: opened ?? prev.opened, closeThreshold: closeThreshold ?? prev.closeThreshold, })); }, [closeThreshold, opened, setState]); 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 touch-none", // touch-none to prevent outside scrolling 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 internalRef = useRef(null); const [state, setState] = useContext(DrawerContext); const { isMounted, isRendered } = useAnimatedMount( state.isDragging ? true : state.opened, internalRef, ); 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"; const backdropFilter = `brightness(${ state.isDragging ? state.movePercentage + DRAWER_OVERLAY_BACKDROP_FILTER_BRIGHTNESS : state.opened ? DRAWER_OVERLAY_BACKDROP_FILTER_BRIGHTNESS : 1 })`; const document = useDocument(); if (!document) return null; return ( <> {isMounted ? createPortal( { internalRef.current = el; if (typeof ref === "function") { ref(el); } else if (ref) { ref.current = el; } }} />, document.body, ) : null} ); }, ); DrawerOverlay.displayName = "DrawerOverlay"; 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 overflow-auto`, variants: { position: { top: "top-0 w-full max-w-screen rounded-t-lg border-b-2", bottom: "bottom-0 w-full max-w-screen rounded-b-lg border-t-2", left: "left-0 h-screen rounded-l-lg border-r-2", right: "right-0 h-screen rounded-r-lg border-l-2", }, maxSize: { sm: "[&.left-0]:max-w-sm [&.right-0]:max-w-sm", md: "[&.left-0]:max-w-md [&.right-0]:max-w-md", lg: "[&.left-0]:max-w-lg [&.right-0]:max-w-lg", xl: "[&.left-0]:max-w-xl [&.right-0]:max-w-xl", }, opened: { true: "", false: "[&.top-0]:-translate-y-full [&.bottom-0]:translate-y-full [&.left-0]:-translate-x-full [&.right-0]:translate-x-full", }, internal: { true: "relative", false: "fixed", }, }, defaults: { position: "left", opened: false, maxSize: "sm", internal: false, }, dynamics: [ ({ position, internal }) => { if (!internal) { if (["top", "bottom"].includes(position)) { return "inset-x-0"; } return "inset-y-0"; } return "w-fit"; }, ], }); interface DrawerContentProps extends Omit< VariantProps, "opened" | "internal" >, 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 / Math.abs(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 (internalRef.current) { const size = ["top", "bottom"].includes(position) ? internalRef.current.getBoundingClientRect().height : internalRef.current.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, setState, dragState, position]); return (
0)) ? `translate${["top", "bottom"].includes(position) ? "Y" : "X"}(${dragState.delta}px)` : undefined, transitionDuration: dragState.isDragging ? "0s" : undefined, userSelect: dragState.isDragging ? "none" : undefined, }} ref={(el: HTMLDivElement | null) => { 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} />
); }, ); DrawerContent.displayName = "DrawerContent"; const DrawerClose = forwardRef< HTMLButtonElement, ComponentPropsWithoutRef<"button"> >((props, ref) => { const [_, setState] = useContext(DrawerContext); return ( setState((prev) => ({ ...prev, opened: false }))} /> ); }); DrawerClose.displayName = "DrawerClose"; 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; const Comp = asChild ? Slot : "div"; return ( ); }, ); DrawerHeader.displayName = "DrawerHeader"; const [drawerBodyVariant, resolveDrawerBodyVariantProps] = vcn({ base: "grow", variants: {}, defaults: {}, }); interface DrawerBodyProps extends ComponentPropsWithoutRef<"div">, VariantProps, AsChild {} const DrawerBody = forwardRef((props, ref) => { const [variantProps, restPropsCompressed] = resolveDrawerBodyVariantProps(props); const { asChild, ...restPropsExtracted } = restPropsCompressed; const Comp = asChild ? Slot : "div"; return ( ); }); DrawerBody.displayName = "DrawerBody"; 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; const Comp = asChild ? Slot : "div"; return ( ); }, ); DrawerFooter.displayName = "DrawerFooter"; export { DrawerRoot, DrawerTrigger, DrawerOverlay, DrawerContent, DrawerClose, DrawerHeader, DrawerBody, DrawerFooter, };