UNPKG

react-modal-sheet

Version:

Flexible bottom sheet component for your React apps

211 lines (179 loc) 5.08 kB
import { type ForwardedRef, type RefCallback, type RefObject } from 'react'; import { IS_SSR } from './constants'; import type { SheetDetent } from './types'; /** * Get the rounded height of the sheet element and log a warning if the * element is not mounted yet but the height is requested. */ export function getSheetHeight(sheetRef: RefObject<HTMLDivElement | null>) { const sheetEl = sheetRef.current; if (!sheetEl) { console.warn( 'Sheet height is not available because the sheet element is not mounted yet.' ); return 0; } return Math.round(sheetEl.getBoundingClientRect().height); } /** * Convert negative / percentage snap points to absolute values */ export function getSnapPoints({ snapPointsProp, sheetHeight, }: { snapPointsProp: number[]; sheetHeight: number; }) { const snapPointValues = snapPointsProp.map((point) => { // Percentage values e.g. between 0.0 and 1.0 if (point > 0 && point <= 1) { return Math.round(point * sheetHeight); } return point < 0 ? sheetHeight + point : point; // negative values }); console.assert( inDescendingOrder(snapPointValues) || sheetHeight === 0, `Snap points need to be in descending order got: [${snapPointsProp.join(', ')}]` ); return snapPointValues; } export function getClosestSnapPoint({ snapPoints, currentY, sheetHeight, detent, }: { snapPoints: number[]; currentY: number; sheetHeight: number; detent?: SheetDetent; }) { // Inverse values are the values that can be passed to `animate` const snapInverse = snapPoints.map( (p) => sheetHeight - Math.min(p, sheetHeight) ); // Allow snapping to the top of the sheet if detent is set to `content-height` if (detent === 'content-height' && !snapInverse.includes(0)) { snapInverse.unshift(0); } // Get the closest snap point const snapTo = getClosest(snapInverse, currentY); const snapIndex = snapInverse.indexOf(snapTo); const snapY = validateSnapTo({ snapTo: getClosest(snapInverse, currentY), sheetHeight, }); return { snapY, snapIndex }; } /** * Get the closest number to the goal from the array of numbers. */ export function getClosest(nums: number[], goal: number) { let closest = nums[0]; let minDifference = Math.abs(nums[0] - goal); for (let i = 1; i < nums.length; i++) { const difference = Math.abs(nums[i] - goal); if (difference < minDifference) { closest = nums[i]; minDifference = difference; } } return closest; } /** * Check if the array is in descending order. * This is used to validate the snap points. */ export function inDescendingOrder(arr: number[]) { for (let i = 0; i < arr.length; i++) { if (arr[i + 1] > arr[i]) return false; } return true; } /** * Get the Y value of the given snap point. This can be passed to the `animate` * function to animate the sheet to the given snap point. */ export function getSnapY({ sheetHeight, snapPoints, snapIndex, }: { sheetHeight: number; snapPoints: number[]; snapIndex: number; }) { const snapPoint = snapPoints[snapIndex]; if (snapPoint === undefined) { console.warn( `Invalid snap index ${snapIndex}. Snap points are: [${snapPoints.join(', ')}]` ); return null; } const y = validateSnapTo({ snapTo: sheetHeight - snapPoint, sheetHeight }); return y; } /** * Make sure the snap point is not out of bounds. * Snap points cannot be negative - `0` means the sheet is fully open. */ export function validateSnapTo({ snapTo, sheetHeight, }: { snapTo: number; sheetHeight: number; }) { if (snapTo < 0) { console.warn( `Snap point is out of bounds. Sheet height is ${sheetHeight} but snap point is ${sheetHeight + Math.abs(snapTo)}.` ); } return Math.max(Math.round(snapTo), 0); } export function mergeRefs<T = any>(refs: ForwardedRef<T>[]): RefCallback<T> { return (value: any) => { refs.forEach((ref: any) => { if (typeof ref === 'function') { ref(value); } else if (ref) { ref.current = value; } }); }; } export function isTouchDevice() { if (IS_SSR) return false; return 'ontouchstart' in window || navigator.maxTouchPoints > 0; } function testPlatform(re: RegExp) { return typeof window !== 'undefined' && window.navigator != null ? re.test( // @ts-expect-error window.navigator.userAgentData?.platform || window.navigator.platform ) : false; } function cached(fn: () => boolean) { let res: boolean | null = null; return () => { if (res == null) { res = fn(); } return res; }; } const isMac = cached(function () { return testPlatform(/^Mac/i); }); const isIPhone = cached(function () { return testPlatform(/^iPhone/i); }); const isIPad = cached(function () { // iPadOS 13 lies and says it's a Mac, but we can distinguish by detecting touch support. return testPlatform(/^iPad/i) || (isMac() && navigator.maxTouchPoints > 1); }); export const isIOS = cached(function () { return isIPhone() || isIPad(); });