UNPKG

@base-ui/react

Version:

Base UI is a library of headless ('unstyled') React components and low-level hooks. You gain complete control over your app's CSS and accessibility features.

944 lines (942 loc) 33.1 kB
'use client'; import * as React from 'react'; import { useStableCallback } from '@base-ui/utils/useStableCallback'; import { ownerDocument } from '@base-ui/utils/owner'; import { contains, getTarget } from "../floating-ui-react/utils.js"; import { findScrollableTouchTarget, hasScrollableAncestor } from "./scrollable.js"; import { clamp } from "./clamp.js"; 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"]'; export function getDisplacement(direction, deltaX, deltaY) { switch (direction) { case 'up': return -deltaY; case 'down': return deltaY; case 'left': return -deltaX; case 'right': return deltaX; default: return 0; } } export function getElementTransform(element) { const computedStyle = window.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) { const values = matrix[1].split(', ').map(parseFloat); if (values.length === 6) { translateX = values[4]; translateY = values[5]; scale = Math.sqrt(values[0] * values[0] + values[1] * values[1]); } else if (values.length === 16) { translateX = values[12]; translateY = values[13]; scale = values[0]; } } } return { x: translateX, y: translateY, scale }; } function getValidTimeStamp(timeStamp) { return Number.isFinite(timeStamp) && timeStamp > 0 ? timeStamp : null; } function hasPrimaryMouseButton(buttons) { return buttons % 2 === 1; } function safelyChangePointerCapture(element, pointerId, method) { const pointerCaptureMethod = element[method]; if (typeof pointerCaptureMethod !== 'function') { return; } try { pointerCaptureMethod.call(element, pointerId); } catch (error) { if (error && typeof error === 'object' && 'name' in error && error.name === 'NotFoundError') { return; } throw error; } } export function useSwipeDismiss(options) { const { enabled, directions, elementRef, movementCssVars, canStart, ignoreSelectorWhenTouch = true, ignoreScrollableAncestors = false, swipeThreshold: swipeThresholdProp, onDismiss, onProgress, onSwipeStart, onRelease, onSwipingChange, trackDrag = true } = options; 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 = React.useMemo(() => { const axes = []; if (hasVertical) { axes.push('vertical'); } if (hasHorizontal) { axes.push('horizontal'); } return axes; }, [hasHorizontal, hasVertical]); const [currentSwipeDirection, setCurrentSwipeDirection] = React.useState(undefined); const [isSwiping, setIsSwiping] = React.useState(false); const [isRealSwipe, setIsRealSwipe] = React.useState(false); const [dragDismissed, setDragDismissed] = React.useState(false); const [dragOffset, setDragOffset] = React.useState({ x: 0, y: 0 }); const [initialTransform, setInitialTransform] = React.useState({ x: 0, y: 0, scale: 1 }); const [lockedDirection, setLockedDirection] = React.useState(null); const dragStartPosRef = React.useRef({ x: 0, y: 0 }); const dragOffsetRef = React.useRef({ x: 0, y: 0 }); const lastMovePosRef = React.useRef(null); const initialTransformRef = React.useRef({ x: 0, y: 0, scale: 1 }); const intendedSwipeDirectionRef = React.useRef(undefined); const maxSwipeDisplacementRef = React.useRef(0); const cancelledSwipeRef = React.useRef(false); const swipeCancelBaselineRef = React.useRef({ x: 0, y: 0 }); const isFirstPointerMoveRef = React.useRef(false); const pendingSwipeRef = React.useRef(false); const pendingSwipeStartPosRef = React.useRef(null); const swipeFromScrollableRef = React.useRef(false); const sawPrimaryButtonsOnMoveRef = React.useRef(false); const elementSizeRef = React.useRef({ width: 0, height: 0 }); const swipeProgressRef = React.useRef(0); const swipeThresholdRef = React.useRef(swipeThresholdDefault); const swipeStartTimeRef = React.useRef(null); const lastDragSampleRef = React.useRef(null); const lastDragVelocityRef = React.useRef({ x: 0, y: 0 }); const lastProgressDetailsRef = React.useRef(null); const isSwipingRef = React.useRef(false); const setSwiping = useStableCallback(nextSwiping => { if (isSwipingRef.current === nextSwiping) { return; } isSwipingRef.current = nextSwiping; setIsSwiping(nextSwiping); onSwipingChange?.(nextSwiping); }); function resolveSwipeThreshold(direction) { if (!direction) { return; } if (typeof swipeThresholdProp !== 'function') { swipeThresholdRef.current = swipeThresholdDefault; return; } const element = elementRef.current; if (!element) { return; } const value = swipeThresholdProp({ element, direction }); swipeThresholdRef.current = Math.max(0, value); } const updateSwipeProgress = useStableCallback((progress, details) => { const nextProgress = Number.isFinite(progress) ? clamp(progress, 0, 1) : 0; const progressChanged = nextProgress !== swipeProgressRef.current; let detailsChanged = false; if (details) { const lastDetails = lastProgressDetailsRef.current; detailsChanged = !lastDetails || lastDetails.deltaX !== details.deltaX || lastDetails.deltaY !== details.deltaY || lastDetails.direction !== details.direction; } if (!progressChanged && !detailsChanged) { return; } swipeProgressRef.current = nextProgress; if (details) { lastProgressDetailsRef.current = details; } else if (progressChanged) { lastProgressDetailsRef.current = null; } onProgress?.(nextProgress, details); }); function recordDragSample(offset, timeStamp) { if (timeStamp === null) { return; } const lastSample = lastDragSampleRef.current; if (lastSample && timeStamp > lastSample.time) { const durationMs = Math.max(timeStamp - lastSample.time, MIN_RELEASE_VELOCITY_DURATION_MS); lastDragVelocityRef.current = { x: (offset.x - lastSample.x) / durationMs, y: (offset.y - lastSample.y) / durationMs }; } lastDragSampleRef.current = { x: offset.x, y: offset.y, time: timeStamp }; } const reset = React.useCallback(() => { setCurrentSwipeDirection(undefined); setSwiping(false); setIsRealSwipe(false); setDragDismissed(false); setDragOffset({ x: 0, y: 0 }); setInitialTransform({ x: 0, y: 0, scale: 1 }); setLockedDirection(null); updateSwipeProgress(0); swipeThresholdRef.current = swipeThresholdDefault; dragStartPosRef.current = { x: 0, y: 0 }; dragOffsetRef.current = { x: 0, y: 0 }; initialTransformRef.current = { x: 0, y: 0, scale: 1 }; intendedSwipeDirectionRef.current = undefined; maxSwipeDisplacementRef.current = 0; cancelledSwipeRef.current = false; swipeCancelBaselineRef.current = { x: 0, y: 0 }; isFirstPointerMoveRef.current = false; lastMovePosRef.current = null; pendingSwipeRef.current = false; pendingSwipeStartPosRef.current = null; swipeFromScrollableRef.current = false; sawPrimaryButtonsOnMoveRef.current = false; elementSizeRef.current = { width: 0, height: 0 }; swipeStartTimeRef.current = null; lastDragSampleRef.current = null; lastDragVelocityRef.current = { x: 0, y: 0 }; lastProgressDetailsRef.current = null; }, [setSwiping, swipeThresholdDefault, updateSwipeProgress]); React.useEffect(() => { if (typeof swipeThresholdProp !== 'function') { swipeThresholdRef.current = swipeThresholdDefault; } }, [swipeThresholdDefault, swipeThresholdProp]); function getPrimaryPointerPosition(event) { 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) { if ('touches' in event) { return true; } return event.pointerType === 'touch'; } function getTargetAtPoint(position, nativeEvent) { const doc = ownerDocument(elementRef.current); const elementAtPoint = typeof doc?.elementFromPoint === 'function' ? doc.elementFromPoint(position.x, position.y) : null; const target = elementAtPoint ?? getTarget(nativeEvent); return target; } function findGestureScrollableTouchTarget(target, root) { 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, position, startOptions) { swipeFromScrollableRef.current = false; const touchLike = isTouchLikeEvent(event); const target = getTargetAtPoint(position, event.nativeEvent); const doc = ownerDocument(elementRef.current); const body = doc.body; const scrollableTarget = touchLike && body ? findGestureScrollableTouchTarget(target, body) : null; const ignoreScrollableTarget = startOptions?.ignoreScrollableTarget ?? false; if (scrollableTarget && !ignoreScrollableTarget) { return false; } swipeFromScrollableRef.current = Boolean(scrollableTarget && ignoreScrollableTarget); const isInteractiveElement = target ? target.closest(ignoreSelector) : false; if (isInteractiveElement && (!touchLike || ignoreSelectorWhenTouch)) { return false; } const element = elementRef.current; if (ignoreScrollableAncestors && element && target && scrollAxes.length > 0) { const ignoreAncestors = startOptions?.ignoreScrollableAncestors ?? false; if (!ignoreAncestors && hasScrollableAncestor(target, element, scrollAxes)) { return false; } } cancelledSwipeRef.current = false; intendedSwipeDirectionRef.current = undefined; maxSwipeDisplacementRef.current = 0; dragStartPosRef.current = position; swipeStartTimeRef.current = getValidTimeStamp(event.timeStamp); swipeCancelBaselineRef.current = position; lastMovePosRef.current = position; if (element) { elementSizeRef.current = { width: element.offsetWidth, height: element.offsetHeight }; resolveSwipeThreshold(primaryDirection); const transform = getElementTransform(element); initialTransformRef.current = transform; dragOffsetRef.current = { x: transform.x, y: transform.y }; setInitialTransform(transform); setDragOffset({ x: transform.x, y: transform.y }); recordDragSample({ x: transform.x, y: transform.y }, swipeStartTimeRef.current); if (!('touches' in event)) { safelyChangePointerCapture(element, event.pointerId, 'setPointerCapture'); } } onSwipeStart?.(event.nativeEvent); setSwiping(true); setIsRealSwipe(false); setLockedDirection(null); isFirstPointerMoveRef.current = true; updateSwipeProgress(0); return true; } function resetPendingSwipeState() { clearPendingSwipeStartState(); swipeFromScrollableRef.current = false; lastMovePosRef.current = null; } function clearPendingSwipeStartState() { pendingSwipeRef.current = false; pendingSwipeStartPosRef.current = null; } function cancelSwipeInteraction(event) { resetPendingSwipeState(); if (!isSwipingRef.current) { return; } setSwiping(false); setIsRealSwipe(false); setLockedDirection(null); const resolvedInitialTransform = trackDrag ? initialTransform : initialTransformRef.current; dragOffsetRef.current = { x: resolvedInitialTransform.x, y: resolvedInitialTransform.y }; setDragOffset({ x: resolvedInitialTransform.x, y: resolvedInitialTransform.y }); setCurrentSwipeDirection(undefined); sawPrimaryButtonsOnMoveRef.current = false; const element = elementRef.current; if (element) { safelyChangePointerCapture(element, event.pointerId, 'releasePointerCapture'); } updateSwipeProgress(0, { deltaX: 0, deltaY: 0, direction: undefined }); } function applyDirectionalDamping(deltaX, deltaY) { const exponent = value => value >= 0 ? value ** 0.5 : -(Math.abs(value) ** 0.5); const dampAxis = (delta, allowNegative, allowPositive) => { 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, deltaX, deltaY) { 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 = useStableCallback(event => { if (!enabled) { return; } if (event.defaultPrevented || event.nativeEvent.defaultPrevented) { return; } if (!('touches' in event) && event.button !== 0) { return; } const startPos = getPrimaryPointerPosition(event); if (!startPos) { return; } pendingSwipeRef.current = true; pendingSwipeStartPosRef.current = startPos; swipeFromScrollableRef.current = false; sawPrimaryButtonsOnMoveRef.current = false; const allowedToStart = canStart ? canStart(startPos, { nativeEvent: event.nativeEvent, direction: primaryDirection }) : true; if (!allowedToStart) { return; } if (startSwipeAtPosition(event, startPos)) { clearPendingSwipeStartState(); } }); function handleMoveCore(event, position, movement) { if (!enabled || !isSwipingRef.current) { return; } const target = getTarget(event.nativeEvent); if (isTouchLikeEvent(event) && !swipeFromScrollableRef.current) { const boundaryElement = event.currentTarget; if (findGestureScrollableTouchTarget(target, boundaryElement)) { return; } } if (!('touches' in event)) { // Prevent text selection on Safari event.preventDefault(); } if (isFirstPointerMoveRef.current) { // Adjust the starting position to the current position on the first move // to account for the delay between pointerdown and the first pointermove on iOS. dragStartPosRef.current = position; const moveTime = getValidTimeStamp(event.timeStamp); if (moveTime !== null) { swipeStartTimeRef.current = moveTime; } isFirstPointerMoveRef.current = false; } const clientX = position.x; const clientY = position.y; const movementX = movement.x; const movementY = movement.y; if (movementY < 0 && clientY > swipeCancelBaselineRef.current.y || movementY > 0 && clientY < swipeCancelBaselineRef.current.y) { swipeCancelBaselineRef.current = { x: swipeCancelBaselineRef.current.x, y: clientY }; } if (movementX < 0 && clientX > swipeCancelBaselineRef.current.x || movementX > 0 && clientX < swipeCancelBaselineRef.current.x) { swipeCancelBaselineRef.current = { x: clientX, y: swipeCancelBaselineRef.current.y }; } const deltaX = clientX - dragStartPosRef.current.x; const deltaY = clientY - dragStartPosRef.current.y; const cancelDeltaY = clientY - swipeCancelBaselineRef.current.y; const cancelDeltaX = clientX - swipeCancelBaselineRef.current.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; if (!intendedSwipeDirectionRef.current) { if (lockedDirection === 'vertical') { if (deltaY > 0) { candidate = 'down'; } else if (deltaY < 0) { candidate = 'up'; } } else if (lockedDirection === '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) { intendedSwipeDirectionRef.current = candidate; maxSwipeDisplacementRef.current = getDisplacement(candidate, deltaX, deltaY); setCurrentSwipeDirection(candidate); resolveSwipeThreshold(candidate); } } } else { const direction = intendedSwipeDirectionRef.current; const currentDisplacement = getDisplacement(direction, cancelDeltaX, cancelDeltaY); if (currentDisplacement > swipeThresholdRef.current) { cancelledSwipeRef.current = false; setCurrentSwipeDirection(direction); } else if (!(allowLeft && allowRight) && !(allowUp && allowDown) && maxSwipeDisplacementRef.current - currentDisplacement >= REVERSE_CANCEL_THRESHOLD) { // Mark that a change-of-mind has occurred cancelledSwipeRef.current = true; } } const dampedDelta = applyDirectionalDamping(deltaX, deltaY); let newOffsetX = initialTransformRef.current.x; let newOffsetY = initialTransformRef.current.y; if (lockedDirection === 'horizontal') { if (hasHorizontal) { newOffsetX += dampedDelta.x; } } else if (lockedDirection === 'vertical') { if (hasVertical) { newOffsetY += dampedDelta.y; } } else { if (hasHorizontal) { newOffsetX += dampedDelta.x; } if (hasVertical) { newOffsetY += dampedDelta.y; } } dragOffsetRef.current = { x: newOffsetX, y: newOffsetY }; if (trackDrag) { setDragOffset({ x: newOffsetX, y: newOffsetY }); } recordDragSample({ x: newOffsetX, y: newOffsetY }, getValidTimeStamp(event.timeStamp)); const dragDeltaX = newOffsetX - initialTransformRef.current.x; const dragDeltaY = newOffsetY - initialTransformRef.current.y; const swipeDirectionDetails = intendedSwipeDirectionRef.current; const progressDirection = primaryDirection ?? intendedSwipeDirectionRef.current; if (!progressDirection) { updateSwipeProgress(0, { deltaX: dragDeltaX, deltaY: dragDeltaY, direction: swipeDirectionDetails }); return; } const size = progressDirection === 'left' || progressDirection === 'right' ? elementSizeRef.current.width : elementSizeRef.current.height; const scale = initialTransformRef.current.scale || 1; if (size <= 0 || scale <= 0) { updateSwipeProgress(0, { deltaX: dragDeltaX, deltaY: dragDeltaY, direction: swipeDirectionDetails }); return; } const progressDisplacement = getDisplacement(progressDirection, newOffsetX - initialTransformRef.current.x, newOffsetY - initialTransformRef.current.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 = useStableCallback(event => { const currentPos = getPrimaryPointerPosition(event); if (!currentPos) { return; } if (!('touches' in event)) { const hasPrimaryButton = hasPrimaryMouseButton(event.buttons); if (hasPrimaryButton) { sawPrimaryButtonsOnMoveRef.current = true; } // Cancel the swipe if a non-primary button takes over the interaction. // This handles cases where a right-click interrupts dragging. const lostPrimaryButtonDuringSwipe = event.buttons === 0 && sawPrimaryButtonsOnMoveRef.current; if (event.buttons !== 0 && !hasPrimaryButton || lostPrimaryButtonDuringSwipe) { cancelSwipeInteraction(event); return; } } if (!isSwiping && pendingSwipeRef.current) { if (!isTouchLikeEvent(event) && (event.defaultPrevented || event.nativeEvent.defaultPrevented)) { resetPendingSwipeState(); return; } const allowedToStart = canStart ? canStart(currentPos, { nativeEvent: event.nativeEvent, direction: primaryDirection }) : true; if (allowedToStart) { const pendingStartPos = pendingSwipeStartPosRef.current; let ignoreScrollableOnStart = false; if (isTouchLikeEvent(event)) { const element = elementRef.current; if (pendingStartPos && element) { const target = getTargetAtPoint(currentPos, event.nativeEvent); 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) { // Preserve displacement between touchstart and the move that activates swipe from // a scroll-edge so quick flicks can dismiss. clearPendingSwipeStartState(); dragStartPosRef.current = pendingStartPos; swipeCancelBaselineRef.current = pendingStartPos; lastMovePosRef.current = pendingStartPos; isFirstPointerMoveRef.current = false; } else { // Start from the current in-bounds position without dropping follow-up move // displacement; this avoids jumps when entering from outside the element while // keeping swipe tracking responsive on the next move. clearPendingSwipeStartState(); swipeFromScrollableRef.current = false; } } } } const previousPos = lastMovePosRef.current; const movement = previousPos === null ? { x: 0, y: 0 } : { x: currentPos.x - previousPos.x, y: currentPos.y - previousPos.y }; lastMovePosRef.current = currentPos; handleMoveCore(event, currentPos, movement); }); const handleEnd = useStableCallback(event => { if (!enabled) { return; } const resolvedDragOffset = dragOffsetRef.current; const resolvedInitialTransform = initialTransformRef.current; const releaseDeltaX = resolvedDragOffset.x - resolvedInitialTransform.x; const releaseDeltaY = resolvedDragOffset.y - resolvedInitialTransform.y; const progressDetails = { deltaX: releaseDeltaX, deltaY: releaseDeltaY, direction: currentSwipeDirection ?? intendedSwipeDirectionRef.current }; if (!isSwipingRef.current) { resetPendingSwipeState(); updateSwipeProgress(0, progressDetails); return; } setSwiping(false); setIsRealSwipe(false); setLockedDirection(null); resetPendingSwipeState(); sawPrimaryButtonsOnMoveRef.current = false; const element = elementRef.current; if (element) { if (!('touches' in event)) { safelyChangePointerCapture(element, event.pointerId, 'releasePointerCapture'); } } const deltaX = releaseDeltaX; const deltaY = releaseDeltaY; const startTime = swipeStartTimeRef.current; 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 = lastDragVelocityRef.current.x; let releaseVelocityY = lastDragVelocityRef.current.y; const lastSample = lastDragSampleRef.current; 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 = onRelease?.({ event: event.nativeEvent, direction: currentSwipeDirection ?? intendedSwipeDirectionRef.current, deltaX, deltaY, velocityX, velocityY, releaseVelocityX, releaseVelocityY }); const hasReleaseDecision = typeof releaseDecision === 'boolean'; if (cancelledSwipeRef.current && !hasReleaseDecision) { dragOffsetRef.current = { x: resolvedInitialTransform.x, y: resolvedInitialTransform.y }; setDragOffset({ x: resolvedInitialTransform.x, y: resolvedInitialTransform.y }); setCurrentSwipeDirection(undefined); updateSwipeProgress(0, progressDetails); return; } let shouldClose = false; let dismissDirection; if (hasReleaseDecision) { shouldClose = releaseDecision; dismissDirection = currentSwipeDirection ?? intendedSwipeDirectionRef.current ?? primaryDirection; } else { for (const direction of directions) { switch (direction) { case 'right': if (deltaX > swipeThresholdRef.current) { shouldClose = true; dismissDirection = 'right'; } break; case 'left': if (deltaX < -swipeThresholdRef.current) { shouldClose = true; dismissDirection = 'left'; } break; case 'down': if (deltaY > swipeThresholdRef.current) { shouldClose = true; dismissDirection = 'down'; } break; case 'up': if (deltaY < -swipeThresholdRef.current) { shouldClose = true; dismissDirection = 'up'; } break; default: break; } if (shouldClose) { break; } } } if (shouldClose && dismissDirection) { setCurrentSwipeDirection(dismissDirection); setDragDismissed(true); onDismiss?.(event.nativeEvent, { direction: dismissDirection }); } else { dragOffsetRef.current = { x: resolvedInitialTransform.x, y: resolvedInitialTransform.y }; setDragOffset({ x: resolvedInitialTransform.x, y: resolvedInitialTransform.y }); setCurrentSwipeDirection(undefined); updateSwipeProgress(0, progressDetails); } }); const getDragStyles = React.useCallback(() => { const resolvedDragOffset = trackDrag ? dragOffset : dragOffsetRef.current; const resolvedInitialTransform = trackDrag ? initialTransform : initialTransformRef.current; if (!isSwiping && resolvedDragOffset.x === resolvedInitialTransform.x && resolvedDragOffset.y === resolvedInitialTransform.y && !dragDismissed) { return { [movementCssVars.x]: '0px', [movementCssVars.y]: '0px' }; } const deltaX = resolvedDragOffset.x - resolvedInitialTransform.x; const deltaY = resolvedDragOffset.y - resolvedInitialTransform.y; return { transition: isSwiping ? 'none' : undefined, // While swiping, freeze the element at its current visual transform so it doesn't snap to the // end position. transform: isSwiping ? `translateX(${resolvedDragOffset.x}px) translateY(${resolvedDragOffset.y}px) scale(${resolvedInitialTransform.scale})` : undefined, [movementCssVars.x]: `${deltaX}px`, [movementCssVars.y]: `${deltaY}px` }; }, [dragDismissed, dragOffset, initialTransform, isSwiping, movementCssVars, trackDrag]); const getPointerProps = React.useCallback(() => { if (!enabled) { return {}; } return { onPointerDown: handleStart, onPointerMove: handleMove, onPointerUp: handleEnd, onPointerCancel: handleEnd }; }, [enabled, handleEnd, handleMove, handleStart]); const getTouchProps = React.useCallback(() => { if (!enabled) { return {}; } return { onTouchStart: handleStart, onTouchMove: handleMove, onTouchEnd: handleEnd, onTouchCancel: handleEnd }; }, [enabled, handleEnd, handleMove, handleStart]); return { swiping: isSwiping, swipeDirection: currentSwipeDirection, dragDismissed, getPointerProps, getTouchProps, getDragStyles, reset }; }