feat: add drawer
This commit is contained in:
parent
2eddfa12f0
commit
ce9ad634f0
318
packages/react/components/Drawer.tsx
Normal file
318
packages/react/components/Drawer.tsx
Normal file
@ -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<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,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
state[1]((prev) => ({
|
||||
...prev,
|
||||
opened: opened ?? prev.opened,
|
||||
closeThreshold: closeThreshold ?? prev.closeThreshold,
|
||||
}));
|
||||
}, [closeThreshold, opened]);
|
||||
|
||||
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",
|
||||
variants: {
|
||||
opened: {
|
||||
true: "pointer-events-auto select-auto",
|
||||
false: "pointer-events-none select-none",
|
||||
},
|
||||
},
|
||||
defaults: {
|
||||
opened: false,
|
||||
},
|
||||
});
|
||||
|
||||
interface DrawerOverlayProps
|
||||
extends VariantProps<typeof drawerOverlayVariant>,
|
||||
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";
|
||||
|
||||
return createPortal(
|
||||
<Comp
|
||||
{...restPropsExtracted}
|
||||
className={drawerOverlayVariant({
|
||||
...variantProps,
|
||||
opened: state.isDragging ? true : state.opened,
|
||||
})}
|
||||
onClick={onOutsideClick}
|
||||
style={/*{ backdropFilter: "brightness(0.5)" }*/ undefined}
|
||||
ref={ref}
|
||||
/>,
|
||||
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<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,
|
||||
delta: 0,
|
||||
});
|
||||
|
||||
const [variantProps, restPropsCompressed] =
|
||||
resolveDrawerContentVariantProps(props);
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
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 (
|
||||
<div
|
||||
className={drawerContentVariant({
|
||||
...variantProps,
|
||||
opened: true,
|
||||
className: dragState.isDragging
|
||||
? "transition-[width_0ms]"
|
||||
: variantProps.className,
|
||||
})}
|
||||
style={
|
||||
state.opened
|
||||
? ["top", "bottom"].includes(variantProps.position ?? "left")
|
||||
? {
|
||||
height:
|
||||
(internalRef.current?.getBoundingClientRect?.()?.height ??
|
||||
0) + dragState.delta,
|
||||
padding: 0,
|
||||
}
|
||||
: {
|
||||
width:
|
||||
(internalRef.current?.getBoundingClientRect?.()?.width ??
|
||||
0) + 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(variantProps.position ?? "left")
|
||||
? "Y"
|
||||
: "X"
|
||||
}(${dragState.delta}px)`
|
||||
: undefined,
|
||||
transitionDuration: dragState.isDragging ? "0s" : undefined,
|
||||
userSelect: dragState.isDragging ? "none" : undefined,
|
||||
}}
|
||||
ref={(el) => {
|
||||
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 }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export { DrawerRoot, DrawerTrigger, DrawerOverlay, DrawerContent };
|
Loading…
x
Reference in New Issue
Block a user