@corvu/drawer
Version:
Unstyled, accessible and customizable UI primitives for SolidJS
675 lines (669 loc) • 24.5 kB
JavaScript
import { createComponent, mergeProps } from 'solid-js/web';
import Dialog2, { Portal, useContext } from '@corvu/dialog';
export { Portal, useContext as useDialogContext } from '@corvu/dialog';
import { dataIf, isFunction } from '@corvu/utils';
import { createContext, useContext as useContext$1, splitProps, createMemo, createEffect, onCleanup, batch, mergeProps as mergeProps$1, createSignal, untrack } from 'solid-js';
import { combineStyle, afterPaint } from '@corvu/utils/dom';
import { getScrollAtLocation } from '@corvu/utils/scroll';
import { useKeyedContext, createKeyedContext } from '@corvu/utils/create/keyedContext';
import createControllableSignal from '@corvu/utils/create/controllableSignal';
import createOnce from '@corvu/utils/create/once';
import createSize from '@corvu/utils/create/size';
import createTransitionSize from 'solid-transition-size';
import { createWritableMemo } from '@solid-primitives/memo';
// src/Close.tsx
var DrawerClose = (props) => {
return createComponent(Dialog2.Close, mergeProps({
"data-corvu-drawer-close": "",
"data-corvu-dialog-close": null
}, props));
};
var Close_default = DrawerClose;
// src/lib.ts
var resolveSnapPoint = (snapPoint, drawerSize, index, breakPoints) => {
if (index === undefined || breakPoints === undefined) {
return {
value: snapPoint,
offset: resolvePoint(snapPoint, drawerSize)
};
}
const upperBreakPoint = breakPoints[index - 1] !== undefined && breakPoints[index - 1] !== null ? resolvePoint(breakPoints[index - 1], drawerSize) : undefined;
const lowerBreakPoint = breakPoints[index] !== undefined && breakPoints[index] !== null ? resolvePoint(breakPoints[index], drawerSize) : undefined;
return {
value: snapPoint,
offset: resolvePoint(snapPoint, drawerSize),
lowerBreakPoint,
upperBreakPoint
};
};
var resolvePoint = (point, drawerSize) => {
if (typeof point === "number") return drawerSize - point * drawerSize;
if (!point.endsWith("px")) {
throw new Error(
`[corvu] Snap and break points must be a number or a string ending with 'px'. Got ${point}`
);
}
return drawerSize - parseInt(point, 10);
};
var findClosestSnapPoint = (snapPoints, offset, offsetWithVelocity, allowSkippingSnapPoints) => {
const upperSnapPoint = findNearbySnapPoint(
"upper",
snapPoints,
allowSkippingSnapPoints ? offsetWithVelocity : offset
);
const lowerSnapPoint = findNearbySnapPoint(
"lower",
snapPoints,
allowSkippingSnapPoints ? offsetWithVelocity : offset
);
if (!upperSnapPoint) return lowerSnapPoint;
if (!lowerSnapPoint) return upperSnapPoint;
if (lowerSnapPoint.upperBreakPoint === undefined || upperSnapPoint.lowerBreakPoint === undefined) {
return Math.abs(lowerSnapPoint.offset - offsetWithVelocity) < Math.abs(upperSnapPoint.offset - offsetWithVelocity) ? lowerSnapPoint : upperSnapPoint;
}
return offsetWithVelocity < upperSnapPoint.lowerBreakPoint ? lowerSnapPoint : upperSnapPoint;
};
var findNearbySnapPoint = (side, snapPoints, offset) => {
return snapPoints.reduce(
(previousSnapPoint, currentSnapPoint) => {
if (side == "upper" && currentSnapPoint.offset >= offset && (!previousSnapPoint || currentSnapPoint.offset < previousSnapPoint.offset)) {
return currentSnapPoint;
} else if (side == "lower" && currentSnapPoint.offset <= offset && (!previousSnapPoint || currentSnapPoint.offset > previousSnapPoint.offset)) {
return currentSnapPoint;
}
return previousSnapPoint;
},
undefined
);
};
var locationIsDraggable = (location, stopAt, pointerType) => {
let currentElement = location;
let stopReached = false;
do {
if (currentElement.hasAttribute("data-corvu-no-drag") || currentElement.type === "range" || currentElement.tagName === "SELECT" && pointerType === "mouse")
return false;
if (currentElement === stopAt) {
stopReached = true;
} else {
currentElement = currentElement.parentElement;
}
} while (currentElement && !stopReached);
return true;
};
var DrawerContext = createContext();
var createDrawerContext = (contextId) => {
if (contextId === undefined) return DrawerContext;
const context = createKeyedContext(`drawer-${contextId}`);
return context;
};
var useDrawerContext = (contextId) => {
if (contextId === undefined) {
const context2 = useContext$1(DrawerContext);
if (!context2) {
throw new Error(
"[corvu]: Drawer context not found. Make sure to wrap Drawer components in <Drawer.Root>"
);
}
return context2;
}
const context = useKeyedContext(`drawer-${contextId}`);
if (!context) {
throw new Error(
`[corvu]: Drawer context with id "${contextId}" not found. Make sure to wrap Drawer components in <Drawer.Root contextId="${contextId}">`
);
}
return context;
};
var InternalDrawerContext = createContext();
var createInternalDrawerContext = (contextId) => {
if (contextId === undefined) return InternalDrawerContext;
const context = createKeyedContext(
`drawer-internal-${contextId}`
);
return context;
};
var useInternalDrawerContext = (contextId) => {
if (contextId === undefined) {
const context2 = useContext$1(InternalDrawerContext);
if (!context2) {
throw new Error(
"[corvu]: Drawer context not found. Make sure to wrap Drawer components in <Drawer.Root>"
);
}
return context2;
}
const context = useKeyedContext(
`drawer-internal-${contextId}`
);
if (!context) {
throw new Error(
`[corvu]: Drawer context with id "${contextId}" not found. Make sure to wrap Drawer components in <Drawer.Root contextId="${contextId}">`
);
}
return context;
};
// src/Content.tsx
var DrawerContent = (props) => {
const [localProps, otherProps] = splitProps(props, ["contextId", "style"]);
let pointerDown = false;
let dragStartPos = null;
let currentPointerStart = [0, 0];
let cachedMoveTimestamp;
let cachedTranslate = 0;
const drawerContext = createMemo(() => useInternalDrawerContext(localProps.contextId));
const dialogContext = createMemo(() => Dialog2.useContext(localProps.contextId));
const snapPoints = createMemo(() => drawerContext().snapPoints().map((snapPoint, index) => resolveSnapPoint(snapPoint, drawerContext().drawerSize(), index, drawerContext().breakPoints())));
const transformValue = createMemo(() => {
switch (drawerContext().side()) {
case "top":
return `translate3d(0, ${-drawerContext().translate()}px, 0)`;
case "bottom":
return `translate3d(0, ${drawerContext().translate()}px, 0)`;
case "right":
return `translate3d(${drawerContext().translate()}px, 0, 0)`;
case "left":
return `translate3d(${-drawerContext().translate()}px, 0, 0)`;
}
});
const transitionHeight = createMemo(() => {
const transitionSize = drawerContext().transitionSize();
if (transitionSize === null) return undefined;
switch (drawerContext().side()) {
case "top":
case "bottom":
return `${transitionSize}px`;
}
return undefined;
});
const transitionWidth = createMemo(() => {
const transitionSize = drawerContext().transitionSize();
if (transitionSize === null) return undefined;
switch (drawerContext().side()) {
case "left":
case "right":
return `${transitionSize}px`;
}
return undefined;
});
createEffect(() => {
if (!dialogContext().open()) return;
document.addEventListener("pointermove", onPointerMove);
document.addEventListener("touchmove", onTouchMove);
document.addEventListener("pointerup", onPointerUp);
document.addEventListener("touchend", onTouchEnd);
document.addEventListener("contextmenu", onUp);
onCleanup(() => {
document.removeEventListener("pointermove", onPointerMove);
document.removeEventListener("touchmove", onTouchMove);
document.removeEventListener("pointerup", onPointerUp);
document.removeEventListener("touchend", onTouchEnd);
document.removeEventListener("contextmenu", onUp);
});
});
createEffect(() => {
if (drawerContext().transitionState() === "closing") {
drawerContext().setIsDragging(false);
}
});
const onPointerDown = (event) => {
if (event.button !== 0) return;
if (!locationIsDraggable(event.target, dialogContext().contentRef(), event.pointerType)) return;
if (drawerContext().transitionState() === "closing") return;
pointerDown = true;
if (drawerContext().handleScrollableElements()) {
currentPointerStart = [event.clientX, event.clientY];
}
};
const onPointerMove = (event) => {
onMove(event.target, event.clientX, event.clientY);
};
const onTouchMove = (event) => {
if (!event.touches[0]) return;
onMove(event.target, event.touches[0].clientX, event.touches[0].clientY);
};
const onMove = (target, x, y) => {
if (!pointerDown) return;
if (!drawerContext().isDragging() || dragStartPos === null) {
const selection = window.getSelection();
if (selection && selection.toString().length > 0) {
onUp();
return;
}
if (drawerContext().handleScrollableElements()) {
const delta2 = [x, y].map((pointer, i) => currentPointerStart[i] - pointer);
const axis = Math.abs(delta2[0]) > Math.abs(delta2[1]) ? "x" : "y";
const axisDelta = axis === "x" ? delta2[0] : delta2[1];
if (Math.abs(axisDelta) < 0.3) return;
const wrapper = dialogContext().contentRef();
const [availableScroll, availableScrollTop] = getScrollAtLocation(target, axis, wrapper);
if (axisDelta > 0 && Math.abs(availableScroll) > 1 || axisDelta < 0 && Math.abs(availableScrollTop) > 0) {
onUp();
return;
}
}
switch (drawerContext().side()) {
case "top":
case "bottom":
dragStartPos = y;
break;
case "right":
case "left":
dragStartPos = x;
}
cachedMoveTimestamp = /* @__PURE__ */ new Date();
cachedTranslate = drawerContext().translate();
batch(() => {
drawerContext().setIsDragging(true);
drawerContext().setTransitionState(null);
});
}
let delta;
switch (drawerContext().side()) {
case "top":
delta = -(dragStartPos - y);
break;
case "bottom":
delta = dragStartPos - y;
break;
case "right":
delta = dragStartPos - x;
break;
case "left":
delta = -(dragStartPos - x);
break;
}
delta -= drawerContext().resolvedActiveSnapPoint().offset;
if (delta > 0) delta = drawerContext().dampFunction(delta);
const now = /* @__PURE__ */ new Date();
if (now.getTime() - cachedMoveTimestamp.getTime() > drawerContext().velocityCacheReset()) {
cachedMoveTimestamp = now;
cachedTranslate = drawerContext().translate();
}
drawerContext().setTranslate(-delta);
};
const onPointerUp = (event) => {
if (event.pointerType !== "touch") onUp();
};
const onTouchEnd = (event) => {
if (event.touches.length === 0) onUp();
};
const onUp = () => {
pointerDown = false;
if (!drawerContext().isDragging()) return;
const now = /* @__PURE__ */ new Date();
const velocity = drawerContext().velocityFunction(-(cachedTranslate - drawerContext().translate()), now.getTime() - cachedMoveTimestamp.getTime() || 1);
const translateWithVelocity = drawerContext().translate() * velocity;
const closestSnapPoint = findClosestSnapPoint(snapPoints(), drawerContext().translate(), translateWithVelocity, drawerContext().allowSkippingSnapPoints());
batch(() => {
drawerContext().setTransitionState("snapping");
drawerContext().setIsDragging(false);
});
batch(() => {
drawerContext().setActiveSnapPoint(closestSnapPoint.value);
if (closestSnapPoint.offset === drawerContext().drawerSize()) {
dialogContext().setOpen(false);
} else {
drawerContext().setTranslate(closestSnapPoint.offset);
const transitionDuration = parseFloat(drawerContext().drawerStyles().transitionDuration);
if (transitionDuration === 0) {
drawerContext().setTransitionState(null);
}
}
});
};
return createComponent(Dialog2.Content, mergeProps({
get contextId() {
return localProps.contextId;
},
get style() {
return combineStyle({
transform: transformValue(),
"transition-duration": drawerContext().isDragging() ? "0ms" : undefined,
height: transitionHeight(),
width: transitionWidth()
}, localProps.style);
},
onPointerDown,
onTouchStart: (e) => {
if (e.touches.length !== 1) return;
dragStartPos = null;
},
onTransitionEnd: (e) => {
if (e.target !== dialogContext().contentRef()) return;
batch(() => {
if (drawerContext().transitionState() === "closing") {
drawerContext().closeDrawer();
}
if (drawerContext().transitionState() !== "resizing") {
drawerContext().setTransitionState(null);
}
});
},
get ["data-closing"]() {
return dataIf(drawerContext().transitionState() === "closing");
},
get ["data-opening"]() {
return dataIf(drawerContext().transitionState() === "opening");
},
get ["data-resizing"]() {
return dataIf(drawerContext().transitionState() === "resizing");
},
get ["data-snapping"]() {
return dataIf(drawerContext().transitionState() === "snapping");
},
get ["data-transitioning"]() {
return dataIf(drawerContext().isTransitioning());
},
"data-corvu-drawer-content": "",
"data-corvu-dialog-content": null
}, otherProps));
};
var Content_default = DrawerContent;
var DrawerDescription = (props) => {
return createComponent(Dialog2.Description, mergeProps({
"data-corvu-drawer-description": "",
"data-corvu-dialog-description": null
}, props));
};
var Description_default = DrawerDescription;
var DrawerLabel = (props) => {
return createComponent(Dialog2.Label, mergeProps({
"data-corvu-drawer-label": "",
"data-corvu-dialog-label": null
}, props));
};
var Label_default = DrawerLabel;
var DrawerOverlay = (props) => {
const [localProps, otherProps] = splitProps(props, ["contextId"]);
const drawerContext = createMemo(() => useInternalDrawerContext(localProps.contextId));
return createComponent(Dialog2.Overlay, mergeProps({
get contextId() {
return localProps.contextId;
},
get ["data-closing"]() {
return dataIf(drawerContext().transitionState() === "closing");
},
get ["data-opening"]() {
return dataIf(drawerContext().transitionState() === "opening");
},
get ["data-resizing"]() {
return dataIf(drawerContext().transitionState() === "resizing");
},
get ["data-snapping"]() {
return dataIf(drawerContext().transitionState() === "snapping");
},
get ["data-transitioning"]() {
return dataIf(drawerContext().isTransitioning());
},
"data-corvu-drawer-overlay": "",
"data-corvu-dialog-overlay": null
}, otherProps));
};
var Overlay_default = DrawerOverlay;
var DrawerRoot = (props) => {
const defaultedProps = mergeProps$1({
initialOpen: false,
snapPoints: [0, 1],
breakPoints: [null],
defaultSnapPoint: 1,
side: "bottom",
dampFunction: (distance) => 6 * Math.log(distance + 1),
velocityFunction: (distance, time) => {
const velocity = distance / time;
return velocity < 1 && velocity > -1 ? 1 : velocity;
},
velocityCacheReset: 200,
allowSkippingSnapPoints: true,
handleScrollableElements: true,
transitionResize: false,
closeOnOutsidePointer: true,
allowPinchZoom: false
}, props);
const [localProps, otherProps] = splitProps(defaultedProps, ["snapPoints", "breakPoints", "defaultSnapPoint", "activeSnapPoint", "onActiveSnapPointChange", "side", "dampFunction", "velocityFunction", "velocityCacheReset", "allowSkippingSnapPoints", "handleScrollableElements", "transitionResize", "open", "initialOpen", "onOpenChange", "closeOnOutsidePointer", "contextId", "children"]);
const [open, setOpen] = createControllableSignal({
value: () => localProps.open,
initialValue: localProps.initialOpen,
onChange: localProps.onOpenChange
});
const [activeSnapPoint, setActiveSnapPoint] = createControllableSignal({
value: () => localProps.activeSnapPoint,
initialValue: 0,
onChange: localProps.onActiveSnapPointChange
});
const [dialogContext, setDialogContext] = createSignal();
const {
transitioning: sizeTransitioning,
transitionSize
} = createTransitionSize({
element: () => dialogContext()?.contentRef() ?? null,
enabled: () => open() && localProps.transitionResize,
dimension: () => {
switch (localProps.side) {
case "top":
case "bottom":
return "height";
case "left":
case "right":
return "width";
}
}
});
const [isDragging, setIsDragging] = createSignal(false);
const [transitionState, setTransitionState] = createWritableMemo(() => {
if (sizeTransitioning()) return "resizing";
return null;
});
const drawerStyles = createMemo(() => {
const contentRef = dialogContext()?.contentRef();
if (!contentRef) return undefined;
return getComputedStyle(contentRef);
});
const [transitionAwareOpen, setTransitionAwareOpen] = createSignal(false);
createEffect(() => {
const _open = open();
untrack(() => {
if (transitionAwareOpen() === _open) {
return;
}
if (_open) {
setTransitionAwareOpen(true);
afterPaint(() => {
batch(() => {
setTransitionState("opening");
setActiveSnapPoint(localProps.defaultSnapPoint);
});
const transitionDuration = parseFloat(drawerStyles().transitionDuration);
if (transitionDuration === 0) {
setTransitionState(null);
}
});
} else {
batch(() => {
setTransitionState("closing");
setActiveSnapPoint(0);
});
afterPaint(() => {
const transitionDuration = parseFloat(drawerStyles().transitionDuration);
if (transitionDuration === 0) {
closeDrawer();
}
});
}
});
});
const closeDrawer = () => {
batch(() => {
setTransitionAwareOpen(false);
setTransitionState(null);
});
};
const drawerSize = createSize({
element: () => dialogContext()?.contentRef() ?? null,
dimension: () => {
switch (localProps.side) {
case "top":
case "bottom":
return "height";
case "left":
case "right":
return "width";
}
}
});
const resolvedActiveSnapPoint = createMemo(() => resolveSnapPoint(activeSnapPoint(), drawerSize()));
const [translate, setTranslate] = createWritableMemo(() => resolvedActiveSnapPoint().offset);
const openPercentage = createMemo(() => {
if (!drawerSize()) return 0;
return (drawerSize() - translate()) / drawerSize();
});
const childrenProps = {
get snapPoints() {
return localProps.snapPoints;
},
get breakPoints() {
return localProps.breakPoints;
},
get defaultSnapPoint() {
return localProps.defaultSnapPoint;
},
get activeSnapPoint() {
return activeSnapPoint();
},
setActiveSnapPoint,
get side() {
return localProps.side;
},
get isDragging() {
return isDragging();
},
get isTransitioning() {
return transitionState() !== null;
},
get transitionState() {
return transitionState();
},
get openPercentage() {
return openPercentage();
},
get translate() {
return translate();
},
get velocityCacheReset() {
return localProps.velocityCacheReset;
},
get allowSkippingSnapPoints() {
return localProps.allowSkippingSnapPoints;
},
get handleScrollableElements() {
return localProps.handleScrollableElements;
},
get transitionResize() {
return localProps.transitionResize;
}
};
const memoizedChildren = createOnce(() => localProps.children);
const resolveChildren = (dialogChildrenProps) => {
setDialogContext(Dialog2.useContext(localProps.contextId));
const children = memoizedChildren()();
if (isFunction(children)) {
const mergedProps = mergeProps$1(dialogChildrenProps, childrenProps);
return children(mergedProps);
}
return children;
};
const memoizedDrawerRoot = createMemo(() => {
const DrawerContext2 = createDrawerContext(localProps.contextId);
const InternalDrawerContext2 = createInternalDrawerContext(localProps.contextId);
return untrack(() => createComponent(DrawerContext2.Provider, {
value: {
snapPoints: () => localProps.snapPoints,
breakPoints: () => localProps.breakPoints,
defaultSnapPoint: () => localProps.defaultSnapPoint,
activeSnapPoint,
setActiveSnapPoint,
side: () => localProps.side,
isDragging,
isTransitioning: () => transitionState() !== null,
transitionState,
openPercentage,
translate,
velocityCacheReset: () => localProps.velocityCacheReset,
allowSkippingSnapPoints: () => localProps.allowSkippingSnapPoints,
handleScrollableElements: () => localProps.handleScrollableElements,
transitionResize: () => localProps.transitionResize
},
get children() {
return createComponent(InternalDrawerContext2.Provider, {
get value() {
return {
snapPoints: () => localProps.snapPoints,
breakPoints: () => localProps.breakPoints,
defaultSnapPoint: () => localProps.defaultSnapPoint,
activeSnapPoint,
setActiveSnapPoint,
side: () => localProps.side,
isDragging,
isTransitioning: () => transitionState() !== null,
transitionState,
openPercentage,
translate,
velocityCacheReset: () => localProps.velocityCacheReset,
allowSkippingSnapPoints: () => localProps.allowSkippingSnapPoints,
handleScrollableElements: () => localProps.handleScrollableElements,
transitionResize: () => localProps.transitionResize,
dampFunction: localProps.dampFunction,
velocityFunction: localProps.velocityFunction,
setIsDragging,
setTranslate,
drawerSize,
resolvedActiveSnapPoint,
drawerStyles,
setTransitionState,
transitionSize,
closeDrawer
};
},
get children() {
return createComponent(Dialog2, mergeProps({
get open() {
return transitionAwareOpen();
},
onOpenChange: setOpen,
get contextId() {
return localProps.contextId;
},
get closeOnOutsidePointer() {
return !isDragging() && localProps.closeOnOutsidePointer;
}
}, otherProps, {
children: (dialogChildrenProps) => resolveChildren(dialogChildrenProps)
}));
}
});
}
}));
});
return memoizedDrawerRoot;
};
var Root_default = DrawerRoot;
var DrawerTrigger = (props) => {
return createComponent(Dialog2.Trigger, mergeProps({
"data-corvu-drawer-trigger": "",
"data-corvu-dialog-trigger": null
}, props));
};
var Trigger_default = DrawerTrigger;
// src/index.ts
var Drawer = Object.assign(Root_default, {
Trigger: Trigger_default,
Portal,
Overlay: Overlay_default,
Content: Content_default,
Label: Label_default,
Description: Description_default,
Close: Close_default,
useContext: useDrawerContext,
useDialogContext: useContext
});
var index_default = Drawer;
export { Close_default as Close, Content_default as Content, Description_default as Description, Label_default as Label, Overlay_default as Overlay, Root_default as Root, Trigger_default as Trigger, index_default as default, useDrawerContext as useContext };