@lodev09/react-native-true-sheet 4.0.0-beta.24 → 4.0.0-beta.25

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 (78) hide show
  1. package/README.md +1 -0
  2. package/android/src/main/java/com/lodev09/truesheet/TrueSheetModule.kt +13 -0
  3. package/android/src/main/java/com/lodev09/truesheet/TrueSheetOverlayView.kt +11 -0
  4. package/android/src/main/java/com/lodev09/truesheet/TrueSheetView.kt +89 -9
  5. package/android/src/main/java/com/lodev09/truesheet/TrueSheetViewController.kt +100 -24
  6. package/android/src/main/java/com/lodev09/truesheet/TrueSheetViewManager.kt +13 -10
  7. package/android/src/main/java/com/lodev09/truesheet/core/RNScreensEventObserver.kt +31 -6
  8. package/android/src/main/java/com/lodev09/truesheet/core/TrueSheetBottomSheetView.kt +3 -8
  9. package/android/src/main/java/com/lodev09/truesheet/core/TrueSheetCoordinatorLayout.kt +14 -1
  10. package/android/src/main/java/com/lodev09/truesheet/core/TrueSheetDimView.kt +11 -17
  11. package/android/src/main/java/com/lodev09/truesheet/core/TrueSheetGrabberView.kt +2 -4
  12. package/android/src/main/java/com/lodev09/truesheet/utils/ColorUtils.kt +19 -0
  13. package/ios/TrueSheetModule.mm +11 -2
  14. package/ios/TrueSheetView.h +2 -0
  15. package/ios/TrueSheetView.mm +53 -7
  16. package/ios/TrueSheetViewController.h +2 -0
  17. package/ios/TrueSheetViewController.mm +158 -4
  18. package/ios/core/RNScreensEventObserver.mm +18 -1
  19. package/ios/core/TrueSheetDimView.h +27 -0
  20. package/ios/core/TrueSheetDimView.mm +41 -0
  21. package/ios/core/TrueSheetKeyboardObserver.mm +11 -8
  22. package/lib/module/TrueSheet.js +88 -17
  23. package/lib/module/TrueSheet.js.map +1 -1
  24. package/lib/module/TrueSheet.web.js +150 -37
  25. package/lib/module/TrueSheet.web.js.map +1 -1
  26. package/lib/module/TrueSheetProvider.web.js +13 -9
  27. package/lib/module/TrueSheetProvider.web.js.map +1 -1
  28. package/lib/module/fabric/TrueSheetViewNativeComponent.ts +2 -0
  29. package/lib/module/scrollable.js +28 -0
  30. package/lib/module/scrollable.js.map +1 -0
  31. package/lib/module/web/keyboard.js +69 -0
  32. package/lib/module/web/keyboard.js.map +1 -0
  33. package/lib/module/web/layout.js +1 -1
  34. package/lib/module/web/layout.js.map +1 -1
  35. package/lib/module/web/vaul/context.js.map +1 -1
  36. package/lib/module/web/vaul/index.js +18 -8
  37. package/lib/module/web/vaul/index.js.map +1 -1
  38. package/lib/module/web/vaul/use-snap-points.js +15 -10
  39. package/lib/module/web/vaul/use-snap-points.js.map +1 -1
  40. package/lib/typescript/src/TrueSheet.d.ts +14 -1
  41. package/lib/typescript/src/TrueSheet.d.ts.map +1 -1
  42. package/lib/typescript/src/TrueSheet.types.d.ts +22 -2
  43. package/lib/typescript/src/TrueSheet.types.d.ts.map +1 -1
  44. package/lib/typescript/src/TrueSheet.web.d.ts.map +1 -1
  45. package/lib/typescript/src/TrueSheetProvider.web.d.ts +5 -1
  46. package/lib/typescript/src/TrueSheetProvider.web.d.ts.map +1 -1
  47. package/lib/typescript/src/fabric/TrueSheetViewNativeComponent.d.ts +2 -0
  48. package/lib/typescript/src/fabric/TrueSheetViewNativeComponent.d.ts.map +1 -1
  49. package/lib/typescript/src/navigation/types.d.ts +1 -1
  50. package/lib/typescript/src/navigation/types.d.ts.map +1 -1
  51. package/lib/typescript/src/scrollable.d.ts +2 -0
  52. package/lib/typescript/src/scrollable.d.ts.map +1 -0
  53. package/lib/typescript/src/web/keyboard.d.ts +15 -0
  54. package/lib/typescript/src/web/keyboard.d.ts.map +1 -0
  55. package/lib/typescript/src/web/layout.d.ts +1 -0
  56. package/lib/typescript/src/web/layout.d.ts.map +1 -1
  57. package/lib/typescript/src/web/vaul/context.d.ts +2 -1
  58. package/lib/typescript/src/web/vaul/context.d.ts.map +1 -1
  59. package/lib/typescript/src/web/vaul/index.d.ts +8 -1
  60. package/lib/typescript/src/web/vaul/index.d.ts.map +1 -1
  61. package/lib/typescript/src/web/vaul/types.d.ts +9 -0
  62. package/lib/typescript/src/web/vaul/types.d.ts.map +1 -1
  63. package/lib/typescript/src/web/vaul/use-snap-points.d.ts +3 -2
  64. package/lib/typescript/src/web/vaul/use-snap-points.d.ts.map +1 -1
  65. package/package.json +1 -1
  66. package/src/TrueSheet.tsx +91 -14
  67. package/src/TrueSheet.types.ts +26 -2
  68. package/src/TrueSheet.web.tsx +170 -41
  69. package/src/TrueSheetProvider.web.tsx +22 -13
  70. package/src/fabric/TrueSheetViewNativeComponent.ts +2 -0
  71. package/src/navigation/types.ts +2 -0
  72. package/src/scrollable.ts +25 -0
  73. package/src/web/keyboard.ts +79 -0
  74. package/src/web/layout.ts +1 -1
  75. package/src/web/vaul/context.ts +2 -1
  76. package/src/web/vaul/index.tsx +20 -5
  77. package/src/web/vaul/types.ts +10 -0
  78. package/src/web/vaul/use-snap-points.ts +19 -9
