@razorpay/blade
Version:
The Design System that powers Razorpay
512 lines (491 loc) • 24.1 kB
JavaScript
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