import { clsx } from 'clsx'; import { CSSProperties, HTMLAttributes, PropsWithChildren, SyntheticEvent, useContext, useRef, useState, } from 'react'; import Dimmer from '../../dimmer'; import Drawer from '../../drawer'; import { OverlayIdContext } from '../../provider/overlay/OverlayIdProvider'; import SlidingPanel from '../../slidingPanel'; import { CloseButton } from '../closeButton'; import { CommonProps } from '../commonProps'; import { isServerSide } from '../domHelpers'; import { useConditionalListener } from '../hooks'; import { useMedia } from '../hooks/useMedia'; import { Breakpoint } from '../propsValues/breakpoint'; import { Position } from '../propsValues/position'; const INITIAL_Y_POSITION = 0; const CONTENT_SCROLL_THRESHOLD = 1; const MOVE_OFFSET_THRESHOLD = 50; export type BottomSheetProps = PropsWithChildren< { onClose?: (event: Event | SyntheticEvent) => void; open: boolean; } & CommonProps & Pick, 'role' | 'aria-labelledby' | 'aria-label'> >; /** * Neptune: https://transferwise.github.io/neptune/components/bottom-sheet/ * * Neptune Web: https://transferwise.github.io/neptune-web/components/overlays/BottomSheet * */ const BottomSheet = ({ role = 'dialog', ...props }: BottomSheetProps) => { const bottomSheetReference = useRef(null); const topBarReference = useRef(null); const contentReference = useRef(null); const [pressed, setPressed] = useState(false); /** * Used to track `requestAnimationFrame` requests * * @see https://developer.mozilla.org/en-US/docs/Web/API/window/requestAnimationFrame#return_value */ const animationId = useRef(0); /** * Difference between initial coordinate ({@link initialYCoordinate}) * and new position (when user moves component), it's get calculated on `onTouchMove` and `onMouseMove` events * * @see {@link calculateOffsetAfterMove} */ const moveOffset = useRef(0); const initialYCoordinate = useRef(0); // apply shadow to the bottom of top-bar when scroll over content useConditionalListener({ attachListener: props.open && !isServerSide(), callback: () => { if (topBarReference.current !== null) { const { classList } = topBarReference.current; if (!isContentScrollPositionAtTop()) { classList.add('np-bottom-sheet--top-bar--shadow'); } else { classList.remove('np-bottom-sheet--top-bar--shadow'); } } }, eventType: 'scroll', parent: isServerSide() ? undefined : document, }); function move(newHeight: number): void { if (bottomSheetReference.current !== null) { bottomSheetReference.current.style.transform = `translateY(${newHeight}px)`; } } function close(event: Event | SyntheticEvent): void { setPressed(false); moveOffset.current = INITIAL_Y_POSITION; if (bottomSheetReference.current !== null) { bottomSheetReference.current.style.removeProperty('transform'); } if (props.onClose) { props.onClose(event); } } const onSwipeStart = ( event: React.MouseEvent | React.TouchEvent, ): void => { initialYCoordinate.current = ('touches' in event ? event.touches[0] : event).clientY; setPressed(true); }; const onSwipeMove = ( event: React.MouseEvent | React.TouchEvent, ): void => { if (pressed) { const { clientY } = 'touches' in event ? event.touches[0] : event; const offset = calculateOffsetAfterMove(clientY); // check whether move is to the bottom only and content scroll position is at the top if (offset > INITIAL_Y_POSITION && isContentScrollPositionAtTop()) { moveOffset.current = offset; animationId.current = requestAnimationFrame(() => { if (animationId.current !== undefined && bottomSheetReference.current !== null) { move(offset); } }); } } }; function onSwipeEnd( event: React.MouseEvent | React.TouchEvent, ): void { // stop moving component cancelAnimationFrame(animationId.current); setPressed(false); // check whether move down is strong enough // and content scroll position is at the top to close the component if (moveOffset.current > MOVE_OFFSET_THRESHOLD && isContentScrollPositionAtTop()) { close(event); } // otherwise move component back to default (initial) position else { move(INITIAL_Y_POSITION); } moveOffset.current = INITIAL_Y_POSITION; } function isContentScrollPositionAtTop(): boolean { return ( contentReference?.current?.scrollTop !== undefined && contentReference.current.scrollTop <= CONTENT_SCROLL_THRESHOLD ); } /** * Calculates how hard user moves component, * result value used to determine whether to hide component or re-position to default state * * @param afterMoveYCoordinate */ function calculateOffsetAfterMove(afterMoveYCoordinate: number): number { return afterMoveYCoordinate - initialYCoordinate.current; } /** * Set `max-height` for content part (in order to keep it scrollable for content overflow cases) of the component * and ensures space for safe zone (32px) at the top. */ function setContentMaxHeight(): CSSProperties { const safeZoneHeight = '64px'; const topbarHeight = '32px'; const windowHight: number = isServerSide() ? 0 : window.innerHeight; /** * Calculate _real_ height of the screen (taking into account parts of browser interface). * * See https://css-tricks.com/the-trick-to-viewport-units-on-mobile for more details. */ const screenHeight = `${windowHight * 0.01 * 100}px`; return { maxHeight: `calc(${screenHeight} - ${safeZoneHeight} - ${topbarHeight})`, }; } const is400Zoom = useMedia(`(max-width: ${Breakpoint.ZOOM_400}px)`); const overlayId = useContext(OverlayIdContext); return is400Zoom ? ( {props.children} ) : (
{props.children}
); }; export default BottomSheet;