import * as React from 'react'; import * as ReactDOM from 'react-dom'; import { animate, useMotionValue, PanInfo, AnimatePresence, } from 'framer-motion'; import { SheetContextType, SheetProps } from './types'; import { getClosest, inDescendingOrder, isSSR, useWindowHeight } from './utils'; import { SheetContext } from './context'; import { useModalEffect } from './hooks'; import styles from './styles'; const Sheet = React.forwardRef( ( { children, isOpen, onClose, onOpenStart, onOpenEnd, onCloseStart, onCloseEnd, onSnap, snapPoints, useContentHeight, initialSnap = 0, rootId, springConfig = { stiffness: 300, damping: 30, mass: 0.2 }, disableDrag = false, ...rest }, ref ) => { const sheetRef = React.useRef(null); const callbacks = React.useRef({ onOpenStart, onOpenEnd, onCloseStart, onCloseEnd }); // prettier-ignore const indicatorRotation = useMotionValue(0); const windowHeight = useWindowHeight(); // NOTE: the inital value for `y` doesn't matter since it is overwritten by // the value driven by the `AnimatePresence` component when the sheet is opened // and after that it is driven by the gestures and/or snapping const y = useMotionValue(0); if (snapPoints) { // Convert negative / percentage snap points to absolute values snapPoints = snapPoints.map(point => { if (point > 0 && point <= 1) return point * windowHeight; // percentage values e.g. between 0.0 and 1.0 return point < 0 ? windowHeight + point : point; // negative values }); console.assert( inDescendingOrder(snapPoints) || windowHeight === 0, `Snap points need to be in descending order got: [${snapPoints}]` ); } const handleDrag = React.useCallback((_, { delta }: PanInfo) => { // Update drag indicator rotation based on drag velocity const velocity = y.getVelocity(); if (velocity > 0) indicatorRotation.set(10); if (velocity < 0) indicatorRotation.set(-10); // Make sure user cannot drag beyond the top of the sheet y.set(Math.max(y.get() + delta.y, 0)); }, []); // eslint-disable-line const handleDragEnd = React.useCallback( (_, { velocity }: PanInfo) => { if (velocity.y > 500) { // User flicked the sheet down onClose(); } else { const sheetEl = sheetRef.current as HTMLDivElement; const contentHeight = sheetEl.getBoundingClientRect().height; const snapTo = snapPoints ? getClosest(snapPoints.map(p => contentHeight - p), y.get()) // prettier-ignore : y.get() / contentHeight > 0.6 // Close if dragged over 60% ? contentHeight : 0; // Update the spring value so that the sheet is animated to the snap point animate(y, snapTo, { type: 'spring', ...springConfig }); if (snapPoints && onSnap) { const snapValue = Math.abs(Math.round(snapPoints[0] - snapTo)); const snapIndex = snapPoints.indexOf(snapValue); onSnap(snapIndex); } if (snapTo >= contentHeight) onClose(); } // Reset indicator rotation after dragging indicatorRotation.set(0); }, [onClose, onSnap] // eslint-disable-line ); // Keep the callback fns up-to-date so that they can be accessed inside // the effect without including them to the dependencies array React.useEffect(() => { callbacks.current = { onOpenStart, onOpenEnd, onCloseStart, onCloseEnd }; }); // Trigger onSnap callback when sheet is opened or closed React.useEffect(() => { if (!snapPoints || !onSnap) return; const snapIndex = isOpen ? initialSnap : snapPoints.length - 1; onSnap(snapIndex); }, [isOpen]); // eslint-disable-line React.useImperativeHandle(ref, () => ({ y, snapTo: (snapIndex: number) => { const sheetEl = sheetRef.current as HTMLDivElement | null; if ( snapPoints && snapPoints[snapIndex] !== undefined && sheetEl !== null ) { const contentHeight = sheetEl.getBoundingClientRect().height; const snapTo = contentHeight - snapPoints[snapIndex]; animate(y, snapTo, { type: 'spring', ...springConfig }); if (onSnap) onSnap(snapIndex); if (snapTo >= contentHeight) onClose(); } }, })); useModalEffect(isOpen, rootId); const dragProps = disableDrag ? ({} as any) : { drag: 'y' as const, dragElastic: 0, dragConstraints: { top: 0, bottom: 0 }, dragMomentum: false, onDrag: handleDrag, onDragEnd: handleDragEnd, }; const context: SheetContextType = { y, sheetRef, isOpen, initialSnap, snapPoints, useContentHeight, indicatorRotation, callbacks, dragProps, windowHeight, springConfig, }; const wrapperProps = { ...rest, ref, style: styles.wrapper, }; const sheet = (
{/* NOTE: AnimatePresence requires us to set keys to children */} {isOpen ? React.Children.map(children, (child: any, i) => React.cloneElement(child, { key: `sheet-child-${i}` }) ) : null}
); if (isSSR) return sheet; return ReactDOM.createPortal(sheet, document.body); } ); export default Sheet;