package/src/TrueSheet.tsx CHANGED
@@ -39,6 +39,7 @@ import TrueSheetFooterViewNativeComponent from './fabric/TrueSheetFooterViewNati
39
39
  import TrueSheetModule from './specs/NativeTrueSheetModule';
40
40
  import { TrueSheetBackground } from './TrueSheetBackground';
41
41
  import { normalizeDetentBackground } from './detentBackgrounds';
42
+ import { resolveScrollable } from './scrollable';
42
43
 
43
44
  import {
44
45
  Platform,
@@ -139,9 +140,19 @@ export class TrueSheet
139
140
  private static readonly instances: { [name: string]: TrueSheet } = {};
140
141
 
141
142
  /**
142
- * Resolver to be called when mount event is received
143
+ * All mounted sheets, named or not.
143
144
  */
144
- private presentationResolver: (() => void) | null = null;
145
+ private static readonly mountedInstances = new Set<TrueSheet>();
146
+
147
+ /**
148
+ * Resolver to be called when mount event is received, or with `false` when the present is cancelled
149
+ */
150
+ private presentationResolver: ((mounted: boolean) => void) | null = null;
151
+
152
+ /**
153
+ * Settles once a scheduled lazy unmount has committed
154
+ */
155
+ private nativeViewUnmount: Promise<void> | null = null;
145
156
 
146
157
  /**
147
158
  * Tracks if a present operation is in progress
@@ -198,13 +209,17 @@ export class TrueSheet
198
209
  if (scrollableRef?.current) {
199
210
  try {
200
211
  scrollableHandle = findNodeHandle(
201
- scrollableRef.current as Parameters<typeof findNodeHandle>[0]
212
+ resolveScrollable(scrollableRef.current) as Parameters<typeof findNodeHandle>[0]
202
213
  );
203
214
  } catch (error) {
204
- // A stale ref (e.g. a wrapper that never clears it on unmount) makes
205
- // findNodeHandle throw. Treat it as no scrollable instead of crashing.
215
+ // findNodeHandle throws on a stale ref (e.g. a wrapper that never clears
216
+ // it on unmount) or a handle with no usable scroll accessor. Treat it as
217
+ // no scrollable instead of crashing.
206
218
  if (__DEV__) {
207
- console.warn('TrueSheet: scrollableRef points to an unmounted component.', error);
219
+ console.warn(
220
+ 'TrueSheet: scrollableRef could not be resolved to a native view. It may be stale or expose no scroll node.',
221
+ error
222
+ );
208
223
  }
209
224
  }
210
225
  }
@@ -343,6 +358,7 @@ export class TrueSheet
343
358
  * @returns Promise that resolves when all sheets are dismissed
344
359
  */
345
360
  public static async dismissAll(animated: boolean = true): Promise<void> {
361
+ TrueSheet.mountedInstances.forEach((sheet) => sheet.cancelPendingPresent());
346
362
  return TrueSheetModule?.dismissAll(animated);
347
363
  }
348
364
 
@@ -396,16 +412,17 @@ export class TrueSheet
396
412
 
397
413
  // Non-lazy content stays mounted; otherwise clean it up unless another presentation is active.
398
414
  if (!this.isPresenting && this.props.lazy !== false) {
399
- this.setState({ shouldRenderNativeView: false });
415
+ this.unmountNativeView();
400
416
  }
401
417
 
402
418
  this.props.onDidDismiss?.(event);
403
419
  }
404
420
 
