502 lines
14 KiB
TypeScript

import {
type AsChild,
Slot,
type VariantProps,
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;
leaveWhileDragging: boolean;
}
const DrawerContextInitial: IDrawerContext = {
opened: false,
closeThreshold: 0.3,
movePercentage: 0,
isDragging: false,
leaveWhileDragging: false,
};
const DrawerContext = React.createContext<
[IDrawerContext, React.Dispatch<React.SetStateAction<IDrawerContext>>]
>([
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<IDrawerContext>({
...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 (
<DrawerContext.Provider value={state}>{children}</DrawerContext.Provider>
);
};
const DrawerTrigger = ({ children }: { children: React.ReactNode }) => {
const [_, setState] = useContext(DrawerContext);
function onClick() {
setState((prev) => ({ ...prev, opened: true }));
}
return <Slot onClick={onClick}>{children}</Slot>;
};
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<VariantProps<typeof drawerOverlayVariant>, "opened">,
AsChild,
ComponentPropsWithoutRef<"div"> {}
const DrawerOverlay = forwardRef<HTMLDivElement, DrawerOverlayProps>(
(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";
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 createPortal(
<Comp
{...restPropsExtracted}
className={drawerOverlayVariant({
...variantProps,
opened: state.isDragging ? true : state.opened,
})}
onClick={onOutsideClick}
style={{
backdropFilter,
WebkitBackdropFilter: backdropFilter,
transitionDuration: state.isDragging ? "0s" : undefined,
}}
ref={ref}
/>,
document.body,
);
},
);
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 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: "",
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<VariantProps<typeof drawerContentVariant>, "opened">,
AsChild,
ComponentPropsWithoutRef<"div"> {}
const DrawerContent = forwardRef<HTMLDivElement, DrawerContentProps>(
(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<HTMLDivElement | null>(null);
function onMouseDown() {
setState((prev) => ({ ...prev, isDragging: true }));
setDragState({
isDragging: true,
delta: 0,
prevTouch: { x: 0, y: 0 },
});
}
function onTouchStart(e: ReactTouchEvent<HTMLDivElement>) {
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 (
<div
className={drawerContentVariant({
...variantProps,
opened: true,
className: dragState.isDragging
? "transition-[width] duration-0"
: variantProps.className,
})}
style={
state.opened
? ["top", "bottom"].includes(position)
? {
height:
(internalRef.current?.getBoundingClientRect?.()?.height ??
0) +
(position === "top" ? dragState.delta : -dragState.delta),
padding: 0,
}
: {
width:
(internalRef.current?.getBoundingClientRect?.()?.width ??
0) +
(position === "left" ? dragState.delta : -dragState.delta),
padding: 0,
}
: { width: 0, height: 0, padding: 0 }
}
>
<Comp
{...restPropsExtracted}
className={drawerContentVariant({
...variantProps,
opened: state.opened,
})}
style={{
transform: dragState.isDragging
? `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}
/>
</div>
);
},
);
DrawerContent.displayName = "DrawerContent";
const DrawerClose = forwardRef<
HTMLButtonElement,
ComponentPropsWithoutRef<"button">
>((props, ref) => {
const [_, setState] = useContext(DrawerContext);
return (
<Slot
ref={ref}
{...props}
onClick={() => 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<typeof drawerHeaderVariant>,
AsChild {}
const DrawerHeader = forwardRef<HTMLDivElement, DrawerHeaderProps>(
(props, ref) => {
const [variantProps, restPropsCompressed] =
resolveDrawerHeaderVariantProps(props);
const { asChild, ...restPropsExtracted } = restPropsCompressed;
const Comp = asChild ? Slot : "div";
return (
<Comp
{...restPropsExtracted}
className={drawerHeaderVariant(variantProps)}
ref={ref}
/>
);
},
);
DrawerHeader.displayName = "DrawerHeader";
const [drawerBodyVariant, resolveDrawerBodyVariantProps] = vcn({
base: "flex-grow",
variants: {},
defaults: {},
});
interface DrawerBodyProps
extends ComponentPropsWithoutRef<"div">,
VariantProps<typeof drawerBodyVariant>,
AsChild {}
const DrawerBody = forwardRef<HTMLDivElement, DrawerBodyProps>((props, ref) => {
const [variantProps, restPropsCompressed] =
resolveDrawerBodyVariantProps(props);
const { asChild, ...restPropsExtracted } = restPropsCompressed;
const Comp = asChild ? Slot : "div";
return (
<Comp
{...restPropsExtracted}
className={drawerBodyVariant(variantProps)}
ref={ref}
/>
);
});
DrawerBody.displayName = "DrawerBody";
const [drawerFooterVariant, resolveDrawerFooterVariantProps] = vcn({
base: "flex flex-row justify-end gap-2",
variants: {},
defaults: {},
});
interface DrawerFooterProps
extends ComponentPropsWithoutRef<"div">,
VariantProps<typeof drawerFooterVariant>,
AsChild {}
const DrawerFooter = forwardRef<HTMLDivElement, DrawerFooterProps>(
(props, ref) => {
const [variantProps, restPropsCompressed] =
resolveDrawerFooterVariantProps(props);
const { asChild, ...restPropsExtracted } = restPropsCompressed;
const Comp = asChild ? Slot : "div";
return (
<Comp
{...restPropsExtracted}
className={drawerFooterVariant(variantProps)}
ref={ref}
/>
);
},
);
DrawerFooter.displayName = "DrawerFooter";
export {
DrawerRoot,
DrawerTrigger,
DrawerOverlay,
DrawerContent,
DrawerClose,
DrawerHeader,
DrawerBody,
DrawerFooter,
};