@simoneggert/react-modal-sheet 5.4.3

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.
@@ -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
+ }
@@ -0,0 +1,52 @@
1
+ import { motion } from 'motion/react';
2
+ import React, { forwardRef } from 'react';
3
+
4
+ import { useSheetContext } from './context';
5
+ import { useDragConstraints } from './hooks/use-drag-constraints';
6
+ import { SheetDragIndicator } from './SheetDragIndicator';
7
+ import { styles } from './styles';
8
+ import { type SheetHeaderProps } from './types';
9
+ import { applyStyles, mergeRefs } from './utils';
10
+
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;
22
+
23
+ const isUnstyled = unstyled ?? sheetContext.unstyled;
24
+
25
+ const headerWrapperStyle = {
26
+ ...applyStyles(styles.headerWrapper, isUnstyled),
27
+ ...style,
28
+ };
29
+
30
+ const headerStyle = applyStyles(styles.header, isUnstyled);
31
+
32
+ return (
33
+ <motion.div
34
+ {...rest}
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}
41
+ >
42
+ {children || (
43
+ <div className="react-modal-sheet-header" style={headerStyle}>
44
+ <SheetDragIndicator />
45
+ </div>
46
+ )}
47
+ </motion.div>
48
+ );
49
+ }
50
+ );
51
+
52
+ SheetHeader.displayName = 'SheetHeader';
@@ -0,0 +1,19 @@
1
+ import type { SheetTweenConfig } from './types';
2
+
3
+ export const DEFAULT_HEIGHT = 'calc(100% - env(safe-area-inset-top) - 34px)';
4
+
5
+ export const IS_SSR = typeof window === 'undefined';
6
+
7
+ export const DEFAULT_TWEEN_CONFIG: SheetTweenConfig = {
8
+ ease: 'easeOut',
9
+ duration: 0.2,
10
+ };
11
+
12
+ export const REDUCED_MOTION_TWEEN_CONFIG: SheetTweenConfig = {
13
+ ease: 'linear',
14
+ duration: 0.01,
15
+ };
16
+
17
+ export const DEFAULT_DRAG_CLOSE_THRESHOLD = 0.6;
18
+
19
+ export const DEFAULT_DRAG_VELOCITY_THRESHOLD = 500;
@@ -0,0 +1,12 @@
1
+ import { createContext, useContext } from 'react';
2
+ import { type SheetContextType } from './types';
3
+
4
+ export const SheetContext = createContext<SheetContextType | undefined>(
5
+ undefined
6
+ );
7
+
8
+ export function useSheetContext() {
9
+ const context = useContext(SheetContext);
10
+ if (!context) throw new Error('Sheet context error');
11
+ return context;
12
+ }
package/src/debug.ts ADDED
@@ -0,0 +1,38 @@
1
+ /**
2
+ * This library runs on mobile devices so we can't use console.log for debugging. Instead, we can use this function to send debug information to a remote server or store it locally on the device.
3
+ * Instead display the "payload" in a fixed position element on the screen
4
+ * and hide it when that element is clicked. Create the element if it doesn't exist,
5
+ * and update its content with the payload (any JSON data).
6
+ */
7
+ export function debug(payload: any, placement: 'top' | 'bottom' = 'top') {
8
+ let debugElement = document.getElementById('debug-element');
9
+
10
+ if (!debugElement) {
11
+ debugElement = document.createElement('div');
12
+ debugElement.id = 'debug-element';
13
+ debugElement.style.position = 'fixed';
14
+ debugElement.style[placement] = '0';
15
+ debugElement.style.left = '0';
16
+ debugElement.style.width = '100%';
17
+ debugElement.style.maxHeight = '50%';
18
+ debugElement.style.overflowY = 'auto';
19
+ debugElement.style.backgroundColor = 'rgba(0, 0, 0, 0.8)';
20
+ debugElement.style.color = 'white';
21
+ debugElement.style.padding = '10px';
22
+ debugElement.style.fontSize = '12px';
23
+ debugElement.style.zIndex = '9999';
24
+ debugElement.style.cursor = 'pointer';
25
+ document.body.appendChild(debugElement);
26
+
27
+ debugElement.addEventListener('click', () => {
28
+ if (debugElement) {
29
+ debugElement.style.display =
30
+ debugElement.style.display === 'none' ? 'block' : 'none';
31
+ }
32
+ });
33
+ }
34
+
35
+ const id = Math.random().toString(36).substring(2, 7);
36
+ debugElement.textContent = JSON.stringify(payload, null, 2) + ` (${id})`;
37
+ debugElement.style.display = 'block';
38
+ }
@@ -0,0 +1,30 @@
1
+ import { useState } from 'react';
2
+
3
+ import { IS_SSR } from '../constants';
4
+ import { useIsomorphicLayoutEffect } from './use-isomorphic-layout-effect';
5
+
6
+ export function useDimensions() {
7
+ const [dimensions, setDimensions] = useState(() => ({
8
+ windowHeight: !IS_SSR ? window.innerHeight : 0,
9
+ windowWidth: !IS_SSR ? window.innerWidth : 0,
10
+ }));
11
+
12
+ useIsomorphicLayoutEffect(() => {
13
+ function handler() {
14
+ setDimensions({
15
+ windowHeight: window.innerHeight,
16
+ windowWidth: window.innerWidth,
17
+ });
18
+ }
19
+
20
+ handler();
21
+
22
+ window.addEventListener('resize', handler);
23
+
24
+ return () => {
25
+ window.removeEventListener('resize', handler);
26
+ };
27
+ }, []);
28
+
29
+ return dimensions;
30
+ }
@@ -0,0 +1,14 @@
1
+ import { useCallback, useRef } from 'react';
2
+
3
+ import { type BoundingBox } from 'motion/react';
4
+
5
+ // This is a hacky way to fix a bug in motion/react where the drag
6
+ // constraints are not updated when window is resized.
7
+ // https://github.com/framer/motion/issues/1659
8
+ const constraints: BoundingBox = { bottom: 0, top: 0, left: 0, right: 0 };
9
+
10
+ export function useDragConstraints() {
11
+ const ref = useRef<any>(null);
12
+ const onMeasure = useCallback(() => constraints, []);
13
+ return { ref, onMeasure };
14
+ }
@@ -0,0 +1,5 @@
1
+ import { useEffect, useLayoutEffect } from 'react';
2
+
3
+ import { IS_SSR } from '../constants';
4
+
5
+ export const useIsomorphicLayoutEffect = IS_SSR ? useEffect : useLayoutEffect;
@@ -0,0 +1,75 @@
1
+ import { type RefObject, useEffect, useState } from 'react';
2
+
3
+ import { willOpenKeyboard } from '../utils';
4
+ import { useStableCallback } from './use-stable-callback';
5
+ import { useVirtualKeyboard } from './use-virtual-keyboard';
6
+
7
+ export function useKeyboardAvoidance({
8
+ isEnabled,
9
+ containerRef,
10
+ onWillOpenKeyboard,
11
+ onDidOpenKeyboard,
12
+ }: {
13
+ isEnabled: boolean;
14
+ containerRef: RefObject<HTMLDivElement | null>;
15
+ onWillOpenKeyboard?: (event: FocusEvent) => Promise<void>;
16
+ onDidOpenKeyboard?: (focusedElement: HTMLElement) => void;
17
+ }) {
18
+ const [focusedElement, setFocusedElement] = useState<HTMLElement | null>(
19
+ null
20
+ );
21
+
22
+ const keyboard = useVirtualKeyboard({
23
+ isEnabled,
24
+ containerRef,
25
+ });
26
+
27
+ const handleFocusIn = useStableCallback(async (event: FocusEvent) => {
28
+ const element = event.target as HTMLElement;
29
+
30
+ if (willOpenKeyboard(element) && containerRef.current?.contains(element)) {
31
+ await onWillOpenKeyboard?.(event);
32
+ setFocusedElement(element);
33
+ }
34
+ });
35
+
36
+ const handleFocusOut = useStableCallback((event: FocusEvent) => {
37
+ const element = event.target as HTMLElement;
38
+
39
+ if (focusedElement === element) {
40
+ setFocusedElement(null);
41
+ }
42
+ });
43
+
44
+ // Keep track of the currently focused input within the container
45
+ useEffect(() => {
46
+ if (!isEnabled) return;
47
+
48
+ document.addEventListener('focusin', handleFocusIn);
49
+ document.addEventListener('focusout', handleFocusOut);
50
+
51
+ return () => {
52
+ document.removeEventListener('focusin', handleFocusIn);
53
+ document.removeEventListener('focusout', handleFocusOut);
54
+ };
55
+ }, [isEnabled]);
56
+
57
+ useEffect(() => {
58
+ const containerElement = containerRef.current;
59
+
60
+ if (
61
+ !isEnabled ||
62
+ !focusedElement ||
63
+ !containerElement ||
64
+ !keyboard.isKeyboardOpen
65
+ ) {
66
+ return;
67
+ }
68
+
69
+ requestAnimationFrame(() => {
70
+ onDidOpenKeyboard?.(focusedElement);
71
+ });
72
+ }, [isEnabled, keyboard.isKeyboardOpen, focusedElement]);
73
+
74
+ return keyboard;
75
+ }
@@ -0,0 +1,161 @@
1
+ import { type MotionValue, transform } from 'motion';
2
+ import { type RefObject } from 'react';
3
+
4
+ import type { SheetDetent, SheetSnapPoint } from '../types';
5
+ import { useIsomorphicLayoutEffect } from './use-isomorphic-layout-effect';
6
+ import { useSafeAreaInsets } from './use-safe-area-insets';
7
+
8
+ export function useModalEffect({
9
+ y,
10
+ detent,
11
+ rootId: _rootId,
12
+ sheetHeight,
13
+ snapPoints,
14
+ startThreshold,
15
+ }: {
16
+ y: MotionValue<number>;
17
+ detent: SheetDetent;
18
+ rootId?: string;
19
+ sheetHeight: number;
20
+ snapPoints: SheetSnapPoint[];
21
+ startThreshold?: number;
22
+ }) {
23
+ const insetTop = useSafeAreaInsets().top;
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
+
32
+ // Cleanup on unmount
33
+ useIsomorphicLayoutEffect(() => {
34
+ return () => {
35
+ if (rootId) cleanupModalEffect(rootId);
36
+ };
37
+ }, []);
38
+
39
+ useIsomorphicLayoutEffect(() => {
40
+ if (!rootId) return;
41
+
42
+ const root = document.querySelector(`#${rootId}`) as HTMLDivElement;
43
+ if (!root) return;
44
+
45
+ const removeStartListener = y.on('animationStart', () => {
46
+ // biome-ignore lint/style/noNonNullAssertion: root is always defined here
47
+ setupModalEffect(rootId!);
48
+ });
49
+
50
+ /**
51
+ * NOTE: The `y` value gets smaller when the sheet is opened and larger
52
+ * when the sheet is being closed.
53
+ */
54
+ const removeChangeListener = y.on('change', (yValue) => {
55
+ if (!root) return;
56
+
57
+ let progress = Math.max(0, 1 - yValue / sheetHeight);
58
+
59
+ /**
60
+ * Start the effect only if we have dragged over the second snap point
61
+ * to make the effect more natural as the sheet will reach it's final
62
+ * position when the user drags it over the second snap point.
63
+ */
64
+ const snapThresholdPoint =
65
+ snapPoints.length > 1 ? snapPoints[snapPoints.length - 2] : undefined;
66
+
67
+ /**
68
+ * If we have snap points, we need to calculate the progress percentage
69
+ * based on the snap point threshold. Note that the maximum value is also
70
+ * different in this case as the range between the start of the effect
71
+ * and its end is different.
72
+ */
73
+ if (snapThresholdPoint !== undefined) {
74
+ const snapThresholdValue = snapThresholdPoint.snapValueY;
75
+
76
+ if (yValue <= snapThresholdValue) {
77
+ progress = (snapThresholdValue - yValue) / snapThresholdValue;
78
+ } else {
79
+ progress = 0;
80
+ }
81
+ }
82
+
83
+ /**
84
+ * If we have a start threshold, we need to calculate the progress
85
+ * percentage based on the start threshold (0 to 1). For example,
86
+ * if the start threshold is 0.5, the progress will be 0 until the sheet
87
+ * is dragged over 50% of the complete drag distance.
88
+ */
89
+ if (startThreshold !== undefined) {
90
+ const startThresholdValue =
91
+ sheetHeight -
92
+ Math.min(Math.floor(startThreshold * sheetHeight), sheetHeight);
93
+
94
+ if (yValue <= startThresholdValue) {
95
+ progress = (startThresholdValue - yValue) / startThresholdValue;
96
+ } else {
97
+ progress = 0;
98
+ }
99
+ }
100
+
101
+ // Make sure progress is between 0 and 1
102
+ progress = Math.max(0, Math.min(1, progress));
103
+
104
+ const pageWidth = window.innerWidth;
105
+ const ty = transform(progress, [0, 1], [0, 24 + insetTop]);
106
+ const s = transform(progress, [0, 1], [1, (pageWidth - 16) / pageWidth]);
107
+ const borderRadius = transform(progress, [0, 1], [0, 10]);
108
+
109
+ root.style.transform = `scale(${s}) translate3d(0, ${ty}px, 0)`;
110
+ root.style.borderTopRightRadius = `${borderRadius}px`;
111
+ root.style.borderTopLeftRadius = `${borderRadius}px`;
112
+ });
113
+
114
+ function onCompleted() {
115
+ // -5 just to take into account some inprecision to ensure the cleanup is done
116
+ if (y.get() - 5 >= sheetHeight) {
117
+ // biome-ignore lint/style/noNonNullAssertion: root is always defined here
118
+ cleanupModalEffect(rootId!);
119
+ }
120
+ }
121
+
122
+ const removeCompleteListener = y.on('animationComplete', onCompleted);
123
+ const removeCancelListener = y.on('animationCancel', onCompleted);
124
+
125
+ return () => {
126
+ removeStartListener();
127
+ removeChangeListener();
128
+ removeCompleteListener();
129
+ removeCancelListener();
130
+ };
131
+ }, [y, rootId, insetTop, startThreshold, sheetHeight]);
132
+ }
133
+
134
+ function setupModalEffect(rootId: string) {
135
+ const root = document.querySelector(`#${rootId}`) as HTMLDivElement;
136
+ const body = document.querySelector('body') as HTMLBodyElement;
137
+ if (!root) return;
138
+
139
+ body.style.backgroundColor = '#000';
140
+ root.style.overflow = 'hidden';
141
+ root.style.transitionTimingFunction = 'cubic-bezier(0.32, 0.72, 0, 1)';
142
+ root.style.transitionProperty = 'transform, border-radius';
143
+ root.style.transitionDuration = '0.5s';
144
+ root.style.transformOrigin = 'center top';
145
+ }
146
+
147
+ function cleanupModalEffect(rootId: string) {
148
+ const root = document.querySelector(`#${rootId}`) as HTMLDivElement;
149
+ const body = document.querySelector('body') as HTMLBodyElement;
150
+ if (!root) return;
151
+
152
+ body.style.removeProperty('background-color');
153
+ root.style.removeProperty('overflow');
154
+ root.style.removeProperty('transition-timing-function');
155
+ root.style.removeProperty('transition-property');
156
+ root.style.removeProperty('transition-duration');
157
+ root.style.removeProperty('transform-origin');
158
+ root.style.removeProperty('transform');
159
+ root.style.removeProperty('border-top-right-radius');
160
+ root.style.removeProperty('border-top-left-radius');
161
+ }