405
421
  private onMount(event: MountEvent): void {
406
- // Resolve the mount promise if waiting
407
- if (this.presentationResolver) {
408
- this.presentationResolver();
422
+ // Resolve the mount promise if waiting. While an unmount is pending, the event is from
423
+ // the outgoing view — present() waits for the remount instead.
424
+ if (this.presentationResolver && !this.nativeViewUnmount) {
425
+ this.presentationResolver(true);
409
426
  this.presentationResolver = null;
410
427
  }
411
428
 
@@ -465,7 +482,9 @@ export class TrueSheet
465
482
  return onBackPress?.() ?? onDismissAttempt != null;
466
483
  }
467
484
 
468
- TrueSheetModule?.handleBackPress(nodeHandle);
485
+ // Rejects when the native view is gone but JS hasn't unmounted yet (e.g. the surface
486
+ // stopped while this back press was queued). Nothing is left to dismiss.
487
+ TrueSheetModule?.handleBackPress(nodeHandle).catch(() => {});
469
488
  return this.props.onBackPress?.() ?? true;
470
489
  }
471
490
 
@@ -482,20 +501,67 @@ export class TrueSheet
482
501
  );
483
502
  }
484
503
 
504
+ // Still waiting on the lazy mount — like native, a second present is a no-op
505
+ if (this.presentationResolver) {
506
+ console.warn('TrueSheet: sheet is already presented. Use resize() to change detent.');
507
+ return;
508
+ }
509
+
485
510
  this.isPresenting = true;
486
511
 
487
512
  // Lazy load: render native view if not already rendered
488
- if (!this.state.shouldRenderNativeView) {
489
- await new Promise<void>((resolve) => {
513
+ const unmount = this.nativeViewUnmount;
514
+ if (unmount || !this.state.shouldRenderNativeView) {
515
+ const mounted = await new Promise<boolean>((resolve) => {
490
516
  this.presentationResolver = resolve;
491
- this.setState({ shouldRenderNativeView: true });
517
+
518
+ const mount = () => {
519
+ if (this.presentationResolver === resolve) {
520
+ this.setState({ shouldRenderNativeView: true });
521
+ }
522
+ };
523
+
524
+ // An unmount from the last dismiss may not have committed yet — state still reads
525
+ // mounted, and presenting now would lose the sheet when it lands. Remount after it.
526
+ if (unmount) {
527
+ unmount.then(mount);
528
+ } else {
529
+ mount();
530
+ }
492
531
  });
532
+
533
+ // Dismissed before the native view mounted
534
+ if (!mounted) {
535
+ this.isPresenting = false;
536
+ return;
537
+ }
493
538
  }
494
539
 
495
540
  await TrueSheetModule?.presentByRef(this.handle, index, animated);
496
541
  this.isPresenting = false;
497
542
  }
498
543
 
544
+ /**
545
+ * Cancels a present still waiting on the lazy mount. Returns whether one was pending.
546
+ */
547
+ private cancelPendingPresent(): boolean {
548
+ if (!this.presentationResolver) return false;
549
+
550
+ this.presentationResolver(false);
551
+ this.presentationResolver = null;
552
+ this.unmountNativeView();
553
+ return true;
554
+ }
555
+
556
+ private unmountNativeView(): void {
557
+ this.nativeViewUnmount = new Promise((resolve) => {
558
+ this.setState({ shouldRenderNativeView: false }, () => {
559
+ this.nativeViewUnmount = null;
560
+ resolve();
561
+ });
562
+ });
563
+ }
564
+
499
565
  /**
500
566
  * Resize the sheet to a given detent index.
501
567
  * @param index - The detent index to resize to
@@ -509,6 +575,9 @@ export class TrueSheet
509
575
  * @param animated - Whether to animate the dismissal (default: true)
510
576
  */
511
577
  public async dismiss(animated: boolean = true): Promise<void> {
578
+ // Dismissing before the lazy mount finishes cancels the present — nothing was presented
579
+ if (this.cancelPendingPresent()) return;
580
+
512
581
  return TrueSheetModule?.dismissByRef(this.handle, animated);
513
582
  }
514
583
 
@@ -522,6 +591,7 @@ export class TrueSheet
522
591
  }
523
592
 
524
593
  componentDidMount(): void {
594
+ TrueSheet.mountedInstances.add(this);
525
595
  this.registerInstance();
526
596
  this.updateScrollableHandle();
527
597
  }
@@ -558,9 +628,12 @@ export class TrueSheet
558
628
  }
559
629
 
560
630
  componentWillUnmount(): void {
631
+ TrueSheet.mountedInstances.delete(this);
561
632
  this.unregisterInstance();
562
633
  this.backHandlerSubscription?.remove();
563
634
  this.backHandlerSubscription = null;
635
+ // Don't leave a present waiting on the lazy mount hanging
636
+ this.presentationResolver?.(false);
564
637
  this.presentationResolver = null;
565
638
  }
