react-modal-sheet 4.4.0 → 5.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,34 +1,97 @@
1
+ import { type MotionStyle, motion } from 'motion/react';
1
2
  import React, { forwardRef } from 'react';
2
- import { motion } from 'motion/react';
3
3
 
4
- import { type SheetDraggableProps } from './types';
5
- import { useSheetScrollerContext, useSheetContext } from './context';
4
+ import { useSheetContext } from './context';
6
5
  import { useDragConstraints } from './hooks/use-drag-constraints';
7
- import { mergeRefs } from './utils';
6
+ import { useScrollPosition } from './hooks/use-scroll-position';
8
7
  import { styles } from './styles';
8
+ import { type SheetContentProps } from './types';
9
+ import { applyStyles, mergeRefs } from './utils';
9
10
 
10
- export const SheetContent = forwardRef<any, SheetDraggableProps>(
11
- ({ children, style, disableDrag, className = '', ...rest }, ref) => {
11
+ export const SheetContent = forwardRef<any, SheetContentProps>(
12
+ (
13
+ {
14
+ disableScroll: disableScrollProp,
15
+ disableDrag: disableDragProp,
16
+ children,
17
+ style: styleProp,
18
+ className = '',
19
+ scrollRef: scrollRefProp = null,
20
+ unstyled,
21
+ ...rest
22
+ },
23
+ ref
24
+ ) => {
12
25
  const sheetContext = useSheetContext();
13
- const sheetScrollerContext = useSheetScrollerContext();
14
- const { constraintsRef, onMeasureDragConstraints } = useDragConstraints();
26
+ const dragConstraints = useDragConstraints();
27
+ const scroll = useScrollPosition();
28
+
29
+ const disableScroll =
30
+ typeof disableScrollProp === 'function'
31
+ ? disableScrollProp({
32
+ scrollPosition: scroll.scrollPosition,
33
+ currentSnap: sheetContext.currentSnap,
34
+ })
35
+ : Boolean(disableScrollProp);
36
+
37
+ const disableDragDueToScroll =
38
+ !disableScroll &&
39
+ scroll.scrollPosition &&
40
+ scroll.scrollPosition !== 'top';
41
+
42
+ const disableDragDueToProp =
43
+ typeof disableDragProp === 'function'
44
+ ? disableDragProp({
45
+ scrollPosition: scroll.scrollPosition,
46
+ currentSnap: sheetContext.currentSnap,
47
+ })
48
+ : Boolean(disableDragProp);
49
+
50
+ const disableDrag =
51
+ disableDragDueToProp ||
52
+ disableDragDueToScroll ||
53
+ sheetContext.disableDrag;
15
54
 
16
55
  const dragProps =
17
- disableDrag || sheetScrollerContext.disableDrag
56
+ disableDrag || sheetContext.disableDrag
18
57
  ? undefined
19
58
  : sheetContext.dragProps;
20
59
 
60
+ const isUnstyled = unstyled ?? sheetContext.unstyled;
61
+
62
+ const contentStyle: MotionStyle = {
63
+ ...applyStyles(styles.content, isUnstyled),
64
+ ...styleProp,
65
+ };
66
+
67
+ const scrollStyle: MotionStyle = applyStyles(styles.scroller, isUnstyled);
68
+
69
+ if (sheetContext.avoidKeyboard) {
70
+ scrollStyle.paddingBottom =
71
+ 'env(keyboard-inset-height, var(--keyboard-inset-height, 0px))';
72
+ }
73
+
74
+ if (disableScroll) {
75
+ scrollStyle.overflowY = 'hidden';
76
+ }
77
+
21
78
  return (
22
79
  <motion.div
23
80
  {...rest}
24
- ref={mergeRefs([ref, constraintsRef])}
81
+ ref={mergeRefs([ref, dragConstraints.ref])}
25
82
  className={`react-modal-sheet-content ${className}`}
26
- style={{ ...styles.content, ...style }}
83
+ style={contentStyle}
27
84
  {...dragProps}
28
- dragConstraints={constraintsRef}
29
- onMeasureDragConstraints={onMeasureDragConstraints}
85
+ dragConstraints={dragConstraints.ref}
86
+ onMeasureDragConstraints={dragConstraints.onMeasure}
30
87
  >
31
- {children}
88
+ <motion.div
89
+ ref={mergeRefs([scroll.ref, scrollRefProp])}
90
+ style={scrollStyle}
91
+ className="react-modal-sheet-content-scroller"
92
+ >
93
+ {children}
94
+ </motion.div>
32
95
  </motion.div>
33
96
  );
34
97
  }
@@ -0,0 +1,52 @@
1
+ import { motion, useTransform } from 'motion/react';
2
+ import React from 'react';
3
+
4
+ import { useSheetContext } from './context';
5
+ import { styles } from './styles';
6
+ import type { SheetDragIndicatorProps } from './types';
7
+ import { applyStyles } from './utils';
8
+
9
+ export function SheetDragIndicator({
10
+ style,
11
+ className = '',
12
+ unstyled,
13
+ ...rest
14
+ }: SheetDragIndicatorProps) {
15
+ const sheetContext = useSheetContext();
16
+
17
+ const indicator1Transform = useTransform(
18
+ sheetContext.indicatorRotation,
19
+ (r) => `translateX(2px) rotate(${r}deg)`
20
+ );
21
+
22
+ const indicator2Transform = useTransform(
23
+ sheetContext.indicatorRotation,
24
+ (r) => `translateX(-2px) rotate(${-1 * r}deg)`
25
+ );
26
+
27
+ const isUnstyled = unstyled ?? sheetContext.unstyled;
28
+
29
+ const indicatorWrapperStyle = {
30
+ ...applyStyles(styles.indicatorWrapper, isUnstyled),
31
+ ...style,
32
+ };
33
+
34
+ const indicatorStyle = applyStyles(styles.indicator, isUnstyled);
35
+
36
+ return (
37
+ <div
38
+ className={`react-modal-sheet-drag-indicator-container ${className}`}
39
+ style={indicatorWrapperStyle}
40
+ {...rest}
41
+ >
42
+ <motion.span
43
+ className="react-modal-sheet-drag-indicator"
44
+ style={{ ...indicatorStyle, transform: indicator1Transform }}
45
+ />
46
+ <motion.span
47
+ className="react-modal-sheet-drag-indicator"
48
+ style={{ ...indicatorStyle, transform: indicator2Transform }}
49
+ />
50
+ </div>
51
+ );
52
+ }
@@ -1,47 +1,47 @@
1
+ import { motion } from 'motion/react';
1
2
  import React, { forwardRef } from 'react';
2
- import { motion, useTransform } from 'motion/react';
3
3
 
4
- import { type SheetDraggableProps } from './types';
4
+ import { SheetDragIndicator } from './SheetDragIndicator';
5
5
  import { useSheetContext } from './context';
6
6
  import { useDragConstraints } from './hooks/use-drag-constraints';
7
- import { mergeRefs } from './utils';
8
7
  import { styles } from './styles';
8
+ import { type SheetHeaderProps } from './types';
9
+ import { applyStyles, mergeRefs } from './utils';
9
10
 
10
- export const SheetHeader = forwardRef<any, SheetDraggableProps>(
11
- ({ children, style, disableDrag, ...rest }, ref) => {
12
- const { indicatorRotation, dragProps } = useSheetContext();
13
- const { constraintsRef, onMeasureDragConstraints } = useDragConstraints();
14
- const _dragProps = disableDrag ? undefined : dragProps;
11
+ export const SheetHeader = forwardRef<any, SheetHeaderProps>(
12
+ (
13
+ { children, style, disableDrag, unstyled, className = '', ...rest },
14
+ ref
15
+ ) => {
16
+ const sheetContext = useSheetContext();
17
+ const dragConstraints = useDragConstraints();
18
+ const dragProps =
19
+ disableDrag || sheetContext.disableDrag
20
+ ? undefined
21
+ : sheetContext.dragProps;
15
22
 
16
- const indicator1Transform = useTransform(
17
- indicatorRotation,
18
- (r) => `translateX(2px) rotate(${r}deg)`
19
- );
23
+ const isUnstyled = unstyled ?? sheetContext.unstyled;
20
24
 
21
- const indicator2Transform = useTransform(
22
- indicatorRotation,
23
- (r) => `translateX(-2px) rotate(${-1 * r}deg)`
24
- );
25
+ const headerWrapperStyle = {
26
+ ...applyStyles(styles.headerWrapper, isUnstyled),
27
+ ...style,
28
+ };
29
+
30
+ const headerStyle = applyStyles(styles.header, isUnstyled);
25
31
 
26
32
  return (
27
33
  <motion.div
28
34
  {...rest}
29
- ref={mergeRefs([ref, constraintsRef])}
30
- style={{ ...styles.headerWrapper, ...style }}
31
- {..._dragProps}
32
- dragConstraints={constraintsRef}
33
- onMeasureDragConstraints={onMeasureDragConstraints}
35
+ ref={mergeRefs([ref, dragConstraints.ref])}
36
+ style={headerWrapperStyle}
37
+ className={`react-modal-sheet-header-container ${className}`}
38
+ {...dragProps}
39
+ dragConstraints={dragConstraints.ref}
40
+ onMeasureDragConstraints={dragConstraints.onMeasure}
34
41
  >
35
42
  {children || (
36
- <div className="react-modal-sheet-header" style={styles.header}>
37
- <motion.span
38
- className="react-modal-sheet-drag-indicator"
39
- style={{ ...styles.indicator, transform: indicator1Transform }}
40
- />
41
- <motion.span
42
- className="react-modal-sheet-drag-indicator"
43
- style={{ ...styles.indicator, transform: indicator2Transform }}
44
- />
43
+ <div className="react-modal-sheet-header" style={headerStyle}>
44
+ <SheetDragIndicator />
45
45
  </div>
46
46
  )}
47
47
  </motion.div>
package/src/constants.ts CHANGED
@@ -1,13 +1,15 @@
1
- export const MAX_HEIGHT = 'calc(100% - env(safe-area-inset-top) - 34px)';
1
+ import type { SheetTweenConfig } from './types';
2
+
3
+ export const DEFAULT_HEIGHT = 'calc(100% - env(safe-area-inset-top) - 34px)';
2
4
 
3
5
  export const IS_SSR = typeof window === 'undefined';
4
6
 
5
- export const DEFAULT_TWEEN_CONFIG = {
7
+ export const DEFAULT_TWEEN_CONFIG: SheetTweenConfig = {
6
8
  ease: 'easeOut',
7
9
  duration: 0.2,
8
10
  };
9
11
 
10
- export const REDUCED_MOTION_TWEEN_CONFIG = {
12
+ export const REDUCED_MOTION_TWEEN_CONFIG: SheetTweenConfig = {
11
13
  ease: 'linear',
12
14
  duration: 0.01,
13
15
  };
package/src/context.tsx CHANGED
@@ -1,10 +1,5 @@
1
- import React, {
2
- type ReactNode,
3
- createContext,
4
- useContext,
5
- useState,
6
- } from 'react';
7
- import { type SheetScrollerContextType, type SheetContextType } from './types';
1
+ import { createContext, useContext } from 'react';
2
+ import { type SheetContextType } from './types';
8
3
 
9
4
  export const SheetContext = createContext<SheetContextType | undefined>(
10
5
  undefined
@@ -15,38 +10,3 @@ export function useSheetContext() {
15
10
  if (!context) throw new Error('Sheet context error');
16
11
  return context;
17
12
  }
18
-
19
- export const SheetScrollerContext = createContext<
20
- SheetScrollerContextType | undefined
21
- >(undefined);
22
-
23
- export function SheetScrollerContextProvider({
24
- children,
25
- }: {
26
- children: ReactNode;
27
- }) {
28
- const sheetContext = useSheetContext();
29
- const [disableDrag, setDisableDrag] = useState(!!sheetContext.disableDrag);
30
-
31
- function setDragEnabled() {
32
- if (!sheetContext.disableDrag) setDisableDrag(false);
33
- }
34
-
35
- function setDragDisabled() {
36
- if (!disableDrag) setDisableDrag(true);
37
- }
38
-
39
- return (
40
- <SheetScrollerContext.Provider
41
- value={{ disableDrag, setDragEnabled, setDragDisabled }}
42
- >
43
- {children}
44
- </SheetScrollerContext.Provider>
45
- );
46
- }
47
-
48
- export function useSheetScrollerContext() {
49
- const context = useContext(SheetScrollerContext);
50
- if (!context) throw new Error('Sheet scroller context error');
51
- return context;
52
- }
@@ -5,15 +5,15 @@ import { useIsomorphicLayoutEffect } from './use-isomorphic-layout-effect';
5
5
 
6
6
  export function useDimensions() {
7
7
  const [dimensions, setDimensions] = useState(() => ({
8
- height: !IS_SSR ? window.innerHeight : 0,
9
- width: !IS_SSR ? window.innerWidth : 0,
8
+ windowHeight: !IS_SSR ? window.innerHeight : 0,
9
+ windowWidth: !IS_SSR ? window.innerWidth : 0,
10
10
  }));
11
11
 
12
12
  useIsomorphicLayoutEffect(() => {
13
13
  function handler() {
14
14
  setDimensions({
15
- height: window.innerHeight,
16
- width: window.innerWidth,
15
+ windowHeight: window.innerHeight,
16
+ windowWidth: window.innerWidth,
17
17
  });
18
18
  }
19
19
 
@@ -8,7 +8,7 @@ import { type BoundingBox } from 'motion/react';
8
8
  const constraints: BoundingBox = { bottom: 0, top: 0, left: 0, right: 0 };
9
9
 
10
10
  export function useDragConstraints() {
11
- const constraintsRef = useRef<any>(null);
12
- const onMeasureDragConstraints = useCallback(() => constraints, []);
13
- return { constraintsRef, onMeasureDragConstraints };
11
+ const ref = useRef<any>(null);
12
+ const onMeasure = useCallback(() => constraints, []);
13
+ return { ref, onMeasure };
14
14
  }
@@ -1,25 +1,34 @@
1
- import { transform, type MotionValue } from 'motion';
1
+ import { type MotionValue, transform } from 'motion';
2
2
  import { type RefObject } from 'react';
3
3
 
4
+ import type { SheetDetent, SheetSnapPoint } from '../types';
4
5
  import { useIsomorphicLayoutEffect } from './use-isomorphic-layout-effect';
5
6
  import { useSafeAreaInsets } from './use-safe-area-insets';
6
- import { getSheetHeight, getSnapPoints } from '../utils';
7
7
 
8
8
  export function useModalEffect({
9
9
  y,
10
- rootId,
11
- sheetRef,
12
- snapPointsProp,
10
+ detent,
11
+ rootId: _rootId,
12
+ sheetHeight,
13
+ snapPoints,
13
14
  startThreshold,
14
15
  }: {
15
16
  y: MotionValue<number>;
17
+ detent: SheetDetent;
16
18
  rootId?: string;
17
- sheetRef: RefObject<HTMLDivElement | null>;
18
- snapPointsProp?: number[];
19
+ sheetHeight: number;
20
+ snapPoints: SheetSnapPoint[];
19
21
  startThreshold?: number;
20
22
  }) {
21
23
  const insetTop = useSafeAreaInsets().top;
22
24
 
25
+ let rootId: string | undefined = _rootId;
26
+
27
+ if (rootId && detent === 'full') {
28
+ console.warn('Using "full" detent with modal effect is not supported.');
29
+ rootId = undefined;
30
+ }
31
+
23
32
  // Cleanup on unmount
24
33
  useIsomorphicLayoutEffect(() => {
25
34
  return () => {
@@ -33,15 +42,7 @@ export function useModalEffect({
33
42
  const root = document.querySelector(`#${rootId}`) as HTMLDivElement;
34
43
  if (!root) return;
35
44
 
36
- let sheetHeight = 0;
37
-
38
45
  const removeStartListener = y.on('animationStart', () => {
39
- /**
40
- * Only calculate the sheet height when the animation starts to avoid
41
- * unnecessary layout calculations when y value changes.
42
- */
43
- sheetHeight = getSheetHeight(sheetRef);
44
-
45
46
  // biome-ignore lint/style/noNonNullAssertion: root is always defined here
46
47
  setupModalEffect(rootId!);
47
48
  });
@@ -55,17 +56,13 @@ export function useModalEffect({
55
56
 
56
57
  let progress = Math.max(0, 1 - yValue / sheetHeight);
57
58
 
58
- const snapPoints = snapPointsProp
59
- ? getSnapPoints({ snapPointsProp, sheetHeight })
60
- : undefined;
61
-
62
59
  /**
63
60
  * Start the effect only if we have dragged over the second snap point
64
61
  * to make the effect more natural as the sheet will reach it's final
65
62
  * position when the user drags it over the second snap point.
66
63
  */
67
64
  const snapThresholdPoint =
68
- snapPoints && snapPoints.length > 1 ? snapPoints[1] : undefined;
65
+ snapPoints.length > 1 ? snapPoints[snapPoints.length - 2] : undefined;
69
66
 
70
67
  /**
71
68
  * If we have snap points, we need to calculate the progress percentage
@@ -74,8 +71,7 @@ export function useModalEffect({
74
71
  * and its end is different.
75
72
  */
76
73
  if (snapThresholdPoint !== undefined) {
77
- const snapThresholdValue =
78
- sheetHeight - Math.min(snapThresholdPoint, sheetHeight);
74
+ const snapThresholdValue = snapThresholdPoint.snapValueY;
79
75
 
80
76
  if (yValue <= snapThresholdValue) {
81
77
  progress = (snapThresholdValue - yValue) / snapThresholdValue;
@@ -132,7 +128,7 @@ export function useModalEffect({
132
128
  removeCompleteListener();
133
129
  removeCancelListener();
134
130
  };
135
- }, [y, rootId, insetTop, startThreshold, snapPointsProp]);
131
+ }, [y, rootId, insetTop, startThreshold, sheetHeight]);
136
132
  }
137
133
 
138
134
  function setupModalEffect(rootId: string) {
@@ -0,0 +1,31 @@
1
+ import { useEffect, useRef } from 'react';
2
+ import { useStableCallback } from './use-stable-callback';
3
+
4
+ export function useResizeObserver<T extends Element = HTMLDivElement>(
5
+ callback: ResizeObserverCallback
6
+ ) {
7
+ const observeRef = useRef<T | null>(null);
8
+ const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
9
+
10
+ const debouncedCallback: ResizeObserverCallback = useStableCallback(
11
+ (entries, observer) => {
12
+ if (timeoutRef.current) clearTimeout(timeoutRef.current);
13
+ timeoutRef.current = setTimeout(() => callback(entries, observer), 32);
14
+ }
15
+ );
16
+
17
+ useEffect(() => {
18
+ const element = observeRef.current;
19
+ if (!element) return;
20
+
21
+ const observer = new ResizeObserver(debouncedCallback);
22
+ observer.observe(element, { box: 'border-box' });
23
+
24
+ return () => {
25
+ observer.disconnect();
26
+ if (timeoutRef.current) clearTimeout(timeoutRef.current);
27
+ };
28
+ }, []);
29
+
30
+ return { observeRef };
31
+ }
@@ -0,0 +1,72 @@
1
+ import { useEffect, useRef, useState } from 'react';
2
+
3
+ export function useScrollPosition() {
4
+ const ref = useRef<HTMLDivElement>(null);
5
+ const [scrollPosition, setScrollPosition] = useState<
6
+ 'top' | 'bottom' | 'middle' | undefined
7
+ >(undefined);
8
+
9
+ useEffect(() => {
10
+ const element = ref.current;
11
+ if (!element) return;
12
+
13
+ let scrollTimeout: number | null = null;
14
+
15
+ function determineScrollPosition(element: HTMLDivElement) {
16
+ const { scrollTop, scrollHeight, clientHeight } = element;
17
+ const isScrollable = scrollHeight > clientHeight;
18
+
19
+ if (!isScrollable) {
20
+ // Reset scroll position if the content is not scrollable anymore
21
+ if (scrollPosition) setScrollPosition(undefined);
22
+ return;
23
+ }
24
+
25
+ const isAtTop = scrollTop <= 0;
26
+ const isAtBottom =
27
+ Math.ceil(scrollHeight) - Math.ceil(scrollTop) ===
28
+ Math.ceil(clientHeight);
29
+
30
+ let position: 'top' | 'bottom' | 'middle';
31
+
32
+ if (isAtTop) {
33
+ position = 'top';
34
+ } else if (isAtBottom) {
35
+ position = 'bottom';
36
+ } else {
37
+ position = 'middle';
38
+ }
39
+
40
+ if (position === scrollPosition) return;
41
+ setScrollPosition(position);
42
+ }
43
+
44
+ function onScroll(event: Event) {
45
+ if (event.currentTarget instanceof HTMLDivElement) {
46
+ const el = event.currentTarget;
47
+ if (scrollTimeout) clearTimeout(scrollTimeout);
48
+ // Debounce the scroll handler
49
+ scrollTimeout = setTimeout(() => determineScrollPosition(el), 32);
50
+ }
51
+ }
52
+
53
+ function onTouchStart(event: Event) {
54
+ if (event.currentTarget instanceof HTMLDivElement) {
55
+ determineScrollPosition(event.currentTarget);
56
+ }
57
+ }
58
+
59
+ determineScrollPosition(element);
60
+
61
+ element.addEventListener('scroll', onScroll);
62
+ element.addEventListener('touchstart', onTouchStart);
63
+
64
+ return () => {
65
+ if (scrollTimeout) clearTimeout(scrollTimeout);
66
+ element.removeEventListener('scroll', onScroll);
67
+ element.removeEventListener('touchstart', onTouchStart);
68
+ };
69
+ }, []);
70
+
71
+ return { ref, scrollPosition };
72
+ }
@@ -0,0 +1,63 @@
1
+ import { useEffect, useState } from 'react';
2
+ import { useStableCallback } from './use-stable-callback';
3
+
4
+ type SheetState = 'closed' | 'opening' | 'open' | 'closing';
5
+
6
+ type UseSheetStatesProps = {
7
+ isOpen: boolean;
8
+ onClosed?: () => Promise<void> | void;
9
+ onOpening?: () => Promise<void> | void;
10
+ onOpen?: () => Promise<void> | void;
11
+ onClosing?: () => Promise<void> | void;
12
+ };
13
+
14
+ export function useSheetState({
15
+ isOpen,
16
+ onClosed: _onClosed,
17
+ onOpening: _onOpening,
18
+ onOpen: _onOpen,
19
+ onClosing: _onClosing,
20
+ }: UseSheetStatesProps) {
21
+ const [state, setState] = useState<SheetState>(isOpen ? 'opening' : 'closed');
22
+ const onClosed = useStableCallback(() => _onClosed?.());
23
+ const onOpening = useStableCallback(() => _onOpening?.());
24
+ const onOpen = useStableCallback(() => _onOpen?.());
25
+ const onClosing = useStableCallback(() => _onClosing?.());
26
+
27
+ useEffect(() => {
28
+ if (isOpen && state === 'closed') {
29
+ setState('opening');
30
+ } else if (!isOpen && (state === 'open' || state === 'opening')) {
31
+ setState('closing');
32
+ }
33
+ }, [isOpen, state]);
34
+
35
+ useEffect(() => {
36
+ async function handle() {
37
+ switch (state) {
38
+ case 'closed':
39
+ await onClosed?.();
40
+ break;
41
+
42
+ case 'opening':
43
+ await onOpening?.();
44
+ setState('open');
45
+ break;
46
+
47
+ case 'open':
48
+ await onOpen?.();
49
+ break;
50
+
51
+ case 'closing':
52
+ await onClosing?.();
53
+ setState('closed');
54
+ break;
55
+ }
56
+ }
57
+ handle().catch((error) => {
58
+ console.error('Internal sheet state error:', error);
59
+ });
60
+ }, [state]);
61
+
62
+ return state;
63
+ }