UNPKG

@corvu/drawer

Version:

Unstyled, accessible and customizable UI primitives for SolidJS

675 lines (669 loc) 24.5 kB
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 };