566
639
 
@@ -578,6 +651,8 @@ export class TrueSheet
578
651
  grabberOptions,
579
652
  accessibilityOptions,
580
653
  dimmed = true,
654
+ dimColor,
655
+ dimOpacity,
581
656
  initialDetentIndex = -1,
582
657
  initialDetentAnimated = true,
583
658
  dimmedDetentIndex,
@@ -686,6 +761,8 @@ export class TrueSheet
686
761
  grabberOptions={this.resolvedGrabberOptions}
687
762
  accessibilityOptions={accessibilityOptions}
688
763
  dimmed={dimmed}
764
+ dimColor={dimColor}
765
+ dimOpacity={dimOpacity}
689
766
  dimmedDetentIndex={dimmedDetentIndex}
690
767
  initialDetentIndex={gatedInitialDetentIndex}
691
768
  initialDetentAnimated={initialDetentAnimated}
@@ -439,6 +439,22 @@ export interface TrueSheetProps extends ViewProps {
439
439
  */
440
440
  dimmed?: boolean;
441
441
 
442
+ /**
443
+ * The color of the dim overlay behind the sheet.
444
+ * This is ignored if `dimmed` is set to `false`.
445
+ *
446
+ * @default '#000000'
447
+ */
448
+ dimColor?: ColorValue;
449
+
450
+ /**
451
+ * The maximum opacity of the dim overlay behind the sheet (0 to 1).
452
+ * This is ignored if `dimmed` is set to `false`.
453
+ *
454
+ * @default 0.5
455
+ */
456
+ dimOpacity?: number;
457
+
442
458
  /**
443
459
  * Initially present the sheet, after mounting, at a given detent index.
444
460
  *
@@ -677,7 +693,9 @@ export interface TrueSheetProps extends ViewProps {
677
693
 
678
694
  /**
679
695
  * A ref to the scrollable component (e.g. `ScrollView`, `FlatList`)
680
- * rendered within the sheet content.
696
+ * rendered within the sheet content. Lists that expose an imperative handle
697
+ * (e.g. `LegendList`, `FlashList`) work too, through their
698
+ * `getScrollableNode()` or `getNativeScrollRef()`.
681
699
  * Required for scrollable handling — nested scrolling, keyboard insets,
682
700
  * and `auto` detent sizing are wired to this scrollable.
683
701
  *
@@ -690,7 +708,13 @@ export interface TrueSheetProps extends ViewProps {
690
708
  * </TrueSheet>
691
709
  * ```
692
710
  */
693
- scrollableRef?: RefObject<Component<unknown> | HostInstance | null>;
711
+ scrollableRef?: RefObject<
712
+ | Component<unknown>
713
+ | HostInstance
714
+ | { getScrollableNode(): unknown }
715
+ | { getNativeScrollRef(): unknown }
716
+ | null
717
+ >;
694
718
 
695
719
  /**
696
720
  * Options for scrollable behavior.
@@ -10,6 +10,7 @@ import {
10
10
  useMemo,
11
11
  useRef,
12
12
  useState,
13
+ type RefObject,
13
14
  } from 'react';
14
15
  import type { DimensionValue, LayoutChangeEvent } from 'react-native';
15
16
  import { StyleSheet, useColorScheme, useWindowDimensions, View } from 'react-native';
@@ -41,7 +42,12 @@ import {
41
42
  TrueSheetPeekContext,
42
43
  type TrueSheetPeekContextValue,
43
44
  } from './TrueSheetPeek.web';
44
- import { usePortalContainer, useRegisterSheet, useSheetStack } from './TrueSheetProvider.web';
45
+ import {
46
+ usePortalContainer,
47
+ useRegisterSheet,
48
+ useSheetStack,
49
+ type SheetHandle,
50
+ } from './TrueSheetProvider.web';
45
51
  import {
46
52
  COLOR_SURFACE_CONTAINER_LOW_DARK,
47
53
  COLOR_SURFACE_CONTAINER_LOW_LIGHT,
@@ -60,18 +66,16 @@ import {
60
66
  import { getDOMElement } from './web/dom';
61
67
  import { TrueSheetBackground } from './TrueSheetBackground';
62
68
  import { getDetentBackgroundIndex, normalizeDetentBackground } from './detentBackgrounds';
69
+ import { revealFocusedElement, useKeyboardViewport } from './web/keyboard';
63
70
  import { observeSheetLayout } from './web/layout';
71
+ import { resolveScrollable } from './scrollable';
64
72
  import { Drawer } from './web/vaul';
65
73
  import { DEFAULT_PEEK_HEIGHT, DRAG_CLASS, TRANSITIONS } from './web/vaul/constants';
66
74
 
67
- // Resolves the user's `scrollableRef` to its scrollable DOM element — RN-web
68
- // ScrollView refs expose the scroll node directly (with helpers attached);
69
- // list refs (e.g. FlatList) expose it via getScrollableNode(). The node's
70
- // first child is the content container.
75
+ // Resolves the user's `scrollableRef` to its scrollable DOM element. The
76
+ // node's first child is the content container.
71
77
  const getScrollableElement = (scrollable: unknown): HTMLElement | null => {
72
- if (!scrollable) return null;
73
- if (scrollable instanceof HTMLElement) return scrollable;
74
- const node = (scrollable as { getScrollableNode?: () => unknown }).getScrollableNode?.();
78
+ const node = resolveScrollable(scrollable);
75
79
  return node instanceof HTMLElement ? node : null;
76
80
  };
77
81
 
@@ -99,6 +103,8 @@ const TrueSheetComponent = forwardRef<TrueSheetMethods, TrueSheetProps>((props,
99
103
  accessibilityOptions,
100
104
  detents = [0.5, 1],
101
105
  dimmed = true,
106
+ dimColor,
107
+ dimOpacity,
102
108
  dimmedDetentIndex = 0,
103
109
  initialDetentIndex = -1,
104
110
  header,
@@ -164,7 +170,19 @@ const TrueSheetComponent = forwardRef<TrueSheetMethods, TrueSheetProps>((props,
164
170
  const [isOpen, setIsOpen] = useState(shouldAutoPresent);
165
171
  const isOpenRef = useRef(isOpen);
166
172
  isOpenRef.current = isOpen;
173
+ const keyboardViewport = useKeyboardViewport(isOpen);
167
174
  const isPresentingRef = useRef(shouldAutoPresent);
175
+ // Native's lifecycle state — updated synchronously so calls in the same tick see each other
176
+ const phaseRef = useRef<SheetPhase>(shouldAutoPresent ? 'presenting' : 'dismissed');
177
+ const resolvePresentRef = useRef<(() => void) | null>(null);
178
+ const resolveDismissRef = useRef<(() => void) | null>(null);
179
+ // Whether willPresent fired for the current open cycle — a present undone
180
+ // before it fires has nothing to pair a dismiss with.
181
+ const didEmitWillPresentRef = useRef(false);
182
+ // Whether the current open cycle came from initialDetentIndex. Native mounts those
183
+ // eagerly, so a dismiss before willPresent lands after the present instead of cancelling it.
184
+ const isAutoPresentRef = useRef(shouldAutoPresent);
185
+ const shouldDismissOnPresentRef = useRef(false);
168
186
  const [displayedBackgroundIndex, setDisplayedBackgroundIndex] = useState(
169
187
  shouldAutoPresent ? initialDetentIndex : 0
170
188
  );
@@ -199,13 +217,31 @@ const TrueSheetComponent = forwardRef<TrueSheetMethods, TrueSheetProps>((props,
199
217
  );
200
218
  }, []);
201
219
 
220
+ // Returns null when there's nothing to dismiss. Resolves on didDismiss.
221
+ const dismissSheet = useCallback((): Promise<void> | null => {
222
+ if (phaseRef.current === 'dismissed' || phaseRef.current === 'dismissing') return null;
223
+ settle(resolvePresentRef);
224
+ if (didEmitWillPresentRef.current) {
225
+ setIsOpen(false);
226
+ } else if (isAutoPresentRef.current) {
227
+ shouldDismissOnPresentRef.current = true;
228
+ } else {
229
+ // Mirror native's lazy-mount cancel: nothing shown yet, nothing to animate out
230
+ setIsOpen(false);
231
+ phaseRef.current = 'dismissed';
232
+ return Promise.resolve();
233
+ }
234
+ phaseRef.current = 'dismissing';
235
+ return new Promise((resolve) => {
236
+ resolveDismissRef.current = resolve;
237
+ });
238
+ }, []);
239
+
202
240
  const handleOpenChange = useCallback(
203
241
  (open: boolean) => {
204
- if (!open && isOpen) {
205
- setIsOpen(false);
206
- }
242
+ if (!open) dismissSheet();
207
243
  },
208
- [isOpen]
244
+ [dismissSheet]
209
245
  );
210
246
 
211
247
  const portalContainer = usePortalContainer();
@@ -248,24 +284,38 @@ const TrueSheetComponent = forwardRef<TrueSheetMethods, TrueSheetProps>((props,
248
284
  const dismissAboveRef = useRef<(animated?: boolean) => Promise<void>>(async () => {});
249
285
  const dismissDescendantsRef = useRef<() => void>(() => {});
250
286
 
287
+ const presentSheet = useCallback(async (index: number, auto: boolean) => {
288
+ const detent = validDetentsRef.current[index];
289
+ if (detent === undefined) {
290
+ throw new Error(
291
+ `TrueSheet: present index (${index}) is out of bounds. detents array has ${validDetentsRef.current.length} item(s)`
292
+ );
293
+ }
294
+ if (phaseRef.current !== 'dismissed') {
295
+ console.warn(ALREADY_PRESENTED_WARNING);
296
+ return;
297
+ }
298
+ phaseRef.current = 'presenting';
299
+ isAutoPresentRef.current = auto;
300
+ isPresentingRef.current = true;
301
+ setDisplayedBackgroundIndex(index);
302
+ setActiveSnapPoint(detent);
303
+ setIsOpen(true);
304
+ await new Promise<void>((resolve) => {
305
+ resolvePresentRef.current = resolve;
306
+ });
307
+ }, []);
308
+
251
309
  const methods = useMemo<TrueSheetMethods>(
252
310
  () => ({
253
- present: async (index = 0) => {
254
- const detent = validDetentsRef.current[index];
255
- if (detent === undefined) {
256
- throw new Error(
257
- `TrueSheet: present index (${index}) is out of bounds. detents array has ${validDetentsRef.current.length} item(s)`
258
- );
259
- }
260
- if (!isOpenRef.current) {
261
- isPresentingRef.current = true;
262
- setDisplayedBackgroundIndex(index);
263
- }
264
- setActiveSnapPoint(detent);
265
- setIsOpen(true);
266
- },
311
+ present: (index = 0) => presentSheet(index, false),
267
312
  dismiss: async () => {
268
- setIsOpen(false);
313
+ const dismissal = dismissSheet();
314
+ if (!dismissal) {
315
+ console.warn(ALREADY_DISMISSED_WARNING);
316
+ return;
317
+ }
318
+ await dismissal;
269
319
  },
270
320
  resize: async (index) => {
271
321
  const detent = validDetentsRef.current[index];
@@ -277,10 +327,14 @@ const TrueSheetComponent = forwardRef<TrueSheetMethods, TrueSheetProps>((props,
277
327
  setActiveSnapPoint(detent);
278
328
  },
279
329
  dismissStack: async (animated) => {
330
+ if (phaseRef.current === 'dismissed' || phaseRef.current === 'dismissing') {
331
+ console.warn(ALREADY_DISMISSED_WARNING);
332
+ return;
333
+ }
280
334
  await dismissAboveRef.current(animated);
281
335
  },
282
336
  }),
283
- []
337
+ [presentSheet, dismissSheet]
284
338
  );
285
339
 
286
340
  useImperativeHandle(ref, () => methods, [methods]);
@@ -288,13 +342,27 @@ const TrueSheetComponent = forwardRef<TrueSheetMethods, TrueSheetProps>((props,
288
342
  useEffect(() => {
289
343
  if (!shouldAutoPresent || didInitiallyPresentRef.current) return;
290
344
  didInitiallyPresentRef.current = true;
291
- if (!isOpenRef.current) {
292
- methods.present(initialDetentIndex);
345
+ if (phaseRef.current === 'dismissed') {
346
+ presentSheet(initialDetentIndex, true);
293
347
  }
294
- }, [initialDetentIndex, shouldAutoPresent, methods]);
348
+ }, [initialDetentIndex, shouldAutoPresent, presentSheet]);
349
+
350
+ // Unmounted mid-transition: don't leave callers awaiting forever
351
+ useEffect(
352
+ () => () => {
353
+ settle(resolvePresentRef);
354
+ settle(resolveDismissRef);
355
+ },
356
+ []
357
+ );
295
358
 
296
- const methodsRef = useRef<TrueSheetMethods | null>(methods);
297
- useRegisterSheet(name, methodsRef);
359
+ const handleRef = useRef<SheetHandle>({
360
+ methods,
361
+ dismissQuietly: async () => {
362
+ await dismissSheet();
363
+ },
364
+ });
365
+ useRegisterSheet(name, handleRef);
298
366
 
299
367
  const drawerContentRef = useRef<HTMLDivElement | null>(null);
300
368
  const [drawerContent, setDrawerContent] = useState<HTMLDivElement | null>(null);
@@ -447,6 +515,22 @@ const TrueSheetComponent = forwardRef<TrueSheetMethods, TrueSheetProps>((props,
447
515
  scrollableRef,
448
516
  ]);
449
517
 
518
+ // Re-reveal the focused field once the sheet settles above the keyboard
519
+ useEffect(() => {
520
+ const drawer = drawerContentRef.current;
521
+ if (!keyboardViewport || !drawer) return undefined;
522
+ let canceled = false;
523
+ // Flush styles so this commit's snap transition shows up in getAnimations()
524
+ // eslint-disable-next-line no-void
525
+ void drawer.offsetHeight;
526
+ Promise.allSettled(drawer.getAnimations().map((a) => a.finished)).then(() => {
527
+ if (!canceled) revealFocusedElement(drawer);
528
+ });
529
+ return () => {
530
+ canceled = true;
531
+ };
532
+ }, [keyboardViewport]);
533
+
450
534
  // Below the last detent a vertical touch pan moves the sheet, not the
451
535
  // content — `[data-vaul-scroll-locked]` disables vertical touch panning on
452
536
  // the scroll container and everything inside it (see vaul/style.css).
@@ -519,6 +603,7 @@ const TrueSheetComponent = forwardRef<TrueSheetMethods, TrueSheetProps>((props,
519
603
  effectiveMaxContentHeight,
520
604
  peekHeight,
521
605
  peekContentHeight,
606
+ keyboardViewport,
522
607
  });
523
608
  geometryInputsRef.current = {
524
609
  effectiveDetached,
@@ -526,14 +611,18 @@ const TrueSheetComponent = forwardRef<TrueSheetMethods, TrueSheetProps>((props,
526
611
  effectiveMaxContentHeight,
527
612
  peekHeight,
528
613
  peekContentHeight,
614
+ keyboardViewport,
529
615
  };
530
616
 
531
617
  const computeDetentGeometry = useCallback(() => {
532
618
  const inputs = geometryInputsRef.current;
533
- const windowH = window.innerHeight;
534
- const effectiveH = inputs.effectiveDetached
535
- ? windowH - inputs.effectiveDetachedOffset
536
- : windowH;
619
+ const windowH = inputs.keyboardViewport?.height ?? window.innerHeight;
620
+ const bottomGap = inputs.effectiveDetached ? inputs.effectiveDetachedOffset : 0;
621
+ const effectiveH = windowH - bottomGap;
622
+ // Above a keyboard, fractions keep their full-height size (see vaul).
623
+ const fractionH = inputs.keyboardViewport
624
+ ? inputs.keyboardViewport.layoutHeight - bottomGap
625
+ : effectiveH;
537
626
  // Matches vaul's height ceiling: min(effectiveH, maxContentHeight).
538
627
  const ceiling =
539
628
  inputs.effectiveMaxContentHeight !== undefined
@@ -569,7 +658,7 @@ const TrueSheetComponent = forwardRef<TrueSheetMethods, TrueSheetProps>((props,
569
658
  for (const d of validDetentsRef.current) {
570
659
  const h =
571
660
  typeof d === 'number'
572
- ? Math.min(d * effectiveH, ceiling)
661
+ ? Math.min(d * fractionH, ceiling)
573
662
  : d === 'peek'
574
663
  ? Math.min(livePeekHeight, ceiling)
575
664
  : autoHeight;
@@ -706,6 +795,9 @@ const TrueSheetComponent = forwardRef<TrueSheetMethods, TrueSheetProps>((props,
706
795
  // Mirror native: dismissing a sheet takes every sheet stacked above it
707
796
  // (iOS dismisses from the presenter; Android runs dismissStack first).
708
797
  dismissDescendantsRef.current();
798
+ // Mirror native's lazy-mount cancel: dismissed before willPresent, no events
799
+ if (!didEmitWillPresentRef.current) return undefined;
800
+ didEmitWillPresentRef.current = false;
709
801
  // Pair willBlur with willDismiss on dismiss — mirrors native iOS
710
802
  // emitWillDismissEvents (blur fires before dismiss). willPresent is
711
803
  // deferred to `start()` below (needs the mounted drawer's geometry).
@@ -716,11 +808,20 @@ const TrueSheetComponent = forwardRef<TrueSheetMethods, TrueSheetProps>((props,
716
808
  const fireDone = () => {
717
809
  if (present) {
718
810
  isPresentingRef.current = false;
811
+ // A dismiss may already be underway (it lands on the next commit)
812
+ if (phaseRef.current === 'presenting') phaseRef.current = 'presented';
719
813
  onDidPresentRef.current?.({ nativeEvent: computeDetentInfo() } as DidPresentEvent);
720
814
  onDidFocusRef.current?.({ nativeEvent: null } as DidFocusEvent);
815
+ settle(resolvePresentRef);
816
+ if (shouldDismissOnPresentRef.current) {
817
+ shouldDismissOnPresentRef.current = false;
818
+ setIsOpen(false);
819
+ }
721
820
  } else {
821
+ phaseRef.current = 'dismissed';
722
822
  onDidBlurRef.current?.({ nativeEvent: null } as DidBlurEvent);
723
823
  onDidDismissRef.current?.({ nativeEvent: null } as DidDismissEvent);
824
+ settle(resolveDismissRef);
724
825
  }
725
826
  };
726
827
 
@@ -746,6 +847,7 @@ const TrueSheetComponent = forwardRef<TrueSheetMethods, TrueSheetProps>((props,
746
847
  // willPresent mirrors native iOS where viewWillAppear dispatches
747
848
  // both; the descendant-stack focus effect handles subsequent
748
849
  // transitions.
850
+ didEmitWillPresentRef.current = true;
749
851
  onWillPresentRef.current?.({ nativeEvent: computeDetentInfo() } as WillPresentEvent);
750
852
  onWillFocusRef.current?.({ nativeEvent: null } as WillFocusEvent);
751
853
  }
@@ -812,7 +914,7 @@ const TrueSheetComponent = forwardRef<TrueSheetMethods, TrueSheetProps>((props,
812
914
  }, [computeDetentInfo]);
813
915
 
814
916
  const { isNested, dismissAbove, descendants } = useSheetStack(
815
- methodsRef,
917
+ handleRef,
816
918
  drawerContentRef,
817
919
  isOpen,
818
920
  isFormSheet
@@ -822,7 +924,7 @@ const TrueSheetComponent = forwardRef<TrueSheetMethods, TrueSheetProps>((props,
822
924
  // already been popped from the stack, so `dismissAbove` can't find it.
823
925
  dismissDescendantsRef.current = () => {
824
926
  for (let i = descendants.length - 1; i >= 0; i--) {
825
- descendants[i]!.ref.current?.dismiss();
927
+ descendants[i]!.ref.current?.dismissQuietly();
826
928
  }
827
929
  };
828
930
 
@@ -1057,6 +1159,16 @@ const TrueSheetComponent = forwardRef<TrueSheetMethods, TrueSheetProps>((props,
1057
1159
  transition: isPresentingRef.current ? 'none' : 'background-color 200ms',
1058
1160
  };
1059
1161
 
1162
+ // Vaul fades the overlay through its opacity, so the dim opacity goes into the color
1163
+ const resolvedOverlayStyle = useMemo<React.CSSProperties>(() => {
1164
+ if (dimColor == null && dimOpacity == null) return overlayStyle;
1165
+ const percentage = Math.min(1, Math.max(0, dimOpacity ?? DEFAULT_DIM_OPACITY)) * 100;
1166
+ return {
1167
+ ...overlayStyle,
1168
+ backgroundColor: `color-mix(in srgb, ${String(dimColor ?? 'black')} ${percentage}%, transparent)`,
1169
+ };
1170
+ }, [dimColor, dimOpacity]);
1171
+
1060
1172
  const mergedContentStyle = useMemo<React.CSSProperties>(
1061
1173
  () => ({
1062
1174
  position: 'fixed',
@@ -1225,12 +1337,13 @@ const TrueSheetComponent = forwardRef<TrueSheetMethods, TrueSheetProps>((props,
1225
1337
  initialAnimated={shouldAutoPresent ? initialDetentAnimated : true}
1226
1338
  detachedWrapperStyle={wrapperStyle}
1227
1339
  contentHeight={measuredContentHeight}
1340
+ keyboardViewport={keyboardViewport}
1228
1341
  activeSnapPoint={activeSnapPoint}
1229
1342
  setActiveSnapPoint={handleSetActiveSnapPoint}
1230
1343
  {...snapPointsProps}
1231
1344
  >
1232
1345
  <Drawer.Portal container={portalContainer ?? undefined}>
1233
- <Drawer.Overlay style={overlayStyle} />
1346
+ <Drawer.Overlay style={resolvedOverlayStyle} />
1234
1347
  <Drawer.Content
1235
1348
  ref={handleDrawerRef}
1236
1349
  style={mergedContentStyle}
@@ -1281,6 +1394,8 @@ const TrueSheetComponent = forwardRef<TrueSheetMethods, TrueSheetProps>((props,
1281
1394
  );
1282
1395
  });
1283
1396
 
1397
+ const DEFAULT_DIM_OPACITY = 0.5;
1398
+
1284
1399
  const overlayStyle: React.CSSProperties = {
1285
1400
  position: 'fixed',
1286
1401
  inset: 0,
@@ -1329,6 +1444,20 @@ const visuallyHiddenStyle: React.CSSProperties = {
1329
1444
  border: 0,
1330
1445
  };
1331
1446
 
1447
+ type SheetPhase = 'dismissed' | 'presenting' | 'presented' | 'dismissing';
1448
+
1449
+ const settle = (resolveRef: RefObject<(() => void) | null>) => {
1450
+ const resolve = resolveRef.current;
1451
+ resolveRef.current = null;
1452
+ resolve?.();
1453
+ };
1454
+
1455
+ // Same as native
1456
+ const ALREADY_PRESENTED_WARNING =
1457
+ 'TrueSheet: sheet is already presented. Use resize() to change detent.';
1458
+ const ALREADY_DISMISSED_WARNING =
1459
+ 'TrueSheet: sheet is already dismissed. No need to dismiss it again.';
1460
+
1332
1461
  const STATIC_METHOD_ERROR =
1333
1462
  'Static methods are not supported on web. Use the useTrueSheet() hook instead.';
1334
1463