@lodev09/react-native-true-sheet 3.10.0 → 3.11.0-beta.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.
- package/android/src/main/java/com/lodev09/truesheet/TrueSheetView.kt +11 -4
- package/android/src/main/java/com/lodev09/truesheet/TrueSheetViewController.kt +10 -5
- package/android/src/main/java/com/lodev09/truesheet/core/TrueSheetStackManager.kt +2 -5
- package/lib/module/TrueSheet.js +3 -0
- package/lib/module/TrueSheet.js.map +1 -1
- package/lib/module/TrueSheet.web.js +604 -376
- package/lib/module/TrueSheet.web.js.map +1 -1
- package/lib/module/TrueSheetProvider.js.map +1 -1
- package/lib/module/TrueSheetProvider.web.js +141 -100
- package/lib/module/TrueSheetProvider.web.js.map +1 -1
- package/lib/module/mocks/index.js.map +1 -1
- package/lib/module/reanimated/ReanimatedTrueSheet.web.js.map +1 -1
- package/lib/module/reanimated/useReanimatedPositionChangeHandler.web.js +2 -3
- package/lib/module/reanimated/useReanimatedPositionChangeHandler.web.js.map +1 -1
- package/lib/module/web/constants.js +16 -0
- package/lib/module/web/constants.js.map +1 -0
- package/lib/module/web/vaul/README.md +9 -0
- package/lib/module/web/vaul/browser.js +30 -0
- package/lib/module/web/vaul/browser.js.map +1 -0
- package/lib/module/web/vaul/constants.js +14 -0
- package/lib/module/web/vaul/constants.js.map +1 -0
- package/lib/module/web/vaul/context.js +57 -0
- package/lib/module/web/vaul/context.js.map +1 -0
- package/lib/module/web/vaul/helpers.js +93 -0
- package/lib/module/web/vaul/helpers.js.map +1 -0
- package/lib/module/web/vaul/index.js +1206 -0
- package/lib/module/web/vaul/index.js.map +1 -0
- package/lib/module/web/vaul/style.css +264 -0
- package/lib/module/web/vaul/types.js +2 -0
- package/lib/module/web/vaul/types.js.map +1 -0
- package/lib/module/web/vaul/use-composed-refs.js +34 -0
- package/lib/module/web/vaul/use-composed-refs.js.map +1 -0
- package/lib/module/web/vaul/use-controllable-state.js +54 -0
- package/lib/module/web/vaul/use-controllable-state.js.map +1 -0
- package/lib/module/web/vaul/use-position-fixed.js +123 -0
- package/lib/module/web/vaul/use-position-fixed.js.map +1 -0
- package/lib/module/web/vaul/use-prevent-scroll.js +258 -0
- package/lib/module/web/vaul/use-prevent-scroll.js.map +1 -0
- package/lib/module/web/vaul/use-scale-background.js +57 -0
- package/lib/module/web/vaul/use-scale-background.js.map +1 -0
- package/lib/module/web/vaul/use-snap-points.js +294 -0
- package/lib/module/web/vaul/use-snap-points.js.map +1 -0
- package/lib/typescript/src/TrueSheet.d.ts +2 -2
- package/lib/typescript/src/TrueSheet.d.ts.map +1 -1
- package/lib/typescript/src/TrueSheet.types.d.ts +79 -36
- package/lib/typescript/src/TrueSheet.types.d.ts.map +1 -1
- package/lib/typescript/src/TrueSheet.web.d.ts +3 -11
- package/lib/typescript/src/TrueSheet.web.d.ts.map +1 -1
- package/lib/typescript/src/TrueSheetProvider.d.ts +2 -2
- package/lib/typescript/src/TrueSheetProvider.d.ts.map +1 -1
- package/lib/typescript/src/TrueSheetProvider.web.d.ts +17 -14
- package/lib/typescript/src/TrueSheetProvider.web.d.ts.map +1 -1
- package/lib/typescript/src/mocks/index.d.ts +1 -2
- package/lib/typescript/src/mocks/index.d.ts.map +1 -1
- package/lib/typescript/src/navigation/types.d.ts +1 -1
- package/lib/typescript/src/navigation/types.d.ts.map +1 -1
- package/lib/typescript/src/reanimated/ReanimatedTrueSheet.web.d.ts +0 -1
- package/lib/typescript/src/reanimated/ReanimatedTrueSheet.web.d.ts.map +1 -1
- package/lib/typescript/src/reanimated/useReanimatedPositionChangeHandler.web.d.ts +2 -3
- package/lib/typescript/src/reanimated/useReanimatedPositionChangeHandler.web.d.ts.map +1 -1
- package/lib/typescript/src/web/constants.d.ts +14 -0
- package/lib/typescript/src/web/constants.d.ts.map +1 -0
- package/lib/typescript/src/web/vaul/browser.d.ts +8 -0
- package/lib/typescript/src/web/vaul/browser.d.ts.map +1 -0
- package/lib/typescript/src/web/vaul/constants.d.ts +12 -0
- package/lib/typescript/src/web/vaul/constants.d.ts.map +1 -0
- package/lib/typescript/src/web/vaul/context.d.ts +45 -0
- package/lib/typescript/src/web/vaul/context.d.ts.map +1 -0
- package/lib/typescript/src/web/vaul/helpers.d.ts +17 -0
- package/lib/typescript/src/web/vaul/helpers.d.ts.map +1 -0
- package/lib/typescript/src/web/vaul/index.d.ts +210 -0
- package/lib/typescript/src/web/vaul/index.d.ts.map +1 -0
- package/lib/typescript/src/web/vaul/types.d.ts +7 -0
- package/lib/typescript/src/web/vaul/types.d.ts.map +1 -0
- package/lib/typescript/src/web/vaul/use-composed-refs.d.ts +14 -0
- package/lib/typescript/src/web/vaul/use-composed-refs.d.ts.map +1 -0
- package/lib/typescript/src/web/vaul/use-controllable-state.d.ts +9 -0
- package/lib/typescript/src/web/vaul/use-controllable-state.d.ts.map +1 -0
- package/lib/typescript/src/web/vaul/use-position-fixed.d.ts +19 -0
- package/lib/typescript/src/web/vaul/use-position-fixed.d.ts.map +1 -0
- package/lib/typescript/src/web/vaul/use-prevent-scroll.d.ts +18 -0
- package/lib/typescript/src/web/vaul/use-prevent-scroll.d.ts.map +1 -0
- package/lib/typescript/src/web/vaul/use-scale-background.d.ts +2 -0
- package/lib/typescript/src/web/vaul/use-scale-background.d.ts.map +1 -0
- package/lib/typescript/src/web/vaul/use-snap-points.d.ts +37 -0
- package/lib/typescript/src/web/vaul/use-snap-points.d.ts.map +1 -0
- package/package.json +4 -4
- package/src/TrueSheet.tsx +9 -1
- package/src/TrueSheet.types.ts +88 -38
- package/src/TrueSheet.web.tsx +675 -450
- package/src/TrueSheetProvider.tsx +2 -4
- package/src/TrueSheetProvider.web.tsx +193 -125
- package/src/mocks/index.ts +1 -2
- package/src/navigation/types.ts +0 -1
- package/src/reanimated/ReanimatedTrueSheet.web.tsx +0 -1
- package/src/reanimated/useReanimatedPositionChangeHandler.web.ts +2 -3
- package/src/web/constants.ts +13 -0
- package/src/web/vaul/README.md +9 -0
- package/src/web/vaul/browser.ts +38 -0
- package/src/web/vaul/constants.ts +18 -0
- package/src/web/vaul/context.ts +87 -0
- package/src/web/vaul/helpers.ts +124 -0
- package/src/web/vaul/index.tsx +1590 -0
- package/src/web/vaul/style.css +264 -0
- package/src/web/vaul/types.ts +7 -0
- package/src/web/vaul/use-composed-refs.ts +35 -0
- package/src/web/vaul/use-controllable-state.ts +66 -0
- package/src/web/vaul/use-position-fixed.ts +147 -0
- package/src/web/vaul/use-prevent-scroll.ts +309 -0
- package/src/web/vaul/use-scale-background.ts +64 -0
- package/src/web/vaul/use-snap-points.ts +433 -0
package/src/TrueSheet.web.tsx
CHANGED
|
@@ -1,571 +1,796 @@
|
|
|
1
|
+
/// <reference lib="dom" />
|
|
1
2
|
import {
|
|
2
3
|
createElement,
|
|
3
|
-
Fragment,
|
|
4
4
|
forwardRef,
|
|
5
5
|
isValidElement,
|
|
6
6
|
useCallback,
|
|
7
|
-
useContext,
|
|
8
7
|
useEffect,
|
|
9
|
-
useId,
|
|
10
8
|
useImperativeHandle,
|
|
11
9
|
useMemo,
|
|
12
10
|
useRef,
|
|
13
11
|
useState,
|
|
14
12
|
} from 'react';
|
|
15
|
-
import { View,
|
|
16
|
-
|
|
17
|
-
import
|
|
18
|
-
|
|
19
|
-
type BottomSheetBackdropProps,
|
|
20
|
-
BottomSheetFooter,
|
|
21
|
-
type BottomSheetFooterProps,
|
|
22
|
-
BottomSheetModal,
|
|
23
|
-
type BottomSheetProps,
|
|
24
|
-
BottomSheetView,
|
|
25
|
-
type SNAP_POINT_TYPE,
|
|
26
|
-
} from '@gorhom/bottom-sheet';
|
|
27
|
-
import { useDerivedValue, useSharedValue } from 'react-native-reanimated';
|
|
28
|
-
|
|
29
|
-
import { BottomSheetContext, type TrueSheetRefMethods } from './TrueSheetProvider.web';
|
|
13
|
+
import { View, useColorScheme, useWindowDimensions } from 'react-native';
|
|
14
|
+
|
|
15
|
+
import { Drawer } from './web/vaul';
|
|
16
|
+
import { TRANSITIONS } from './web/vaul/constants';
|
|
30
17
|
import type {
|
|
31
|
-
TrueSheetProps,
|
|
32
18
|
DetentChangeEvent,
|
|
19
|
+
DetentInfoEventPayload,
|
|
33
20
|
DidBlurEvent,
|
|
34
21
|
DidDismissEvent,
|
|
35
22
|
DidFocusEvent,
|
|
36
23
|
DidPresentEvent,
|
|
24
|
+
DragBeginEvent,
|
|
25
|
+
DragChangeEvent,
|
|
26
|
+
DragEndEvent,
|
|
37
27
|
MountEvent,
|
|
38
28
|
PositionChangeEvent,
|
|
29
|
+
SheetDetent,
|
|
30
|
+
TrueSheetMethods,
|
|
31
|
+
TrueSheetProps,
|
|
32
|
+
TrueSheetStaticMethods,
|
|
39
33
|
WillBlurEvent,
|
|
40
34
|
WillDismissEvent,
|
|
41
35
|
WillFocusEvent,
|
|
42
36
|
WillPresentEvent,
|
|
43
|
-
DragBeginEvent,
|
|
44
|
-
DragChangeEvent,
|
|
45
|
-
DragEndEvent,
|
|
46
37
|
} from './TrueSheet.types';
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
* Uses a combination of ambient and key shadows for realistic depth.
|
|
66
|
-
*/
|
|
67
|
-
const getElevationShadow = (elevation: number): string => {
|
|
68
|
-
if (elevation <= 0) return 'none';
|
|
69
|
-
|
|
70
|
-
const ambientY = elevation * 0.5;
|
|
71
|
-
const ambientBlur = elevation * 1.5;
|
|
72
|
-
const ambientOpacity = 0.08 + elevation * 0.01;
|
|
73
|
-
|
|
74
|
-
const keyY = elevation;
|
|
75
|
-
const keyBlur = elevation * 2;
|
|
76
|
-
const keyOpacity = 0.12 + elevation * 0.02;
|
|
77
|
-
|
|
78
|
-
return `0px ${ambientY}px ${ambientBlur}px rgba(0, 0, 0, ${ambientOpacity}), 0px ${keyY}px ${keyBlur}px rgba(0, 0, 0, ${keyOpacity})`;
|
|
79
|
-
};
|
|
80
|
-
|
|
81
|
-
const renderSlot = (slot: TrueSheetProps['header'] | TrueSheetProps['footer']) => {
|
|
82
|
-
if (!slot) return null;
|
|
83
|
-
if (isValidElement(slot)) return slot;
|
|
84
|
-
return createElement(slot);
|
|
85
|
-
};
|
|
86
|
-
|
|
87
|
-
const TrueSheetComponent = forwardRef<TrueSheetRefMethods, TrueSheetProps>((props, ref) => {
|
|
38
|
+
import { usePortalContainer, useRegisterSheet, useSheetStack } from './TrueSheetProvider.web';
|
|
39
|
+
import {
|
|
40
|
+
COLOR_SURFACE_CONTAINER_LOW_DARK,
|
|
41
|
+
COLOR_SURFACE_CONTAINER_LOW_LIGHT,
|
|
42
|
+
DEFAULT_ANCHOR_OFFSET,
|
|
43
|
+
DEFAULT_CORNER_RADIUS,
|
|
44
|
+
DEFAULT_DETACHED_OFFSET,
|
|
45
|
+
DEFAULT_GRABBER_COLOR_DARK,
|
|
46
|
+
DEFAULT_GRABBER_COLOR_LIGHT,
|
|
47
|
+
DEFAULT_GRABBER_HEIGHT,
|
|
48
|
+
DEFAULT_GRABBER_TOP_MARGIN,
|
|
49
|
+
DEFAULT_GRABBER_WIDTH,
|
|
50
|
+
DEFAULT_FORM_SHEET_HEIGHT_RATIO,
|
|
51
|
+
DEFAULT_FORM_SHEET_WIDTH,
|
|
52
|
+
DEFAULT_MAX_WIDTH,
|
|
53
|
+
} from './web/constants';
|
|
54
|
+
|
|
55
|
+
const TrueSheetComponent = forwardRef<TrueSheetMethods, TrueSheetProps>((props, ref) => {
|
|
88
56
|
const {
|
|
57
|
+
children,
|
|
89
58
|
name,
|
|
90
|
-
detents = [0.5, 1],
|
|
91
59
|
dismissible = true,
|
|
92
60
|
draggable = true,
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
children,
|
|
96
|
-
scrollable = false,
|
|
97
|
-
initialDetentIndex = -1,
|
|
61
|
+
cornerRadius,
|
|
62
|
+
style,
|
|
98
63
|
backgroundColor: backgroundColorProp,
|
|
99
|
-
cornerRadius = DEFAULT_CORNER_RADIUS,
|
|
100
|
-
elevation = DEFAULT_ELEVATION,
|
|
101
|
-
grabber = true,
|
|
102
|
-
grabberOptions,
|
|
103
64
|
maxContentHeight,
|
|
104
65
|
maxContentWidth,
|
|
105
66
|
anchor = 'center',
|
|
106
67
|
anchorOffset = DEFAULT_ANCHOR_OFFSET,
|
|
68
|
+
grabber = true,
|
|
69
|
+
grabberOptions,
|
|
70
|
+
detents = [0.5, 1],
|
|
71
|
+
dimmed = true,
|
|
72
|
+
dimmedDetentIndex = 0,
|
|
73
|
+
initialDetentIndex = -1,
|
|
107
74
|
header,
|
|
108
75
|
headerStyle,
|
|
109
76
|
footer,
|
|
110
77
|
footerStyle,
|
|
111
|
-
|
|
78
|
+
scrollable = false,
|
|
79
|
+
scrollableOptions,
|
|
80
|
+
pageSizing = true,
|
|
81
|
+
detached = false,
|
|
82
|
+
detachedOffset = DEFAULT_DETACHED_OFFSET,
|
|
83
|
+
elevation = 4,
|
|
84
|
+
insetAdjustment = 'automatic',
|
|
85
|
+
initialDetentAnimated = true,
|
|
86
|
+
onPositionChange,
|
|
112
87
|
onWillPresent,
|
|
113
88
|
onDidPresent,
|
|
114
89
|
onWillDismiss,
|
|
115
90
|
onDidDismiss,
|
|
116
91
|
onDetentChange,
|
|
117
|
-
onPositionChange,
|
|
118
92
|
onDragBegin,
|
|
119
93
|
onDragChange,
|
|
120
94
|
onDragEnd,
|
|
95
|
+
onMount,
|
|
121
96
|
onWillFocus,
|
|
122
97
|
onDidFocus,
|
|
123
98
|
onWillBlur,
|
|
124
99
|
onDidBlur,
|
|
125
|
-
detached,
|
|
126
|
-
detachedOffset = DEFAULT_DETACHED_OFFSET,
|
|
127
|
-
stackBehavior = 'switch',
|
|
128
|
-
style,
|
|
129
100
|
} = props;
|
|
130
101
|
|
|
102
|
+
const validDetents = useMemo(
|
|
103
|
+
() => detents.filter((d): d is SheetDetent => typeof d === 'number' || d === 'auto'),
|
|
104
|
+
[detents]
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
const snapPointsProps = useMemo<
|
|
108
|
+
{ snapPoints: SheetDetent[]; fadeFromIndex: number } | { snapPoints?: undefined }
|
|
109
|
+
>(() => {
|
|
110
|
+
if (validDetents.length === 0) return {};
|
|
111
|
+
return {
|
|
112
|
+
snapPoints: validDetents,
|
|
113
|
+
fadeFromIndex: Math.min(dimmedDetentIndex, validDetents.length - 1),
|
|
114
|
+
};
|
|
115
|
+
}, [validDetents, dimmedDetentIndex]);
|
|
116
|
+
|
|
117
|
+
const { width: windowWidth, height: windowHeight } = useWindowDimensions();
|
|
118
|
+
const isLandscapeOrTablet = windowWidth >= 600 || windowWidth > windowHeight;
|
|
119
|
+
|
|
131
120
|
const colorScheme = useColorScheme();
|
|
132
121
|
const backgroundColor =
|
|
133
122
|
backgroundColorProp ??
|
|
134
123
|
(colorScheme === 'dark' ? COLOR_SURFACE_CONTAINER_LOW_DARK : COLOR_SURFACE_CONTAINER_LOW_LIGHT);
|
|
135
124
|
|
|
136
|
-
const
|
|
137
|
-
const
|
|
138
|
-
const
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
|
142
|
-
const bottomSheetRef = useRef<BottomSheet>(null);
|
|
143
|
-
const initialDetentIndexRef = useRef(initialDetentIndex);
|
|
144
|
-
const currentIndexRef = useRef(0);
|
|
145
|
-
const isPresenting = useRef(false);
|
|
146
|
-
const isDismissing = useRef(false);
|
|
147
|
-
const isMinimized = useRef(false);
|
|
148
|
-
const isDragging = useRef(false);
|
|
149
|
-
|
|
150
|
-
const presentResolver = useRef<(() => void) | null>(null);
|
|
151
|
-
const dismissResolver = useRef<(() => void) | null>(null);
|
|
152
|
-
|
|
153
|
-
const animatedPosition = useSharedValue(windowHeight);
|
|
154
|
-
const animatedIndex = useSharedValue(0);
|
|
155
|
-
|
|
156
|
-
const [snapIndex, setSnapIndex] = useState(initialDetentIndex);
|
|
157
|
-
const [isMounted, setIsMounted] = useState(false);
|
|
158
|
-
|
|
159
|
-
const isNonModal = stackBehavior === 'none';
|
|
160
|
-
|
|
161
|
-
useDerivedValue(() => {
|
|
162
|
-
onPositionChange?.({
|
|
163
|
-
nativeEvent: {
|
|
164
|
-
position: animatedPosition.value,
|
|
165
|
-
index: animatedIndex.value,
|
|
166
|
-
detent: detents[animatedIndex.value] ?? 0,
|
|
167
|
-
realtime: true,
|
|
168
|
-
},
|
|
169
|
-
} as PositionChangeEvent);
|
|
170
|
-
});
|
|
125
|
+
const shouldAutoPresent = initialDetentIndex >= 0 && initialDetentIndex < validDetents.length;
|
|
126
|
+
const [isOpen, setIsOpen] = useState(shouldAutoPresent);
|
|
127
|
+
const [activeSnapPoint, setActiveSnapPoint] = useState<SheetDetent | null>(
|
|
128
|
+
() => validDetents[shouldAutoPresent ? initialDetentIndex : 0] ?? null
|
|
129
|
+
);
|
|
171
130
|
|
|
172
|
-
|
|
131
|
+
// Keep activeSnapPoint valid if detents change (e.g., prop updates).
|
|
132
|
+
useEffect(() => {
|
|
133
|
+
if (validDetents.length === 0) return;
|
|
134
|
+
setActiveSnapPoint((current) =>
|
|
135
|
+
current != null && validDetents.includes(current) ? current : validDetents[0]!
|
|
136
|
+
);
|
|
137
|
+
}, [validDetents]);
|
|
138
|
+
|
|
139
|
+
const validDetentsRef = useRef(validDetents);
|
|
140
|
+
validDetentsRef.current = validDetents;
|
|
141
|
+
|
|
142
|
+
const handleSetActiveSnapPoint = useCallback((snapPoint: number | string | null) => {
|
|
143
|
+
setActiveSnapPoint(
|
|
144
|
+
snapPoint == null
|
|
145
|
+
? null
|
|
146
|
+
: typeof snapPoint === 'number' || snapPoint === 'auto'
|
|
147
|
+
? (snapPoint as SheetDetent)
|
|
148
|
+
: null
|
|
149
|
+
);
|
|
150
|
+
}, []);
|
|
173
151
|
|
|
174
|
-
const
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
[
|
|
152
|
+
const handleOpenChange = useCallback(
|
|
153
|
+
(open: boolean) => {
|
|
154
|
+
if (!open && isOpen) {
|
|
155
|
+
setIsOpen(false);
|
|
156
|
+
}
|
|
157
|
+
},
|
|
158
|
+
[isOpen]
|
|
181
159
|
);
|
|
182
160
|
|
|
183
|
-
const
|
|
184
|
-
(index: number, _position: number, _type: SNAP_POINT_TYPE) => {
|
|
185
|
-
const previousIndex = currentIndexRef.current;
|
|
186
|
-
currentIndexRef.current = index;
|
|
187
|
-
|
|
188
|
-
// Handle drag end
|
|
189
|
-
if (isDragging.current && !isPresenting.current) {
|
|
190
|
-
isDragging.current = false;
|
|
191
|
-
onDragEnd?.({
|
|
192
|
-
nativeEvent: {
|
|
193
|
-
index,
|
|
194
|
-
position: animatedPosition.value,
|
|
195
|
-
detent: detents[index] ?? 0,
|
|
196
|
-
},
|
|
197
|
-
} as DragEndEvent);
|
|
198
|
-
}
|
|
161
|
+
const portalContainer = usePortalContainer();
|
|
199
162
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
163
|
+
const handlePointerDownOutside = (e: Event) => {
|
|
164
|
+
const target = e.target;
|
|
165
|
+
if (!(target instanceof Node)) return;
|
|
166
|
+
// Pointer down that landed outside this sheet's portal container (e.g.,
|
|
167
|
+
// in another screen's tree when navigating) should not close the drawer.
|
|
168
|
+
if (portalContainer && !portalContainer.contains(target)) {
|
|
169
|
+
e.preventDefault();
|
|
170
|
+
}
|
|
171
|
+
};
|
|
209
172
|
|
|
210
|
-
|
|
211
|
-
isPresenting.current = false;
|
|
173
|
+
const dismissAboveRef = useRef<(animated?: boolean) => Promise<void>>(async () => {});
|
|
212
174
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
175
|
+
const methods = useMemo<TrueSheetMethods>(
|
|
176
|
+
() => ({
|
|
177
|
+
present: async (index = 0) => {
|
|
178
|
+
const detent = validDetentsRef.current[index];
|
|
179
|
+
if (detent === undefined) {
|
|
180
|
+
throw new Error(
|
|
181
|
+
`TrueSheet: present index (${index}) is out of bounds. detents array has ${validDetentsRef.current.length} item(s)`
|
|
182
|
+
);
|
|
217
183
|
}
|
|
184
|
+
setActiveSnapPoint(detent);
|
|
185
|
+
setIsOpen(true);
|
|
186
|
+
},
|
|
187
|
+
dismiss: async () => {
|
|
188
|
+
setIsOpen(false);
|
|
189
|
+
},
|
|
190
|
+
resize: async (index) => {
|
|
191
|
+
const detent = validDetentsRef.current[index];
|
|
192
|
+
if (detent === undefined) {
|
|
193
|
+
throw new Error(
|
|
194
|
+
`TrueSheet: resize index (${index}) is out of bounds. detents array has ${validDetentsRef.current.length} item(s)`
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
setActiveSnapPoint(detent);
|
|
198
|
+
},
|
|
199
|
+
dismissStack: async (animated) => {
|
|
200
|
+
await dismissAboveRef.current(animated);
|
|
201
|
+
},
|
|
202
|
+
}),
|
|
203
|
+
[]
|
|
204
|
+
);
|
|
205
|
+
|
|
206
|
+
useImperativeHandle(ref, () => methods, [methods]);
|
|
207
|
+
|
|
208
|
+
const methodsRef = useRef<TrueSheetMethods | null>(methods);
|
|
209
|
+
useRegisterSheet(name, methodsRef);
|
|
210
|
+
|
|
211
|
+
const drawerContentRef = useRef<HTMLDivElement | null>(null);
|
|
212
|
+
|
|
213
|
+
// Present/dismiss events. The sheet settles via a CSS `transform` transition
|
|
214
|
+
// on either the drawer (snap-points on autopresent) or the wrapper (whole-
|
|
215
|
+
// card slide on reopen/dismiss). `Animation.finished` from the Web Animations
|
|
216
|
+
// API tracks whichever is actually running — reflects what the browser is
|
|
217
|
+
// doing, doesn't miss when no transition runs (same-value change), and
|
|
218
|
+
// handles interruptions correctly (a drag/resnap mid-present resolves only
|
|
219
|
+
// once all transform animations drain).
|
|
220
|
+
const onWillPresentRef = useRef(onWillPresent);
|
|
221
|
+
const onDidPresentRef = useRef(onDidPresent);
|
|
222
|
+
const onWillDismissRef = useRef(onWillDismiss);
|
|
223
|
+
const onDidDismissRef = useRef(onDidDismiss);
|
|
224
|
+
const onDetentChangeRef = useRef(onDetentChange);
|
|
225
|
+
const onDragBeginRef = useRef(onDragBegin);
|
|
226
|
+
const onDragChangeRef = useRef(onDragChange);
|
|
227
|
+
const onDragEndRef = useRef(onDragEnd);
|
|
228
|
+
const onPositionChangeRef = useRef(onPositionChange);
|
|
229
|
+
const activeSnapPointRef = useRef(activeSnapPoint);
|
|
230
|
+
useEffect(() => {
|
|
231
|
+
onWillPresentRef.current = onWillPresent;
|
|
232
|
+
onDidPresentRef.current = onDidPresent;
|
|
233
|
+
onWillDismissRef.current = onWillDismiss;
|
|
234
|
+
onDidDismissRef.current = onDidDismiss;
|
|
235
|
+
onDetentChangeRef.current = onDetentChange;
|
|
236
|
+
onDragBeginRef.current = onDragBegin;
|
|
237
|
+
onDragChangeRef.current = onDragChange;
|
|
238
|
+
onDragEndRef.current = onDragEnd;
|
|
239
|
+
onPositionChangeRef.current = onPositionChange;
|
|
240
|
+
activeSnapPointRef.current = activeSnapPoint;
|
|
241
|
+
});
|
|
218
242
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
243
|
+
const computeDetentInfo = useCallback((): DetentInfoEventPayload => {
|
|
244
|
+
const snap = activeSnapPointRef.current;
|
|
245
|
+
const index = snap != null ? validDetentsRef.current.indexOf(snap) : -1;
|
|
246
|
+
const position = drawerContentRef.current?.getBoundingClientRect().top ?? 0;
|
|
247
|
+
const detent = typeof snap === 'number' ? snap : 0;
|
|
248
|
+
return { index, position, detent };
|
|
249
|
+
}, []);
|
|
226
250
|
|
|
227
|
-
|
|
251
|
+
// Mirror Android: interpolate fractional index and detent from the drawer's
|
|
252
|
+
// top-Y so continuous position updates (drag, animation) carry smooth values
|
|
253
|
+
// between detent boundaries. Numeric detent d → top-Y = (1 - d) * effectiveH.
|
|
254
|
+
// 'auto' resolves to the [data-vaul-auto-size-wrapper] element's measured
|
|
255
|
+
// offsetHeight — same signal vaul uses to compute its snap offset.
|
|
256
|
+
const interpolateFromPosition = useCallback(
|
|
257
|
+
(position: number): { index: number; detent: number } => {
|
|
258
|
+
const snaps = validDetentsRef.current;
|
|
259
|
+
const count = snaps.length;
|
|
260
|
+
if (count === 0) return { index: -1, detent: 0 };
|
|
261
|
+
|
|
262
|
+
const windowH = window.innerHeight;
|
|
263
|
+
const effectiveH = detached ? windowH - detachedOffset : windowH;
|
|
264
|
+
// Matches vaul's height ceiling: min(effectiveH, maxContentHeight).
|
|
265
|
+
const ceiling =
|
|
266
|
+
maxContentHeight !== undefined ? Math.min(effectiveH, maxContentHeight) : effectiveH;
|
|
267
|
+
|
|
268
|
+
const autoWrapper = drawerContentRef.current?.querySelector<HTMLElement>(
|
|
269
|
+
'[data-vaul-auto-size-wrapper]'
|
|
270
|
+
);
|
|
271
|
+
const autoHeight = Math.min(autoWrapper?.offsetHeight ?? ceiling / 2, ceiling);
|
|
272
|
+
|
|
273
|
+
const positions: number[] = [];
|
|
274
|
+
const values: number[] = [];
|
|
275
|
+
for (let i = 0; i < count; i++) {
|
|
276
|
+
const d = snaps[i];
|
|
277
|
+
if (typeof d === 'number') {
|
|
278
|
+
const h = Math.min(d * effectiveH, ceiling);
|
|
279
|
+
positions.push(effectiveH - h);
|
|
280
|
+
values.push(effectiveH > 0 ? h / effectiveH : 0);
|
|
281
|
+
} else {
|
|
282
|
+
positions.push(effectiveH - autoHeight);
|
|
283
|
+
values.push(effectiveH > 0 ? autoHeight / effectiveH : 0);
|
|
284
|
+
}
|
|
228
285
|
}
|
|
229
286
|
|
|
230
|
-
//
|
|
231
|
-
|
|
232
|
-
|
|
287
|
+
// Absorb subpixel drift from getBoundingClientRect so at-rest positions
|
|
288
|
+
// don't sneak into the below-first branch and emit near-zero negatives
|
|
289
|
+
// like `-1e-8` (which render as "-1" via JS scientific-notation toString).
|
|
290
|
+
const epsilon = 0.5;
|
|
291
|
+
const firstPos = positions[0]!;
|
|
292
|
+
const lastPos = positions[count - 1]!;
|
|
293
|
+
|
|
294
|
+
if (position > firstPos + epsilon) {
|
|
295
|
+
// Two ranges: index spans the full animation (windowH of wrapper
|
|
296
|
+
// travel) so it's smooth for driving dependent animations end-to-end;
|
|
297
|
+
// detent tracks the sheet's visible-height ratio (windowH - firstPos)
|
|
298
|
+
// so its 0–values[0] fade has fine resolution while the sheet is still
|
|
299
|
+
// in view. Both clamp to keep outputs in [-1, 0].
|
|
300
|
+
const indexRaw = (position - firstPos) / windowH;
|
|
301
|
+
const detentRaw = (position - firstPos) / Math.max(1, windowH - firstPos);
|
|
302
|
+
const indexProgress = Math.max(0, Math.min(1, indexRaw));
|
|
303
|
+
const detentProgress = Math.max(0, Math.min(1, detentRaw));
|
|
304
|
+
return {
|
|
305
|
+
index: -indexProgress,
|
|
306
|
+
detent: Math.max(0, values[0]! * (1 - detentProgress)),
|
|
307
|
+
};
|
|
233
308
|
}
|
|
234
309
|
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
310
|
+
if (count === 1) return { index: 0, detent: values[0]! };
|
|
311
|
+
|
|
312
|
+
// Clamp into the segment range so subpixel drift at the boundaries
|
|
313
|
+
// resolves cleanly to the nearest segment edge (index 0 or count-1).
|
|
314
|
+
const clamped = Math.max(lastPos, Math.min(firstPos, position));
|
|
315
|
+
|
|
316
|
+
for (let i = 0; i < count - 1; i++) {
|
|
317
|
+
const pos = positions[i]!;
|
|
318
|
+
const nextPos = positions[i + 1]!;
|
|
319
|
+
if (clamped >= nextPos && clamped <= pos) {
|
|
320
|
+
const range = pos - nextPos;
|
|
321
|
+
const progress = range > 0 ? (pos - clamped) / range : 0;
|
|
322
|
+
const clampedProgress = Math.max(0, Math.min(1, progress));
|
|
323
|
+
return {
|
|
324
|
+
index: i + clampedProgress,
|
|
325
|
+
detent: values[i]! + clampedProgress * (values[i + 1]! - values[i]!),
|
|
326
|
+
};
|
|
327
|
+
}
|
|
239
328
|
}
|
|
329
|
+
|
|
330
|
+
return { index: count - 1, detent: values[count - 1]! };
|
|
240
331
|
},
|
|
241
|
-
[
|
|
332
|
+
[detached, detachedOffset, maxContentHeight]
|
|
242
333
|
);
|
|
243
334
|
|
|
244
|
-
const
|
|
245
|
-
|
|
246
|
-
|
|
335
|
+
const handlePositionChange = useCallback(
|
|
336
|
+
(position: number) => {
|
|
337
|
+
const { index, detent } = interpolateFromPosition(position);
|
|
338
|
+
onPositionChangeRef.current?.({
|
|
339
|
+
nativeEvent: { index, position, detent, realtime: true },
|
|
340
|
+
} as PositionChangeEvent);
|
|
341
|
+
},
|
|
342
|
+
[interpolateFromPosition]
|
|
343
|
+
);
|
|
247
344
|
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
345
|
+
// Fire onMount once after first render. React-mount is the earliest point
|
|
346
|
+
// the component is ready for imperative calls, matching the native
|
|
347
|
+
// "ready for present" contract. Declared before the present/dismiss effect
|
|
348
|
+
// so it fires first during autopresent (onMount → onWillPresent).
|
|
349
|
+
const onMountRef = useRef(onMount);
|
|
350
|
+
useEffect(() => {
|
|
351
|
+
onMountRef.current = onMount;
|
|
352
|
+
});
|
|
353
|
+
useEffect(() => {
|
|
354
|
+
onMountRef.current?.({ nativeEvent: null } as MountEvent);
|
|
355
|
+
}, []);
|
|
356
|
+
|
|
357
|
+
// Start at `false` so a mount with `isOpen=true` (autopresent via
|
|
358
|
+
// `initialDetentIndex`) is detected as a false→true transition and fires
|
|
359
|
+
// `onWillPresent`.
|
|
360
|
+
const wasOpenRef = useRef(false);
|
|
361
|
+
useEffect(() => {
|
|
362
|
+
const wasOpen = wasOpenRef.current;
|
|
363
|
+
wasOpenRef.current = isOpen;
|
|
364
|
+
|
|
365
|
+
if (!isOpen && !wasOpen) return undefined;
|
|
366
|
+
|
|
367
|
+
const present = !wasOpen && isOpen;
|
|
368
|
+
if (present) {
|
|
369
|
+
onWillPresentRef.current?.({ nativeEvent: computeDetentInfo() } as WillPresentEvent);
|
|
370
|
+
} else if (wasOpen && !isOpen) {
|
|
371
|
+
onWillDismissRef.current?.({ nativeEvent: null } as WillDismissEvent);
|
|
372
|
+
} else {
|
|
373
|
+
return undefined;
|
|
252
374
|
}
|
|
253
375
|
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
isDragging.current = false;
|
|
260
|
-
}, [sheetName]);
|
|
261
|
-
|
|
262
|
-
const handleAnimate = useCallback(
|
|
263
|
-
(_fromIndex: number, toIndex: number) => {
|
|
264
|
-
// Detect drag begin (when not presenting or dismissing)
|
|
265
|
-
if (!isPresenting.current && !isDismissing.current && !isDragging.current && toIndex >= 0) {
|
|
266
|
-
isDragging.current = true;
|
|
267
|
-
onDragBegin?.({
|
|
268
|
-
nativeEvent: {
|
|
269
|
-
index: currentIndexRef.current,
|
|
270
|
-
position: animatedPosition.value,
|
|
271
|
-
detent: detents[currentIndexRef.current] ?? 0,
|
|
272
|
-
},
|
|
273
|
-
} as DragBeginEvent);
|
|
376
|
+
const fireDone = () => {
|
|
377
|
+
if (present) {
|
|
378
|
+
onDidPresentRef.current?.({ nativeEvent: computeDetentInfo() } as DidPresentEvent);
|
|
379
|
+
} else {
|
|
380
|
+
onDidDismissRef.current?.({ nativeEvent: null } as DidDismissEvent);
|
|
274
381
|
}
|
|
382
|
+
};
|
|
275
383
|
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
384
|
+
let canceled = false;
|
|
385
|
+
let rafId = 0;
|
|
386
|
+
|
|
387
|
+
const start = () => {
|
|
388
|
+
if (canceled) return;
|
|
389
|
+
const drawer = drawerContentRef.current;
|
|
390
|
+
if (!drawer) {
|
|
391
|
+
// Drawer hasn't mounted yet (Radix Presence defers the portal mount
|
|
392
|
+
// past the first effect pass). Poll until the ref is populated.
|
|
393
|
+
rafId = window.requestAnimationFrame(start);
|
|
394
|
+
return;
|
|
285
395
|
}
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
396
|
+
const wrapper = drawer.closest<HTMLElement>('[data-vaul-detached-wrapper]') ?? null;
|
|
397
|
+
const targets = wrapper ? [drawer, wrapper] : [drawer];
|
|
398
|
+
|
|
399
|
+
const waitForSettle = (): void => {
|
|
400
|
+
if (canceled) return;
|
|
401
|
+
// Force style recalc so transitions queued by vaul's effects this
|
|
402
|
+
// commit are registered in `getAnimations()`. RAF callbacks run BEFORE
|
|
403
|
+
// the frame's style recalc, and ignoring this returns a stale empty
|
|
404
|
+
// list — we'd fire `did` immediately with nothing queued.
|
|
405
|
+
|
|
406
|
+
drawer.offsetHeight;
|
|
407
|
+
const pending = targets.flatMap((el) =>
|
|
408
|
+
el.getAnimations().filter((a) => a.playState !== 'finished')
|
|
409
|
+
);
|
|
410
|
+
if (pending.length === 0) {
|
|
411
|
+
fireDone();
|
|
412
|
+
return;
|
|
292
413
|
}
|
|
414
|
+
// allSettled: resolve even when a transition is canceled (drag /
|
|
415
|
+
// resnap), then re-check — a replacement transition may have started.
|
|
416
|
+
Promise.allSettled(pending.map((a) => a.finished)).then(() => {
|
|
417
|
+
if (!canceled) waitForSettle();
|
|
418
|
+
});
|
|
419
|
+
};
|
|
420
|
+
|
|
421
|
+
waitForSettle();
|
|
422
|
+
};
|
|
293
423
|
|
|
294
|
-
|
|
295
|
-
nativeEvent: {
|
|
296
|
-
index: toIndex,
|
|
297
|
-
position: animatedPosition.value,
|
|
298
|
-
detent: detents[toIndex] ?? 0,
|
|
299
|
-
},
|
|
300
|
-
} as WillPresentEvent);
|
|
424
|
+
rafId = window.requestAnimationFrame(start);
|
|
301
425
|
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
426
|
+
return () => {
|
|
427
|
+
canceled = true;
|
|
428
|
+
window.cancelAnimationFrame(rafId);
|
|
429
|
+
};
|
|
430
|
+
}, [isOpen, computeDetentInfo]);
|
|
305
431
|
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
432
|
+
// Fire onDetentChange only while open→open. Present/dismiss have their own
|
|
433
|
+
// events and carry detent info via onDidPresent, so we skip those edges.
|
|
434
|
+
const detentChangeStateRef = useRef({ isOpen, activeSnapPoint });
|
|
435
|
+
useEffect(() => {
|
|
436
|
+
const prev = detentChangeStateRef.current;
|
|
437
|
+
detentChangeStateRef.current = { isOpen, activeSnapPoint };
|
|
438
|
+
if (!prev.isOpen || !isOpen) return;
|
|
439
|
+
if (prev.activeSnapPoint === activeSnapPoint) return;
|
|
440
|
+
onDetentChangeRef.current?.({ nativeEvent: computeDetentInfo() } as DetentChangeEvent);
|
|
441
|
+
}, [isOpen, activeSnapPoint, computeDetentInfo]);
|
|
442
|
+
|
|
443
|
+
// Vaul's `onDrag` fires once per pointermove while dragging; the first tick
|
|
444
|
+
// after an idle gap marks the drag boundary, so track it via a ref.
|
|
445
|
+
const isDraggingRef = useRef(false);
|
|
446
|
+
const handleDrag = useCallback(() => {
|
|
447
|
+
if (!isDraggingRef.current) {
|
|
448
|
+
isDraggingRef.current = true;
|
|
449
|
+
onDragBeginRef.current?.({ nativeEvent: computeDetentInfo() } as DragBeginEvent);
|
|
450
|
+
}
|
|
451
|
+
onDragChangeRef.current?.({ nativeEvent: computeDetentInfo() } as DragChangeEvent);
|
|
452
|
+
}, [computeDetentInfo]);
|
|
453
|
+
const handleRelease = useCallback(() => {
|
|
454
|
+
if (!isDraggingRef.current) return;
|
|
455
|
+
isDraggingRef.current = false;
|
|
456
|
+
onDragEndRef.current?.({ nativeEvent: computeDetentInfo() } as DragEndEvent);
|
|
457
|
+
}, [computeDetentInfo]);
|
|
458
|
+
|
|
459
|
+
const { isNested, dismissAbove, descendants } = useSheetStack(
|
|
460
|
+
methodsRef,
|
|
461
|
+
drawerContentRef,
|
|
462
|
+
isOpen
|
|
463
|
+
);
|
|
464
|
+
dismissAboveRef.current = dismissAbove;
|
|
310
465
|
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
466
|
+
// Mirror Android: translate this sheet down to match the deepest descendant's
|
|
467
|
+
// top so the whole stack visually aligns. Cascades because every ancestor
|
|
468
|
+
// re-runs whenever the stack (and thus its descendants) changes.
|
|
469
|
+
useEffect(() => {
|
|
470
|
+
const parent = drawerContentRef.current;
|
|
471
|
+
if (!parent) return;
|
|
314
472
|
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
473
|
+
const transition = `transform ${TRANSITIONS.DURATION}s cubic-bezier(${TRANSITIONS.EASE.join(',')})`;
|
|
474
|
+
|
|
475
|
+
if (descendants.length === 0) {
|
|
476
|
+
parent.style.transition = transition;
|
|
477
|
+
parent.style.transform = '';
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
const computeTargetY = () => {
|
|
482
|
+
const parentSnap = parseFloat(parent.style.getPropertyValue('--snap-point-height')) || 0;
|
|
483
|
+
let targetY = parentSnap;
|
|
484
|
+
for (const d of descendants) {
|
|
485
|
+
const node = d.nodeRef.current;
|
|
486
|
+
if (!node) continue;
|
|
487
|
+
const snap = parseFloat(node.style.getPropertyValue('--snap-point-height')) || 0;
|
|
488
|
+
if (snap > targetY) targetY = snap;
|
|
320
489
|
}
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
490
|
+
return targetY;
|
|
491
|
+
};
|
|
492
|
+
|
|
493
|
+
const apply = () => {
|
|
494
|
+
const targetY = computeTargetY();
|
|
495
|
+
const match = parent.style.transform.match(/translate3d\([^,]*,\s*(-?\d*\.?\d+)px/);
|
|
496
|
+
const currentY = match ? parseFloat(match[1]!) : 0;
|
|
497
|
+
if (Math.abs(currentY - targetY) < 0.5) return;
|
|
498
|
+
parent.style.transition = transition;
|
|
499
|
+
parent.style.transform = `translate3d(0, ${targetY}px, 0)`;
|
|
500
|
+
};
|
|
501
|
+
|
|
502
|
+
const raf = requestAnimationFrame(apply);
|
|
503
|
+
// Vaul re-runs snapToPoint on window resize (e.g., mobile keyboard open)
|
|
504
|
+
// which clobbers the cascade transform. Re-apply whenever the parent's
|
|
505
|
+
// inline style changes.
|
|
506
|
+
const observer = new MutationObserver(apply);
|
|
507
|
+
observer.observe(parent, { attributes: true, attributeFilter: ['style'] });
|
|
508
|
+
|
|
509
|
+
return () => {
|
|
510
|
+
cancelAnimationFrame(raf);
|
|
511
|
+
observer.disconnect();
|
|
512
|
+
};
|
|
513
|
+
}, [descendants, activeSnapPoint]);
|
|
514
|
+
|
|
515
|
+
// Focus/blur events fire when a descendant sheet appears on top of this one
|
|
516
|
+
// (blur) or when all descendants are dismissed (focus). will-events fire
|
|
517
|
+
// synchronously at the transition boundary; did-events fire once the cascade
|
|
518
|
+
// transform drains. Intermediate count changes (1↔2) don't re-fire — this
|
|
519
|
+
// sheet stays blurred throughout.
|
|
520
|
+
const onWillBlurRef = useRef(onWillBlur);
|
|
521
|
+
const onDidBlurRef = useRef(onDidBlur);
|
|
522
|
+
const onWillFocusRef = useRef(onWillFocus);
|
|
523
|
+
const onDidFocusRef = useRef(onDidFocus);
|
|
524
|
+
useEffect(() => {
|
|
525
|
+
onWillBlurRef.current = onWillBlur;
|
|
526
|
+
onDidBlurRef.current = onDidBlur;
|
|
527
|
+
onWillFocusRef.current = onWillFocus;
|
|
528
|
+
onDidFocusRef.current = onDidFocus;
|
|
529
|
+
});
|
|
324
530
|
|
|
325
|
-
const
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
531
|
+
const prevDescendantCountRef = useRef(0);
|
|
532
|
+
useEffect(() => {
|
|
533
|
+
const prevCount = prevDescendantCountRef.current;
|
|
534
|
+
const count = descendants.length;
|
|
535
|
+
prevDescendantCountRef.current = count;
|
|
536
|
+
if (!isOpen) return;
|
|
537
|
+
const gained = count > 0 && prevCount === 0;
|
|
538
|
+
const lost = count === 0 && prevCount > 0;
|
|
539
|
+
if (!gained && !lost) return;
|
|
540
|
+
|
|
541
|
+
if (gained) {
|
|
542
|
+
onWillBlurRef.current?.({ nativeEvent: null } as WillBlurEvent);
|
|
543
|
+
} else {
|
|
544
|
+
onWillFocusRef.current?.({ nativeEvent: null } as WillFocusEvent);
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
const drawer = drawerContentRef.current;
|
|
548
|
+
if (!drawer) return;
|
|
549
|
+
|
|
550
|
+
let canceled = false;
|
|
551
|
+
const fireDone = () => {
|
|
552
|
+
if (canceled) return;
|
|
553
|
+
if (gained) onDidBlurRef.current?.({ nativeEvent: null } as DidBlurEvent);
|
|
554
|
+
else onDidFocusRef.current?.({ nativeEvent: null } as DidFocusEvent);
|
|
555
|
+
};
|
|
556
|
+
const rafId = window.requestAnimationFrame(() => {
|
|
557
|
+
if (canceled) return;
|
|
558
|
+
// Force style recalc so the cascade effect's queued transform registers.
|
|
559
|
+
|
|
560
|
+
drawer.offsetHeight;
|
|
561
|
+
const pending = drawer.getAnimations().filter((a) => a.playState !== 'finished');
|
|
562
|
+
if (pending.length === 0) {
|
|
563
|
+
fireDone();
|
|
564
|
+
return;
|
|
329
565
|
}
|
|
566
|
+
Promise.allSettled(pending.map((a) => a.finished)).then(() => {
|
|
567
|
+
if (!canceled) fireDone();
|
|
568
|
+
});
|
|
569
|
+
});
|
|
330
570
|
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
return (
|
|
339
|
-
<BottomSheetBackdrop
|
|
340
|
-
{...backdropProps}
|
|
341
|
-
opacity={0.5}
|
|
342
|
-
appearsOnIndex={dimmedDetentIndex}
|
|
343
|
-
disappearsOnIndex={dimmedDetentIndex - 1}
|
|
344
|
-
pressBehavior={pressBehavior}
|
|
345
|
-
/>
|
|
346
|
-
);
|
|
347
|
-
},
|
|
348
|
-
[dimmed, dimmedDetentIndex, dismissible]
|
|
349
|
-
);
|
|
571
|
+
return () => {
|
|
572
|
+
canceled = true;
|
|
573
|
+
window.cancelAnimationFrame(rafId);
|
|
574
|
+
};
|
|
575
|
+
}, [isOpen, descendants.length]);
|
|
576
|
+
|
|
577
|
+
const effectiveCornerRadius = cornerRadius ?? DEFAULT_CORNER_RADIUS;
|
|
350
578
|
|
|
351
|
-
|
|
579
|
+
// Shadow cast upward from the sheet's top edge toward the background. Matches
|
|
580
|
+
// Android's `elevation` semantics roughly — the sheet "lifts" off whatever is
|
|
581
|
+
// behind it. Scales linearly so higher elevation reads as more separation.
|
|
582
|
+
const boxShadow =
|
|
583
|
+
elevation > 0 ? `0 ${-elevation}px ${elevation * 3}px rgba(0, 0, 0, 0.15)` : undefined;
|
|
352
584
|
|
|
353
|
-
const
|
|
354
|
-
() =>
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
585
|
+
const mergedContentStyle = useMemo<React.CSSProperties>(
|
|
586
|
+
() => ({
|
|
587
|
+
position: 'fixed',
|
|
588
|
+
top: 0,
|
|
589
|
+
left: 0,
|
|
590
|
+
right: 0,
|
|
591
|
+
bottom: 0,
|
|
592
|
+
display: 'flex',
|
|
593
|
+
flexDirection: 'column',
|
|
594
|
+
borderTopLeftRadius: effectiveCornerRadius,
|
|
595
|
+
borderTopRightRadius: effectiveCornerRadius,
|
|
596
|
+
backgroundColor: backgroundColor as string,
|
|
597
|
+
boxShadow,
|
|
598
|
+
// Lift content above iOS home indicator / bottom safe area when enabled.
|
|
599
|
+
paddingBottom: insetAdjustment === 'automatic' ? 'env(safe-area-inset-bottom, 0px)' : 0,
|
|
600
|
+
}),
|
|
601
|
+
[backgroundColor, effectiveCornerRadius, boxShadow, insetAdjustment]
|
|
359
602
|
);
|
|
360
603
|
|
|
361
604
|
const defaultGrabberColor =
|
|
362
605
|
colorScheme === 'dark' ? DEFAULT_GRABBER_COLOR_DARK : DEFAULT_GRABBER_COLOR_LIGHT;
|
|
363
606
|
|
|
364
|
-
const
|
|
607
|
+
const grabberHeight = grabberOptions?.height ?? DEFAULT_GRABBER_HEIGHT;
|
|
608
|
+
|
|
609
|
+
// Footer is rendered inside the wrapper via `detachedSiblings`, so it
|
|
610
|
+
// follows the wrapper on dismiss and drag-overshoot. Positioning is
|
|
611
|
+
// relative to the wrapper (contain: paint creates the containing block).
|
|
612
|
+
const footerFloatStyle = useMemo<React.CSSProperties>(
|
|
365
613
|
() => ({
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
614
|
+
position: 'fixed',
|
|
615
|
+
left: 0,
|
|
616
|
+
right: 0,
|
|
617
|
+
bottom: 0,
|
|
618
|
+
// Wrapper has `pointer-events: none` to let clicks fall through; the
|
|
619
|
+
// footer must opt back in.
|
|
620
|
+
pointerEvents: 'auto',
|
|
370
621
|
}),
|
|
371
|
-
[
|
|
372
|
-
);
|
|
373
|
-
|
|
374
|
-
const footerComponent = useMemo(
|
|
375
|
-
() =>
|
|
376
|
-
footer
|
|
377
|
-
? (footerProps: BottomSheetFooterProps) => (
|
|
378
|
-
<BottomSheetFooter
|
|
379
|
-
style={StyleSheet.flatten([styles.footer, footerStyle])}
|
|
380
|
-
{...footerProps}
|
|
381
|
-
>
|
|
382
|
-
{renderSlot(footer)}
|
|
383
|
-
</BottomSheetFooter>
|
|
384
|
-
)
|
|
385
|
-
: undefined,
|
|
386
|
-
[footer, footerStyle]
|
|
622
|
+
[]
|
|
387
623
|
);
|
|
388
624
|
|
|
389
|
-
//
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
}
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
bottomSheetRef.current?.snapToIndex(index);
|
|
434
|
-
} else {
|
|
435
|
-
bottomSheetModalRef.current?.snapToIndex(index);
|
|
436
|
-
}
|
|
437
|
-
},
|
|
438
|
-
});
|
|
439
|
-
|
|
440
|
-
useImperativeHandle(ref, () => sheetMethodsRef.current);
|
|
441
|
-
|
|
442
|
-
// Register with context provider
|
|
443
|
-
useEffect(() => {
|
|
444
|
-
bottomSheetContext?.register(sheetName, sheetMethodsRef);
|
|
445
|
-
return () => {
|
|
446
|
-
bottomSheetContext?.unregister(sheetName);
|
|
447
|
-
};
|
|
448
|
-
}, [sheetName]);
|
|
449
|
-
|
|
450
|
-
// Auto-present on mount if initialDetentIndex is set
|
|
451
|
-
useEffect(() => {
|
|
452
|
-
if (initialDetentIndexRef.current >= 0) {
|
|
453
|
-
sheetMethodsRef.current.present(initialDetentIndexRef.current);
|
|
625
|
+
// Form-sheet style (iOS pageSizing=false): centered floating card with a
|
|
626
|
+
// default width and a height capped to a fraction of the window. We reuse
|
|
627
|
+
// the existing detached mechanic so drag/snap math stays correct — the
|
|
628
|
+
// wrapper is bottom-attached with a computed offset that centers it
|
|
629
|
+
// vertically.
|
|
630
|
+
const isFormSheet = isLandscapeOrTablet && maxContentWidth == null && !pageSizing;
|
|
631
|
+
|
|
632
|
+
const effectiveMaxContentHeight =
|
|
633
|
+
maxContentHeight ?? (isFormSheet ? windowHeight * DEFAULT_FORM_SHEET_HEIGHT_RATIO : undefined);
|
|
634
|
+
|
|
635
|
+
const effectiveDetached = isFormSheet || detached;
|
|
636
|
+
|
|
637
|
+
const effectiveDetachedOffset = isFormSheet
|
|
638
|
+
? Math.max(0, (windowHeight - (effectiveMaxContentHeight ?? 0)) / 2)
|
|
639
|
+
: detachedOffset;
|
|
640
|
+
|
|
641
|
+
// The wrapper holds all horizontal sizing/anchoring so its rounded-bottom
|
|
642
|
+
// clip (when detached) aligns with the drawer's horizontal bounds on
|
|
643
|
+
// desktop — otherwise its corners sit at the far viewport edges.
|
|
644
|
+
// Mirrors iOS setupSheetSizing: maxContentWidth wins (forces pageSizing
|
|
645
|
+
// off). pageSizing on → constrain to readable width (page-sheet);
|
|
646
|
+
// pageSizing off with no maxContentWidth → form-sheet width.
|
|
647
|
+
// Detached without a width constraint applies anchorOffset on both edges so
|
|
648
|
+
// the floating card breathes from the viewport sides.
|
|
649
|
+
const wrapperStyle = useMemo<React.CSSProperties | undefined>(() => {
|
|
650
|
+
const maxWidth = isLandscapeOrTablet
|
|
651
|
+
? isFormSheet
|
|
652
|
+
? DEFAULT_FORM_SHEET_WIDTH
|
|
653
|
+
: (maxContentWidth ?? (pageSizing ? DEFAULT_MAX_WIDTH : undefined))
|
|
654
|
+
: undefined;
|
|
655
|
+
|
|
656
|
+
if (maxWidth == null && !detached) return undefined;
|
|
657
|
+
|
|
658
|
+
let marginLeft: number | string;
|
|
659
|
+
let marginRight: number | string;
|
|
660
|
+
if (isFormSheet) {
|
|
661
|
+
marginLeft = 'auto';
|
|
662
|
+
marginRight = 'auto';
|
|
663
|
+
} else if (maxWidth == null) {
|
|
664
|
+
marginLeft = anchorOffset;
|
|
665
|
+
marginRight = anchorOffset;
|
|
666
|
+
} else {
|
|
667
|
+
marginLeft = anchor === 'left' ? anchorOffset : 'auto';
|
|
668
|
+
marginRight = anchor === 'right' ? anchorOffset : 'auto';
|
|
454
669
|
}
|
|
455
|
-
}, []);
|
|
456
670
|
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
borderTopLeftRadius: cornerRadius,
|
|
470
|
-
borderTopRightRadius: cornerRadius,
|
|
471
|
-
borderBottomLeftRadius: detached ? cornerRadius : 0,
|
|
472
|
-
borderBottomRightRadius: detached ? cornerRadius : 0,
|
|
473
|
-
boxShadow: getElevationShadow(elevation),
|
|
474
|
-
maxWidth: isLandscapeOrTablet ? (maxContentWidth ?? DEFAULT_MAX_WIDTH) : undefined,
|
|
475
|
-
marginLeft: isLandscapeOrTablet ? (anchor === 'left' ? anchorOffset : 'auto') : undefined,
|
|
476
|
-
marginRight: isLandscapeOrTablet ? (anchor === 'right' ? anchorOffset : 'auto') : undefined,
|
|
477
|
-
marginHorizontal: detached ? anchorOffset : undefined,
|
|
478
|
-
},
|
|
479
|
-
],
|
|
480
|
-
backgroundComponent: null,
|
|
481
|
-
index: snapIndex,
|
|
482
|
-
enablePanDownToClose: dismissible,
|
|
483
|
-
enableContentPanningGesture: draggable,
|
|
484
|
-
enableHandlePanningGesture: draggable,
|
|
485
|
-
animatedPosition,
|
|
486
|
-
animatedIndex,
|
|
487
|
-
handleStyle,
|
|
488
|
-
handleIndicatorStyle,
|
|
489
|
-
onChange: handleChange,
|
|
490
|
-
onAnimate: handleAnimate,
|
|
491
|
-
enableDynamicSizing: hasAutoDetent,
|
|
492
|
-
maxDynamicContentSize: maxContentHeight,
|
|
493
|
-
snapPoints: snapPoints.length > 0 ? snapPoints : undefined,
|
|
671
|
+
return {
|
|
672
|
+
...(maxWidth != null && { maxWidth }),
|
|
673
|
+
marginLeft,
|
|
674
|
+
marginRight,
|
|
675
|
+
};
|
|
676
|
+
}, [
|
|
677
|
+
isLandscapeOrTablet,
|
|
678
|
+
isFormSheet,
|
|
679
|
+
maxContentWidth,
|
|
680
|
+
pageSizing,
|
|
681
|
+
anchor,
|
|
682
|
+
anchorOffset,
|
|
494
683
|
detached,
|
|
495
|
-
|
|
496
|
-
backdropComponent,
|
|
497
|
-
footerComponent,
|
|
498
|
-
};
|
|
684
|
+
]);
|
|
499
685
|
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
686
|
+
const handleStyle = useMemo<React.CSSProperties>(
|
|
687
|
+
() => ({
|
|
688
|
+
height: grabberHeight,
|
|
689
|
+
width: grabberOptions?.width ?? DEFAULT_GRABBER_WIDTH,
|
|
690
|
+
borderRadius: grabberOptions?.cornerRadius ?? grabberHeight / 2,
|
|
691
|
+
backgroundColor: (grabberOptions?.color ?? defaultGrabberColor) as string,
|
|
692
|
+
opacity: 1,
|
|
693
|
+
marginTop: grabberOptions?.topMargin ?? DEFAULT_GRABBER_TOP_MARGIN,
|
|
694
|
+
}),
|
|
695
|
+
[grabberOptions, grabberHeight, defaultGrabberColor]
|
|
696
|
+
);
|
|
507
697
|
|
|
508
698
|
return (
|
|
509
|
-
<
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
{
|
|
699
|
+
<Drawer.Root
|
|
700
|
+
open={isOpen}
|
|
701
|
+
onOpenChange={handleOpenChange}
|
|
702
|
+
onPositionChange={handlePositionChange}
|
|
703
|
+
onDrag={handleDrag}
|
|
704
|
+
onRelease={handleRelease}
|
|
705
|
+
dismissible={dismissible}
|
|
706
|
+
draggable={draggable}
|
|
707
|
+
handleOnly={scrollable && scrollableOptions?.scrollingExpandsSheet === false}
|
|
708
|
+
repositionInputs={false}
|
|
709
|
+
modal={dimmed}
|
|
710
|
+
nested={isNested}
|
|
711
|
+
detached={effectiveDetached}
|
|
712
|
+
detachedOffset={effectiveDetachedOffset}
|
|
713
|
+
detachedRadius={effectiveCornerRadius}
|
|
714
|
+
maxContentHeight={effectiveMaxContentHeight}
|
|
715
|
+
initialAnimated={initialDetentAnimated}
|
|
716
|
+
detachedWrapperStyle={wrapperStyle}
|
|
717
|
+
activeSnapPoint={activeSnapPoint}
|
|
718
|
+
setActiveSnapPoint={handleSetActiveSnapPoint}
|
|
719
|
+
{...snapPointsProps}
|
|
516
720
|
>
|
|
517
|
-
{
|
|
518
|
-
|
|
721
|
+
<Drawer.Portal container={portalContainer ?? undefined}>
|
|
722
|
+
<Drawer.Overlay style={overlayStyle} />
|
|
723
|
+
<Drawer.Content
|
|
724
|
+
ref={drawerContentRef}
|
|
725
|
+
style={mergedContentStyle}
|
|
726
|
+
onPointerDownOutside={handlePointerDownOutside}
|
|
727
|
+
detachedSiblings={
|
|
728
|
+
footer ? (
|
|
729
|
+
<div style={footerFloatStyle}>
|
|
730
|
+
<View style={footerStyle}>
|
|
731
|
+
{isValidElement(footer) ? footer : createElement(footer)}
|
|
732
|
+
</View>
|
|
733
|
+
</div>
|
|
734
|
+
) : undefined
|
|
735
|
+
}
|
|
736
|
+
>
|
|
737
|
+
<Drawer.Title style={visuallyHiddenStyle}>Sheet</Drawer.Title>
|
|
738
|
+
{grabber && <Drawer.Handle style={handleStyle} />}
|
|
739
|
+
{header && (
|
|
740
|
+
<View style={headerStyle}>
|
|
741
|
+
{isValidElement(header) ? header : createElement(header)}
|
|
742
|
+
</View>
|
|
743
|
+
)}
|
|
744
|
+
{scrollable ? (
|
|
745
|
+
<div style={scrollableContainerStyle}>
|
|
746
|
+
<View style={style}>{children}</View>
|
|
747
|
+
</div>
|
|
748
|
+
) : (
|
|
749
|
+
<View style={style}>{children}</View>
|
|
750
|
+
)}
|
|
751
|
+
</Drawer.Content>
|
|
752
|
+
</Drawer.Portal>
|
|
753
|
+
</Drawer.Root>
|
|
519
754
|
);
|
|
520
755
|
});
|
|
521
756
|
|
|
522
|
-
const
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
present: (name: string, index?: number) => Promise<void>;
|
|
527
|
-
dismiss: (name: string) => Promise<void>;
|
|
528
|
-
dismissStack: (name: string) => Promise<void>;
|
|
529
|
-
resize: (name: string, index: number) => Promise<void>;
|
|
530
|
-
dismissAll: () => Promise<void>;
|
|
531
|
-
}
|
|
532
|
-
|
|
533
|
-
export const TrueSheet = TrueSheetComponent as typeof TrueSheetComponent & TrueSheetStatic;
|
|
534
|
-
|
|
535
|
-
TrueSheet.present = async () => {
|
|
536
|
-
throw new Error(STATIC_METHOD_ERROR);
|
|
757
|
+
const overlayStyle: React.CSSProperties = {
|
|
758
|
+
position: 'fixed',
|
|
759
|
+
inset: 0,
|
|
760
|
+
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
|
537
761
|
};
|
|
538
762
|
|
|
539
|
-
|
|
540
|
-
|
|
763
|
+
const scrollableContainerStyle: React.CSSProperties = {
|
|
764
|
+
flex: 1,
|
|
765
|
+
minHeight: 0,
|
|
766
|
+
overflowY: 'auto',
|
|
767
|
+
overscrollBehavior: 'contain',
|
|
768
|
+
touchAction: 'pan-y',
|
|
541
769
|
};
|
|
542
770
|
|
|
543
|
-
|
|
544
|
-
|
|
771
|
+
const visuallyHiddenStyle: React.CSSProperties = {
|
|
772
|
+
position: 'absolute',
|
|
773
|
+
width: 1,
|
|
774
|
+
height: 1,
|
|
775
|
+
padding: 0,
|
|
776
|
+
margin: -1,
|
|
777
|
+
overflow: 'hidden',
|
|
778
|
+
clip: 'rect(0, 0, 0, 0)',
|
|
779
|
+
whiteSpace: 'nowrap',
|
|
780
|
+
border: 0,
|
|
545
781
|
};
|
|
546
782
|
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
};
|
|
783
|
+
const STATIC_METHOD_ERROR =
|
|
784
|
+
'Static methods are not supported on web. Use the useTrueSheet() hook instead.';
|
|
550
785
|
|
|
551
|
-
TrueSheet
|
|
786
|
+
export const TrueSheet = TrueSheetComponent as typeof TrueSheetComponent & TrueSheetStaticMethods;
|
|
787
|
+
|
|
788
|
+
const rejectStatic = async (): Promise<never> => {
|
|
552
789
|
throw new Error(STATIC_METHOD_ERROR);
|
|
553
790
|
};
|
|
554
791
|
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
position: 'absolute',
|
|
561
|
-
top: 0,
|
|
562
|
-
left: 0,
|
|
563
|
-
right: 0,
|
|
564
|
-
zIndex: 999,
|
|
565
|
-
paddingVertical: 10,
|
|
566
|
-
pointerEvents: 'none',
|
|
567
|
-
},
|
|
568
|
-
footer: {
|
|
569
|
-
pointerEvents: 'box-none',
|
|
570
|
-
},
|
|
571
|
-
});
|
|
792
|
+
TrueSheet.present = rejectStatic;
|
|
793
|
+
TrueSheet.dismiss = rejectStatic;
|
|
794
|
+
TrueSheet.dismissStack = rejectStatic;
|
|
795
|
+
TrueSheet.resize = rejectStatic;
|
|
796
|
+
TrueSheet.dismissAll = rejectStatic;
|