UNPKG

@razorpay/blade

Version:

The Design System that powers Razorpay

512 lines (491 loc) 24.1 kB
import _defineProperty from '@babel/runtime/helpers/defineProperty'; import _slicedToArray from '@babel/runtime/helpers/slicedToArray'; import _objectWithoutProperties from '@babel/runtime/helpers/objectWithoutProperties'; import React__default from 'react'; import styled from 'styled-components'; import { useDrag, rubberbandIfOutOfBounds } from '@use-gesture/react'; import usePresence from 'use-presence'; import { clearAllBodyScrollLocks, enableBodyScroll } from 'body-scroll-lock-upgrade'; export { BottomSheetHeader } from './BottomSheetHeader.web.js'; export { BottomSheetFooter } from './BottomSheetFooter.web.js'; export { BottomSheetBody } from './BottomSheetBody.web.js'; import { computeMaxContent, computeSnapPointBounds } from './utils.js'; import { BottomSheetBackdrop } from './BottomSheetBackdrop.web.js'; import { useBottomSheetAndDropdownGlue, BottomSheetContext } from './BottomSheetContext.js'; import { ComponentIds } from './componentIds.js'; import { BottomSheetGrabHandle } from './BottomSheetGrabHandle.web.js'; import { useBottomSheetStack } from './BottomSheetStack.js'; import '../Box/BaseBox/index.js'; import '../../utils/metaAttribute/index.js'; import { useScrollLock } from '../../utils/useScrollLock.js'; import { useWindowSize } from '../../utils/useWindowSize.js'; import { useIsomorphicLayoutEffect } from '../../utils/useIsomorphicLayoutEffect.js'; import '../BladeProvider/index.js'; import { useId } from '../../utils/useId.js'; import '../../utils/assignWithoutSideEffects/index.js'; import '../../utils/makeSize/index.js'; import '../../utils/makeAccessible/index.js'; import '../../tokens/global/index.js'; import '../../utils/makeMotionTime/index.web.js'; import { componentZIndices } from '../../utils/componentZIndices.js'; import '../../utils/makeAnalyticsAttribute/index.js'; import { jsx, Fragment, jsxs } from 'react/jsx-runtime'; import { makeSize } from '../../utils/makeSize/makeSize.js'; import { size } from '../../tokens/global/size.js'; import { makeMotionTime } from '../../utils/makeMotionTime/makeMotionTime.web.js'; import useTheme from '../BladeProvider/useTheme.js'; import { metaAttribute } from '../../utils/metaAttribute/metaAttribute.web.js'; import { MetaConstants } from '../../utils/metaAttribute/metaConstants.js'; import { makeAccessible } from '../../utils/makeAccessible/makeAccessible.web.js'; import { makeAnalyticsAttribute } from '../../utils/makeAnalyticsAttribute/makeAnalyticsAttribute.js'; import { BaseBox } from '../Box/BaseBox/BaseBox.web.js'; import { assignWithoutSideEffects } from '../../utils/assignWithoutSideEffects/assignWithoutSideEffects.js'; var _excluded = ["isOpen", "onDismiss", "children", "initialFocusRef", "snapPoints", "zIndex"]; function ownKeys(e, r) { var t = Object.keys(e); if (Object.getOwnPropertySymbols) { var o = Object.getOwnPropertySymbols(e); r && (o = o.filter(function (r) { return Object.getOwnPropertyDescriptor(e, r).enumerable; })), t.push.apply(t, o); } return t; } function _objectSpread(e) { for (var r = 1; r < arguments.length; r++) { var t = null != arguments[r] ? arguments[r] : {}; r % 2 ? ownKeys(Object(t), !0).forEach(function (r) { _defineProperty(e, r, t[r]); }) : Object.getOwnPropertyDescriptors ? Object.defineProperties(e, Object.getOwnPropertyDescriptors(t)) : ownKeys(Object(t)).forEach(function (r) { Object.defineProperty(e, r, Object.getOwnPropertyDescriptor(t, r)); }); } return e; } var BOTTOM_SHEET_EASING = 'cubic-bezier(.15,0,.24,.97)'; var AUTOCOMPLETE_DEFAULT_SNAPPOINT = 0.85; var BottomSheetSurface = /*#__PURE__*/styled.div.withConfig({ displayName: "BottomSheetweb__BottomSheetSurface", componentId: "qlloie-0" })(function (_ref) { var theme = _ref.theme, windowHeight = _ref.windowHeight, isDragging = _ref.isDragging; return { background: theme.colors.popup.background.subtle, borderTopLeftRadius: makeSize(size[16]), borderTopRightRadius: makeSize(size[16]), borderColor: theme.colors.popup.border.subtle, // this is reverse top elevation of highRaised elevation token boxShadow: '0px -24px 48px -12px hsla(217, 56%, 17%, 0.18)', opacity: 0, pointerEvents: 'none', transitionDuration: isDragging ? undefined : "".concat(makeMotionTime(theme.motion.duration.moderate)), transitionTimingFunction: BOTTOM_SHEET_EASING, willChange: 'transform, opacity, height', transitionProperty: 'transform, opacity, height', position: 'fixed', left: 0, right: 0, bottom: 0, top: windowHeight, backgroundColor: theme.colors.popup.background.subtle, justifyContent: 'center', alignItems: 'center', touchAction: 'none', overflow: 'hidden' }; }); var _BottomSheet = function _BottomSheet(_ref2) { var isOpen = _ref2.isOpen, onDismiss = _ref2.onDismiss, children = _ref2.children, initialFocusRef = _ref2.initialFocusRef, _ref2$snapPoints = _ref2.snapPoints, snapPoints = _ref2$snapPoints === void 0 ? [0.35, 0.5, 0.85] : _ref2$snapPoints, _ref2$zIndex = _ref2.zIndex, zIndex = _ref2$zIndex === void 0 ? componentZIndices.bottomSheet : _ref2$zIndex, dataAnalyticsProps = _objectWithoutProperties(_ref2, _excluded); var _useTheme = useTheme(), theme = _useTheme.theme; var dimensions = useWindowSize(); var _React$useState = React__default.useState(0), _React$useState2 = _slicedToArray(_React$useState, 2), contentHeight = _React$useState2[0], setContentHeight = _React$useState2[1]; var _React$useState3 = React__default.useState(0), _React$useState4 = _slicedToArray(_React$useState3, 2), headerHeight = _React$useState4[0], setHeaderHeight = _React$useState4[1]; var _React$useState5 = React__default.useState(0), _React$useState6 = _slicedToArray(_React$useState5, 2), footerHeight = _React$useState6[0], setFooterHeight = _React$useState6[1]; var _React$useState7 = React__default.useState(0), _React$useState8 = _slicedToArray(_React$useState7, 2), grabHandleHeight = _React$useState8[0], setGrabHandleHeight = _React$useState8[1]; var _React$useState9 = React__default.useState(true), _React$useState10 = _slicedToArray(_React$useState9, 2), hasBodyPadding = _React$useState10[0], setHasBodyPadding = _React$useState10[1]; var _React$useState11 = React__default.useState(false), _React$useState12 = _slicedToArray(_React$useState11, 2), isHeaderEmpty = _React$useState12[0], setIsHeaderEmpty = _React$useState12[1]; var bottomSheetAndDropdownGlue = useBottomSheetAndDropdownGlue(); var _React$useState13 = React__default.useState(0), _React$useState14 = _slicedToArray(_React$useState13, 2), positionY = _React$useState14[0], _setPositionY = _React$useState14[1]; var _isOpen = isOpen !== null && isOpen !== void 0 ? isOpen : bottomSheetAndDropdownGlue === null || bottomSheetAndDropdownGlue === void 0 ? void 0 : bottomSheetAndDropdownGlue.isOpen; var _React$useState15 = React__default.useState(false), _React$useState16 = _slicedToArray(_React$useState15, 2), isDragging = _React$useState16[0], setIsDragging = _React$useState16[1]; var preventScrollingRef = React__default.useRef(true); var scrollRef = React__default.useRef(null); var grabHandleRef = React__default.useRef(null); var defaultInitialFocusRef = React__default.useRef(null); var originalFocusElement = React__default.useRef(null); var initialSnapPoint = React__default.useRef(snapPoints[1]); var totalHeight = React__default.useMemo(function () { return grabHandleHeight + headerHeight + footerHeight + contentHeight; }, [contentHeight, footerHeight, grabHandleHeight, headerHeight]); var id = useId(); var _useBottomSheetStack = useBottomSheetStack(), stack = _useBottomSheetStack.stack, addBottomSheetToStack = _useBottomSheetStack.addBottomSheetToStack, removeBottomSheetFromStack = _useBottomSheetStack.removeBottomSheetFromStack, getTopOfTheStack = _useBottomSheetStack.getTopOfTheStack, getCurrentStackIndexById = _useBottomSheetStack.getCurrentStackIndexById; var currentStackIndex = getCurrentStackIndexById(id); var isOnTopOfStack = getTopOfTheStack() === id; var bottomSheetZIndex = zIndex - currentStackIndex; var setPositionY = React__default.useCallback(function (value) { var limit = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : true; // In AutoComplete, we want BottomSheet to be docked to top snappoint so we remove the limits var shouldLimitPositionY = limit && !(bottomSheetAndDropdownGlue !== null && bottomSheetAndDropdownGlue !== void 0 && bottomSheetAndDropdownGlue.hasAutoCompleteInHeader); var maxValue = computeMaxContent({ contentHeight: contentHeight, footerHeight: footerHeight, headerHeight: headerHeight + grabHandleHeight, maxHeight: value }); _setPositionY(shouldLimitPositionY ? maxValue : value); }, [bottomSheetAndDropdownGlue === null || bottomSheetAndDropdownGlue === void 0 ? void 0 : bottomSheetAndDropdownGlue.hasAutoCompleteInHeader, contentHeight, footerHeight, grabHandleHeight, headerHeight]); // locks the body scroll to prevent accidental scrolling of content when we drag the sheet // We are ready when we calculated the height of the content var isReady = contentHeight > 0; // only lock the body when we atleast have 1 bottomsheet open var shouldLock = isReady && stack.length > 0; var scrollLockRef = useScrollLock({ enabled: shouldLock, targetRef: scrollRef, reserveScrollBarGap: true }); // clear all body locks to avoid memory leaks & accidental body locking React__default.useEffect(function () { var hasNoBottomSheets = stack.length < 1; if (hasNoBottomSheets) { clearAllBodyScrollLocks(); } }, [stack]); // take the grabHandle's height into headerHeight too useIsomorphicLayoutEffect(function () { if (!grabHandleRef.current) return; setGrabHandleHeight(grabHandleRef.current.getBoundingClientRect().height); }, [grabHandleRef.current, _isOpen]); // if bottomSheet height is >35% & <50% then set initial snapPoint to 35% useIsomorphicLayoutEffect(function () { if (bottomSheetAndDropdownGlue !== null && bottomSheetAndDropdownGlue !== void 0 && bottomSheetAndDropdownGlue.hasAutoCompleteInHeader) { initialSnapPoint.current = AUTOCOMPLETE_DEFAULT_SNAPPOINT; } else { var middleSnapPoint = snapPoints[1] * dimensions.height; var lowerSnapPoint = snapPoints[0] * dimensions.height; if (totalHeight > lowerSnapPoint && totalHeight < middleSnapPoint) { initialSnapPoint.current = snapPoints[0]; } } }, [dimensions.height, snapPoints, totalHeight]); var returnFocus = React__default.useCallback(function () { if (!originalFocusElement.current) return; originalFocusElement.current.focus(); // After returning focus we will clear the original focus // Because if sheet can be opened up via multiple triggers // We want to ensure the focus returns back to the most recent triggerer originalFocusElement.current = null; }, [originalFocusElement]); var focusOnInitialRef = React__default.useCallback(function () { if (!initialFocusRef) { var _defaultInitialFocusR; // focus on close button (_defaultInitialFocusR = defaultInitialFocusRef.current) === null || _defaultInitialFocusR === void 0 ? void 0 : _defaultInitialFocusR.focus(); } else { var _initialFocusRef$curr; // focus on the initialRef passed by the user (_initialFocusRef$curr = initialFocusRef.current) === null || _initialFocusRef$curr === void 0 ? void 0 : _initialFocusRef$curr.focus(); } }, [initialFocusRef]); // focus on the initial ref when the sheet is opened React__default.useLayoutEffect(function () { if (_isOpen) { focusOnInitialRef(); } }, [_isOpen, focusOnInitialRef]); var handleOnOpen = React__default.useCallback(function () { var _originalFocusElement; setPositionY(dimensions.height * initialSnapPoint.current); scrollLockRef.current.activate(); // initialize the original focused element // On first render it will be the activeElement, eg: the button trigger or select input // On Subsequent open operations it won't further update the original focus originalFocusElement.current = (_originalFocusElement = originalFocusElement.current) !== null && _originalFocusElement !== void 0 ? _originalFocusElement : document.activeElement; }, [dimensions.height, scrollLockRef, setPositionY]); var handleOnClose = React__default.useCallback(function () { setPositionY(0); }, [setPositionY]); var close = React__default.useCallback(function () { onDismiss === null || onDismiss === void 0 ? void 0 : onDismiss(); bottomSheetAndDropdownGlue === null || bottomSheetAndDropdownGlue === void 0 ? void 0 : bottomSheetAndDropdownGlue.onBottomSheetDismiss(); returnFocus(); }, [bottomSheetAndDropdownGlue, onDismiss, returnFocus]); // sync controlled state to our actions React__default.useEffect(function () { if (_isOpen) { // open on the next frame, otherwise the animations will not run on first render window.setTimeout(function () { handleOnOpen(); }); } else { handleOnClose(); } }, [_isOpen, handleOnClose, handleOnOpen]); // let the Dropdown component know that it's rendering a bottomsheet React__default.useEffect(function () { if (!bottomSheetAndDropdownGlue) return; bottomSheetAndDropdownGlue.setDropdownHasBottomSheet(true); }, [bottomSheetAndDropdownGlue]); var bind = useDrag(function (_ref3) { var active = _ref3.active, last = _ref3.last, cancel = _ref3.cancel, tap = _ref3.tap, _ref3$movement = _slicedToArray(_ref3.movement, 2), _movementX = _ref3$movement[0], movementY = _ref3$movement[1], _ref3$velocity = _slicedToArray(_ref3.velocity, 2), _velocityX = _ref3$velocity[0], velocityY = _ref3$velocity[1], _ref3$lastOffset = _slicedToArray(_ref3.lastOffset, 2), _ = _ref3$lastOffset[0], lastOffsetY = _ref3$lastOffset[1], down = _ref3.down, dragging = _ref3.dragging, _ref3$args = _ref3.args, _ref3$args2 = _ref3$args === void 0 ? [] : _ref3$args, _ref3$args3 = _slicedToArray(_ref3$args2, 1), _ref3$args3$ = _ref3$args3[0], _ref3$args3$2 = _ref3$args3$ === void 0 ? {} : _ref3$args3$, _ref3$args3$2$isConte = _ref3$args3$2.isContentDragging, isContentDragging = _ref3$args3$2$isConte === void 0 ? false : _ref3$args3$2$isConte; setIsDragging(Boolean(dragging)); // lastOffsetY is the previous position user stopped dragging the sheet // movementY is the drag amount from the bottom of the screen, so as you drag up the movementY goes into negatives // and rawY is the calculated offset from the last position of the bottomsheet to current drag amount. var rawY = lastOffsetY - movementY; var lowerSnapPoint = dimensions.height * snapPoints[0]; var upperSnapPoint = dimensions.height * snapPoints[snapPoints.length - 1]; // predictedY is used to create velocity driven swipe // the faster you swipe the more distance you cover // this enables users to reach upper & lower snappoint with a single swipe var predictedDistance = movementY * (velocityY / 2); var predictedY = Math.max(lowerSnapPoint, Math.min(upperSnapPoint, rawY - predictedDistance * 2)); var newY = rawY; if (down) { // Ensure that users aren't able to drag the sheet // more than the upperSnapPoint or maximum height of the sheet // this is basically a clamp() function but creates a nice rubberband effect var dampening = 0.55; if (totalHeight < upperSnapPoint) { newY = rubberbandIfOutOfBounds(rawY, 0, totalHeight, dampening); } else { newY = rubberbandIfOutOfBounds(rawY, 0, upperSnapPoint, dampening); } } else { newY = predictedY; } var isPosAtUpperSnapPoint = newY >= upperSnapPoint; if (isContentDragging) { if (isPosAtUpperSnapPoint) { newY = upperSnapPoint; } // keep the newY at upper snap point // until the scrollable content is not at top // and previously saved Y position is greater than or equal to upper snap point // Note: how using newY won't work here since we need the previous value of the newY // since we always keep updating the newY, // this is cruicial in making the scroll feel natural var isContentScrolledAtTop = scrollRef.current && scrollRef.current.scrollTop <= 0; if (lastOffsetY === upperSnapPoint && !isContentScrolledAtTop) { newY = upperSnapPoint; } preventScrollingRef.current = newY < upperSnapPoint; } if (last) { // calculate the nearest snapPoint var _computeSnapPointBoun = computeSnapPointBounds(newY, snapPoints.map(function (point) { return dimensions.height * point; })), _computeSnapPointBoun2 = _slicedToArray(_computeSnapPointBoun, 2), nearest = _computeSnapPointBoun2[0], lower = _computeSnapPointBoun2[1]; // This ensure that the lower snapPoint will always have atleast some buffer // When the bottomsheet total height is less than the lower snapPoint // Video walkthrough: https://www.loom.com/share/a9a8db7688d64194b13df8b3e25859ae var lowerPointBuffer = 60; var lowerestSnap = Math.min(lower, totalHeight) - lowerPointBuffer; var shouldClose = rawY < lowerestSnap; if (shouldClose) { setIsDragging(false); cancel(); close(); return; } // if we stop dragging assign snap to the nearest point if (!active && !tap) { newY = nearest; } } setPositionY(newY, !down); }, { from: [0, positionY], filterTaps: true, enabled: isOnTopOfStack && _isOpen }); // Here we are preventing the scrolling of the content, until the preventScrollingRef value is true useIsomorphicLayoutEffect(function () { var scrollElement = scrollRef.current; if (!scrollElement) return; var preventScrolling = function preventScrolling(e) { if (preventScrollingRef !== null && preventScrollingRef !== void 0 && preventScrollingRef.current) { e.preventDefault(); } }; // https://www.bram.us/2016/05/02/prevent-overscroll-bounce-in-ios-mobilesafari-pure-css/ var preventSafariOverscroll = function preventSafariOverscroll(e) { if (scrollElement.scrollTop < 0) { // TODO: figure this out, it doesn't seem to work >iOS12 // requestAnimationFrame(() => { // elem.style.overflow = 'hidden'; // elem.scrollTop = 0; // elem.style.removeProperty('overflow'); // }); e.preventDefault(); } }; scrollElement.addEventListener('scroll', preventScrolling); scrollElement.addEventListener('touchmove', preventScrolling); scrollElement.addEventListener('touchstart', preventSafariOverscroll); return function () { scrollElement.removeEventListener('scroll', preventScrolling); scrollElement.removeEventListener('touchmove', preventScrolling); scrollElement.removeEventListener('touchstart', preventSafariOverscroll); }; // Only run this hook when we know all the layout calculations are done, // Otherwise the scrollRef.current will be null. // isReady prop will ensure that we are done measuring the content height }, [isReady]); // usePresence hook waits for the animation to finish before unmounting the component // It's similar to motion/react's usePresence hook // https://www.framer.com/docs/animate-presence/#usepresence var _usePresence = usePresence(Boolean(_isOpen), { transitionDuration: theme.motion.duration.moderate }), isMounted = _usePresence.isMounted, isVisible = _usePresence.isVisible; var isHeaderFloating = !hasBodyPadding && isHeaderEmpty; var contextValue = React__default.useMemo(function () { return { isInBottomSheet: true, isOpen: Boolean(isVisible), close: close, positionY: positionY, headerHeight: headerHeight, contentHeight: contentHeight, footerHeight: footerHeight, setContentHeight: setContentHeight, setFooterHeight: setFooterHeight, setHeaderHeight: setHeaderHeight, setHasBodyPadding: setHasBodyPadding, setIsHeaderEmpty: setIsHeaderEmpty, scrollRef: scrollRef, bind: bind, defaultInitialFocusRef: defaultInitialFocusRef, isHeaderFloating: isHeaderFloating }; }, [isVisible, close, positionY, headerHeight, contentHeight, footerHeight, setContentHeight, setFooterHeight, setHeaderHeight, setHasBodyPadding, setIsHeaderEmpty, scrollRef, bind, defaultInitialFocusRef, isHeaderFloating]); React__default.useEffect(function () { if (isMounted) { addBottomSheetToStack(id); } else { removeBottomSheetFromStack(id); } }, [addBottomSheetToStack, id, isMounted, removeBottomSheetFromStack]); // Remove the bottomsheet from the stack, if it's unmounted forcefully React__default.useEffect(function () { return function () { if (id === undefined) return; removeBottomSheetFromStack(id); }; }, [id, removeBottomSheetFromStack]); // Disable body scroll lock when the component is unmounted forcefully React__default.useEffect(function () { var lockTarget = scrollRef.current; return function () { enableBodyScroll(lockTarget); }; }, []); // We will need to reset these values otherwise the next time the bottomsheet opens // this will be populated and the animations won't run // why?: because how the usePresence hook works, we actually just unmount the // html contents not the whole <BottomSheet /> react component React__default.useEffect(function () { if (!isMounted) { setHeaderHeight(0); setFooterHeight(0); setContentHeight(0); setGrabHandleHeight(0); _setPositionY(0); } }, [isMounted, scrollLockRef]); // We don't want to destroy the react tree when we are rendering inside Dropdown // Because if we bail out early then ActionList won't render, // and Dropdown manages it's state based on the rendered JSX of ActionList // If we don't render ActionList Dropdown state will reset each time we open/close BottomSheet var isInsideDropdown = Boolean(bottomSheetAndDropdownGlue); if (!isMounted && !isInsideDropdown) { return /*#__PURE__*/jsx(Fragment, {}); } return /*#__PURE__*/jsxs(BottomSheetContext.Provider, { value: contextValue, children: [/*#__PURE__*/jsx(BottomSheetBackdrop, { zIndex: bottomSheetZIndex }), /*#__PURE__*/jsx(BottomSheetSurface, _objectSpread(_objectSpread(_objectSpread(_objectSpread({}, metaAttribute({ name: MetaConstants.BottomSheet, testID: 'bottomsheet-surface' })), makeAccessible({ modal: true, role: 'dialog' })), {}, { windowHeight: dimensions.height, isDragging: isDragging, style: { opacity: isVisible ? 1 : 0, pointerEvents: isVisible ? 'all' : 'none', height: positionY, bottom: 0, top: 'auto', zIndex: bottomSheetZIndex } }, makeAnalyticsAttribute(dataAnalyticsProps)), {}, { children: /*#__PURE__*/jsxs(BaseBox, { height: "100%", display: "flex", flexDirection: "column", children: [/*#__PURE__*/jsx(BottomSheetGrabHandle, _objectSpread(_objectSpread({ ref: grabHandleRef, isHeaderFloating: isHeaderFloating }, metaAttribute({ name: ComponentIds.BottomSheetGrabHandle })), bind())), children] }) }))] }); }; var BottomSheet = /*#__PURE__*/assignWithoutSideEffects(_BottomSheet, { componentId: ComponentIds.BottomSheet }); export { BOTTOM_SHEET_EASING, BottomSheet }; //# sourceMappingURL=BottomSheet.web.js.map