@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
JavaScript
'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
};
}