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