@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.
420 lines (415 loc) • 16.3 kB
JavaScript
'use client';
import * as React from 'react';
import { useStableCallback } from '@base-ui/utils/useStableCallback';
import { useIsoLayoutEffect } from '@base-ui/utils/useIsoLayoutEffect';
import { ownerDocument } from '@base-ui/utils/owner';
import { useDialogRootContext } from "../../dialog/root/DialogRootContext.mjs";
import { useRenderElement } from "../../internals/useRenderElement.mjs";
import { NOOP } from "../../internals/noop.mjs";
import { createChangeEventDetails } from "../../internals/createBaseUIEventDetails.mjs";
import { REASONS } from "../../internals/reasons.mjs";
import { getDisplacement, getElementTransform, useSwipeDismiss } from "../../utils/useSwipeDismiss.mjs";
import { DrawerPopupCssVars } from "../popup/DrawerPopupCssVars.mjs";
import { DrawerPopupDataAttributes } from "../popup/DrawerPopupDataAttributes.mjs";
import { DrawerBackdropCssVars } from "../backdrop/DrawerBackdropCssVars.mjs";
import { useDrawerRootContext } from "../root/DrawerRootContext.mjs";
import { useBaseUiId } from "../../internals/useBaseUiId.mjs";
import { useTriggerRegistration } from "../../utils/popups/index.mjs";
import { useDrawerProviderContext } from "../provider/DrawerProviderContext.mjs";
import { isVirtualClick } from "../../floating-ui-react/utils/event.mjs";
import { DrawerSwipeAreaDataAttributes } from "./DrawerSwipeAreaDataAttributes.mjs";
const DEFAULT_SWIPE_OPEN_RATIO = 0.5;
const MIN_SWIPE_START_DISTANCE = 1;
const VELOCITY_THRESHOLD = 0.1;
const FALLBACK_SWIPE_OPEN_THRESHOLD = 40;
const SWIPE_AREA_OPEN_HOOK = {
[DrawerSwipeAreaDataAttributes.open]: ''
};
const SWIPE_AREA_CLOSED_HOOK = {
[DrawerSwipeAreaDataAttributes.closed]: ''
};
const SWIPE_AREA_SWIPING_HOOK = {
[DrawerSwipeAreaDataAttributes.swiping]: ''
};
const SWIPE_AREA_DISABLED_HOOK = {
[DrawerSwipeAreaDataAttributes.disabled]: ''
};
const stateAttributesMapping = {
open(value) {
return value ? SWIPE_AREA_OPEN_HOOK : SWIPE_AREA_CLOSED_HOOK;
},
swiping(value) {
return value ? SWIPE_AREA_SWIPING_HOOK : null;
},
swipeDirection(value) {
return {
[DrawerSwipeAreaDataAttributes.swipeDirection]: value
};
},
disabled(value) {
return value ? SWIPE_AREA_DISABLED_HOOK : null;
}
};
const oppositeSwipeDirection = {
up: 'down',
down: 'up',
left: 'right',
right: 'left'
};
function resolveTouchAction(direction) {
return direction === 'left' || direction === 'right' ? 'pan-y' : 'pan-x';
}
/**
* An invisible area that listens for swipe gestures to open the drawer.
* Renders a `<div>` element.
*
* Documentation: [Base UI Drawer](https://base-ui.com/react/components/drawer)
*/
export const DrawerSwipeArea = /*#__PURE__*/React.forwardRef(function DrawerSwipeArea(componentProps, forwardedRef) {
const {
render,
className,
style,
disabled = false,
swipeDirection: swipeDirectionProp,
...elementProps
} = componentProps;
const store = useDialogRootContext();
const {
swipeDirection,
frontmostHeight,
swipeAreaActiveRef
} = useDrawerRootContext();
const providerContext = useDrawerProviderContext();
const [swipeActive, setSwipeActive] = React.useState(false);
const swipeAreaRef = React.useRef(null);
const swipeStartEventRef = React.useRef(null);
const openedBySwipeRef = React.useRef(false);
const dragDeltaRef = React.useRef({
x: 0,
y: 0
});
const closedOffsetRef = React.useRef(null);
const appliedSwipeStylesRef = React.useRef(false);
const swipePopupElementRef = React.useRef(null);
const swipeBackdropElementRef = React.useRef(null);
const popupTransitionRef = React.useRef(null);
const releaseGuardCleanupRef = React.useRef(NOOP);
const swipeAreaId = useBaseUiId(componentProps.id);
const registerTrigger = useTriggerRegistration(swipeAreaId, store);
const open = store.useState('open');
const resetDragDelta = useStableCallback(() => {
dragDeltaRef.current.x = 0;
dragDeltaRef.current.y = 0;
});
const resolvedSwipeDirection = swipeDirectionProp ?? oppositeSwipeDirection[swipeDirection];
const dismissDirection = oppositeSwipeDirection[resolvedSwipeDirection];
const enabled = !disabled && (!open || swipeActive);
function disableDismissForSwipe() {
releaseGuardCleanupRef.current();
store.context.outsidePressEnabledRef.current = false;
}
const enableDismissAfterRelease = useStableCallback(() => {
releaseGuardCleanupRef.current();
const doc = ownerDocument(swipeAreaRef.current);
function restore(event) {
// The gesture's trailing release click is the one physical click with no `pointerdown` of
// its own. Ignore it and keep waiting, so it cannot dismiss the drawer it just opened,
// while a click-only activation (keyboard or assistive tech) still re-enables in time.
if (event?.type === 'click' && event.detail !== 0 && !isVirtualClick(event)) {
return;
}
releaseGuardCleanupRef.current = NOOP;
doc.removeEventListener('pointerdown', restore, true);
doc.removeEventListener('click', restore, true);
store.context.outsidePressEnabledRef.current = true;
}
// The pointerup that ends a swipe-open gesture synthesizes a `click`. When the drag released
// outside the popup (e.g. it was dragged past the popup's size), that click would be treated as
// an outside press and immediately dismiss the drawer that was just opened. Keep outside-press
// dismissal disabled until the next interaction that isn't that release click: a deliberate
// outside press starts with a `pointerdown`, and a click-only activation (keyboard or
// assistive tech) is distinguishable from a physical release. This is deterministic, unlike
// re-enabling on a timer that can race the synthesized click and dismiss at random.
//
// `restore` runs in document capture, ahead of floating-ui's own outside-press check (which
// happens on the event target, after capture), so the triggering press still dismisses.
releaseGuardCleanupRef.current = restore;
doc.addEventListener('pointerdown', restore, true);
doc.addEventListener('click', restore, true);
});
function getPopupSize(popupElement) {
const isHorizontal = dismissDirection === 'left' || dismissDirection === 'right';
const size = isHorizontal ? popupElement.offsetWidth : popupElement.offsetHeight;
if (size <= 0) {
return null;
}
return size;
}
function resolvePopupSize() {
const popupElement = store.context.popupRef.current;
return popupElement ? getPopupSize(popupElement) : null;
}
function resolveClosedOffset(popupElement) {
const offset = getPopupSize(popupElement);
if (offset == null) {
return null;
}
const isHorizontal = dismissDirection === 'left' || dismissDirection === 'right';
const transform = getElementTransform(popupElement);
const transformOffset = isHorizontal ? transform.x : transform.y;
if (Number.isFinite(transformOffset) && Math.abs(transformOffset) > 0.5) {
return Math.min(offset, Math.abs(transformOffset));
}
return offset;
}
function resolveSwipeOpenThreshold() {
const popupSize = resolvePopupSize();
if (popupSize == null) {
return FALLBACK_SWIPE_OPEN_THRESHOLD;
}
return popupSize * DEFAULT_SWIPE_OPEN_RATIO;
}
function applySwipeMovement() {
const popupElement = store.context.popupRef.current;
if (!popupElement) {
return;
}
if (!store.select('open') || !store.select('mounted')) {
return;
}
if (closedOffsetRef.current == null) {
closedOffsetRef.current = resolveClosedOffset(popupElement);
}
const closedOffset = closedOffsetRef.current;
if (closedOffset === null) {
return;
}
const {
x,
y
} = dragDeltaRef.current;
const displacement = getDisplacement(resolvedSwipeDirection, x, y);
const clampedDisplacement = Math.max(0, displacement);
const dampedDisplacement = clampedDisplacement > closedOffset ? closedOffset + Math.sqrt(clampedDisplacement - closedOffset) : clampedDisplacement;
const remaining = closedOffset - dampedDisplacement;
const directionSign = dismissDirection === 'left' || dismissDirection === 'up' ? -1 : 1;
const movement = remaining * directionSign;
const isHorizontal = dismissDirection === 'left' || dismissDirection === 'right';
const movementX = isHorizontal ? movement : 0;
const movementY = isHorizontal ? 0 : movement;
const openProgress = Math.max(0, Math.min(1, clampedDisplacement / closedOffset));
const backdropProgress = Math.max(0, Math.min(1, 1 - openProgress));
popupElement.style.setProperty(DrawerPopupCssVars.swipeMovementX, `${movementX}px`);
popupElement.style.setProperty(DrawerPopupCssVars.swipeMovementY, `${movementY}px`);
popupElement.setAttribute(DrawerPopupDataAttributes.swiping, '');
swipePopupElementRef.current = popupElement;
if (popupTransitionRef.current === null) {
popupTransitionRef.current = popupElement.style.transition;
}
popupElement.style.transition = 'none';
const backdropElement = store.context.backdropRef.current;
if (backdropElement) {
backdropElement.setAttribute(DrawerPopupDataAttributes.swiping, '');
swipeBackdropElementRef.current = backdropElement;
backdropElement.style.setProperty(DrawerBackdropCssVars.swipeProgress, `${backdropProgress}`);
if (openProgress > 0 && frontmostHeight > 0) {
backdropElement.style.setProperty(DrawerPopupCssVars.height, `${frontmostHeight}px`);
} else {
backdropElement.style.removeProperty(DrawerPopupCssVars.height);
}
}
providerContext?.visualStateStore.set({
swipeProgress: openProgress,
frontmostHeight: openProgress > 0 ? frontmostHeight : 0
});
appliedSwipeStylesRef.current = true;
swipeAreaActiveRef.current = true;
}
const clearSwipeStyles = useStableCallback(() => {
const popupElement = swipePopupElementRef.current;
if (popupElement) {
popupElement.style.removeProperty(DrawerPopupCssVars.swipeMovementX);
popupElement.style.removeProperty(DrawerPopupCssVars.swipeMovementY);
popupElement.removeAttribute(DrawerPopupDataAttributes.swiping);
}
if (popupElement && popupTransitionRef.current !== null) {
popupElement.style.transition = popupTransitionRef.current;
popupTransitionRef.current = null;
}
const backdropElement = swipeBackdropElementRef.current;
if (backdropElement) {
backdropElement.removeAttribute(DrawerPopupDataAttributes.swiping);
backdropElement.style.setProperty(DrawerBackdropCssVars.swipeProgress, '0');
backdropElement.style.removeProperty(DrawerPopupCssVars.height);
}
providerContext?.visualStateStore.set({
swipeProgress: 0,
frontmostHeight: 0
});
appliedSwipeStylesRef.current = false;
swipePopupElementRef.current = null;
swipeBackdropElementRef.current = null;
swipeAreaActiveRef.current = false;
});
function openDrawer(event) {
openedBySwipeRef.current = true;
store.setOpen(true, createChangeEventDetails(REASONS.swipe, event, swipeAreaRef.current));
}
function closeDrawer(event) {
store.setOpen(false, createChangeEventDetails(REASONS.swipe, event, swipeAreaRef.current));
}
function resetSwipeInteractionState() {
swipeStartEventRef.current = null;
openedBySwipeRef.current = false;
closedOffsetRef.current = null;
setSwipeActive(false);
}
function finishSwipeInteraction() {
resetSwipeInteractionState();
enableDismissAfterRelease();
resetDragDelta();
clearSwipeStyles();
}
const swipe = useSwipeDismiss({
enabled,
directions: [resolvedSwipeDirection],
elementRef: swipeAreaRef,
trackDrag: false,
movementCssVars: {
x: DrawerPopupCssVars.swipeMovementX,
y: DrawerPopupCssVars.swipeMovementY
},
onSwipeStart(event) {
disableDismissForSwipe();
swipeStartEventRef.current = event;
openedBySwipeRef.current = false;
setSwipeActive(true);
resetDragDelta();
},
onProgress(_progress, details) {
if (!details) {
return;
}
if (!swipeStartEventRef.current) {
return;
}
dragDeltaRef.current.x = details.deltaX;
dragDeltaRef.current.y = details.deltaY;
if (details.direction !== resolvedSwipeDirection) {
return;
}
const displacement = getDisplacement(resolvedSwipeDirection, details.deltaX, details.deltaY);
if (!openedBySwipeRef.current && displacement < MIN_SWIPE_START_DISTANCE) {
return;
}
if (!openedBySwipeRef.current && !store.select('open')) {
openDrawer(swipeStartEventRef.current);
}
applySwipeMovement();
},
onRelease({
event,
direction,
deltaX,
deltaY,
releaseVelocityX,
releaseVelocityY
}) {
const displacement = getDisplacement(resolvedSwipeDirection, deltaX, deltaY);
const releaseVelocity = getDisplacement(resolvedSwipeDirection, releaseVelocityX, releaseVelocityY);
const threshold = resolveSwipeOpenThreshold();
const hasEnoughDistance = displacement >= threshold;
const hasEnoughVelocity = releaseVelocity >= VELOCITY_THRESHOLD;
const shouldOpen = direction === resolvedSwipeDirection && (hasEnoughDistance || hasEnoughVelocity) && !disabled;
if (shouldOpen) {
if (!store.select('open')) {
openDrawer(event);
}
} else if (openedBySwipeRef.current && store.select('open')) {
closeDrawer(event);
}
finishSwipeInteraction();
return false;
},
onCancel: finishSwipeInteraction
});
const swipePointerProps = swipe.getPointerProps();
const swipeTouchProps = swipe.getTouchProps();
const resetSwipe = swipe.reset;
// The commit that opens the drawer re-renders the popup, resetting `--swipe-movement-*` to `0px`
// (the viewport isn't swiping). Re-assert after the DOM mutation but before paint. No deps: must
// run on every commit the swipe area participates in.
useIsoLayoutEffect(() => {
if (swipeActive && appliedSwipeStylesRef.current) {
applySwipeMovement();
}
});
useIsoLayoutEffect(() => {
if (!enabled) {
if (swipeActive) {
enableDismissAfterRelease();
}
resetSwipe();
resetDragDelta();
clearSwipeStyles();
resetSwipeInteractionState();
}
}, [clearSwipeStyles, enableDismissAfterRelease, enabled, resetDragDelta, resetSwipe, swipeActive]);
React.useEffect(() => {
return () => {
releaseGuardCleanupRef.current();
store.context.outsidePressEnabledRef.current = true;
};
}, [store]);
const state = {
open,
swiping: swipe.swiping,
swipeDirection: resolvedSwipeDirection,
disabled
};
return useRenderElement('div', componentProps, {
state,
ref: [forwardedRef, swipeAreaRef, registerTrigger],
stateAttributesMapping,
props: [{
role: 'presentation',
'aria-hidden': true,
style: {
pointerEvents: !enabled ? 'none' : undefined,
touchAction: resolveTouchAction(resolvedSwipeDirection)
},
onPointerDown(event) {
if (event.pointerType === 'touch') {
return;
}
swipePointerProps.onPointerDown?.(event);
// Prevent native text selection/drag gestures from competing with swipe-open dragging.
if (event.cancelable) {
event.preventDefault();
}
},
onPointerMove(event) {
if (event.pointerType === 'touch') {
return;
}
swipePointerProps.onPointerMove?.(event);
},
onPointerUp(event) {
if (event.pointerType === 'touch') {
return;
}
swipePointerProps.onPointerUp?.(event);
},
onPointerCancel(event) {
if (event.pointerType === 'touch') {
return;
}
swipePointerProps.onPointerCancel?.(event);
}
}, swipeTouchProps, swipeAreaId ? {
id: swipeAreaId
} : undefined, elementProps]
});
});
if (process.env.NODE_ENV !== "production") DrawerSwipeArea.displayName = "DrawerSwipeArea";