import type { JSX } from "@solidjs/web"; /** * `useSwipeDismiss` — pointer + touch drag state machine for swipe-to- * dismiss gestures. Full port of Base UI React's * `packages/react/src/utils/useSwipeDismiss.ts` (~1000 LOC). * * Solid adaptations vs. the React source: * * - `elementRef: RefObject` → `elementRef: () => * HTMLElement | null` (accessor). Callers pass a signal / accessor * produced by the owning component. * - `enabled: boolean` → `enabled: () => boolean` (accessor) per * solidify.md P3 — lets consumers flip the hook on/off reactively * without remounting. * - `useState` for `isSwiping` / `dragOffset` / `initialTransform` / * `lockedDirection` / `currentSwipeDirection` / `dragDismissed` → * `createSignal` with `ownedWrite: true` (owned, written from event * handlers). * - `useStableCallback` → plain closures — Solid doesn't re-render and * event handlers close over mutable state directly. * - React's `React.PointerEvent` / `React.TouchEvent` are plain DOM * events here. `event.nativeEvent` is rewritten to `event` itself; * callers of `onDismiss` / `onRelease` / `onSwipeStart` receive the * native DOM event directly. * - Return shape matches React (`swiping` / `swipeDirection` / ... / * `getPointerProps` / `getTouchProps` / `getDragStyles` / `reset`) * except `swiping` / `swipeDirection` / `dragDismissed` are accessors * (functions), not plain values. * * No comments annotate the React → Solid deltas at each call site; the * behavior is 1:1. */ import { createSignal } from "solid-js"; import { contains } from "./dom/contains"; import { getTarget } from "./dom/get-target"; import { ownerDocument } from "./dom/owner-document"; import { ownerWindow } from "./dom/owner-window"; import { getElementAtPoint } from "./get-element-at-point"; import { findScrollableTouchTarget, hasScrollableAncestor, type ScrollAxis, } from "./scrollable"; import { clamp } from "./slider-utils/clamp"; export type SwipeDirection = "up" | "down" | "left" | "right"; type SwipeDismissNativeEvent = PointerEvent | TouchEvent; type SwipeDismissStartEvent = PointerEvent | TouchEvent; type SwipeDismissMoveEvent = PointerEvent | TouchEvent; type SwipeDismissEndEvent = PointerEvent | TouchEvent; interface SwipeProgressDetailsInternal { deltaX: number; deltaY: number; direction: SwipeDirection | undefined; } const DEFAULT_SWIPE_THRESHOLD = 40; const REVERSE_CANCEL_THRESHOLD = 10; const MIN_DRAG_THRESHOLD = 1; const MIN_VELOCITY_DURATION_MS = 50; const MIN_RELEASE_VELOCITY_DURATION_MS = 16; const MAX_RELEASE_VELOCITY_AGE_MS = 80; const DEFAULT_IGNORE_SELECTOR = 'button,a,input,select,textarea,label,[role="button"]'; /** * Returns the signed displacement of `deltaX` / `deltaY` along the given * direction. A positive return value means motion toward the dismiss edge. */ export function getDisplacement( direction: SwipeDirection, deltaX: number, deltaY: number, ): number { switch (direction) { case "up": return -deltaY; case "down": return deltaY; case "left": return -deltaX; case "right": return deltaX; default: return 0; } } /** * Parses `transform` off the computed style, returning translation + * uniform-scale components. Mirrors React's implementation. */ export function getElementTransform(element: HTMLElement): { x: number; y: number; scale: number; } { const computedStyle = ownerWindow(element).getComputedStyle(element); const transform = computedStyle.transform; let translateX = 0; let translateY = 0; let scale = 1; if (transform && transform !== "none") { const matrix = transform.match(/matrix(?:3d)?\(([^)]+)\)/); if (matrix?.[1]) { const values = matrix[1].split(", ").map(Number.parseFloat); if (values.length === 6) { translateX = values[4] ?? 0; translateY = values[5] ?? 0; const a = values[0] ?? 0; const b = values[1] ?? 0; scale = Math.sqrt(a * a + b * b); } else if (values.length === 16) { translateX = values[12] ?? 0; translateY = values[13] ?? 0; scale = values[0] ?? 1; } } } return { x: translateX, y: translateY, scale }; } function getValidTimeStamp(timeStamp: number): number | null { return Number.isFinite(timeStamp) && timeStamp > 0 ? timeStamp : null; } function hasPrimaryMouseButton(buttons: number): boolean { return buttons % 2 === 1; } function safelyChangePointerCapture( element: HTMLElement, pointerId: number, method: "setPointerCapture" | "releasePointerCapture", ): void { const fn = element[method]; if (typeof fn !== "function") return; try { fn.call(element, pointerId); } catch (error) { if ( error && typeof error === "object" && "name" in error && (error as { name?: string }).name === "NotFoundError" ) { return; } throw error; } } export interface UseSwipeDismissDetails { nativeEvent: PointerEvent | TouchEvent; direction: SwipeDirection | undefined; } export type UseSwipeDismissProgressDetails = SwipeProgressDetailsInternal; export interface UseSwipeDismissOptions { /** Reactive on/off flag. */ enabled: () => boolean; /** Allowed dismissal directions. A single-entry array is the common case. */ directions: SwipeDirection[]; /** Accessor returning the tracked element. */ elementRef: () => HTMLElement | null; /** CSS custom-property names used for the drag-movement style output. */ movementCssVars: { x: string; y: string }; swipeThreshold?: | number | ((details: { element: HTMLElement; direction: SwipeDirection }) => number) | undefined; canStart?: | (( position: { x: number; y: number }, details: UseSwipeDismissDetails, ) => boolean) | undefined; ignoreScrollableAncestors?: boolean | undefined; ignoreSelectorWhenTouch?: boolean | undefined; trackDrag?: boolean | undefined; onSwipeStart?: ((event: PointerEvent | TouchEvent) => void) | undefined; onProgress?: | ((progress: number, details?: UseSwipeDismissProgressDetails) => void) | undefined; onCancel?: ((event: PointerEvent | TouchEvent) => void) | undefined; onSwipingChange?: ((swiping: boolean) => void) | undefined; onRelease?: | ((details: { event: PointerEvent | TouchEvent; direction: SwipeDirection | undefined; deltaX: number; deltaY: number; velocityX: number; velocityY: number; releaseVelocityX: number; releaseVelocityY: number; }) => boolean | undefined) | undefined; onDismiss?: | (( event: PointerEvent | TouchEvent, details: { direction: SwipeDirection }, ) => void) | undefined; } export interface UseSwipeDismissReturnValue { swiping: () => boolean; swipeDirection: () => SwipeDirection | undefined; dragDismissed: () => boolean; getPointerProps: () => { onPointerDown?: ((event: PointerEvent) => void) | undefined; onPointerMove?: ((event: PointerEvent) => void) | undefined; onPointerUp?: ((event: PointerEvent) => void) | undefined; onPointerCancel?: ((event: PointerEvent) => void) | undefined; }; getTouchProps: () => { onTouchStart?: ((event: TouchEvent) => void) | undefined; onTouchMove?: ((event: TouchEvent) => void) | undefined; onTouchEnd?: ((event: TouchEvent) => void) | undefined; onTouchCancel?: ((event: TouchEvent) => void) | undefined; }; getDragStyles: () => JSX.CSSProperties; reset: () => void; } export function useSwipeDismiss( options: UseSwipeDismissOptions, ): UseSwipeDismissReturnValue { const { directions, elementRef, movementCssVars, trackDrag: trackDragOption, } = options; const trackDrag = trackDragOption ?? true; const ignoreSelectorWhenTouch = options.ignoreSelectorWhenTouch ?? true; const ignoreScrollableAncestors = options.ignoreScrollableAncestors ?? false; const swipeThresholdProp = options.swipeThreshold; const ignoreSelector = DEFAULT_IGNORE_SELECTOR; const primaryDirection = directions.length === 1 ? directions[0] : undefined; const swipeThresholdDefault = Math.max( 0, typeof swipeThresholdProp === "number" ? swipeThresholdProp : DEFAULT_SWIPE_THRESHOLD, ); const allowLeft = directions.includes("left"); const allowRight = directions.includes("right"); const allowUp = directions.includes("up"); const allowDown = directions.includes("down"); const hasHorizontal = allowLeft || allowRight; const hasVertical = allowUp || allowDown; const scrollAxes: ScrollAxis[] = []; if (hasVertical) scrollAxes.push("vertical"); if (hasHorizontal) scrollAxes.push("horizontal"); const [currentSwipeDirection, setCurrentSwipeDirection] = createSignal< SwipeDirection | undefined >(undefined, { ownedWrite: true }); const [isSwiping, setIsSwipingSig] = createSignal(false, { ownedWrite: true, }); const [isRealSwipe, setIsRealSwipe] = createSignal(false, { ownedWrite: true, }); const [dragDismissed, setDragDismissed] = createSignal(false, { ownedWrite: true, }); const [dragOffsetX, setDragOffsetX] = createSignal(0, { ownedWrite: true }); const [dragOffsetY, setDragOffsetY] = createSignal(0, { ownedWrite: true }); const [initialTransformX, setInitialTransformX] = createSignal(0, { ownedWrite: true, }); const [initialTransformY, setInitialTransformY] = createSignal(0, { ownedWrite: true, }); const [initialTransformScale, setInitialTransformScale] = createSignal(1, { ownedWrite: true, }); const [lockedDirection, setLockedDirection] = createSignal< "horizontal" | "vertical" | null >(null, { ownedWrite: true }); // Mutable refs (not reactive — mirror React's `useRef`). let dragStartPos = { x: 0, y: 0 }; let dragOffsetRef = { x: 0, y: 0 }; let lastMovePos: { x: number; y: number } | null = null; let initialTransformRef = { x: 0, y: 0, scale: 1 }; let intendedSwipeDirection: SwipeDirection | undefined; let maxSwipeDisplacement = 0; let cancelledSwipe = false; let swipeCancelBaseline = { x: 0, y: 0 }; let isFirstPointerMove = false; let pendingSwipe = false; let pendingSwipeStartPos: { x: number; y: number } | null = null; let swipeFromScrollable = false; let sawPrimaryButtonsOnMove = false; let elementSize = { width: 0, height: 0 }; let swipeProgressValue = 0; let swipeThresholdRef = swipeThresholdDefault; let swipeStartTime: number | null = null; let lastDragSample: { x: number; y: number; time: number } | null = null; let lastDragVelocity = { x: 0, y: 0 }; let lastProgressDetails: SwipeProgressDetailsInternal | null = null; let isSwipingRef = false; const setSwiping = (nextSwiping: boolean): void => { if (isSwipingRef === nextSwiping) return; isSwipingRef = nextSwiping; setIsSwipingSig(nextSwiping); options.onSwipingChange?.(nextSwiping); }; function resolveSwipeThreshold(direction: SwipeDirection | undefined): void { if (!direction) return; if (typeof swipeThresholdProp !== "function") { swipeThresholdRef = swipeThresholdDefault; return; } const element = elementRef(); if (!element) return; const value = swipeThresholdProp({ element, direction }); swipeThresholdRef = Math.max(0, value); } const updateSwipeProgress = ( progress: number, details?: SwipeProgressDetailsInternal, ): void => { const nextProgress = Number.isFinite(progress) ? clamp(progress, 0, 1) : 0; const progressChanged = nextProgress !== swipeProgressValue; let detailsChanged = false; if (details) { const last = lastProgressDetails; detailsChanged = !last || last.deltaX !== details.deltaX || last.deltaY !== details.deltaY || last.direction !== details.direction; } if (!progressChanged && !detailsChanged) return; swipeProgressValue = nextProgress; if (details) { lastProgressDetails = details; } else if (progressChanged) { lastProgressDetails = null; } options.onProgress?.(nextProgress, details); }; function recordDragSample( offset: { x: number; y: number }, timeStamp: number | null, ): void { if (timeStamp === null) return; const last = lastDragSample; if (last && timeStamp > last.time) { const durationMs = Math.max( timeStamp - last.time, MIN_RELEASE_VELOCITY_DURATION_MS, ); lastDragVelocity = { x: (offset.x - last.x) / durationMs, y: (offset.y - last.y) / durationMs, }; } lastDragSample = { x: offset.x, y: offset.y, time: timeStamp }; } const reset = (): void => { setCurrentSwipeDirection(undefined); setSwiping(false); setIsRealSwipe(false); setDragDismissed(false); setDragOffsetX(0); setDragOffsetY(0); setInitialTransformX(0); setInitialTransformY(0); setInitialTransformScale(1); setLockedDirection(null); updateSwipeProgress(0); swipeThresholdRef = swipeThresholdDefault; dragStartPos = { x: 0, y: 0 }; dragOffsetRef = { x: 0, y: 0 }; initialTransformRef = { x: 0, y: 0, scale: 1 }; intendedSwipeDirection = undefined; maxSwipeDisplacement = 0; cancelledSwipe = false; swipeCancelBaseline = { x: 0, y: 0 }; isFirstPointerMove = false; lastMovePos = null; pendingSwipe = false; pendingSwipeStartPos = null; swipeFromScrollable = false; sawPrimaryButtonsOnMove = false; elementSize = { width: 0, height: 0 }; swipeStartTime = null; lastDragSample = null; lastDragVelocity = { x: 0, y: 0 }; lastProgressDetails = null; }; function getPrimaryPointerPosition( event: | SwipeDismissStartEvent | SwipeDismissMoveEvent | SwipeDismissEndEvent, ): { x: number; y: number } | null { if ("touches" in event) { const touch = event.touches[0]; return touch ? { x: touch.clientX, y: touch.clientY } : null; } return { x: event.clientX, y: event.clientY }; } function isTouchLikeEvent(event: Event): boolean { if ("touches" in event) return true; return (event as PointerEvent).pointerType === "touch"; } function getTargetAtPoint( position: { x: number; y: number }, nativeEvent: Event, ): HTMLElement | null { const doc = ownerDocument(elementRef()); const elementAtPoint = getElementAtPoint(doc, position.x, position.y); const target = elementAtPoint ?? getTarget(nativeEvent); return target as HTMLElement | null; } function findGestureScrollableTouchTarget( target: EventTarget | null, root: HTMLElement, ): HTMLElement | null { if (hasHorizontal && !hasVertical) { return findScrollableTouchTarget(target, root, "horizontal"); } if (hasVertical && !hasHorizontal) { return findScrollableTouchTarget(target, root, "vertical"); } return ( findScrollableTouchTarget(target, root, "vertical") ?? findScrollableTouchTarget(target, root, "horizontal") ); } function startSwipeAtPosition( event: SwipeDismissStartEvent | SwipeDismissMoveEvent, position: { x: number; y: number }, startOptions?: { ignoreScrollableTarget?: boolean | undefined; ignoreScrollableAncestors?: boolean | undefined; }, ): boolean { swipeFromScrollable = false; const touchLike = isTouchLikeEvent(event); const target = getTargetAtPoint(position, event); const doc = ownerDocument(elementRef()); const body = doc.body; const scrollableTarget = touchLike && body ? findGestureScrollableTouchTarget(target, body) : null; const ignoreScrollableTarget = startOptions?.ignoreScrollableTarget ?? false; if (scrollableTarget && !ignoreScrollableTarget) return false; swipeFromScrollable = Boolean(scrollableTarget && ignoreScrollableTarget); const isInteractiveElement = target ? target.closest(ignoreSelector) : false; if (isInteractiveElement && (!touchLike || ignoreSelectorWhenTouch)) { return false; } const element = elementRef(); if ( ignoreScrollableAncestors && element && target && scrollAxes.length > 0 ) { const ignoreAncestors = startOptions?.ignoreScrollableAncestors ?? false; if ( !ignoreAncestors && hasScrollableAncestor(target, element, scrollAxes) ) { return false; } } cancelledSwipe = false; intendedSwipeDirection = undefined; maxSwipeDisplacement = 0; dragStartPos = position; swipeStartTime = getValidTimeStamp(event.timeStamp); swipeCancelBaseline = position; lastMovePos = position; if (element) { elementSize = { width: element.offsetWidth, height: element.offsetHeight, }; resolveSwipeThreshold(primaryDirection); const transform = getElementTransform(element); initialTransformRef = transform; dragOffsetRef = { x: transform.x, y: transform.y }; setInitialTransformX(transform.x); setInitialTransformY(transform.y); setInitialTransformScale(transform.scale); setDragOffsetX(transform.x); setDragOffsetY(transform.y); recordDragSample({ x: transform.x, y: transform.y }, swipeStartTime); if (!("touches" in event)) { safelyChangePointerCapture( element, (event as PointerEvent).pointerId, "setPointerCapture", ); } } options.onSwipeStart?.(event as SwipeDismissNativeEvent); setSwiping(true); setIsRealSwipe(false); setLockedDirection(null); isFirstPointerMove = true; updateSwipeProgress(0); return true; } function resetPendingSwipeState(): void { clearPendingSwipeStartState(); swipeFromScrollable = false; lastMovePos = null; } function clearPendingSwipeStartState(): void { pendingSwipe = false; pendingSwipeStartPos = null; } function cancelSwipeInteraction(event: PointerEvent): void { resetPendingSwipeState(); if (!isSwipingRef) return; setSwiping(false); setIsRealSwipe(false); setLockedDirection(null); const resolvedInitialTransform = trackDrag ? { x: initialTransformX(), y: initialTransformY() } : { x: initialTransformRef.x, y: initialTransformRef.y }; dragOffsetRef = { x: resolvedInitialTransform.x, y: resolvedInitialTransform.y, }; setDragOffsetX(resolvedInitialTransform.x); setDragOffsetY(resolvedInitialTransform.y); setCurrentSwipeDirection(undefined); sawPrimaryButtonsOnMove = false; const element = elementRef(); if (element) { safelyChangePointerCapture( element, event.pointerId, "releasePointerCapture", ); } updateSwipeProgress(0, { deltaX: 0, deltaY: 0, direction: undefined, }); options.onCancel?.(event); } function applyDirectionalDamping( deltaX: number, deltaY: number, ): { x: number; y: number } { const exponent = (value: number): number => value >= 0 ? value ** 0.5 : -(Math.abs(value) ** 0.5); const dampAxis = ( delta: number, allowNegative: boolean, allowPositive: boolean, ): number => { if (!allowNegative && delta < 0) return exponent(delta); if (!allowPositive && delta > 0) return exponent(delta); return delta; }; const newDeltaX = hasHorizontal ? dampAxis(deltaX, allowLeft, allowRight) : exponent(deltaX); const newDeltaY = hasVertical ? dampAxis(deltaY, allowUp, allowDown) : exponent(deltaY); return { x: newDeltaX, y: newDeltaY }; } function canSwipeFromScrollEdgeOnPendingMove( scrollTarget: HTMLElement, deltaX: number, deltaY: number, ): boolean | null { const absDeltaX = Math.abs(deltaX); const absDeltaY = Math.abs(deltaY); const useVerticalAxis = hasVertical && deltaY !== 0 && (!hasHorizontal || absDeltaY >= absDeltaX); if (useVerticalAxis) { const maxScrollTop = Math.max( 0, scrollTarget.scrollHeight - scrollTarget.clientHeight, ); const atTop = scrollTarget.scrollTop <= 0; const atBottom = scrollTarget.scrollTop >= maxScrollTop; const movingDown = deltaY > 0; const movingUp = deltaY < 0; const canSwipeDown = movingDown && atTop && allowDown; const canSwipeUp = movingUp && atBottom && allowUp; return canSwipeDown || canSwipeUp; } const useHorizontalAxis = hasHorizontal && deltaX !== 0 && (!hasVertical || absDeltaX > absDeltaY); if (useHorizontalAxis) { const maxScrollLeft = Math.max( 0, scrollTarget.scrollWidth - scrollTarget.clientWidth, ); const atLeft = scrollTarget.scrollLeft <= 0; const atRight = scrollTarget.scrollLeft >= maxScrollLeft; const movingRight = deltaX > 0; const movingLeft = deltaX < 0; const canSwipeRight = movingRight && atLeft && allowRight; const canSwipeLeft = movingLeft && atRight && allowLeft; return canSwipeRight || canSwipeLeft; } return null; } const handleStart = (event: SwipeDismissStartEvent): void => { if (!options.enabled()) return; if (event.defaultPrevented) return; if (!("touches" in event) && (event as PointerEvent).button !== 0) return; const startPos = getPrimaryPointerPosition(event); if (!startPos) return; pendingSwipe = true; pendingSwipeStartPos = startPos; swipeFromScrollable = false; sawPrimaryButtonsOnMove = false; const allowedToStart = options.canStart ? options.canStart(startPos, { nativeEvent: event as SwipeDismissNativeEvent, direction: primaryDirection, }) : true; if (!allowedToStart) return; if (startSwipeAtPosition(event, startPos)) { clearPendingSwipeStartState(); } }; function handleMoveCore( event: SwipeDismissMoveEvent, position: { x: number; y: number }, movement: { x: number; y: number }, ): void { if (!options.enabled() || !isSwipingRef) return; const target = getTarget(event) as HTMLElement | null; if (isTouchLikeEvent(event) && !swipeFromScrollable) { const boundaryElement = event.currentTarget as HTMLElement; if (findGestureScrollableTouchTarget(target, boundaryElement)) return; } if (!("touches" in event)) { // Prevent text selection on Safari during a pointer drag. event.preventDefault(); } if (isFirstPointerMove) { dragStartPos = position; const moveTime = getValidTimeStamp(event.timeStamp); if (moveTime !== null) swipeStartTime = moveTime; isFirstPointerMove = false; } const clientX = position.x; const clientY = position.y; const movementX = movement.x; const movementY = movement.y; if ( (movementY < 0 && clientY > swipeCancelBaseline.y) || (movementY > 0 && clientY < swipeCancelBaseline.y) ) { swipeCancelBaseline = { x: swipeCancelBaseline.x, y: clientY }; } if ( (movementX < 0 && clientX > swipeCancelBaseline.x) || (movementX > 0 && clientX < swipeCancelBaseline.x) ) { swipeCancelBaseline = { x: clientX, y: swipeCancelBaseline.y }; } const deltaX = clientX - dragStartPos.x; const deltaY = clientY - dragStartPos.y; const cancelDeltaY = clientY - swipeCancelBaseline.y; const cancelDeltaX = clientX - swipeCancelBaseline.x; if (!isRealSwipe()) { const movementDistance = Math.sqrt(deltaX * deltaX + deltaY * deltaY); if (movementDistance >= MIN_DRAG_THRESHOLD) { setIsRealSwipe(true); if (lockedDirection() === null) { if (hasHorizontal && hasVertical) { const absX = Math.abs(deltaX); const absY = Math.abs(deltaY); setLockedDirection(absX > absY ? "horizontal" : "vertical"); } } } } let candidate: SwipeDirection | undefined; if (!intendedSwipeDirection) { const locked = lockedDirection(); if (locked === "vertical") { if (deltaY > 0) candidate = "down"; else if (deltaY < 0) candidate = "up"; } else if (locked === "horizontal") { if (deltaX > 0) candidate = "right"; else if (deltaX < 0) candidate = "left"; } else if (Math.abs(deltaX) >= Math.abs(deltaY)) { candidate = deltaX > 0 ? "right" : "left"; } else { candidate = deltaY > 0 ? "down" : "up"; } if (candidate) { const isAllowed = (candidate === "left" && allowLeft) || (candidate === "right" && allowRight) || (candidate === "up" && allowUp) || (candidate === "down" && allowDown); if (isAllowed) { intendedSwipeDirection = candidate; maxSwipeDisplacement = getDisplacement(candidate, deltaX, deltaY); setCurrentSwipeDirection(candidate); resolveSwipeThreshold(candidate); } } } else { const direction = intendedSwipeDirection; const currentDisplacement = getDisplacement( direction, cancelDeltaX, cancelDeltaY, ); if (currentDisplacement > swipeThresholdRef) { cancelledSwipe = false; setCurrentSwipeDirection(direction); } else if ( !(allowLeft && allowRight) && !(allowUp && allowDown) && maxSwipeDisplacement - currentDisplacement >= REVERSE_CANCEL_THRESHOLD ) { cancelledSwipe = true; } } const dampedDelta = applyDirectionalDamping(deltaX, deltaY); let newOffsetX = initialTransformRef.x; let newOffsetY = initialTransformRef.y; const locked = lockedDirection(); if (locked === "horizontal") { if (hasHorizontal) newOffsetX += dampedDelta.x; } else if (locked === "vertical") { if (hasVertical) newOffsetY += dampedDelta.y; } else { if (hasHorizontal) newOffsetX += dampedDelta.x; if (hasVertical) newOffsetY += dampedDelta.y; } dragOffsetRef = { x: newOffsetX, y: newOffsetY }; if (trackDrag) { setDragOffsetX(newOffsetX); setDragOffsetY(newOffsetY); } recordDragSample( { x: newOffsetX, y: newOffsetY }, getValidTimeStamp(event.timeStamp), ); const dragDeltaX = newOffsetX - initialTransformRef.x; const dragDeltaY = newOffsetY - initialTransformRef.y; const swipeDirectionDetails = intendedSwipeDirection; const progressDirection = primaryDirection ?? intendedSwipeDirection; if (!progressDirection) { updateSwipeProgress(0, { deltaX: dragDeltaX, deltaY: dragDeltaY, direction: swipeDirectionDetails, }); return; } const size = progressDirection === "left" || progressDirection === "right" ? elementSize.width : elementSize.height; const scale = initialTransformRef.scale || 1; if (size <= 0 || scale <= 0) { updateSwipeProgress(0, { deltaX: dragDeltaX, deltaY: dragDeltaY, direction: swipeDirectionDetails, }); return; } const progressDisplacement = getDisplacement( progressDirection, newOffsetX - initialTransformRef.x, newOffsetY - initialTransformRef.y, ); if (progressDisplacement <= 0) { updateSwipeProgress(0, { deltaX: dragDeltaX, deltaY: dragDeltaY, direction: swipeDirectionDetails, }); return; } updateSwipeProgress(progressDisplacement / (size * scale), { deltaX: dragDeltaX, deltaY: dragDeltaY, direction: swipeDirectionDetails, }); } const handleMove = (event: SwipeDismissMoveEvent): void => { const currentPos = getPrimaryPointerPosition(event); if (!currentPos) return; if (!("touches" in event)) { const hasPrimaryButton = hasPrimaryMouseButton( (event as PointerEvent).buttons, ); if (hasPrimaryButton) sawPrimaryButtonsOnMove = true; const lostPrimaryButtonDuringSwipe = (event as PointerEvent).buttons === 0 && sawPrimaryButtonsOnMove; if ( ((event as PointerEvent).buttons !== 0 && !hasPrimaryButton) || lostPrimaryButtonDuringSwipe ) { cancelSwipeInteraction(event as PointerEvent); return; } } if (!isSwiping() && pendingSwipe) { if (!isTouchLikeEvent(event) && event.defaultPrevented) { resetPendingSwipeState(); return; } const allowedToStart = options.canStart ? options.canStart(currentPos, { nativeEvent: event as SwipeDismissNativeEvent, direction: primaryDirection, }) : true; if (allowedToStart) { const pendingStartPos = pendingSwipeStartPos; let ignoreScrollableOnStart = false; if (isTouchLikeEvent(event)) { const element = elementRef(); if (pendingStartPos && element) { const target = getTargetAtPoint(currentPos, event); const doc = ownerDocument(element); const body = doc.body; const scrollTarget = body ? findGestureScrollableTouchTarget(target, body) : null; if ( scrollTarget && (contains(element, scrollTarget) || contains(scrollTarget, element)) ) { const deltaX = currentPos.x - pendingStartPos.x; const deltaY = currentPos.y - pendingStartPos.y; const canSwipeFromEdge = canSwipeFromScrollEdgeOnPendingMove( scrollTarget, deltaX, deltaY, ); if (canSwipeFromEdge === false) return; if (canSwipeFromEdge === true) ignoreScrollableOnStart = true; } } } const started = startSwipeAtPosition(event, currentPos, { ignoreScrollableTarget: ignoreScrollableOnStart, ignoreScrollableAncestors: ignoreScrollableOnStart, }); if (started) { if (pendingStartPos && ignoreScrollableOnStart) { clearPendingSwipeStartState(); dragStartPos = pendingStartPos; swipeCancelBaseline = pendingStartPos; lastMovePos = pendingStartPos; isFirstPointerMove = false; } else { clearPendingSwipeStartState(); swipeFromScrollable = false; } } } } const previousPos = lastMovePos; const movement = previousPos === null ? { x: 0, y: 0 } : { x: currentPos.x - previousPos.x, y: currentPos.y - previousPos.y, }; lastMovePos = currentPos; handleMoveCore(event, currentPos, movement); }; const handleEnd = (event: SwipeDismissEndEvent): void => { if (!options.enabled()) return; const resolvedDragOffset = dragOffsetRef; const resolvedInitialTransform = initialTransformRef; const releaseDeltaX = resolvedDragOffset.x - resolvedInitialTransform.x; const releaseDeltaY = resolvedDragOffset.y - resolvedInitialTransform.y; const progressDetails: SwipeProgressDetailsInternal = { deltaX: releaseDeltaX, deltaY: releaseDeltaY, direction: currentSwipeDirection() ?? intendedSwipeDirection, }; if (!isSwipingRef) { resetPendingSwipeState(); updateSwipeProgress(0, progressDetails); return; } setSwiping(false); setIsRealSwipe(false); setLockedDirection(null); resetPendingSwipeState(); sawPrimaryButtonsOnMove = false; const element = elementRef(); if (element) { if (!("touches" in event)) { safelyChangePointerCapture( element, (event as PointerEvent).pointerId, "releasePointerCapture", ); } } const deltaX = releaseDeltaX; const deltaY = releaseDeltaY; const startTime = swipeStartTime; const endTime = getValidTimeStamp(event.timeStamp); const durationMs = startTime !== null && endTime !== null && endTime > startTime ? endTime - startTime : 0; const velocityDurationMs = durationMs > 0 ? Math.max(durationMs, MIN_VELOCITY_DURATION_MS) : 0; const velocityX = velocityDurationMs > 0 ? deltaX / velocityDurationMs : 0; const velocityY = velocityDurationMs > 0 ? deltaY / velocityDurationMs : 0; let releaseVelocityX = lastDragVelocity.x; let releaseVelocityY = lastDragVelocity.y; const lastSample = lastDragSample; if (lastSample && endTime !== null && endTime >= lastSample.time) { const ageMs = endTime - lastSample.time; if (ageMs <= MAX_RELEASE_VELOCITY_AGE_MS) { const sampleDurationMs = Math.max( ageMs, MIN_RELEASE_VELOCITY_DURATION_MS, ); const deltaFromLastSampleX = resolvedDragOffset.x - lastSample.x; const deltaFromLastSampleY = resolvedDragOffset.y - lastSample.y; const sampleVelocityX = deltaFromLastSampleX / sampleDurationMs; const sampleVelocityY = deltaFromLastSampleY / sampleDurationMs; if (sampleVelocityX !== 0) releaseVelocityX = sampleVelocityX; if (sampleVelocityY !== 0) releaseVelocityY = sampleVelocityY; } else { releaseVelocityX = 0; releaseVelocityY = 0; } } const releaseDecision = options.onRelease?.({ event: event as SwipeDismissNativeEvent, direction: currentSwipeDirection() ?? intendedSwipeDirection, deltaX, deltaY, velocityX, velocityY, releaseVelocityX, releaseVelocityY, }); const hasReleaseDecision = typeof releaseDecision === "boolean"; if (cancelledSwipe && !hasReleaseDecision) { dragOffsetRef = { x: resolvedInitialTransform.x, y: resolvedInitialTransform.y, }; setDragOffsetX(resolvedInitialTransform.x); setDragOffsetY(resolvedInitialTransform.y); setCurrentSwipeDirection(undefined); updateSwipeProgress(0, progressDetails); return; } let shouldClose = false; let dismissDirection: SwipeDirection | undefined; if (hasReleaseDecision) { shouldClose = releaseDecision; dismissDirection = currentSwipeDirection() ?? intendedSwipeDirection ?? primaryDirection; } else { for (const direction of directions) { switch (direction) { case "right": if (deltaX > swipeThresholdRef) { shouldClose = true; dismissDirection = "right"; } break; case "left": if (deltaX < -swipeThresholdRef) { shouldClose = true; dismissDirection = "left"; } break; case "down": if (deltaY > swipeThresholdRef) { shouldClose = true; dismissDirection = "down"; } break; case "up": if (deltaY < -swipeThresholdRef) { shouldClose = true; dismissDirection = "up"; } break; default: break; } if (shouldClose) break; } } if (shouldClose && dismissDirection) { setCurrentSwipeDirection(dismissDirection); setDragDismissed(true); options.onDismiss?.(event as SwipeDismissNativeEvent, { direction: dismissDirection, }); } else { dragOffsetRef = { x: resolvedInitialTransform.x, y: resolvedInitialTransform.y, }; setDragOffsetX(resolvedInitialTransform.x); setDragOffsetY(resolvedInitialTransform.y); setCurrentSwipeDirection(undefined); updateSwipeProgress(0, progressDetails); } }; const getDragStyles = (): JSX.CSSProperties => { const resolvedDragOffset = trackDrag ? { x: dragOffsetX(), y: dragOffsetY() } : { x: dragOffsetRef.x, y: dragOffsetRef.y }; const resolvedInitialTransform = trackDrag ? { x: initialTransformX(), y: initialTransformY(), scale: initialTransformScale(), } : initialTransformRef; const swiping = isSwiping(); if ( !swiping && resolvedDragOffset.x === resolvedInitialTransform.x && resolvedDragOffset.y === resolvedInitialTransform.y && !dragDismissed() ) { return { [movementCssVars.x]: "0px", [movementCssVars.y]: "0px", } as JSX.CSSProperties; } const deltaX = resolvedDragOffset.x - resolvedInitialTransform.x; const deltaY = resolvedDragOffset.y - resolvedInitialTransform.y; return { transition: swiping ? "none" : undefined, transform: swiping ? `translateX(${resolvedDragOffset.x}px) translateY(${resolvedDragOffset.y}px) scale(${resolvedInitialTransform.scale})` : undefined, [movementCssVars.x]: `${deltaX}px`, [movementCssVars.y]: `${deltaY}px`, } as JSX.CSSProperties; }; const getPointerProps: UseSwipeDismissReturnValue["getPointerProps"] = () => { if (!options.enabled()) return {}; return { onPointerDown: handleStart, onPointerMove: handleMove, onPointerUp: handleEnd, onPointerCancel: handleEnd, }; }; const getTouchProps: UseSwipeDismissReturnValue["getTouchProps"] = () => { if (!options.enabled()) return {}; return { onTouchStart: handleStart, onTouchMove: handleMove, onTouchEnd: handleEnd, onTouchCancel: handleEnd, }; }; return { swiping: isSwiping, swipeDirection: currentSwipeDirection, dragDismissed, getPointerProps, getTouchProps, getDragStyles, reset, }; }