@lodev09/react-native-true-sheet 4.0.0-beta.20 → 4.0.0-beta.22

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.
Files changed (44) hide show
  1. package/android/build.gradle +1 -0
  2. package/android/proguard-rules.pro +3 -0
  3. package/android/src/main/java/com/lodev09/truesheet/TrueSheetContentView.kt +1 -0
  4. package/android/src/main/java/com/lodev09/truesheet/TrueSheetView.kt +5 -0
  5. package/android/src/main/java/com/lodev09/truesheet/TrueSheetViewController.kt +9 -0
  6. package/android/src/main/java/com/lodev09/truesheet/TrueSheetViewManager.kt +5 -0
  7. package/android/src/main/java/com/lodev09/truesheet/core/TrueSheetBottomSheetBehavior.kt +126 -0
  8. package/ios/TrueSheetView.mm +63 -1
  9. package/lib/module/TrueSheet.js +3 -2
  10. package/lib/module/TrueSheet.js.map +1 -1
  11. package/lib/module/TrueSheet.web.js +41 -40
  12. package/lib/module/TrueSheet.web.js.map +1 -1
  13. package/lib/module/fabric/TrueSheetViewNativeComponent.ts +1 -0
  14. package/lib/module/web/vaul/constants.js +5 -0
  15. package/lib/module/web/vaul/constants.js.map +1 -1
  16. package/lib/module/web/vaul/context.js +4 -0
  17. package/lib/module/web/vaul/context.js.map +1 -1
  18. package/lib/module/web/vaul/index.js +21 -7
  19. package/lib/module/web/vaul/index.js.map +1 -1
  20. package/lib/module/web/vaul/use-snap-points.js +36 -10
  21. package/lib/module/web/vaul/use-snap-points.js.map +1 -1
  22. package/lib/typescript/src/TrueSheet.d.ts.map +1 -1
  23. package/lib/typescript/src/TrueSheet.types.d.ts +22 -0
  24. package/lib/typescript/src/TrueSheet.types.d.ts.map +1 -1
  25. package/lib/typescript/src/TrueSheet.web.d.ts.map +1 -1
  26. package/lib/typescript/src/fabric/TrueSheetViewNativeComponent.d.ts +1 -0
  27. package/lib/typescript/src/fabric/TrueSheetViewNativeComponent.d.ts.map +1 -1
  28. package/lib/typescript/src/web/vaul/constants.d.ts +2 -0
  29. package/lib/typescript/src/web/vaul/constants.d.ts.map +1 -1
  30. package/lib/typescript/src/web/vaul/context.d.ts +2 -0
  31. package/lib/typescript/src/web/vaul/context.d.ts.map +1 -1
  32. package/lib/typescript/src/web/vaul/index.d.ts +7 -1
  33. package/lib/typescript/src/web/vaul/index.d.ts.map +1 -1
  34. package/lib/typescript/src/web/vaul/use-snap-points.d.ts +3 -2
  35. package/lib/typescript/src/web/vaul/use-snap-points.d.ts.map +1 -1
  36. package/package.json +1 -1
  37. package/src/TrueSheet.tsx +3 -2
  38. package/src/TrueSheet.types.ts +24 -0
  39. package/src/TrueSheet.web.tsx +46 -43
  40. package/src/fabric/TrueSheetViewNativeComponent.ts +1 -0
  41. package/src/web/vaul/constants.ts +5 -0
  42. package/src/web/vaul/context.ts +4 -0
  43. package/src/web/vaul/index.tsx +26 -6
  44. package/src/web/vaul/use-snap-points.ts +61 -10
