UNPKG

@base-ui-components/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.

503 lines (500 loc) 17.3 kB
"use strict"; 'use client'; var _interopRequireWildcard = require("@babel/runtime/helpers/interopRequireWildcard").default; Object.defineProperty(exports, "__esModule", { value: true }); exports.ToastRoot = void 0; var React = _interopRequireWildcard(require("react")); var _owner = require("@base-ui-components/utils/owner"); var _inertValue = require("@base-ui-components/utils/inertValue"); var _useIsoLayoutEffect = require("@base-ui-components/utils/useIsoLayoutEffect"); var _useEventCallback = require("@base-ui-components/utils/useEventCallback"); var _utils = require("../../floating-ui-react/utils"); var _ToastRootContext = require("./ToastRootContext"); var _stateAttributesMapping = require("../../utils/stateAttributesMapping"); var _ToastProviderContext = require("../provider/ToastProviderContext"); var _useRenderElement = require("../../utils/useRenderElement"); var _useOpenChangeComplete = require("../../utils/useOpenChangeComplete"); var _ToastRootCssVars = require("./ToastRootCssVars"); var _jsxRuntime = require("react/jsx-runtime"); const stateAttributesMapping = { ..._stateAttributesMapping.transitionStatusMapping, swipeDirection(value) { return value ? { 'data-swipe-direction': value } : null; } }; const SWIPE_THRESHOLD = 40; const REVERSE_CANCEL_THRESHOLD = 10; const OPPOSITE_DIRECTION_DAMPING_FACTOR = 0.5; const MIN_DRAG_THRESHOLD = 1; 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; } } 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 }; } /** * Groups all parts of an individual toast. * Renders a `<div>` element. * * Documentation: [Base UI Toast](https://base-ui.com/react/components/toast) */ const ToastRoot = exports.ToastRoot = /*#__PURE__*/React.forwardRef(function ToastRoot(componentProps, forwardedRef) { const { toast, render, className, swipeDirection = ['down', 'right'], ...elementProps } = componentProps; const swipeDirections = Array.isArray(swipeDirection) ? swipeDirection : [swipeDirection]; const { toasts, focused, close, remove, setToasts, pauseTimers, expanded, setHovering } = (0, _ToastProviderContext.useToastContext)(); 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 [titleId, setTitleId] = React.useState(); const [descriptionId, setDescriptionId] = React.useState(); const [lockedDirection, setLockedDirection] = React.useState(null); const rootRef = React.useRef(null); const dragStartPosRef = React.useRef({ x: 0, y: 0 }); 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 domIndex = React.useMemo(() => toasts.indexOf(toast), [toast, toasts]); const visibleIndex = React.useMemo(() => toasts.filter(t => t.transitionStatus !== 'ending').indexOf(toast), [toast, toasts]); const offsetY = React.useMemo(() => { return toasts.slice(0, toasts.indexOf(toast)).reduce((acc, t) => acc + (t.height || 0), 0); }, [toasts, toast]); (0, _useOpenChangeComplete.useOpenChangeComplete)({ open: toast.transitionStatus !== 'ending', ref: rootRef, onComplete() { if (toast.transitionStatus === 'ending') { remove(toast.id); } } }); const recalculateHeight = (0, _useEventCallback.useEventCallback)(() => { const element = rootRef.current; if (!element) { return; } const previousHeight = element.style.height; element.style.height = 'auto'; const height = element.offsetHeight; element.style.height = previousHeight; setToasts(prev => prev.map(t => t.id === toast.id ? { ...t, ref: rootRef, height, transitionStatus: undefined } : t)); }); (0, _useIsoLayoutEffect.useIsoLayoutEffect)(recalculateHeight, [recalculateHeight]); function applyDirectionalDamping(deltaX, deltaY) { let newDeltaX = deltaX; let newDeltaY = deltaY; if (!swipeDirections.includes('left') && !swipeDirections.includes('right')) { newDeltaX = deltaX > 0 ? deltaX ** OPPOSITE_DIRECTION_DAMPING_FACTOR : -(Math.abs(deltaX) ** OPPOSITE_DIRECTION_DAMPING_FACTOR); } else { if (!swipeDirections.includes('right') && deltaX > 0) { newDeltaX = deltaX ** OPPOSITE_DIRECTION_DAMPING_FACTOR; } if (!swipeDirections.includes('left') && deltaX < 0) { newDeltaX = -(Math.abs(deltaX) ** OPPOSITE_DIRECTION_DAMPING_FACTOR); } } if (!swipeDirections.includes('up') && !swipeDirections.includes('down')) { newDeltaY = deltaY > 0 ? deltaY ** OPPOSITE_DIRECTION_DAMPING_FACTOR : -(Math.abs(deltaY) ** OPPOSITE_DIRECTION_DAMPING_FACTOR); } else { if (!swipeDirections.includes('down') && deltaY > 0) { newDeltaY = deltaY ** OPPOSITE_DIRECTION_DAMPING_FACTOR; } if (!swipeDirections.includes('up') && deltaY < 0) { newDeltaY = -(Math.abs(deltaY) ** OPPOSITE_DIRECTION_DAMPING_FACTOR); } } return { x: newDeltaX, y: newDeltaY }; } function handlePointerDown(event) { if (event.button !== 0) { return; } if (event.pointerType === 'touch') { pauseTimers(); } const target = (0, _utils.getTarget)(event.nativeEvent); const isInteractiveElement = target ? target.closest('button,a,input,textarea,[role="button"],[data-swipe-ignore]') : false; if (isInteractiveElement) { return; } cancelledSwipeRef.current = false; intendedSwipeDirectionRef.current = undefined; maxSwipeDisplacementRef.current = 0; dragStartPosRef.current = { x: event.clientX, y: event.clientY }; swipeCancelBaselineRef.current = dragStartPosRef.current; if (rootRef.current) { const transform = getElementTransform(rootRef.current); initialTransformRef.current = transform; setInitialTransform(transform); setDragOffset({ x: transform.x, y: transform.y }); } setHovering(true); setIsSwiping(true); setIsRealSwipe(false); setLockedDirection(null); isFirstPointerMoveRef.current = true; rootRef.current?.setPointerCapture(event.pointerId); } function handlePointerMove(event) { if (!isSwiping) { return; } // 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 = { x: event.clientX, y: event.clientY }; isFirstPointerMoveRef.current = false; } const { clientY, clientX, movementX, movementY } = event; 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) { const hasHorizontal = swipeDirections.includes('left') || swipeDirections.includes('right'); const hasVertical = swipeDirections.includes('up') || swipeDirections.includes('down'); 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 && swipeDirections.includes(candidate)) { intendedSwipeDirectionRef.current = candidate; maxSwipeDisplacementRef.current = getDisplacement(candidate, deltaX, deltaY); setCurrentSwipeDirection(candidate); } } else { const direction = intendedSwipeDirectionRef.current; const currentDisplacement = getDisplacement(direction, cancelDeltaX, cancelDeltaY); if (currentDisplacement > SWIPE_THRESHOLD) { cancelledSwipeRef.current = false; setCurrentSwipeDirection(direction); } else if (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 (swipeDirections.includes('left') || swipeDirections.includes('right')) { newOffsetX += dampedDelta.x; } } else if (lockedDirection === 'vertical') { if (swipeDirections.includes('up') || swipeDirections.includes('down')) { newOffsetY += dampedDelta.y; } } else { if (swipeDirections.includes('left') || swipeDirections.includes('right')) { newOffsetX += dampedDelta.x; } if (swipeDirections.includes('up') || swipeDirections.includes('down')) { newOffsetY += dampedDelta.y; } } setDragOffset({ x: newOffsetX, y: newOffsetY }); } function handlePointerUp(event) { if (!isSwiping) { return; } setIsSwiping(false); setIsRealSwipe(false); setLockedDirection(null); rootRef.current?.releasePointerCapture(event.pointerId); if (cancelledSwipeRef.current) { setDragOffset({ x: initialTransform.x, y: initialTransform.y }); setCurrentSwipeDirection(undefined); return; } let shouldClose = false; const deltaX = dragOffset.x - initialTransform.x; const deltaY = dragOffset.y - initialTransform.y; let dismissDirection; for (const direction of swipeDirections) { switch (direction) { case 'right': if (deltaX > SWIPE_THRESHOLD) { shouldClose = true; dismissDirection = 'right'; } break; case 'left': if (deltaX < -SWIPE_THRESHOLD) { shouldClose = true; dismissDirection = 'left'; } break; case 'down': if (deltaY > SWIPE_THRESHOLD) { shouldClose = true; dismissDirection = 'down'; } break; case 'up': if (deltaY < -SWIPE_THRESHOLD) { shouldClose = true; dismissDirection = 'up'; } break; default: break; } if (shouldClose) { break; } } if (shouldClose) { setCurrentSwipeDirection(dismissDirection); setDragDismissed(true); close(toast.id); } else { setDragOffset({ x: initialTransform.x, y: initialTransform.y }); setCurrentSwipeDirection(undefined); } } function handleKeyDown(event) { if (event.key === 'Escape') { if (!rootRef.current || !(0, _utils.contains)(rootRef.current, (0, _utils.activeElement)((0, _owner.ownerDocument)(rootRef.current)))) { return; } close(toast.id); } } React.useEffect(() => { const element = rootRef.current; if (!element) { return undefined; } function preventDefaultTouchStart(event) { if ((0, _utils.contains)(element, event.target)) { event.preventDefault(); } } element.addEventListener('touchmove', preventDefaultTouchStart, { passive: false }); return () => { element.removeEventListener('touchmove', preventDefaultTouchStart); }; }, []); function getDragStyles() { if (!isSwiping && dragOffset.x === initialTransform.x && dragOffset.y === initialTransform.y && !dragDismissed) { return { [_ToastRootCssVars.ToastRootCssVars.swipeMovementX]: '0px', [_ToastRootCssVars.ToastRootCssVars.swipeMovementY]: '0px' }; } const deltaX = dragOffset.x - initialTransform.x; const deltaY = dragOffset.y - initialTransform.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(${dragOffset.x}px) translateY(${dragOffset.y}px) scale(${initialTransform.scale})` : undefined, [_ToastRootCssVars.ToastRootCssVars.swipeMovementX]: `${deltaX}px`, [_ToastRootCssVars.ToastRootCssVars.swipeMovementY]: `${deltaY}px` }; } const isHighPriority = toast.priority === 'high'; const defaultProps = { role: isHighPriority ? 'alertdialog' : 'dialog', tabIndex: 0, 'aria-modal': false, 'aria-labelledby': titleId, 'aria-describedby': descriptionId, 'aria-hidden': isHighPriority && !focused ? true : undefined, onPointerDown: handlePointerDown, onPointerMove: handlePointerMove, onPointerUp: handlePointerUp, onKeyDown: handleKeyDown, inert: (0, _inertValue.inertValue)(toast.limited), style: { ...getDragStyles(), [_ToastRootCssVars.ToastRootCssVars.index]: toast.transitionStatus === 'ending' ? domIndex : visibleIndex, [_ToastRootCssVars.ToastRootCssVars.offsetY]: `${offsetY}px`, [_ToastRootCssVars.ToastRootCssVars.height]: toast.height ? `${toast.height}px` : undefined } }; const toastRoot = React.useMemo(() => ({ rootRef, toast, titleId, setTitleId, descriptionId, setDescriptionId, swiping: isSwiping, swipeDirection: currentSwipeDirection, recalculateHeight, index: domIndex, visibleIndex, expanded }), [toast, titleId, descriptionId, isSwiping, currentSwipeDirection, recalculateHeight, domIndex, visibleIndex, expanded]); const state = React.useMemo(() => ({ transitionStatus: toast.transitionStatus, expanded, limited: toast.limited || false, type: toast.type, swiping: toastRoot.swiping, swipeDirection: toastRoot.swipeDirection }), [expanded, toast.transitionStatus, toast.limited, toast.type, toastRoot.swiping, toastRoot.swipeDirection]); const element = (0, _useRenderElement.useRenderElement)('div', componentProps, { ref: [forwardedRef, toastRoot.rootRef], state, stateAttributesMapping, props: [defaultProps, elementProps] }); return /*#__PURE__*/(0, _jsxRuntime.jsx)(_ToastRootContext.ToastRootContext.Provider, { value: toastRoot, children: element }); }); if (process.env.NODE_ENV !== "production") ToastRoot.displayName = "ToastRoot";