UNPKG

vaul

Version:

Drawer component for React.

1,033 lines (1,020 loc) 79.1 kB
'use client'; function __insertCSS(code) { if (!code || typeof document == 'undefined') return let head = document.head || document.getElementsByTagName('head')[0] let style = document.createElement('style') style.type = 'text/css' head.appendChild(style) ;style.styleSheet ? (style.styleSheet.cssText = code) : style.appendChild(document.createTextNode(code)) } import * as DialogPrimitive from '@radix-ui/react-dialog'; import * as React from 'react'; import React__default, { useLayoutEffect, useEffect, useMemo } from 'react'; const DrawerContext = React__default.createContext({ drawerRef: { current: null }, overlayRef: { current: null }, onPress: ()=>{}, onRelease: ()=>{}, onDrag: ()=>{}, onNestedDrag: ()=>{}, onNestedOpenChange: ()=>{}, onNestedRelease: ()=>{}, openProp: undefined, dismissible: false, isOpen: false, isDragging: false, keyboardIsOpen: { current: false }, snapPointsOffset: null, snapPoints: null, handleOnly: false, modal: false, shouldFade: false, activeSnapPoint: null, onOpenChange: ()=>{}, setActiveSnapPoint: ()=>{}, closeDrawer: ()=>{}, direction: 'bottom', shouldAnimate: { current: true }, shouldScaleBackground: false, setBackgroundColorOnScale: true, noBodyStyles: false, container: null, autoFocus: false }); const useDrawerContext = ()=>{ const context = React__default.useContext(DrawerContext); if (!context) { throw new Error('useDrawerContext must be used within a Drawer.Root'); } return context; }; __insertCSS("[data-vaul-drawer]{touch-action:none;will-change:transform;transition:transform .5s cubic-bezier(.32, .72, 0, 1);animation-duration:.5s;animation-timing-function:cubic-bezier(0.32,0.72,0,1)}[data-vaul-drawer][data-vaul-snap-points=false][data-vaul-drawer-direction=bottom][data-state=open]{animation-name:slideFromBottom}[data-vaul-drawer][data-vaul-snap-points=false][data-vaul-drawer-direction=bottom][data-state=closed]{animation-name:slideToBottom}[data-vaul-drawer][data-vaul-snap-points=false][data-vaul-drawer-direction=top][data-state=open]{animation-name:slideFromTop}[data-vaul-drawer][data-vaul-snap-points=false][data-vaul-drawer-direction=top][data-state=closed]{animation-name:slideToTop}[data-vaul-drawer][data-vaul-snap-points=false][data-vaul-drawer-direction=left][data-state=open]{animation-name:slideFromLeft}[data-vaul-drawer][data-vaul-snap-points=false][data-vaul-drawer-direction=left][data-state=closed]{animation-name:slideToLeft}[data-vaul-drawer][data-vaul-snap-points=false][data-vaul-drawer-direction=right][data-state=open]{animation-name:slideFromRight}[data-vaul-drawer][data-vaul-snap-points=false][data-vaul-drawer-direction=right][data-state=closed]{animation-name:slideToRight}[data-vaul-drawer][data-vaul-snap-points=true][data-vaul-drawer-direction=bottom]{transform:translate3d(0,var(--initial-transform,100%),0)}[data-vaul-drawer][data-vaul-snap-points=true][data-vaul-drawer-direction=top]{transform:translate3d(0,calc(var(--initial-transform,100%) * -1),0)}[data-vaul-drawer][data-vaul-snap-points=true][data-vaul-drawer-direction=left]{transform:translate3d(calc(var(--initial-transform,100%) * -1),0,0)}[data-vaul-drawer][data-vaul-snap-points=true][data-vaul-drawer-direction=right]{transform:translate3d(var(--initial-transform,100%),0,0)}[data-vaul-drawer][data-vaul-delayed-snap-points=true][data-vaul-drawer-direction=top]{transform:translate3d(0,var(--snap-point-height,0),0)}[data-vaul-drawer][data-vaul-delayed-snap-points=true][data-vaul-drawer-direction=bottom]{transform:translate3d(0,var(--snap-point-height,0),0)}[data-vaul-drawer][data-vaul-delayed-snap-points=true][data-vaul-drawer-direction=left]{transform:translate3d(var(--snap-point-height,0),0,0)}[data-vaul-drawer][data-vaul-delayed-snap-points=true][data-vaul-drawer-direction=right]{transform:translate3d(var(--snap-point-height,0),0,0)}[data-vaul-overlay][data-vaul-snap-points=false]{animation-duration:.5s;animation-timing-function:cubic-bezier(0.32,0.72,0,1)}[data-vaul-overlay][data-vaul-snap-points=false][data-state=open]{animation-name:fadeIn}[data-vaul-overlay][data-state=closed]{animation-name:fadeOut}[data-vaul-animate=false]{animation:none!important}[data-vaul-overlay][data-vaul-snap-points=true]{opacity:0;transition:opacity .5s cubic-bezier(.32, .72, 0, 1)}[data-vaul-overlay][data-vaul-snap-points=true]{opacity:1}[data-vaul-drawer]:not([data-vaul-custom-container=true])::after{content:'';position:absolute;background:inherit;background-color:inherit}[data-vaul-drawer][data-vaul-drawer-direction=top]::after{top:initial;bottom:100%;left:0;right:0;height:200%}[data-vaul-drawer][data-vaul-drawer-direction=bottom]::after{top:100%;bottom:initial;left:0;right:0;height:200%}[data-vaul-drawer][data-vaul-drawer-direction=left]::after{left:initial;right:100%;top:0;bottom:0;width:200%}[data-vaul-drawer][data-vaul-drawer-direction=right]::after{left:100%;right:initial;top:0;bottom:0;width:200%}[data-vaul-overlay][data-vaul-snap-points=true]:not([data-vaul-snap-points-overlay=true]):not(\n[data-state=closed]\n){opacity:0}[data-vaul-overlay][data-vaul-snap-points-overlay=true]{opacity:1}[data-vaul-handle]{display:block;position:relative;opacity:.7;background:#e2e2e4;margin-left:auto;margin-right:auto;height:5px;width:32px;border-radius:1rem;touch-action:pan-y}[data-vaul-handle]:active,[data-vaul-handle]:hover{opacity:1}[data-vaul-handle-hitarea]{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);width:max(100%,2.75rem);height:max(100%,2.75rem);touch-action:inherit}@media (hover:hover) and (pointer:fine){[data-vaul-drawer]{user-select:none}}@media (pointer:fine){[data-vaul-handle-hitarea]:{width:100%;height:100%}}@keyframes fadeIn{from{opacity:0}to{opacity:1}}@keyframes fadeOut{to{opacity:0}}@keyframes slideFromBottom{from{transform:translate3d(0,var(--initial-transform,100%),0)}to{transform:translate3d(0,0,0)}}@keyframes slideToBottom{to{transform:translate3d(0,var(--initial-transform,100%),0)}}@keyframes slideFromTop{from{transform:translate3d(0,calc(var(--initial-transform,100%) * -1),0)}to{transform:translate3d(0,0,0)}}@keyframes slideToTop{to{transform:translate3d(0,calc(var(--initial-transform,100%) * -1),0)}}@keyframes slideFromLeft{from{transform:translate3d(calc(var(--initial-transform,100%) * -1),0,0)}to{transform:translate3d(0,0,0)}}@keyframes slideToLeft{to{transform:translate3d(calc(var(--initial-transform,100%) * -1),0,0)}}@keyframes slideFromRight{from{transform:translate3d(var(--initial-transform,100%),0,0)}to{transform:translate3d(0,0,0)}}@keyframes slideToRight{to{transform:translate3d(var(--initial-transform,100%),0,0)}}"); function isMobileFirefox() { const userAgent = navigator.userAgent; return typeof window !== 'undefined' && (/Firefox/.test(userAgent) && /Mobile/.test(userAgent) || // Android Firefox /FxiOS/.test(userAgent) // iOS Firefox ); } function isMac() { return testPlatform(/^Mac/); } function isIPhone() { return testPlatform(/^iPhone/); } function isSafari() { return /^((?!chrome|android).)*safari/i.test(navigator.userAgent); } function isIPad() { return testPlatform(/^iPad/) || // iPadOS 13 lies and says it's a Mac, but we can distinguish by detecting touch support. isMac() && navigator.maxTouchPoints > 1; } function isIOS() { return isIPhone() || isIPad(); } function testPlatform(re) { return typeof window !== 'undefined' && window.navigator != null ? re.test(window.navigator.platform) : undefined; } // This code comes from https://github.com/adobe/react-spectrum/blob/main/packages/%40react-aria/overlays/src/usePreventScroll.ts const KEYBOARD_BUFFER = 24; const useIsomorphicLayoutEffect = typeof window !== 'undefined' ? useLayoutEffect : useEffect; function chain$1(...callbacks) { return (...args)=>{ for (let callback of callbacks){ if (typeof callback === 'function') { callback(...args); } } }; } // @ts-ignore const visualViewport = typeof document !== 'undefined' && window.visualViewport; function isScrollable(node) { let style = window.getComputedStyle(node); return /(auto|scroll)/.test(style.overflow + style.overflowX + style.overflowY); } function getScrollParent(node) { if (isScrollable(node)) { node = node.parentElement; } while(node && !isScrollable(node)){ node = node.parentElement; } return node || document.scrollingElement || document.documentElement; } // HTML input types that do not cause the software keyboard to appear. const nonTextInputTypes = new Set([ 'checkbox', 'radio', 'range', 'color', 'file', 'image', 'button', 'submit', 'reset' ]); // The number of active usePreventScroll calls. Used to determine whether to revert back to the original page style/scroll position let preventScrollCount = 0; let restore; /** * Prevents scrolling on the document body on mount, and * restores it on unmount. Also ensures that content does not * shift due to the scrollbars disappearing. */ function usePreventScroll(options = {}) { let { isDisabled } = options; useIsomorphicLayoutEffect(()=>{ if (isDisabled) { return; } preventScrollCount++; if (preventScrollCount === 1) { if (isIOS()) { restore = preventScrollMobileSafari(); } } return ()=>{ preventScrollCount--; if (preventScrollCount === 0) { restore == null ? void 0 : restore(); } }; }, [ isDisabled ]); } // Mobile Safari is a whole different beast. Even with overflow: hidden, // it still scrolls the page in many situations: // // 1. When the bottom toolbar and address bar are collapsed, page scrolling is always allowed. // 2. When the keyboard is visible, the viewport does not resize. Instead, the keyboard covers part of // it, so it becomes scrollable. // 3. When tapping on an input, the page always scrolls so that the input is centered in the visual viewport. // This may cause even fixed position elements to scroll off the screen. // 4. When using the next/previous buttons in the keyboard to navigate between inputs, the whole page always // scrolls, even if the input is inside a nested scrollable element that could be scrolled instead. // // In order to work around these cases, and prevent scrolling without jankiness, we do a few things: // // 1. Prevent default on `touchmove` events that are not in a scrollable element. This prevents touch scrolling // on the window. // 2. Prevent default on `touchmove` events inside a scrollable element when the scroll position is at the // top or bottom. This avoids the whole page scrolling instead, but does prevent overscrolling. // 3. Prevent default on `touchend` events on input elements and handle focusing the element ourselves. // 4. When focusing an input, apply a transform to trick Safari into thinking the input is at the top // of the page, which prevents it from scrolling the page. After the input is focused, scroll the element // into view ourselves, without scrolling the whole page. // 5. Offset the body by the scroll position using a negative margin and scroll to the top. This should appear the // same visually, but makes the actual scroll position always zero. This is required to make all of the // above work or Safari will still try to scroll the page when focusing an input. // 6. As a last resort, handle window scroll events, and scroll back to the top. This can happen when attempting // to navigate to an input with the next/previous buttons that's outside a modal. function preventScrollMobileSafari() { let scrollable; let lastY = 0; let onTouchStart = (e)=>{ // Store the nearest scrollable parent element from the element that the user touched. scrollable = getScrollParent(e.target); if (scrollable === document.documentElement && scrollable === document.body) { return; } lastY = e.changedTouches[0].pageY; }; let onTouchMove = (e)=>{ // Prevent scrolling the window. if (!scrollable || scrollable === document.documentElement || scrollable === document.body) { e.preventDefault(); return; } // Prevent scrolling up when at the top and scrolling down when at the bottom // of a nested scrollable area, otherwise mobile Safari will start scrolling // the window instead. Unfortunately, this disables bounce scrolling when at // the top but it's the best we can do. let y = e.changedTouches[0].pageY; let scrollTop = scrollable.scrollTop; let bottom = scrollable.scrollHeight - scrollable.clientHeight; if (bottom === 0) { return; } if (scrollTop <= 0 && y > lastY || scrollTop >= bottom && y < lastY) { e.preventDefault(); } lastY = y; }; let onTouchEnd = (e)=>{ let target = e.target; // Apply this change if we're not already focused on the target element if (isInput(target) && target !== document.activeElement) { e.preventDefault(); // Apply a transform to trick Safari into thinking the input is at the top of the page // so it doesn't try to scroll it into view. When tapping on an input, this needs to // be done before the "focus" event, so we have to focus the element ourselves. target.style.transform = 'translateY(-2000px)'; target.focus(); requestAnimationFrame(()=>{ target.style.transform = ''; }); } }; let onFocus = (e)=>{ let target = e.target; if (isInput(target)) { // Transform also needs to be applied in the focus event in cases where focus moves // other than tapping on an input directly, e.g. the next/previous buttons in the // software keyboard. In these cases, it seems applying the transform in the focus event // is good enough, whereas when tapping an input, it must be done before the focus event. 🤷‍♂️ target.style.transform = 'translateY(-2000px)'; requestAnimationFrame(()=>{ target.style.transform = ''; // This will have prevented the browser from scrolling the focused element into view, // so we need to do this ourselves in a way that doesn't cause the whole page to scroll. if (visualViewport) { if (visualViewport.height < window.innerHeight) { // If the keyboard is already visible, do this after one additional frame // to wait for the transform to be removed. requestAnimationFrame(()=>{ scrollIntoView(target); }); } else { // Otherwise, wait for the visual viewport to resize before scrolling so we can // measure the correct position to scroll to. visualViewport.addEventListener('resize', ()=>scrollIntoView(target), { once: true }); } } }); } }; let onWindowScroll = ()=>{ // Last resort. If the window scrolled, scroll it back to the top. // It should always be at the top because the body will have a negative margin (see below). window.scrollTo(0, 0); }; // Record the original scroll position so we can restore it. // Then apply a negative margin to the body to offset it by the scroll position. This will // enable us to scroll the window to the top, which is required for the rest of this to work. let scrollX = window.pageXOffset; let scrollY = window.pageYOffset; let restoreStyles = chain$1(setStyle(document.documentElement, 'paddingRight', `${window.innerWidth - document.documentElement.clientWidth}px`)); // Scroll to the top. The negative margin on the body will make this appear the same. window.scrollTo(0, 0); let removeEvents = chain$1(addEvent(document, 'touchstart', onTouchStart, { passive: false, capture: true }), addEvent(document, 'touchmove', onTouchMove, { passive: false, capture: true }), addEvent(document, 'touchend', onTouchEnd, { passive: false, capture: true }), addEvent(document, 'focus', onFocus, true), addEvent(window, 'scroll', onWindowScroll)); return ()=>{ // Restore styles and scroll the page back to where it was. restoreStyles(); removeEvents(); window.scrollTo(scrollX, scrollY); }; } // Sets a CSS property on an element, and returns a function to revert it to the previous value. function setStyle(element, style, value) { // https://github.com/microsoft/TypeScript/issues/17827#issuecomment-391663310 // @ts-ignore let cur = element.style[style]; // @ts-ignore element.style[style] = value; return ()=>{ // @ts-ignore element.style[style] = cur; }; } // Adds an event listener to an element, and returns a function to remove it. function addEvent(target, event, handler, options) { // @ts-ignore target.addEventListener(event, handler, options); return ()=>{ // @ts-ignore target.removeEventListener(event, handler, options); }; } function scrollIntoView(target) { let root = document.scrollingElement || document.documentElement; while(target && target !== root){ // Find the parent scrollable element and adjust the scroll position if the target is not already in view. let scrollable = getScrollParent(target); if (scrollable !== document.documentElement && scrollable !== document.body && scrollable !== target) { let scrollableTop = scrollable.getBoundingClientRect().top; let targetTop = target.getBoundingClientRect().top; let targetBottom = target.getBoundingClientRect().bottom; // Buffer is needed for some edge cases const keyboardHeight = scrollable.getBoundingClientRect().bottom + KEYBOARD_BUFFER; if (targetBottom > keyboardHeight) { scrollable.scrollTop += targetTop - scrollableTop; } } // @ts-ignore target = scrollable.parentElement; } } function isInput(target) { return target instanceof HTMLInputElement && !nonTextInputTypes.has(target.type) || target instanceof HTMLTextAreaElement || target instanceof HTMLElement && target.isContentEditable; } // This code comes from https://github.com/radix-ui/primitives/tree/main/packages/react/compose-refs /** * Set a given ref to a given value * This utility takes care of different types of refs: callback refs and RefObject(s) */ function setRef(ref, value) { if (typeof ref === 'function') { ref(value); } else if (ref !== null && ref !== undefined) { ref.current = value; } } /** * A utility to compose multiple refs together * Accepts callback refs and RefObject(s) */ function composeRefs(...refs) { return (node)=>refs.forEach((ref)=>setRef(ref, node)); } /** * A custom hook that composes multiple refs * Accepts callback refs and RefObject(s) */ function useComposedRefs(...refs) { // eslint-disable-next-line react-hooks/exhaustive-deps return React.useCallback(composeRefs(...refs), refs); } const cache = new WeakMap(); function set(el, styles, ignoreCache = false) { if (!el || !(el instanceof HTMLElement)) return; let originalStyles = {}; Object.entries(styles).forEach(([key, value])=>{ if (key.startsWith('--')) { el.style.setProperty(key, value); return; } originalStyles[key] = el.style[key]; el.style[key] = value; }); if (ignoreCache) return; cache.set(el, originalStyles); } function reset(el, prop) { if (!el || !(el instanceof HTMLElement)) return; let originalStyles = cache.get(el); if (!originalStyles) { return; } { el.style[prop] = originalStyles[prop]; } } const isVertical = (direction)=>{ switch(direction){ case 'top': case 'bottom': return true; case 'left': case 'right': return false; default: return direction; } }; function getTranslate(element, direction) { if (!element) { return null; } const style = window.getComputedStyle(element); const transform = // @ts-ignore style.transform || style.webkitTransform || style.mozTransform; let mat = transform.match(/^matrix3d\((.+)\)$/); if (mat) { // https://developer.mozilla.org/en-US/docs/Web/CSS/transform-function/matrix3d return parseFloat(mat[1].split(', ')[isVertical(direction) ? 13 : 12]); } // https://developer.mozilla.org/en-US/docs/Web/CSS/transform-function/matrix mat = transform.match(/^matrix\((.+)\)$/); return mat ? parseFloat(mat[1].split(', ')[isVertical(direction) ? 5 : 4]) : null; } function dampenValue(v) { return 8 * (Math.log(v + 1) - 2); } function assignStyle(element, style) { if (!element) return ()=>{}; const prevStyle = element.style.cssText; Object.assign(element.style, style); return ()=>{ element.style.cssText = prevStyle; }; } /** * Receives functions as arguments and returns a new function that calls all. */ function chain(...fns) { return (...args)=>{ for (const fn of fns){ if (typeof fn === 'function') { // @ts-ignore fn(...args); } } }; } const TRANSITIONS = { DURATION: 0.5, EASE: [ 0.32, 0.72, 0, 1 ] }; const VELOCITY_THRESHOLD = 0.4; const CLOSE_THRESHOLD = 0.25; const SCROLL_LOCK_TIMEOUT = 100; const BORDER_RADIUS = 8; const NESTED_DISPLACEMENT = 16; const WINDOW_TOP_OFFSET = 26; const DRAG_CLASS = 'vaul-dragging'; // This code comes from https://github.com/radix-ui/primitives/blob/main/packages/react/use-controllable-state/src/useControllableState.tsx function useCallbackRef(callback) { const callbackRef = React__default.useRef(callback); React__default.useEffect(()=>{ callbackRef.current = callback; }); // https://github.com/facebook/react/issues/19240 return React__default.useMemo(()=>(...args)=>callbackRef.current == null ? void 0 : callbackRef.current.call(callbackRef, ...args), []); } function useUncontrolledState({ defaultProp, onChange }) { const uncontrolledState = React__default.useState(defaultProp); const [value] = uncontrolledState; const prevValueRef = React__default.useRef(value); const handleChange = useCallbackRef(onChange); React__default.useEffect(()=>{ if (prevValueRef.current !== value) { handleChange(value); prevValueRef.current = value; } }, [ value, prevValueRef, handleChange ]); return uncontrolledState; } function useControllableState({ prop, defaultProp, onChange = ()=>{} }) { const [uncontrolledProp, setUncontrolledProp] = useUncontrolledState({ defaultProp, onChange }); const isControlled = prop !== undefined; const value = isControlled ? prop : uncontrolledProp; const handleChange = useCallbackRef(onChange); const setValue = React__default.useCallback((nextValue)=>{ if (isControlled) { const setter = nextValue; const value = typeof nextValue === 'function' ? setter(prop) : nextValue; if (value !== prop) handleChange(value); } else { setUncontrolledProp(nextValue); } }, [ isControlled, prop, setUncontrolledProp, handleChange ]); return [ value, setValue ]; } function useSnapPoints({ activeSnapPointProp, setActiveSnapPointProp, snapPoints, drawerRef, overlayRef, fadeFromIndex, onSnapPointChange, direction = 'bottom', container, snapToSequentialPoint }) { const [activeSnapPoint, setActiveSnapPoint] = useControllableState({ prop: activeSnapPointProp, defaultProp: snapPoints == null ? void 0 : snapPoints[0], onChange: setActiveSnapPointProp }); const [windowDimensions, setWindowDimensions] = React__default.useState(typeof window !== 'undefined' ? { innerWidth: window.innerWidth, innerHeight: window.innerHeight } : undefined); React__default.useEffect(()=>{ function onResize() { setWindowDimensions({ innerWidth: window.innerWidth, innerHeight: window.innerHeight }); } window.addEventListener('resize', onResize); return ()=>window.removeEventListener('resize', onResize); }, []); const isLastSnapPoint = React__default.useMemo(()=>activeSnapPoint === (snapPoints == null ? void 0 : snapPoints[snapPoints.length - 1]) || null, [ snapPoints, activeSnapPoint ]); const activeSnapPointIndex = React__default.useMemo(()=>{ var _snapPoints_findIndex; return (_snapPoints_findIndex = snapPoints == null ? void 0 : snapPoints.findIndex((snapPoint)=>snapPoint === activeSnapPoint)) != null ? _snapPoints_findIndex : null; }, [ snapPoints, activeSnapPoint ]); const shouldFade = snapPoints && snapPoints.length > 0 && (fadeFromIndex || fadeFromIndex === 0) && !Number.isNaN(fadeFromIndex) && snapPoints[fadeFromIndex] === activeSnapPoint || !snapPoints; const snapPointsOffset = React__default.useMemo(()=>{ const containerSize = container ? { width: container.getBoundingClientRect().width, height: container.getBoundingClientRect().height } : typeof window !== 'undefined' ? { width: window.innerWidth, height: window.innerHeight } : { width: 0, height: 0 }; var _snapPoints_map; return (_snapPoints_map = snapPoints == null ? void 0 : snapPoints.map((snapPoint)=>{ const isPx = typeof snapPoint === 'string'; let snapPointAsNumber = 0; if (isPx) { snapPointAsNumber = parseInt(snapPoint, 10); } if (isVertical(direction)) { const height = isPx ? snapPointAsNumber : windowDimensions ? snapPoint * containerSize.height : 0; if (windowDimensions) { return direction === 'bottom' ? containerSize.height - height : -containerSize.height + height; } return height; } const width = isPx ? snapPointAsNumber : windowDimensions ? snapPoint * containerSize.width : 0; if (windowDimensions) { return direction === 'right' ? containerSize.width - width : -containerSize.width + width; } return width; })) != null ? _snapPoints_map : []; }, [ snapPoints, windowDimensions, container ]); const activeSnapPointOffset = React__default.useMemo(()=>activeSnapPointIndex !== null ? snapPointsOffset == null ? void 0 : snapPointsOffset[activeSnapPointIndex] : null, [ snapPointsOffset, activeSnapPointIndex ]); const snapToPoint = React__default.useCallback((dimension)=>{ var _snapPointsOffset_findIndex; const newSnapPointIndex = (_snapPointsOffset_findIndex = snapPointsOffset == null ? void 0 : snapPointsOffset.findIndex((snapPointDim)=>snapPointDim === dimension)) != null ? _snapPointsOffset_findIndex : null; onSnapPointChange(newSnapPointIndex); set(drawerRef.current, { transition: `transform ${TRANSITIONS.DURATION}s cubic-bezier(${TRANSITIONS.EASE.join(',')})`, transform: isVertical(direction) ? `translate3d(0, ${dimension}px, 0)` : `translate3d(${dimension}px, 0, 0)` }); if (snapPointsOffset && newSnapPointIndex !== snapPointsOffset.length - 1 && fadeFromIndex !== undefined && newSnapPointIndex !== fadeFromIndex && newSnapPointIndex < fadeFromIndex) { set(overlayRef.current, { transition: `opacity ${TRANSITIONS.DURATION}s cubic-bezier(${TRANSITIONS.EASE.join(',')})`, opacity: '0' }); } else { set(overlayRef.current, { transition: `opacity ${TRANSITIONS.DURATION}s cubic-bezier(${TRANSITIONS.EASE.join(',')})`, opacity: '1' }); } setActiveSnapPoint(snapPoints == null ? void 0 : snapPoints[Math.max(newSnapPointIndex, 0)]); }, [ drawerRef.current, snapPoints, snapPointsOffset, fadeFromIndex, overlayRef, setActiveSnapPoint ]); React__default.useEffect(()=>{ if (activeSnapPoint || activeSnapPointProp) { var _snapPoints_findIndex; const newIndex = (_snapPoints_findIndex = snapPoints == null ? void 0 : snapPoints.findIndex((snapPoint)=>snapPoint === activeSnapPointProp || snapPoint === activeSnapPoint)) != null ? _snapPoints_findIndex : -1; if (snapPointsOffset && newIndex !== -1 && typeof snapPointsOffset[newIndex] === 'number') { snapToPoint(snapPointsOffset[newIndex]); } } }, [ activeSnapPoint, activeSnapPointProp, snapPoints, snapPointsOffset, snapToPoint ]); function onRelease({ draggedDistance, closeDrawer, velocity, dismissible }) { if (fadeFromIndex === undefined) return; const currentPosition = direction === 'bottom' || direction === 'right' ? (activeSnapPointOffset != null ? activeSnapPointOffset : 0) - draggedDistance : (activeSnapPointOffset != null ? activeSnapPointOffset : 0) + draggedDistance; const isOverlaySnapPoint = activeSnapPointIndex === fadeFromIndex - 1; const isFirst = activeSnapPointIndex === 0; const hasDraggedUp = draggedDistance > 0; if (isOverlaySnapPoint) { set(overlayRef.current, { transition: `opacity ${TRANSITIONS.DURATION}s cubic-bezier(${TRANSITIONS.EASE.join(',')})` }); } if (!snapToSequentialPoint && velocity > 2 && !hasDraggedUp) { if (dismissible) closeDrawer(); else snapToPoint(snapPointsOffset[0]); // snap to initial point return; } if (!snapToSequentialPoint && velocity > 2 && hasDraggedUp && snapPointsOffset && snapPoints) { snapToPoint(snapPointsOffset[snapPoints.length - 1]); return; } // Find the closest snap point to the current position const closestSnapPoint = snapPointsOffset == null ? void 0 : snapPointsOffset.reduce((prev, curr)=>{ if (typeof prev !== 'number' || typeof curr !== 'number') return prev; return Math.abs(curr - currentPosition) < Math.abs(prev - currentPosition) ? curr : prev; }); const dim = isVertical(direction) ? window.innerHeight : window.innerWidth; if (velocity > VELOCITY_THRESHOLD && Math.abs(draggedDistance) < dim * 0.4) { const dragDirection = hasDraggedUp ? 1 : -1; // 1 = up, -1 = down // Don't do anything if we swipe upwards while being on the last snap point if (dragDirection > 0 && isLastSnapPoint && snapPoints) { snapToPoint(snapPointsOffset[snapPoints.length - 1]); return; } if (isFirst && dragDirection < 0 && dismissible) { closeDrawer(); } if (activeSnapPointIndex === null) return; snapToPoint(snapPointsOffset[activeSnapPointIndex + dragDirection]); return; } snapToPoint(closestSnapPoint); } function onDrag({ draggedDistance }) { if (activeSnapPointOffset === null) return; const newValue = direction === 'bottom' || direction === 'right' ? activeSnapPointOffset - draggedDistance : activeSnapPointOffset + draggedDistance; // Don't do anything if we exceed the last(biggest) snap point if ((direction === 'bottom' || direction === 'right') && newValue < snapPointsOffset[snapPointsOffset.length - 1]) { return; } if ((direction === 'top' || direction === 'left') && newValue > snapPointsOffset[snapPointsOffset.length - 1]) { return; } set(drawerRef.current, { transform: isVertical(direction) ? `translate3d(0, ${newValue}px, 0)` : `translate3d(${newValue}px, 0, 0)` }); } function getPercentageDragged(absDraggedDistance, isDraggingDown) { if (!snapPoints || typeof activeSnapPointIndex !== 'number' || !snapPointsOffset || fadeFromIndex === undefined) return null; // If this is true we are dragging to a snap point that is supposed to have an overlay const isOverlaySnapPoint = activeSnapPointIndex === fadeFromIndex - 1; const isOverlaySnapPointOrHigher = activeSnapPointIndex >= fadeFromIndex; if (isOverlaySnapPointOrHigher && isDraggingDown) { return 0; } // Don't animate, but still use this one if we are dragging away from the overlaySnapPoint if (isOverlaySnapPoint && !isDraggingDown) return 1; if (!shouldFade && !isOverlaySnapPoint) return null; // Either fadeFrom index or the one before const targetSnapPointIndex = isOverlaySnapPoint ? activeSnapPointIndex + 1 : activeSnapPointIndex - 1; // Get the distance from overlaySnapPoint to the one before or vice-versa to calculate the opacity percentage accordingly const snapPointDistance = isOverlaySnapPoint ? snapPointsOffset[targetSnapPointIndex] - snapPointsOffset[targetSnapPointIndex - 1] : snapPointsOffset[targetSnapPointIndex + 1] - snapPointsOffset[targetSnapPointIndex]; const percentageDragged = absDraggedDistance / Math.abs(snapPointDistance); if (isOverlaySnapPoint) { return 1 - percentageDragged; } else { return percentageDragged; } } return { isLastSnapPoint, activeSnapPoint, shouldFade, getPercentageDragged, setActiveSnapPoint, activeSnapPointIndex, onRelease, onDrag, snapPointsOffset }; } const noop = ()=>()=>{}; function useScaleBackground() { const { direction, isOpen, shouldScaleBackground, setBackgroundColorOnScale, noBodyStyles } = useDrawerContext(); const timeoutIdRef = React__default.useRef(null); const initialBackgroundColor = useMemo(()=>document.body.style.backgroundColor, []); function getScale() { return (window.innerWidth - WINDOW_TOP_OFFSET) / window.innerWidth; } React__default.useEffect(()=>{ if (isOpen && shouldScaleBackground) { if (timeoutIdRef.current) clearTimeout(timeoutIdRef.current); const wrapper = document.querySelector('[data-vaul-drawer-wrapper]') || document.querySelector('[vaul-drawer-wrapper]'); if (!wrapper) return; chain(setBackgroundColorOnScale && !noBodyStyles ? assignStyle(document.body, { background: 'black' }) : noop, assignStyle(wrapper, { transformOrigin: isVertical(direction) ? 'top' : 'left', transitionProperty: 'transform, border-radius', transitionDuration: `${TRANSITIONS.DURATION}s`, transitionTimingFunction: `cubic-bezier(${TRANSITIONS.EASE.join(',')})` })); const wrapperStylesCleanup = assignStyle(wrapper, { borderRadius: `${BORDER_RADIUS}px`, overflow: 'hidden', ...isVertical(direction) ? { transform: `scale(${getScale()}) translate3d(0, calc(env(safe-area-inset-top) + 14px), 0)` } : { transform: `scale(${getScale()}) translate3d(calc(env(safe-area-inset-top) + 14px), 0, 0)` } }); return ()=>{ wrapperStylesCleanup(); timeoutIdRef.current = window.setTimeout(()=>{ if (initialBackgroundColor) { document.body.style.background = initialBackgroundColor; } else { document.body.style.removeProperty('background'); } }, TRANSITIONS.DURATION * 1000); }; } }, [ isOpen, shouldScaleBackground, initialBackgroundColor ]); } let previousBodyPosition = null; /** * This hook is necessary to prevent buggy behavior on iOS devices (need to test on Android). * I won't get into too much detail about what bugs it solves, but so far I've found that setting the body to `position: fixed` is the most reliable way to prevent those bugs. * Issues that this hook solves: * https://github.com/emilkowalski/vaul/issues/435 * https://github.com/emilkowalski/vaul/issues/433 * And more that I discovered, but were just not reported. */ function usePositionFixed({ isOpen, modal, nested, hasBeenOpened, preventScrollRestoration, noBodyStyles }) { const [activeUrl, setActiveUrl] = React__default.useState(()=>typeof window !== 'undefined' ? window.location.href : ''); const scrollPos = React__default.useRef(0); const setPositionFixed = React__default.useCallback(()=>{ // All browsers on iOS will return true here. if (!isSafari()) return; // If previousBodyPosition is already set, don't set it again. if (previousBodyPosition === null && isOpen && !noBodyStyles) { previousBodyPosition = { position: document.body.style.position, top: document.body.style.top, left: document.body.style.left, height: document.body.style.height, right: 'unset' }; // Update the dom inside an animation frame const { scrollX, innerHeight } = window; document.body.style.setProperty('position', 'fixed', 'important'); Object.assign(document.body.style, { top: `${-scrollPos.current}px`, left: `${-scrollX}px`, right: '0px', height: 'auto' }); window.setTimeout(()=>window.requestAnimationFrame(()=>{ // Attempt to check if the bottom bar appeared due to the position change const bottomBarHeight = innerHeight - window.innerHeight; if (bottomBarHeight && scrollPos.current >= innerHeight) { // Move the content further up so that the bottom bar doesn't hide it document.body.style.top = `${-(scrollPos.current + bottomBarHeight)}px`; } }), 300); } }, [ isOpen ]); const restorePositionSetting = React__default.useCallback(()=>{ // All browsers on iOS will return true here. if (!isSafari()) return; if (previousBodyPosition !== null && !noBodyStyles) { // Convert the position from "px" to Int const y = -parseInt(document.body.style.top, 10); const x = -parseInt(document.body.style.left, 10); // Restore styles Object.assign(document.body.style, previousBodyPosition); window.requestAnimationFrame(()=>{ if (preventScrollRestoration && activeUrl !== window.location.href) { setActiveUrl(window.location.href); return; } window.scrollTo(x, y); }); previousBodyPosition = null; } }, [ activeUrl ]); React__default.useEffect(()=>{ function onScroll() { scrollPos.current = window.scrollY; } onScroll(); window.addEventListener('scroll', onScroll); return ()=>{ window.removeEventListener('scroll', onScroll); }; }, []); React__default.useEffect(()=>{ if (!modal) return; return ()=>{ if (typeof document === 'undefined') return; // Another drawer is opened, safe to ignore the execution const hasDrawerOpened = !!document.querySelector('[data-vaul-drawer]'); if (hasDrawerOpened) return; restorePositionSetting(); }; }, [ modal, restorePositionSetting ]); React__default.useEffect(()=>{ if (nested || !hasBeenOpened) return; // This is needed to force Safari toolbar to show **before** the drawer starts animating to prevent a gnarly shift from happening if (isOpen) { // avoid for standalone mode (PWA) const isStandalone = window.matchMedia('(display-mode: standalone)').matches; !isStandalone && setPositionFixed(); if (!modal) { window.setTimeout(()=>{ restorePositionSetting(); }, 500); } } else { restorePositionSetting(); } }, [ isOpen, hasBeenOpened, activeUrl, modal, nested, setPositionFixed, restorePositionSetting ]); return { restorePositionSetting }; } function Root({ open: openProp, onOpenChange, children, onDrag: onDragProp, onRelease: onReleaseProp, snapPoints, shouldScaleBackground = false, setBackgroundColorOnScale = true, closeThreshold = CLOSE_THRESHOLD, scrollLockTimeout = SCROLL_LOCK_TIMEOUT, dismissible = true, handleOnly = false, fadeFromIndex = snapPoints && snapPoints.length - 1, activeSnapPoint: activeSnapPointProp, setActiveSnapPoint: setActiveSnapPointProp, fixed, modal = true, onClose, nested, noBodyStyles = false, direction = 'bottom', defaultOpen = false, disablePreventScroll = true, snapToSequentialPoint = false, preventScrollRestoration = false, repositionInputs = true, onAnimationEnd, container, autoFocus = false }) { var _drawerRef_current, _drawerRef_current1; const [isOpen = false, setIsOpen] = useControllableState({ defaultProp: defaultOpen, prop: openProp, onChange: (o)=>{ onOpenChange == null ? void 0 : onOpenChange(o); if (!o && !nested) { restorePositionSetting(); } setTimeout(()=>{ onAnimationEnd == null ? void 0 : onAnimationEnd(o); }, TRANSITIONS.DURATION * 1000); if (o && !modal) { if (typeof window !== 'undefined') { window.requestAnimationFrame(()=>{ document.body.style.pointerEvents = 'auto'; }); } } if (!o) { // This will be removed when the exit animation ends (`500ms`) document.body.style.pointerEvents = 'auto'; } } }); const [hasBeenOpened, setHasBeenOpened] = React__default.useState(false); const [isDragging, setIsDragging] = React__default.useState(false); const [justReleased, setJustReleased] = React__default.useState(false); const overlayRef = React__default.useRef(null); const openTime = React__default.useRef(null); const dragStartTime = React__default.useRef(null); const dragEndTime = React__default.useRef(null); const lastTimeDragPrevented = React__default.useRef(null); const isAllowedToDrag = React__default.useRef(false); const nestedOpenChangeTimer = React__default.useRef(null); const pointerStart = React__default.useRef(0); const keyboardIsOpen = React__default.useRef(false); const shouldAnimate = React__default.useRef(!defaultOpen); const previousDiffFromInitial = React__default.useRef(0); const drawerRef = React__default.useRef(null); const drawerHeightRef = React__default.useRef(((_drawerRef_current = drawerRef.current) == null ? void 0 : _drawerRef_current.getBoundingClientRect().height) || 0); const drawerWidthRef = React__default.useRef(((_drawerRef_current1 = drawerRef.current) == null ? void 0 : _drawerRef_current1.getBoundingClientRect().width) || 0); const initialDrawerHeight = React__default.useRef(0); const onSnapPointChange = React__default.useCallback((activeSnapPointIndex)=>{ // Change openTime ref when we reach the last snap point to prevent dragging for 500ms incase it's scrollable. if (snapPoints && activeSnapPointIndex === snapPointsOffset.length - 1) openTime.current = new Date(); }, []); const { activeSnapPoint, activeSnapPointIndex, setActiveSnapPoint, onRelease: onReleaseSnapPoints, snapPointsOffset, onDrag: onDragSnapPoints, shouldFade, getPercentageDragged: getSnapPointsPercentageDragged } = useSnapPoints({ snapPoints, activeSnapPointProp, setActiveSnapPointProp, drawerRef, fadeFromIndex, overlayRef, onSnapPointChange, direction, container, snapToSequentialPoint }); usePreventScroll({ isDisabled: !isOpen || isDragging || !modal || justReleased || !hasBeenOpened || !repositionInputs || !disablePreventScroll }); const { restorePositionSetting } = usePositionFixed({ isOpen, modal, nested: nested != null ? nested : false, hasBeenOpened, preventScrollRestoration, noBodyStyles }); function getScale() { return (window.innerWidth - WINDOW_TOP_OFFSET) / window.innerWidth; } function onPress(event) { var _drawerRef_current, _drawerRef_current1; if (!dismissible && !snapPoints) return; if (drawerRef.current && !drawerRef.current.contains(event.target)) return; drawerHeightRef.current = ((_drawerRef_current = drawerRef.current) == null ? void 0 : _drawerRef_current.getBoundingClientRect().height) || 0; drawerWidthRef.current = ((_drawerRef_current1 = drawerRef.current) == null ? void 0 : _drawerRef_current1.getBoundingClientRect().width) || 0; setIsDragging(true); dragStartTime.current = new Date(); // iOS doesn't trigger mouseUp after scrolling so we need to listen to touched in order to disallow dragging if (isIOS()) { window.addEventListener('touchend', ()=>isAllowedToDrag.current = false, { once: true }); } // Ensure we maintain correct pointer capture even when going outside of the drawer event.target.setPointerCapture(event.pointerId); pointerStart.current = isVertical(direction) ? event.pageY : event.pageX; } function shouldDrag(el, isDraggingInDirection) { var _window_getSelection; let element = el; const highlightedText = (_window_getSelection = window.getSelection()) == null ? void 0 : _window_getSelection.toString(); const swipeAmount = drawerRef.current ? getTranslate(drawerRef.current, direction) : null; const date = new Date(); // Fixes https://github.com/emilkowalski/vaul/issues/483 if (element.tagName === 'SELECT') { return false; } if (element.hasAttribute('data-vaul-no-drag') || element.closest('[data-vaul-no-drag]')) { return false; } if (direction === 'right' || direction === 'left') { return true; } // Allow scrolling when animating if (openTime.current && date.getTime() - openTime.current.getTime() < 500) { return false; } if (swipeAmount !== null) { if (direction === 'bottom' ? swipeAmount > 0 : swipeAmount < 0) { return true; } } // Don't drag if there's highlighted text if (highlightedText && highlightedText.length > 0) { return false; } // Disallow dragging if drawer was scrolled within `scrollLockTimeout` if (lastTimeDragPrevented.current && date.getTime() - lastTimeDragPrevented.current.getTime() < scrollLockTimeout && swipeAmount === 0) { lastTimeDragPrevented.current = date; return false; } if (isDraggingInDirection) { lastTimeDragPrevented.current = date; // We are dragging down so we should allow scrolling return false; } // Keep climbing up the DOM tree as long as there's a parent while(element){ // Check if the element is scrollable if (element.scrollHeight > element.clientHeight) { if (element.scrollTop !== 0) { lastTimeDragPrevented.current = new Date(); // The element is scrollable and not scrolled to the top, so don't drag return false; } if (element.getAttribute('role') === 'dialog') { return true; } } // Move up to the parent element element = element.parentNode; } // No scrollable parents not scrolled to the top found, so drag return true; } function onDrag(event) { if (!drawerRef.current) { return; } //