diff --git a/packages/react/components/Drawer.tsx b/packages/react/components/Drawer.tsx new file mode 100644 index 0000000..3d12ba1 --- /dev/null +++ b/packages/react/components/Drawer.tsx @@ -0,0 +1,318 @@ +import React, { + ComponentPropsWithoutRef, + 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", + variants: { + opened: { + true: "pointer-events-auto select-auto", + false: "pointer-events-none select-none", + }, + }, + defaults: { + opened: false, + }, +}); + +interface DrawerOverlayProps + extends VariantProps, + 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`, + variants: { + position: { + top: "top-0 inset-x-0 rounded-t-lg border-b-2", + bottom: "bottom-0 inset-x-0 rounded-b-lg border-t-2", + left: "left-0 inset-y-0 rounded-l-lg border-r-2", + right: "right-0 inset-y-0 rounded-r-lg border-l-2", + }, + opened: { + true: "", + 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, + delta: 0, + }); + + const [variantProps, restPropsCompressed] = + resolveDrawerContentVariantProps(props); + 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, + }); + } + + useEffect(() => { + function onMouseUp(e: MouseEvent) { + if ( + e.target instanceof Element && + internalRef.current && + internalRef.current.contains(e.target) + ) { + const size = + variantProps.position === "top" || + variantProps.position === "bottom" + ? e.target.getBoundingClientRect().height + : e.target.getBoundingClientRect().width; + setState((prev) => ({ + ...prev, + isDragging: false, + opened: + Math.abs(dragState.delta) > state.closeThreshold * size + ? false + : prev.opened, + })); + } else { + setState((prev) => ({ + ...prev, + isDragging: false, + })); + } + setDragState({ + isDragging: false, + delta: 0, + }); + } + + function onMouseMove(e: MouseEvent) { + if (dragState.isDragging) { + setDragState((prev) => { + let movement = ["top", "bottom"].includes( + variantProps.position ?? "left" + ) + ? e.movementY + : e.movementX; + if ( + (["top", "left"].includes(variantProps.position ?? "left") && + dragState.delta >= 0 && + movement > 0) || + (["bottom", "right"].includes(variantProps.position ?? "left") && + dragState.delta <= 0 && + movement < 0) + ) { + movement = + movement / (dragState.delta === 0 ? 1 : dragState.delta); + } + return { + ...prev, + delta: prev.delta + movement, + }; + }); + } + } + + window.addEventListener("mousemove", onMouseMove); + window.addEventListener("mouseup", onMouseUp); + return () => { + window.removeEventListener("mousemove", onMouseMove); + window.removeEventListener("mouseup", 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={() => + setState((prev) => ({ ...prev, leaveWhileDragging: true })) + } + onMouseEnter={() => + setState((prev) => ({ ...prev, leaveWhileDragging: false })) + } + /> +
+ ); + } +); + +export { DrawerRoot, DrawerTrigger, DrawerOverlay, DrawerContent };