@@ -6,6 +6,7 @@ import {
6
6
  useCallback,
7
7
  useEffect,
8
8
  useImperativeHandle,
9
+ useLayoutEffect,
9
10
  useMemo,
10
11
  useRef,
11
12
  useState,
@@ -80,6 +81,7 @@ const TrueSheetComponent = forwardRef<TrueSheetMethods, TrueSheetProps>((props,
80
81
  name,
81
82
  dismissible = true,
82
83
  draggable = true,
84
+ dismissThreshold = 'half',
83
85
  cornerRadius,
84
86
  style,
85
87
  backgroundColor: backgroundColorProp,
@@ -158,6 +160,7 @@ const TrueSheetComponent = forwardRef<TrueSheetMethods, TrueSheetProps>((props,
158
160
 
159
161
  const colorScheme = useColorScheme();
160
162
  const shouldAutoPresent = initialDetentIndex >= 0 && initialDetentIndex < validDetents.length;
163
+ const didInitiallyPresentRef = useRef(shouldAutoPresent);
161
164
  const [isOpen, setIsOpen] = useState(shouldAutoPresent);
162
165
  const isOpenRef = useRef(isOpen);
163
166
  isOpenRef.current = isOpen;
@@ -282,10 +285,23 @@ const TrueSheetComponent = forwardRef<TrueSheetMethods, TrueSheetProps>((props,
282
285
 
283
286
  useImperativeHandle(ref, () => methods, [methods]);
284
287
 
288
+ useEffect(() => {
289
+ if (!shouldAutoPresent || didInitiallyPresentRef.current) return;
290
+ didInitiallyPresentRef.current = true;
291
+ if (!isOpenRef.current) {
292
+ methods.present(initialDetentIndex);
293
+ }
294
+ }, [initialDetentIndex, shouldAutoPresent, methods]);
295
+
285
296
  const methodsRef = useRef<TrueSheetMethods | null>(methods);
286
297
  useRegisterSheet(name, methodsRef);
287
298
 
288
299
  const drawerContentRef = useRef<HTMLDivElement | null>(null);
300
+ const [drawerContent, setDrawerContent] = useState<HTMLDivElement | null>(null);
301
+ const handleDrawerRef = useCallback((node: HTMLDivElement | null) => {
302
+ drawerContentRef.current = node;
303
+ setDrawerContent(node);
304
+ }, []);
289
305
 
290
306
  // Measured header/footer heights drive the 'peek' snap point — mirrors
291
307
  // native, where the controller tracks headerHeight/footerHeight.
@@ -394,50 +410,36 @@ const TrueSheetComponent = forwardRef<TrueSheetMethods, TrueSheetProps>((props,
394
410
  (insetBehavior === 'automatic' || insetBehavior === 'footer');
395
411
  const measureContent = validDetents.includes('auto') || isFormSheet;
396
412
 
397
- useEffect(() => {
398
- if (!isOpen) return undefined;
399
-
400
- let canceled = false;
401
- let rafId = 0;
402
- let observer: ReturnType<typeof observeSheetLayout> | undefined;
403
-
404
- const attach = () => {
405
- if (canceled) return;
406
- const layoutEl = sizedLayoutRef.current;
407
- const drawer = drawerContentRef.current;
408
- if (!drawer || !layoutEl?.isConnected) {
409
- // Radix Presence defers the portal mount; poll until the drawer is live.
410
- rafId = window.requestAnimationFrame(attach);
411
- return;
412
- }
413
-
414
- observer = observeSheetLayout({
415
- drawer,
416
- layout: layoutEl,
417
- getContent: () => getDOMElement(contentRef.current),
418
- getHeader: () => getDOMElement(headerElRef.current),
419
- getFooter: () => getDOMElement(footerElRef.current),
420
- getScrollable: () => getScrollableElement(scrollableRef?.current),
421
- absoluteHeader,
422
- absoluteFooter,
423
- footerInsetAdjustment,
424
- measureContent,
425
- onHeightChange: (height) => {
426
- naturalHeightRef.current = height;
427
- setMeasuredContentHeight(height);
428
- },
429
- });
430
- };
431
-
432
- rafId = window.requestAnimationFrame(attach);
413
+ useLayoutEffect(() => {
414
+ const layoutEl = sizedLayoutRef.current;
415
+ if (!isOpen || !drawerContent || !layoutEl?.isConnected) return undefined;
416
+
417
+ setHeaderHeight(getDOMElement(headerElRef.current)?.offsetHeight ?? 0);
418
+ setFooterHeight(getDOMElement(footerElRef.current)?.offsetHeight ?? 0);
419
+ measurePeek();
420
+
421
+ const observer = observeSheetLayout({
422
+ drawer: drawerContent,
423
+ layout: layoutEl,
424
+ getContent: () => getDOMElement(contentRef.current),
425
+ getHeader: () => getDOMElement(headerElRef.current),
426
+ getFooter: () => getDOMElement(footerElRef.current),
427
+ getScrollable: () => getScrollableElement(scrollableRef?.current),
428
+ absoluteHeader,
429
+ absoluteFooter,
430
+ footerInsetAdjustment,
431
+ measureContent,
432
+ onHeightChange: (height) => {
433
+ naturalHeightRef.current = height;
434
+ setMeasuredContentHeight(height);
435
+ },
436
+ });
433
437
 
434
- return () => {
435
- canceled = true;
436
- window.cancelAnimationFrame(rafId);
437
- observer?.disconnect();
438
- };
438
+ return () => observer.disconnect();
439
439
  }, [
440
440
  isOpen,
441
+ drawerContent,
442
+ measurePeek,
441
443
  absoluteHeader,
442
444
  absoluteFooter,
443
445
  footerInsetAdjustment,
@@ -1209,6 +1211,7 @@ const TrueSheetComponent = forwardRef<TrueSheetMethods, TrueSheetProps>((props,
1209
1211
  onDrag={handleDrag}
1210
1212
  onRelease={handleRelease}
1211
1213
  dismissible={dismissible}
1214
+ dismissThreshold={dismissThreshold}
1212
1215
  onDismissAttempt={handleDismissAttempt}
1213
1216
  draggable={draggable}
1214
1217
  repositionInputs={false}
@@ -1219,7 +1222,7 @@ const TrueSheetComponent = forwardRef<TrueSheetMethods, TrueSheetProps>((props,
1219
1222
  detachedRadius={effectiveCornerRadius}
1220
1223
  maxContentHeight={effectiveMaxContentHeight}
1221
1224
  peekHeight={peekHeight}
1222
- initialAnimated={initialDetentAnimated}
1225
+ initialAnimated={shouldAutoPresent ? initialDetentAnimated : true}
1223
1226
  detachedWrapperStyle={wrapperStyle}
1224
1227
  contentHeight={measuredContentHeight}
1225
1228
  activeSnapPoint={activeSnapPoint}
@@ -1229,7 +1232,7 @@ const TrueSheetComponent = forwardRef<TrueSheetMethods, TrueSheetProps>((props,
1229
1232
  <Drawer.Portal container={portalContainer ?? undefined}>
1230
1233
  <Drawer.Overlay style={overlayStyle} />
1231
1234
  <Drawer.Content
1232
- ref={drawerContentRef}
1235
+ ref={handleDrawerRef}
1233
1236
  style={mergedContentStyle}
1234
1237
  onPointerDownOutside={handlePointerDownOutside}
1235
1238
  detachedSiblings={
@@ -114,6 +114,7 @@ export interface NativeProps extends ViewProps {
114
114
  placement?: WithDefault<'automatic' | 'leading' | 'center' | 'trailing', 'automatic'>;
115
115
  placementOffset?: WithDefault<Double, 16>;
116
116
  insetAdjustment?: WithDefault<'automatic' | 'never', 'automatic'>;
117
+ dismissThreshold?: WithDefault<'half' | 'short', 'half'>;
117
118
 
118
119
  // Boolean properties - match defaults from TrueSheet.types.ts
119
120
  grabber?: WithDefault<boolean, true>;
@@ -9,6 +9,11 @@ export const DEFAULT_PEEK_HEIGHT = 150;
9
9
 
10
10
  export const CLOSE_THRESHOLD = 0.25;
11
11
 
12
+ // Compose Material3 SheetState's dismiss thresholds, used by the 'short'
13
+ // dismiss threshold: velocity in px/ms, distance in px.
14
+ export const SHORT_DISMISS_VELOCITY = 0.125;
15
+ export const SHORT_DISMISS_DISTANCE = 56;
16
+
12
17
  // Minimum downward pull (px) on a non-dismissible drawer at its first snap
13
18
  // point before the release counts as a dismiss attempt.
14
19
  export const DISMISS_ATTEMPT_THRESHOLD = 24;
@@ -46,6 +46,8 @@ interface DrawerContextValue {
46
46
  container?: HTMLElement | null;
47
47
  autoFocus?: boolean;
48
48
  shouldAnimate?: React.RefObject<boolean>;
49
+ initialAnimated: boolean;
50
+ hasPresented: React.RefObject<boolean>;
49
51
  onPositionChangeRef: React.RefObject<((position: number) => void) | undefined>;
50
52
  setContentHeight: (height: number) => void;
51
53
  detached: boolean;
@@ -81,6 +83,8 @@ export const DrawerContext = React.createContext<DrawerContextValue>({
81
83
  closeDrawer: () => {},
82
84
  direction: 'bottom',
83
85
  shouldAnimate: { current: true },
86
+ initialAnimated: true,
87
+ hasPresented: { current: false },
84
88
  shouldScaleBackground: false,
85
89
  setBackgroundColorOnScale: true,
86
90
  noBodyStyles: false,
@@ -101,6 +101,12 @@ export type DialogProps = {
101
101
  * @default true
102
102
  */
103
103
  dismissible?: boolean;
104
+ /**
105
+ * How far the drawer must be dragged past its first snap point to close.
106
+ * `'half'` closes past half of it, `'short'` on a short drag or any fling.
107
+ * @default 'half'
108
+ */
109
+ dismissThreshold?: 'half' | 'short';
104
110
  /**
105
111
  * Called when the user tries to close a non-dismissible drawer — dragging down
106
112
  * at the first snap point, pressing escape, or tapping the handle of a
@@ -232,6 +238,7 @@ export function Root({
232
238
  closeThreshold = CLOSE_THRESHOLD,
233
239
  scrollLockTimeout = SCROLL_LOCK_TIMEOUT,
234
240
  dismissible = true,
241
+ dismissThreshold = 'half',
235
242
  onDismissAttempt,
236
243
  draggable = true,
237
244
  handleOnly = false,
@@ -292,6 +299,7 @@ export function Root({
292
299
  const dismissAttempted = React.useRef(false);
293
300
  const keyboardIsOpen = React.useRef(false);
294
301
  const shouldAnimate = React.useRef(!defaultOpen);
302
+ const hasPresented = React.useRef(false);
295
303
  const previousDiffFromInitial = React.useRef(0);
296
304
  const drawerRef = React.useRef<HTMLDivElement>(null);
297
305
  const drawerHeightRef = React.useRef(drawerRef.current?.getBoundingClientRect().height || 0);
@@ -308,10 +316,14 @@ export function Root({
308
316
  onContentHeightChangeRef.current?.(measuredContentHeight);
309
317
  }, [measuredContentHeight]);
310
318
 
311
- const onSnapPointChange = React.useCallback((activeSnapPointIndex: number) => {
319
+ const onSnapPointChange = React.useCallback((activeSnapPointIndex: number, animated: boolean) => {
312
320
  // Change openTime ref when we reach the last snap point to prevent dragging for 500ms incase it's scrollable.
313
321
  if (snapPoints && activeSnapPointIndex === snapPointsOffset.length - 1)
314
322
  openTime.current = new Date();
323
+ // Instant snaps do not emit transition events for position tracking.
324
+ if (!animated && drawerRef.current) {
325
+ onPositionChangeRef.current?.(drawerRef.current.getBoundingClientRect().top);
326
+ }
315
327
  }, []);
316
328
 
317
329
  const {
@@ -855,6 +867,7 @@ export function Root({
855
867
  closeDrawer,
856
868
  velocity,
857
869
  dismissible,
870
+ dismissThreshold,
858
871
  });
859
872
  if (pointerEvent) onReleaseProp?.(pointerEvent, true);
860
873
  return;
@@ -1009,6 +1022,8 @@ export function Root({
1009
1022
  dismissible,
1010
1023
  onDismissAttempt,
1011
1024
  shouldAnimate,
1025
+ initialAnimated,
1026
+ hasPresented,
1012
1027
  handleOnly,
1013
1028
  isOpen,
1014
1029
  isDragging,
@@ -1167,6 +1182,8 @@ export const Content = React.forwardRef<HTMLDivElement, ContentProps>(
1167
1182
  container,
1168
1183
  handleOnly,
1169
1184
  shouldAnimate,
1185
+ initialAnimated,
1186
+ hasPresented,
1170
1187
  autoFocus,
1171
1188
  onPositionChangeRef,
1172
1189
  setContentHeight,
@@ -1415,15 +1432,18 @@ export const Content = React.forwardRef<HTMLDivElement, ContentProps>(
1415
1432
  wrapper.style.transform = `translate3d(0, ${viewportH}px, 0)`;
1416
1433
  } else if (isOpen && !wasOpenRef.current) {
1417
1434
  wrapper.style.transition = 'none';
1418
- wrapper.style.transform = `translate3d(0, ${viewportH}px, 0)`;
1419
- // eslint-disable-next-line no-void
1420
- void wrapper.offsetHeight;
1421
- wrapper.style.transition = transition;
1435
+ if (hasPresented.current || initialAnimated) {
1436
+ wrapper.style.transform = `translate3d(0, ${viewportH}px, 0)`;
1437
+ // eslint-disable-next-line no-void
1438
+ void wrapper.offsetHeight;
1439
+ wrapper.style.transition = transition;
1440
+ }
1422
1441
  wrapper.style.transform = 'translate3d(0, 0, 0)';
1442
+ hasPresented.current = true;
1423
1443
  }
1424
1444
  }
1425
1445
  wasOpenRef.current = isOpen;
1426
- }, [isOpen, detached, drawerRef]);
1446
+ }, [isOpen, detached, drawerRef, initialAnimated, hasPresented]);
1427
1447
 
1428
1448
  // Subsequent snap writes belong to useSnapPoints. A React style update on
1429
1449
  // release would overwrite the live drag value before its transition starts.
@@ -1,7 +1,13 @@
1
1
  // @ts-nocheck — vendored upstream, incompatible with noUncheckedIndexedAccess
2
2
  import React from 'react';
3
3
  import { set, isVertical, getSnapPointTransform } from './helpers';
4
- import { DEFAULT_PEEK_HEIGHT, TRANSITIONS, VELOCITY_THRESHOLD } from './constants';
4
+ import {
5
+ DEFAULT_PEEK_HEIGHT,
6
+ SHORT_DISMISS_DISTANCE,
7
+ SHORT_DISMISS_VELOCITY,
8
+ TRANSITIONS,
9
+ VELOCITY_THRESHOLD,
10
+ } from './constants';
5
11
  import { useControllableState } from './use-controllable-state';
6
12
  import type { DrawerDirection } from './types';
7
13
 
@@ -29,7 +35,7 @@ export function useSnapPoints({
29
35
  fadeFromIndex?: number;
30
36
  drawerRef: React.RefObject<HTMLDivElement | null>;
31
37
  overlayRef: React.RefObject<HTMLDivElement | null>;
32
- onSnapPointChange(activeSnapPointIndex: number): void;
38
+ onSnapPointChange(activeSnapPointIndex: number, animated: boolean): void;
33
39
  direction?: DrawerDirection;
34
40
  container?: HTMLElement | null | undefined;
35
41
  snapToSequentialPoint?: boolean;
@@ -187,17 +193,31 @@ export function useSnapPoints({
187
193
  });
188
194
  };
189
195
 
190
- // Skip the transition on the very first snap when the consumer opts out of
191
- // the initial presentation animation. All subsequent snaps still animate.
196
+ // Initial measurements can take several commits before the first paint.
197
+ // Keep all of them unanimated when initial presentation animation is disabled.
192
198
  const hasSnappedRef = React.useRef(false);
199
+ const initialSnapFrameRef = React.useRef<number | null>(null);
200
+ React.useEffect(
201
+ () => () => {
202
+ if (initialSnapFrameRef.current !== null) {
203
+ window.cancelAnimationFrame(initialSnapFrameRef.current);
204
+ initialSnapFrameRef.current = null;
205
+ }
206
+ },
207
+ []
208
+ );
193
209
  const snapToPoint = React.useCallback(
194
210
  (dimension: number) => {
211
+ if (!drawerRef.current) return;
195
212
  const newSnapPointIndex =
196
213
  snapPointsOffset?.findIndex((snapPointDim) => snapPointDim === dimension) ?? null;
197
- onSnapPointChange(newSnapPointIndex);
198
214
 
199
215
  const animateThisSnap = hasSnappedRef.current || initialAnimated;
200
- hasSnappedRef.current = true;
216
+ if (initialSnapFrameRef.current === null) {
217
+ initialSnapFrameRef.current = window.requestAnimationFrame(() => {
218
+ hasSnappedRef.current = true;
219
+ });
220
+ }
201
221
  // One animated value drives both the translation and the visible height.
202
222
  set(drawerRef.current, {
203
223
  'transition': animateThisSnap
@@ -210,6 +230,9 @@ export function useSnapPoints({
210
230
  // Snapping implies drag overshoot (if any) should be undone.
211
231
  setDetachedWrapperTransform(0, animateThisSnap);
212
232
 
233
+ const overlayTransition = animateThisSnap
234
+ ? `opacity ${TRANSITIONS.DURATION}s cubic-bezier(${TRANSITIONS.EASE.join(',')})`
235
+ : 'none';
213
236
  if (
214
237
  snapPointsOffset &&
215
238
  newSnapPointIndex !== snapPointsOffset.length - 1 &&
@@ -218,24 +241,33 @@ export function useSnapPoints({
218
241
  newSnapPointIndex < fadeFromIndex
219
242
  ) {
220
243
  set(overlayRef.current, {
221
- transition: `opacity ${TRANSITIONS.DURATION}s cubic-bezier(${TRANSITIONS.EASE.join(',')})`,
244
+ transition: overlayTransition,
222
245
  opacity: '0',
223
246
  pointerEvents: 'none',
224
247
  });
225
248
  } else {
226
249
  set(overlayRef.current, {
227
- transition: `opacity ${TRANSITIONS.DURATION}s cubic-bezier(${TRANSITIONS.EASE.join(',')})`,
250
+ transition: overlayTransition,
228
251
  opacity: '1',
229
252
  pointerEvents: 'auto',
230
253
  });
231
254
  }
232
255
 
256
+ onSnapPointChange(newSnapPointIndex, animateThisSnap);
233
257
  setActiveSnapPoint(snapPoints?.[Math.max(newSnapPointIndex, 0)]);
234
258
  },
235
- [drawerRef.current, snapPoints, snapPointsOffset, fadeFromIndex, overlayRef, setActiveSnapPoint]
259
+ [
260
+ drawerRef.current,
261
+ snapPoints,
262
+ snapPointsOffset,
263
+ fadeFromIndex,
264
+ overlayRef,
265
+ setActiveSnapPoint,
266
+ initialAnimated,
267
+ ]
236
268
  );
237
269
 
238
- React.useEffect(() => {
270
+ React.useLayoutEffect(() => {
239
271
  if (!isOpen) return;
240
272
  if (activeSnapPoint || activeSnapPointProp) {
241
273
  const newIndex =
@@ -253,11 +285,13 @@ export function useSnapPoints({
253
285
  closeDrawer,
254
286
  velocity,
255
287
  dismissible,
288
+ dismissThreshold,
256
289
  }: {
257
290
  draggedDistance: number;
258
291
  closeDrawer: () => void;
259
292
  velocity: number;
260
293
  dismissible: boolean;
294
+ dismissThreshold: 'half' | 'short';
261
295
  }) {
262
296
  if (fadeFromIndex === undefined) return;
263
297
 
@@ -275,6 +309,23 @@ export function useSnapPoints({
275
309
  });
276
310
  }
277
311
 
312
+ // Short threshold: below the lowest detent, close on a short drag or any
313
+ // downward fling, like Compose Material3's sheet.
314
+ if (
315
+ dismissThreshold === 'short' &&
316
+ dismissible &&
317
+ (direction === 'bottom' || direction === 'right') &&
318
+ currentPosition > snapPointsOffset[0]
319
+ ) {
320
+ const overshoot = currentPosition - snapPointsOffset[0];
321
+ if (velocity > SHORT_DISMISS_VELOCITY || overshoot > SHORT_DISMISS_DISTANCE) {
322
+ closeDrawer();
323
+ } else {
324
+ snapToPoint(snapPointsOffset[0]);
325
+ }
326
+ return;
327
+ }
328
+
278
329
  // Distance-based dismiss: when overshooting past the lowest detent by more
279
330
  // than half of its visible height, commit to dismiss regardless of
280
331
  // velocity — lets a slow drag-down still close the sheet.