@lodev09/react-native-true-sheet 3.11.10 → 4.0.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/README.md +6 -5
- package/android/src/main/java/com/lodev09/truesheet/TrueSheetContainerView.kt +23 -2
- package/android/src/main/java/com/lodev09/truesheet/TrueSheetContentView.kt +116 -26
- package/android/src/main/java/com/lodev09/truesheet/TrueSheetContentViewManager.kt +7 -0
- package/android/src/main/java/com/lodev09/truesheet/TrueSheetFooterView.kt +48 -0
- package/android/src/main/java/com/lodev09/truesheet/TrueSheetFooterViewManager.kt +7 -0
- package/android/src/main/java/com/lodev09/truesheet/TrueSheetStateUpdater.kt +45 -0
- package/android/src/main/java/com/lodev09/truesheet/TrueSheetView.kt +28 -12
- package/android/src/main/java/com/lodev09/truesheet/TrueSheetViewController.kt +99 -34
- package/android/src/main/java/com/lodev09/truesheet/TrueSheetViewManager.kt +9 -5
- package/android/src/main/java/com/lodev09/truesheet/core/TrueSheetCoordinatorLayout.kt +12 -3
- package/android/src/main/java/com/lodev09/truesheet/core/TrueSheetDetentCalculator.kt +33 -4
- package/android/src/main/jni/TrueSheetSpec.h +2 -0
- package/android/src/main/jni/TrueSheetStateUpdater.cpp +96 -0
- package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetContentViewComponentDescriptor.h +24 -0
- package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetContentViewShadowNode.cpp +43 -0
- package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetContentViewShadowNode.h +28 -0
- package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetContentViewState.cpp +11 -0
- package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetContentViewState.h +37 -0
- package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetFooterViewComponentDescriptor.h +24 -0
- package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetFooterViewShadowNode.cpp +56 -0
- package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetFooterViewShadowNode.h +28 -0
- package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetFooterViewState.cpp +11 -0
- package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetFooterViewState.h +41 -0
- package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetInsets.cpp +18 -0
- package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetInsets.h +16 -0
- package/ios/TrueSheetContainerView.h +12 -10
- package/ios/TrueSheetContainerView.mm +11 -29
- package/ios/TrueSheetContentView.h +22 -7
- package/ios/TrueSheetContentView.mm +143 -41
- package/ios/TrueSheetFooterView.h +12 -2
- package/ios/TrueSheetFooterView.mm +74 -71
- package/ios/TrueSheetHeaderView.mm +0 -1
- package/ios/TrueSheetView.mm +60 -27
- package/ios/TrueSheetViewController.h +9 -0
- package/ios/TrueSheetViewController.mm +288 -122
- package/ios/core/TrueSheetDetentCalculator.h +9 -0
- package/ios/core/TrueSheetDetentCalculator.mm +13 -4
- package/ios/tvos/TrueSheetStubs.mm +2 -0
- package/lib/module/TrueSheet.js +32 -13
- package/lib/module/TrueSheet.js.map +1 -1
- package/lib/module/TrueSheet.web.js +262 -38
- package/lib/module/TrueSheet.web.js.map +1 -1
- package/lib/module/fabric/TrueSheetContentViewNativeComponent.ts +5 -1
- package/lib/module/fabric/TrueSheetFooterViewNativeComponent.ts +10 -2
- package/lib/module/fabric/TrueSheetViewNativeComponent.ts +8 -1
- package/lib/module/web/vaul/index.js +17 -6
- package/lib/module/web/vaul/index.js.map +1 -1
- package/lib/typescript/src/TrueSheet.d.ts.map +1 -1
- package/lib/typescript/src/TrueSheet.types.d.ts +35 -11
- package/lib/typescript/src/TrueSheet.types.d.ts.map +1 -1
- package/lib/typescript/src/TrueSheet.web.d.ts.map +1 -1
- package/lib/typescript/src/fabric/TrueSheetContentViewNativeComponent.d.ts.map +1 -1
- package/lib/typescript/src/fabric/TrueSheetFooterViewNativeComponent.d.ts +2 -0
- package/lib/typescript/src/fabric/TrueSheetFooterViewNativeComponent.d.ts.map +1 -1
- package/lib/typescript/src/fabric/TrueSheetViewNativeComponent.d.ts +5 -1
- package/lib/typescript/src/fabric/TrueSheetViewNativeComponent.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/web/vaul/index.d.ts +8 -1
- package/lib/typescript/src/web/vaul/index.d.ts.map +1 -1
- package/package.json +10 -10
- package/react-native.config.js +5 -1
- package/src/TrueSheet.tsx +45 -14
- package/src/TrueSheet.types.ts +38 -12
- package/src/TrueSheet.web.tsx +314 -44
- package/src/fabric/TrueSheetContentViewNativeComponent.ts +5 -1
- package/src/fabric/TrueSheetFooterViewNativeComponent.ts +10 -2
- package/src/fabric/TrueSheetViewNativeComponent.ts +8 -1
- package/src/navigation/types.ts +3 -1
- package/src/web/vaul/index.tsx +23 -5
- package/ios/utils/LayoutUtil.h +0 -77
- package/ios/utils/LayoutUtil.mm +0 -106
package/src/TrueSheet.web.tsx
CHANGED
|
@@ -10,8 +10,8 @@ import {
|
|
|
10
10
|
useRef,
|
|
11
11
|
useState,
|
|
12
12
|
} from 'react';
|
|
13
|
-
import type { LayoutChangeEvent } from 'react-native';
|
|
14
|
-
import { useColorScheme, useWindowDimensions, View } from 'react-native';
|
|
13
|
+
import type { LayoutChangeEvent, ViewStyle } from 'react-native';
|
|
14
|
+
import { StyleSheet, useColorScheme, useWindowDimensions, View } from 'react-native';
|
|
15
15
|
|
|
16
16
|
import type {
|
|
17
17
|
DetentChangeEvent,
|
|
@@ -54,6 +54,17 @@ import {
|
|
|
54
54
|
import { Drawer } from './web/vaul';
|
|
55
55
|
import { DEFAULT_PEEK_HEIGHT, TRANSITIONS } from './web/vaul/constants';
|
|
56
56
|
|
|
57
|
+
// First vertically scrollable descendant — web mirror of native's
|
|
58
|
+
// findScrollView (RN-web ScrollView/FlatList render a node with
|
|
59
|
+
// `overflow-y: auto`; its first child is the content container).
|
|
60
|
+
const findVerticalScroller = (root: HTMLElement): HTMLElement | null => {
|
|
61
|
+
for (const el of root.querySelectorAll<HTMLElement>('*')) {
|
|
62
|
+
const { overflowY } = window.getComputedStyle(el);
|
|
63
|
+
if (overflowY === 'auto' || overflowY === 'scroll') return el;
|
|
64
|
+
}
|
|
65
|
+
return null;
|
|
66
|
+
};
|
|
67
|
+
|
|
57
68
|
const TrueSheetComponent = forwardRef<TrueSheetMethods, TrueSheetProps>((props, ref) => {
|
|
58
69
|
const {
|
|
59
70
|
children,
|
|
@@ -76,9 +87,10 @@ const TrueSheetComponent = forwardRef<TrueSheetMethods, TrueSheetProps>((props,
|
|
|
76
87
|
initialDetentIndex = -1,
|
|
77
88
|
header,
|
|
78
89
|
headerStyle,
|
|
90
|
+
headerOptions,
|
|
79
91
|
footer,
|
|
80
92
|
footerStyle,
|
|
81
|
-
|
|
93
|
+
footerOptions,
|
|
82
94
|
presentation = 'page',
|
|
83
95
|
detached = false,
|
|
84
96
|
detachedOffset = DEFAULT_DETACHED_OFFSET,
|
|
@@ -151,6 +163,8 @@ const TrueSheetComponent = forwardRef<TrueSheetMethods, TrueSheetProps>((props,
|
|
|
151
163
|
const validDetentsRef = useRef(validDetents);
|
|
152
164
|
validDetentsRef.current = validDetents;
|
|
153
165
|
|
|
166
|
+
const hasAutoDetent = validDetents.includes('auto');
|
|
167
|
+
|
|
154
168
|
const handleSetActiveSnapPoint = useCallback((snapPoint: number | string | null) => {
|
|
155
169
|
setActiveSnapPoint(
|
|
156
170
|
snapPoint == null
|
|
@@ -260,9 +274,164 @@ const TrueSheetComponent = forwardRef<TrueSheetMethods, TrueSheetProps>((props,
|
|
|
260
274
|
const peekElRef = useRef<View>(null);
|
|
261
275
|
const peekContext = useMemo(() => ({ contentRef, peekRef: peekElRef, setPeekContentHeight }), []);
|
|
262
276
|
|
|
277
|
+
// An absolute (floating) header overlaps the content, so it contributes no
|
|
278
|
+
// height to the 'auto' detent measurement.
|
|
279
|
+
const absoluteHeader = headerOptions?.position === 'absolute';
|
|
280
|
+
const absoluteHeaderRef = useRef(absoluteHeader);
|
|
281
|
+
absoluteHeaderRef.current = absoluteHeader;
|
|
282
|
+
|
|
283
|
+
const resolvedHeaderStyle = absoluteHeader ? [absoluteHeaderStyle, headerStyle] : headerStyle;
|
|
284
|
+
|
|
285
|
+
// Same for an absolute (floating) footer — rendered via vaul's
|
|
286
|
+
// `detachedSiblings` instead of in the content flow.
|
|
287
|
+
const absoluteFooter = footerOptions?.position === 'absolute';
|
|
288
|
+
const absoluteFooterRef = useRef(absoluteFooter);
|
|
289
|
+
absoluteFooterRef.current = absoluteFooter;
|
|
290
|
+
|
|
291
|
+
// The footer owns the sheet's bottom edge, so it absorbs the bottom
|
|
292
|
+
// safe-area inset as padding (on top of its own) — mirrors native, where the
|
|
293
|
+
// footer shadow node pads the inset so its background fills it.
|
|
294
|
+
const footerOwnsInset = Boolean(footer) && insetAdjustment === 'automatic';
|
|
295
|
+
const resolvedFooterStyle = useMemo(() => {
|
|
296
|
+
if (!footerOwnsInset) return footerStyle;
|
|
297
|
+
|
|
298
|
+
const flat = StyleSheet.flatten(footerStyle);
|
|
299
|
+
const base = flat?.paddingBottom ?? flat?.paddingVertical ?? flat?.padding;
|
|
300
|
+
const baseCss =
|
|
301
|
+
typeof base === 'number' ? `${base}px` : typeof base === 'string' ? base : '0px';
|
|
302
|
+
return [
|
|
303
|
+
footerStyle,
|
|
304
|
+
{
|
|
305
|
+
paddingBottom: `calc(${baseCss} + env(safe-area-inset-bottom, 0px))`,
|
|
306
|
+
} as unknown as ViewStyle,
|
|
307
|
+
];
|
|
308
|
+
}, [footerOwnsInset, footerStyle]);
|
|
309
|
+
|
|
310
|
+
// A relative footer is laid out below the content, so it's pushed off-screen
|
|
311
|
+
// at the peek detent and contributes no height.
|
|
263
312
|
const peekHeight =
|
|
264
|
-
(header ? headerHeight : 0) +
|
|
265
|
-
|
|
313
|
+
(header ? headerHeight : 0) +
|
|
314
|
+
(footer && absoluteFooter ? footerHeight : 0) +
|
|
315
|
+
peekContentHeight || DEFAULT_PEEK_HEIGHT;
|
|
316
|
+
|
|
317
|
+
// Web mirror of native's scrollable handling for 'auto' detents: a plugged
|
|
318
|
+
// scrollable keeps the sized (bounded) layout so its viewport is capped to
|
|
319
|
+
// the visible sheet and can scroll, while the 'auto' height is measured with
|
|
320
|
+
// the viewport replaced by the scrollable's content size — mirrors native
|
|
321
|
+
// `naturalHeight`.
|
|
322
|
+
const [hasBoundedScrollable, setHasBoundedScrollable] = useState(false);
|
|
323
|
+
const [scrollableAutoHeight, setScrollableAutoHeight] = useState(0);
|
|
324
|
+
const pinnedScrollerRef = useRef<HTMLElement | null>(null);
|
|
325
|
+
|
|
326
|
+
// Natural content height (header + content + footer, with a pinned
|
|
327
|
+
// scrollable's viewport replaced by its content size) — the height the
|
|
328
|
+
// content wants regardless of the sheet's bounds.
|
|
329
|
+
const measureNaturalHeight = useCallback(() => {
|
|
330
|
+
const contentEl = contentRef.current as unknown as HTMLElement | null;
|
|
331
|
+
if (!contentEl || !contentEl.isConnected) return 0;
|
|
332
|
+
const headerEl = absoluteHeaderRef.current
|
|
333
|
+
? null
|
|
334
|
+
: (headerElRef.current as unknown as HTMLElement | null);
|
|
335
|
+
const footerEl = absoluteFooterRef.current
|
|
336
|
+
? null
|
|
337
|
+
: (footerElRef.current as unknown as HTMLElement | null);
|
|
338
|
+
let height =
|
|
339
|
+
(headerEl?.offsetHeight ?? 0) + (footerEl?.offsetHeight ?? 0) + contentEl.offsetHeight;
|
|
340
|
+
const scroller = pinnedScrollerRef.current;
|
|
341
|
+
const scrollContent = scroller?.firstElementChild;
|
|
342
|
+
if (scroller?.isConnected && scrollContent instanceof HTMLElement) {
|
|
343
|
+
height += scrollContent.offsetHeight - scroller.clientHeight;
|
|
344
|
+
}
|
|
345
|
+
return Math.max(0, height);
|
|
346
|
+
}, []);
|
|
347
|
+
|
|
348
|
+
useEffect(() => {
|
|
349
|
+
if (!isOpen || !hasAutoDetent) return undefined;
|
|
350
|
+
|
|
351
|
+
let canceled = false;
|
|
352
|
+
let rafId = 0;
|
|
353
|
+
let mutationObserver: MutationObserver | null = null;
|
|
354
|
+
let resizeObserver: ResizeObserver | null = null;
|
|
355
|
+
|
|
356
|
+
const attach = () => {
|
|
357
|
+
if (canceled) return;
|
|
358
|
+
const drawerEl = drawerContentRef.current;
|
|
359
|
+
if (!drawerEl || !drawerEl.isConnected) {
|
|
360
|
+
// Radix Presence defers the portal mount; poll until the drawer is live.
|
|
361
|
+
rafId = window.requestAnimationFrame(attach);
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const measure = () => setScrollableAutoHeight(measureNaturalHeight());
|
|
366
|
+
|
|
367
|
+
let observed: {
|
|
368
|
+
contentEl: HTMLElement | null;
|
|
369
|
+
headerEl: HTMLElement | null;
|
|
370
|
+
scroller: HTMLElement;
|
|
371
|
+
scrollContent: Element | null;
|
|
372
|
+
} | null = null;
|
|
373
|
+
|
|
374
|
+
// (Re)pin the first vertical scrollable and observe the nodes whose size
|
|
375
|
+
// feeds the natural height — content growth inside a bounded scroller is
|
|
376
|
+
// invisible to the sheet's own layout (mirrors native's contentSize
|
|
377
|
+
// observation). Resolves the content node live: the layout-branch swap
|
|
378
|
+
// (natural flow ↔ sized) remounts it.
|
|
379
|
+
const observe = () => {
|
|
380
|
+
const contentEl = contentRef.current as unknown as HTMLElement | null;
|
|
381
|
+
const headerEl = headerElRef.current as unknown as HTMLElement | null;
|
|
382
|
+
|
|
383
|
+
// Skip mutations that don't change the observed topology — e.g. a
|
|
384
|
+
// virtualized list mounting rows inside the pinned scroller, which
|
|
385
|
+
// would otherwise trigger a full-subtree rescan (findVerticalScroller
|
|
386
|
+
// resolves computed styles) on every scroll frame. Size changes are
|
|
387
|
+
// already covered by the ResizeObserver.
|
|
388
|
+
if (
|
|
389
|
+
observed &&
|
|
390
|
+
observed.contentEl === contentEl &&
|
|
391
|
+
contentEl?.isConnected &&
|
|
392
|
+
observed.headerEl === headerEl &&
|
|
393
|
+
observed.scroller.isConnected &&
|
|
394
|
+
observed.scrollContent === observed.scroller.firstElementChild
|
|
395
|
+
) {
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
const scroller =
|
|
400
|
+
contentEl && contentEl.isConnected ? findVerticalScroller(contentEl) : null;
|
|
401
|
+
pinnedScrollerRef.current = scroller;
|
|
402
|
+
setHasBoundedScrollable(scroller != null);
|
|
403
|
+
observed = scroller
|
|
404
|
+
? { contentEl, headerEl, scroller, scrollContent: scroller.firstElementChild }
|
|
405
|
+
: null;
|
|
406
|
+
|
|
407
|
+
resizeObserver?.disconnect();
|
|
408
|
+
resizeObserver = new ResizeObserver(measure);
|
|
409
|
+
if (contentEl?.isConnected) resizeObserver.observe(contentEl);
|
|
410
|
+
if (headerEl) resizeObserver.observe(headerEl);
|
|
411
|
+
if (scroller) {
|
|
412
|
+
resizeObserver.observe(scroller);
|
|
413
|
+
if (scroller.firstElementChild) resizeObserver.observe(scroller.firstElementChild);
|
|
414
|
+
}
|
|
415
|
+
measure();
|
|
416
|
+
};
|
|
417
|
+
|
|
418
|
+
observe();
|
|
419
|
+
// Watch the whole drawer subtree — catches the scrollable mounting/
|
|
420
|
+
// unmounting AND the layout-branch swap, which remounts the content node
|
|
421
|
+
// (a content-scoped observer would go stale after the swap).
|
|
422
|
+
mutationObserver = new MutationObserver(observe);
|
|
423
|
+
mutationObserver.observe(drawerEl, { childList: true, subtree: true });
|
|
424
|
+
};
|
|
425
|
+
|
|
426
|
+
rafId = window.requestAnimationFrame(attach);
|
|
427
|
+
|
|
428
|
+
return () => {
|
|
429
|
+
canceled = true;
|
|
430
|
+
window.cancelAnimationFrame(rafId);
|
|
431
|
+
mutationObserver?.disconnect();
|
|
432
|
+
resizeObserver?.disconnect();
|
|
433
|
+
};
|
|
434
|
+
}, [isOpen, hasAutoDetent, measureNaturalHeight]);
|
|
266
435
|
|
|
267
436
|
// Below the last detent a vertical touch pan moves the sheet, not the
|
|
268
437
|
// content — `[data-vaul-scroll-locked]` disables vertical touch panning on
|
|
@@ -365,15 +534,12 @@ const TrueSheetComponent = forwardRef<TrueSheetMethods, TrueSheetProps>((props,
|
|
|
365
534
|
inputs.effectiveMaxContentHeight !== undefined
|
|
366
535
|
? Math.min(effectiveH, inputs.effectiveMaxContentHeight)
|
|
367
536
|
: effectiveH;
|
|
368
|
-
// 'auto' resolves to the
|
|
537
|
+
// 'auto' resolves to the content's natural height. Read it live — the
|
|
369
538
|
// `measuredContentHeight` state lags mount by a frame (vaul's initial
|
|
370
539
|
// ref-callback measure runs while the portal subtree is still detached,
|
|
371
540
|
// so it reads 0 until the ResizeObserver fires). Falls back to the state
|
|
372
541
|
// value, then to vaul's pre-measure fallback (effectiveHeight / 2).
|
|
373
|
-
const
|
|
374
|
-
'[data-vaul-auto-size-wrapper]'
|
|
375
|
-
);
|
|
376
|
-
const measuredHeight = autoWrapper?.offsetHeight || inputs.measuredContentHeight;
|
|
542
|
+
const measuredHeight = measureNaturalHeight() || inputs.measuredContentHeight;
|
|
377
543
|
const autoHeight = Math.min(measuredHeight > 0 ? measuredHeight : effectiveH / 2, ceiling);
|
|
378
544
|
|
|
379
545
|
// 'peek' is measured live from the DOM (offsetHeight is layout-based, so
|
|
@@ -384,7 +550,10 @@ const TrueSheetComponent = forwardRef<TrueSheetMethods, TrueSheetProps>((props,
|
|
|
384
550
|
// Geometry only runs while the drawer is mounted, so the elements are
|
|
385
551
|
// measurable here; fall back to the state value when they're absent.
|
|
386
552
|
const headerEl = headerElRef.current as unknown as HTMLElement | null;
|
|
387
|
-
|
|
553
|
+
// A relative footer is pushed off-screen at the peek detent — excluded
|
|
554
|
+
const footerEl = absoluteFooterRef.current
|
|
555
|
+
? (footerElRef.current as unknown as HTMLElement | null)
|
|
556
|
+
: null;
|
|
388
557
|
const peekEl = peekElRef.current as unknown as HTMLElement | null;
|
|
389
558
|
const contentEl = contentRef.current as unknown as HTMLElement | null;
|
|
390
559
|
const livePeekContentHeight =
|
|
@@ -407,8 +576,8 @@ const TrueSheetComponent = forwardRef<TrueSheetMethods, TrueSheetProps>((props,
|
|
|
407
576
|
positions.push(effectiveH - h);
|
|
408
577
|
values.push(effectiveH > 0 ? h / effectiveH : 0);
|
|
409
578
|
}
|
|
410
|
-
return { windowH, positions, values };
|
|
411
|
-
}, []);
|
|
579
|
+
return { windowH, effectiveH, ceiling, positions, values };
|
|
580
|
+
}, [measureNaturalHeight]);
|
|
412
581
|
|
|
413
582
|
// Detent info for lifecycle events. Position/detent come from the active
|
|
414
583
|
// detent's target geometry — not the live DOM rect — so willPresent (drawer
|
|
@@ -487,14 +656,54 @@ const TrueSheetComponent = forwardRef<TrueSheetMethods, TrueSheetProps>((props,
|
|
|
487
656
|
[computeDetentGeometry]
|
|
488
657
|
);
|
|
489
658
|
|
|
659
|
+
// Mirror native synchronous layout: while dragging (and through the post-
|
|
660
|
+
// release settle) the sized layout tracks the sheet's visible height in
|
|
661
|
+
// realtime, clamped to the smallest detent — dragging below it slides the
|
|
662
|
+
// sheet out without resizing. Once settled, the inline height is cleared so
|
|
663
|
+
// the CSS calc() owns sizing again (adapts to viewport resizes at rest).
|
|
664
|
+
const sizedLayoutRef = useRef<HTMLDivElement>(null);
|
|
665
|
+
const isDraggingRef = useRef(false);
|
|
666
|
+
const isSettlingAfterDragRef = useRef(false);
|
|
667
|
+
const updateSizedLayoutHeight = useCallback(
|
|
668
|
+
(position: number) => {
|
|
669
|
+
const node = sizedLayoutRef.current;
|
|
670
|
+
if (!node) return;
|
|
671
|
+
|
|
672
|
+
const { effectiveH, ceiling, positions } = computeDetentGeometry();
|
|
673
|
+
if (positions.length === 0) return;
|
|
674
|
+
|
|
675
|
+
if (isSettlingAfterDragRef.current) {
|
|
676
|
+
const snap = activeSnapPointRef.current;
|
|
677
|
+
const index = snap != null ? validDetentsRef.current.indexOf(snap) : -1;
|
|
678
|
+
const target = index >= 0 ? positions[index]! : null;
|
|
679
|
+
if (target != null && Math.abs(position - target) < 1) {
|
|
680
|
+
isSettlingAfterDragRef.current = false;
|
|
681
|
+
// Restore the calc — height is an inline style, so clearing it would
|
|
682
|
+
// leave the div unsized (React won't re-apply it without a render)
|
|
683
|
+
node.style.height = SIZED_LAYOUT_HEIGHT;
|
|
684
|
+
return;
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
// positions[0] is the smallest detent's top-Y — its height is the clamp floor.
|
|
689
|
+
const minHeight = effectiveH - positions[0]!;
|
|
690
|
+
const height = Math.min(Math.max(effectiveH - position, minHeight), ceiling);
|
|
691
|
+
node.style.height = `${height}px`;
|
|
692
|
+
},
|
|
693
|
+
[computeDetentGeometry]
|
|
694
|
+
);
|
|
695
|
+
|
|
490
696
|
const handlePositionChange = useCallback(
|
|
491
697
|
(position: number) => {
|
|
698
|
+
if (isDraggingRef.current || isSettlingAfterDragRef.current) {
|
|
699
|
+
updateSizedLayoutHeight(position);
|
|
700
|
+
}
|
|
492
701
|
const { index, detent } = interpolateFromPosition(position);
|
|
493
702
|
onPositionChangeRef.current?.({
|
|
494
703
|
nativeEvent: { index, position, detent, realtime: true },
|
|
495
704
|
} as PositionChangeEvent);
|
|
496
705
|
},
|
|
497
|
-
[interpolateFromPosition]
|
|
706
|
+
[interpolateFromPosition, updateSizedLayoutHeight]
|
|
498
707
|
);
|
|
499
708
|
|
|
500
709
|
// Fire onMount once after first render. React-mount is the earliest point
|
|
@@ -612,7 +821,6 @@ const TrueSheetComponent = forwardRef<TrueSheetMethods, TrueSheetProps>((props,
|
|
|
612
821
|
|
|
613
822
|
// Vaul's `onDrag` fires once per pointermove while dragging; the first tick
|
|
614
823
|
// after an idle gap marks the drag boundary, so track it via a ref.
|
|
615
|
-
const isDraggingRef = useRef(false);
|
|
616
824
|
const handleDrag = useCallback(() => {
|
|
617
825
|
if (!isDraggingRef.current) {
|
|
618
826
|
isDraggingRef.current = true;
|
|
@@ -620,11 +828,17 @@ const TrueSheetComponent = forwardRef<TrueSheetMethods, TrueSheetProps>((props,
|
|
|
620
828
|
}
|
|
621
829
|
onDragChangeRef.current?.({ nativeEvent: computeDetentInfo(true) } as DragChangeEvent);
|
|
622
830
|
}, [computeDetentInfo]);
|
|
623
|
-
const handleRelease = useCallback(
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
831
|
+
const handleRelease = useCallback(
|
|
832
|
+
(_event: unknown, open: boolean) => {
|
|
833
|
+
if (!isDraggingRef.current) return;
|
|
834
|
+
isDraggingRef.current = false;
|
|
835
|
+
// Track the sized layout through the settle animation only when the sheet
|
|
836
|
+
// stays open — a dismissal slides out at the min-clamped height.
|
|
837
|
+
isSettlingAfterDragRef.current = open;
|
|
838
|
+
onDragEndRef.current?.({ nativeEvent: computeDetentInfo(true) } as DragEndEvent);
|
|
839
|
+
},
|
|
840
|
+
[computeDetentInfo]
|
|
841
|
+
);
|
|
628
842
|
|
|
629
843
|
const { isNested, dismissAbove, descendants } = useSheetStack(
|
|
630
844
|
methodsRef,
|
|
@@ -816,6 +1030,11 @@ const TrueSheetComponent = forwardRef<TrueSheetMethods, TrueSheetProps>((props,
|
|
|
816
1030
|
};
|
|
817
1031
|
}, [isOpen, descendants.length]);
|
|
818
1032
|
|
|
1033
|
+
// Definite-height flex layout (per-detent sizing) unless content must be
|
|
1034
|
+
// measured in natural flow: 'auto' detents (without a plugged scrollable —
|
|
1035
|
+
// those measure via `measureNaturalHeight`) and form-sheet content-fit sizing.
|
|
1036
|
+
const useSizedLayout = (!hasAutoDetent || hasBoundedScrollable) && !isFormSheet;
|
|
1037
|
+
|
|
819
1038
|
const effectiveCornerRadius = cornerRadius ?? DEFAULT_CORNER_RADIUS;
|
|
820
1039
|
|
|
821
1040
|
// Shadow cast upward from the sheet's top edge toward the background. Matches
|
|
@@ -846,12 +1065,20 @@ const TrueSheetComponent = forwardRef<TrueSheetMethods, TrueSheetProps>((props,
|
|
|
846
1065
|
borderTopRightRadius: effectiveCornerRadius,
|
|
847
1066
|
backgroundColor: backgroundColor as string,
|
|
848
1067
|
// Clip children to the rounded top so headers/content with their own
|
|
849
|
-
// background don't bleed past the corners.
|
|
850
|
-
|
|
1068
|
+
// background don't bleed past the corners. `clip` (not `hidden`): a
|
|
1069
|
+
// hidden box is still a scroll container, so browser focus-reveal (e.g.
|
|
1070
|
+
// tapping a button near the sheet bottom) can scroll-offset the drawer —
|
|
1071
|
+
// shifting content and blocking vaul's shouldDrag walk (scrollTop > 0).
|
|
1072
|
+
overflow: 'clip',
|
|
851
1073
|
// Lift content above iOS home indicator / bottom safe area when enabled.
|
|
852
|
-
|
|
1074
|
+
// A relative footer owns the inset instead (see resolvedFooterStyle); an
|
|
1075
|
+
// absolute footer floats over the content, so the content keeps its lift.
|
|
1076
|
+
paddingBottom:
|
|
1077
|
+
insetAdjustment === 'automatic' && !(footerOwnsInset && !absoluteFooter)
|
|
1078
|
+
? 'env(safe-area-inset-bottom, 0px)'
|
|
1079
|
+
: 0,
|
|
853
1080
|
}),
|
|
854
|
-
[backgroundColor, effectiveCornerRadius, insetAdjustment]
|
|
1081
|
+
[backgroundColor, effectiveCornerRadius, insetAdjustment, footerOwnsInset, absoluteFooter]
|
|
855
1082
|
);
|
|
856
1083
|
|
|
857
1084
|
const defaultGrabberColor =
|
|
@@ -980,6 +1207,13 @@ const TrueSheetComponent = forwardRef<TrueSheetMethods, TrueSheetProps>((props,
|
|
|
980
1207
|
initialAnimated={initialDetentAnimated}
|
|
981
1208
|
detachedWrapperStyle={wrapperStyle}
|
|
982
1209
|
onContentHeightChange={setMeasuredContentHeight}
|
|
1210
|
+
contentHeight={
|
|
1211
|
+
// Bounded scrollable → the auto-size wrapper measures 0 (sized
|
|
1212
|
+
// layout is absolute), so feed vaul the natural height instead.
|
|
1213
|
+
hasAutoDetent && hasBoundedScrollable && scrollableAutoHeight > 0
|
|
1214
|
+
? scrollableAutoHeight
|
|
1215
|
+
: undefined
|
|
1216
|
+
}
|
|
983
1217
|
activeSnapPoint={activeSnapPoint}
|
|
984
1218
|
setActiveSnapPoint={handleSetActiveSnapPoint}
|
|
985
1219
|
{...snapPointsProps}
|
|
@@ -991,9 +1225,9 @@ const TrueSheetComponent = forwardRef<TrueSheetMethods, TrueSheetProps>((props,
|
|
|
991
1225
|
style={mergedContentStyle}
|
|
992
1226
|
onPointerDownOutside={handlePointerDownOutside}
|
|
993
1227
|
detachedSiblings={
|
|
994
|
-
footer ? (
|
|
1228
|
+
footer && absoluteFooter ? (
|
|
995
1229
|
<div style={footerFloatStyle}>
|
|
996
|
-
<View ref={footerElRef} style={
|
|
1230
|
+
<View ref={footerElRef} style={resolvedFooterStyle} onLayout={handleFooterLayout}>
|
|
997
1231
|
{isValidElement(footer) ? footer : createElement(footer)}
|
|
998
1232
|
</View>
|
|
999
1233
|
</div>
|
|
@@ -1004,37 +1238,60 @@ const TrueSheetComponent = forwardRef<TrueSheetMethods, TrueSheetProps>((props,
|
|
|
1004
1238
|
{accessibilityOptions?.paneTitle ?? 'Sheet'}
|
|
1005
1239
|
</Drawer.Title>
|
|
1006
1240
|
{grabber && <Drawer.Handle style={handleStyle} />}
|
|
1007
|
-
{
|
|
1241
|
+
{useSizedLayout ? (
|
|
1008
1242
|
// vaul wraps children in `[data-vaul-auto-size-wrapper]` (display:
|
|
1009
1243
|
// flow-root) which doesn't honor descendant flex layout. Use an
|
|
1010
1244
|
// absolute fill sized to the visible portion (via vaul's
|
|
1011
1245
|
// `--snap-point-height` var) so the inner flex column has a
|
|
1012
|
-
// definite height for
|
|
1013
|
-
|
|
1246
|
+
// definite height for flex layouts to fill — mirrors native, where
|
|
1247
|
+
// the container is sized to the sheet's visible height per detent.
|
|
1248
|
+
<div
|
|
1249
|
+
ref={sizedLayoutRef}
|
|
1250
|
+
style={sizedLayoutStyle}
|
|
1251
|
+
data-vaul-scroll-locked={isScrollLocked ? '' : undefined}
|
|
1252
|
+
>
|
|
1014
1253
|
{header && (
|
|
1015
|
-
<View ref={headerElRef} style={
|
|
1254
|
+
<View ref={headerElRef} style={resolvedHeaderStyle} onLayout={handleHeaderLayout}>
|
|
1016
1255
|
{isValidElement(header) ? header : createElement(header)}
|
|
1017
1256
|
</View>
|
|
1018
1257
|
)}
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1258
|
+
{/* Content lays out naturally like native — fill only for auto
|
|
1259
|
+
detents with a plugged scrollable, where natural layout is
|
|
1260
|
+
circular (sheet height derives from the scroll content size).
|
|
1261
|
+
Mirrors the native shadow node's scrollableBounded behavior. */}
|
|
1262
|
+
<View
|
|
1263
|
+
ref={contentRef}
|
|
1264
|
+
style={hasAutoDetent && hasBoundedScrollable ? [contentFillStyle, style] : style}
|
|
1022
1265
|
>
|
|
1023
|
-
|
|
1024
|
-
|
|
1266
|
+
{children}
|
|
1267
|
+
</View>
|
|
1268
|
+
{footer && !absoluteFooter && (
|
|
1269
|
+
<View
|
|
1270
|
+
ref={footerElRef}
|
|
1271
|
+
style={[relativeFooterStyle, resolvedFooterStyle]}
|
|
1272
|
+
onLayout={handleFooterLayout}
|
|
1273
|
+
>
|
|
1274
|
+
{isValidElement(footer) ? footer : createElement(footer)}
|
|
1025
1275
|
</View>
|
|
1026
|
-
|
|
1276
|
+
)}
|
|
1027
1277
|
</div>
|
|
1028
1278
|
) : (
|
|
1279
|
+
// Natural flow so vaul can measure content height — required for
|
|
1280
|
+
// 'auto' detents and form-sheet content-fit sizing.
|
|
1029
1281
|
<>
|
|
1030
1282
|
{header && (
|
|
1031
|
-
<View ref={headerElRef} style={
|
|
1283
|
+
<View ref={headerElRef} style={resolvedHeaderStyle} onLayout={handleHeaderLayout}>
|
|
1032
1284
|
{isValidElement(header) ? header : createElement(header)}
|
|
1033
1285
|
</View>
|
|
1034
1286
|
)}
|
|
1035
1287
|
<View ref={contentRef} style={style}>
|
|
1036
1288
|
{children}
|
|
1037
1289
|
</View>
|
|
1290
|
+
{footer && !absoluteFooter && (
|
|
1291
|
+
<View ref={footerElRef} style={resolvedFooterStyle} onLayout={handleFooterLayout}>
|
|
1292
|
+
{isValidElement(footer) ? footer : createElement(footer)}
|
|
1293
|
+
</View>
|
|
1294
|
+
)}
|
|
1038
1295
|
</>
|
|
1039
1296
|
)}
|
|
1040
1297
|
</Drawer.Content>
|
|
@@ -1050,23 +1307,36 @@ const overlayStyle: React.CSSProperties = {
|
|
|
1050
1307
|
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
|
1051
1308
|
};
|
|
1052
1309
|
|
|
1053
|
-
|
|
1310
|
+
// Pinned to the sheet's bottom edge — takes up layout space below the content
|
|
1311
|
+
const relativeFooterStyle = {
|
|
1312
|
+
marginTop: 'auto',
|
|
1313
|
+
} as const;
|
|
1314
|
+
|
|
1315
|
+
// Floats over the content so it doesn't take up layout space
|
|
1316
|
+
const absoluteHeaderStyle = {
|
|
1317
|
+
position: 'absolute',
|
|
1318
|
+
top: 0,
|
|
1319
|
+
left: 0,
|
|
1320
|
+
right: 0,
|
|
1321
|
+
zIndex: 1,
|
|
1322
|
+
} as const;
|
|
1323
|
+
|
|
1324
|
+
const SIZED_LAYOUT_HEIGHT = 'calc(100% - var(--snap-point-height, 0px))';
|
|
1325
|
+
|
|
1326
|
+
const sizedLayoutStyle: React.CSSProperties = {
|
|
1054
1327
|
position: 'absolute',
|
|
1055
1328
|
top: 0,
|
|
1056
1329
|
left: 0,
|
|
1057
1330
|
right: 0,
|
|
1058
|
-
height:
|
|
1331
|
+
height: SIZED_LAYOUT_HEIGHT,
|
|
1059
1332
|
display: 'flex',
|
|
1060
1333
|
flexDirection: 'column',
|
|
1061
1334
|
};
|
|
1062
1335
|
|
|
1063
|
-
const
|
|
1336
|
+
const contentFillStyle = {
|
|
1064
1337
|
flex: 1,
|
|
1065
1338
|
minHeight: 0,
|
|
1066
|
-
|
|
1067
|
-
overscrollBehavior: 'contain',
|
|
1068
|
-
touchAction: 'pan-y',
|
|
1069
|
-
};
|
|
1339
|
+
} as const;
|
|
1070
1340
|
|
|
1071
1341
|
const visuallyHiddenStyle: React.CSSProperties = {
|
|
1072
1342
|
position: 'absolute',
|
|
@@ -5,4 +5,8 @@ export interface NativeProps extends ViewProps {
|
|
|
5
5
|
// No props needed - size will be controlled by parent
|
|
6
6
|
}
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
// interfaceOnly: shadow node/state/descriptor are custom (common/cpp) so the
|
|
9
|
+
// content can be bounded to the container when a ScrollView is pinned
|
|
10
|
+
export default codegenNativeComponent<NativeProps>('TrueSheetContentView', {
|
|
11
|
+
interfaceOnly: true,
|
|
12
|
+
});
|
|
@@ -1,8 +1,16 @@
|
|
|
1
1
|
import type { ViewProps } from 'react-native';
|
|
2
|
+
import type { WithDefault } from 'react-native/Libraries/Types/CodegenTypes';
|
|
2
3
|
import { codegenNativeComponent } from 'react-native';
|
|
3
4
|
|
|
4
5
|
export interface NativeProps extends ViewProps {
|
|
5
|
-
//
|
|
6
|
+
// Seeds the footer's first layout with the sheet's bottom safe-area inset
|
|
7
|
+
// (insetAdjustment: 'automatic') so a late-set footer doesn't need a second
|
|
8
|
+
// layout pass — and a visible sheet resize — once native pushes the inset
|
|
9
|
+
autoBottomInset?: WithDefault<boolean, false>;
|
|
6
10
|
}
|
|
7
11
|
|
|
8
|
-
|
|
12
|
+
// interfaceOnly: shadow node/state/descriptor are custom (common/cpp) so the
|
|
13
|
+
// footer can absorb the sheet's bottom safe-area inset as padding
|
|
14
|
+
export default codegenNativeComponent<NativeProps>('TrueSheetFooterView', {
|
|
15
|
+
interfaceOnly: true,
|
|
16
|
+
});
|
|
@@ -42,9 +42,16 @@ type ScrollableOptionsType = Readonly<{
|
|
|
42
42
|
}>;
|
|
43
43
|
|
|
44
44
|
type FooterOptionsType = Readonly<{
|
|
45
|
+
// Named uniquely across option structs — codegen derives the enum name from
|
|
46
|
+
// the field name, so a second `position` would redefine TrueSheetViewPosition
|
|
47
|
+
footerPosition?: WithDefault<'relative' | 'absolute', 'relative'>;
|
|
45
48
|
keyboardOffset?: WithDefault<Double, 0>;
|
|
46
49
|
}>;
|
|
47
50
|
|
|
51
|
+
type HeaderOptionsType = Readonly<{
|
|
52
|
+
position?: WithDefault<'relative' | 'absolute', 'relative'>;
|
|
53
|
+
}>;
|
|
54
|
+
|
|
48
55
|
export interface DetentInfoEventPayload {
|
|
49
56
|
index: Int32;
|
|
50
57
|
position: Double;
|
|
@@ -115,8 +122,8 @@ export interface NativeProps extends ViewProps {
|
|
|
115
122
|
draggable?: WithDefault<boolean, true>;
|
|
116
123
|
dimmed?: WithDefault<boolean, true>;
|
|
117
124
|
initialDetentAnimated?: WithDefault<boolean, true>;
|
|
118
|
-
scrollable?: WithDefault<boolean, false>;
|
|
119
125
|
scrollableOptions?: ScrollableOptionsType;
|
|
126
|
+
headerOptions?: HeaderOptionsType;
|
|
120
127
|
footerOptions?: FooterOptionsType;
|
|
121
128
|
presentation?: WithDefault<'page' | 'form', 'page'>;
|
|
122
129
|
|
package/src/navigation/types.ts
CHANGED
|
@@ -136,12 +136,14 @@ export type TrueSheetNavigationSheetProps = Pick<
|
|
|
136
136
|
| 'blurOptions'
|
|
137
137
|
| 'maxContentHeight'
|
|
138
138
|
| 'maxContentWidth'
|
|
139
|
-
| 'scrollable'
|
|
140
139
|
| 'presentation'
|
|
140
|
+
| 'style'
|
|
141
141
|
| 'header'
|
|
142
142
|
| 'headerStyle'
|
|
143
|
+
| 'headerOptions'
|
|
143
144
|
| 'footer'
|
|
144
145
|
| 'footerStyle'
|
|
146
|
+
| 'footerOptions'
|
|
145
147
|
| 'scrollableOptions'
|
|
146
148
|
| 'insetAdjustment'
|
|
147
149
|
| 'anchor'
|
package/src/web/vaul/index.tsx
CHANGED
|
@@ -197,6 +197,13 @@ export type DialogProps = {
|
|
|
197
197
|
* content (e.g. iPad-style form sheets).
|
|
198
198
|
*/
|
|
199
199
|
onContentHeightChange?: (height: number) => void;
|
|
200
|
+
/**
|
|
201
|
+
* Overrides the measured content height used to resolve the 'auto' snap
|
|
202
|
+
* point. Used when the caller bounds the content to the visible sheet
|
|
203
|
+
* (e.g. a plugged scrollable) so the auto-size wrapper can't be measured
|
|
204
|
+
* from natural flow.
|
|
205
|
+
*/
|
|
206
|
+
contentHeight?: number;
|
|
200
207
|
} & (WithFadeFromProps | WithoutFadeFromProps);
|
|
201
208
|
|
|
202
209
|
export function Root({
|
|
@@ -239,6 +246,7 @@ export function Root({
|
|
|
239
246
|
peekHeight,
|
|
240
247
|
initialAnimated = true,
|
|
241
248
|
onContentHeightChange,
|
|
249
|
+
contentHeight: contentHeightProp,
|
|
242
250
|
}: DialogProps) {
|
|
243
251
|
const [isOpen = false, setIsOpen] = useControllableState({
|
|
244
252
|
defaultProp: defaultOpen,
|
|
@@ -273,15 +281,16 @@ export function Root({
|
|
|
273
281
|
const drawerHeightRef = React.useRef(drawerRef.current?.getBoundingClientRect().height || 0);
|
|
274
282
|
const drawerWidthRef = React.useRef(drawerRef.current?.getBoundingClientRect().width || 0);
|
|
275
283
|
const initialDrawerHeight = React.useRef(0);
|
|
276
|
-
const [
|
|
284
|
+
const [measuredContentHeight, setContentHeight] = React.useState(0);
|
|
285
|
+
const contentHeight = contentHeightProp ?? measuredContentHeight;
|
|
277
286
|
|
|
278
287
|
const onContentHeightChangeRef = React.useRef(onContentHeightChange);
|
|
279
288
|
React.useEffect(() => {
|
|
280
289
|
onContentHeightChangeRef.current = onContentHeightChange;
|
|
281
290
|
});
|
|
282
291
|
React.useEffect(() => {
|
|
283
|
-
onContentHeightChangeRef.current?.(
|
|
284
|
-
}, [
|
|
292
|
+
onContentHeightChangeRef.current?.(measuredContentHeight);
|
|
293
|
+
}, [measuredContentHeight]);
|
|
285
294
|
|
|
286
295
|
const onSnapPointChange = React.useCallback((activeSnapPointIndex: number) => {
|
|
287
296
|
// Change openTime ref when we reach the last snap point to prevent dragging for 500ms incase it's scrollable.
|
|
@@ -771,7 +780,16 @@ export function Root({
|
|
|
771
780
|
dragEndTime.current = new Date();
|
|
772
781
|
const swipeAmount = getTranslate(drawerRef.current, direction);
|
|
773
782
|
|
|
774
|
-
|
|
783
|
+
// Drag overshoot below the lowest snap point translates the detached
|
|
784
|
+
// wrapper while the drawer stays pinned (see onDrag in use-snap-points) —
|
|
785
|
+
// common when a clamped 'auto' puts the lowest snap point at 0. Count the
|
|
786
|
+
// wrapper's translation as movement, or the release is swallowed and the
|
|
787
|
+
// sheet is left stuck at the overshoot position.
|
|
788
|
+
const wrapper = drawerRef.current.closest<HTMLElement>('[data-vaul-detached-wrapper]');
|
|
789
|
+
const wrapperSwipeAmount = wrapper ? getTranslate(wrapper, direction) : null;
|
|
790
|
+
const hasMoved = Boolean(swipeAmount) || Boolean(wrapperSwipeAmount);
|
|
791
|
+
|
|
792
|
+
if (!event || !shouldDrag(event.target, false) || !hasMoved || Number.isNaN(swipeAmount))
|
|
775
793
|
return;
|
|
776
794
|
|
|
777
795
|
if (dragStartTime.current === null) return;
|
|
@@ -825,7 +843,7 @@ export function Root({
|
|
|
825
843
|
|
|
826
844
|
const isHorizontalSwipe = direction === 'left' || direction === 'right';
|
|
827
845
|
if (
|
|
828
|
-
Math.abs(swipeAmount) >=
|
|
846
|
+
Math.abs(swipeAmount ?? 0) >=
|
|
829
847
|
(isHorizontalSwipe ? visibleDrawerWidth : visibleDrawerHeight) * closeThreshold
|
|
830
848
|
) {
|
|
831
849
|
closeDrawer();
|