@delacour/react-native-bottom-sheet 0.1.0-alpha.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. package/README.md +132 -0
  2. package/package.json +57 -0
  3. package/src/animation/AGENTS.md +58 -0
  4. package/src/animation/animation.types.ts +35 -0
  5. package/src/animation/resolve-animation.ts +75 -0
  6. package/src/animation/use-animate-to.ts +107 -0
  7. package/src/animation/use-settle-callbacks.ts +60 -0
  8. package/src/components/AGENTS.md +80 -0
  9. package/src/components/bottom-sheet-background.tsx +34 -0
  10. package/src/components/bottom-sheet-close.tsx +45 -0
  11. package/src/components/bottom-sheet-container.tsx +79 -0
  12. package/src/components/bottom-sheet-content.tsx +102 -0
  13. package/src/components/bottom-sheet-description.tsx +33 -0
  14. package/src/components/bottom-sheet-footer.tsx +97 -0
  15. package/src/components/bottom-sheet-handle.tsx +72 -0
  16. package/src/components/bottom-sheet-overlay.tsx +91 -0
  17. package/src/components/bottom-sheet-portal.tsx +96 -0
  18. package/src/components/bottom-sheet-text-input.tsx +36 -0
  19. package/src/components/bottom-sheet-title.tsx +30 -0
  20. package/src/components/bottom-sheet-trigger.tsx +51 -0
  21. package/src/components/bottom-sheet.context.tsx +134 -0
  22. package/src/components/bottom-sheet.tsx +475 -0
  23. package/src/components/bottom-sheet.types.ts +235 -0
  24. package/src/core/AGENTS.md +160 -0
  25. package/src/core/animation/select-animation.test.ts +57 -0
  26. package/src/core/animation/select-animation.ts +104 -0
  27. package/src/core/backdrop/backdrop-opacity.test.ts +49 -0
  28. package/src/core/backdrop/backdrop-opacity.ts +21 -0
  29. package/src/core/detents/dynamic-detent.test.ts +40 -0
  30. package/src/core/detents/dynamic-detent.ts +41 -0
  31. package/src/core/detents/index-for-height.test.ts +65 -0
  32. package/src/core/detents/index-for-height.ts +44 -0
  33. package/src/core/detents/normalize-detents.test.ts +72 -0
  34. package/src/core/detents/normalize-detents.ts +63 -0
  35. package/src/core/detents/over-drag.test.ts +46 -0
  36. package/src/core/detents/over-drag.ts +23 -0
  37. package/src/core/detents/select-snap-height.test.ts +52 -0
  38. package/src/core/detents/select-snap-height.ts +44 -0
  39. package/src/core/detents/sheet-state.test.ts +42 -0
  40. package/src/core/detents/sheet-state.ts +32 -0
  41. package/src/core/footer/body-inset.test.ts +95 -0
  42. package/src/core/footer/body-inset.ts +65 -0
  43. package/src/core/footer/bottom-band.test.ts +46 -0
  44. package/src/core/footer/bottom-band.ts +30 -0
  45. package/src/core/footer/footer-top.test.ts +61 -0
  46. package/src/core/footer/footer-top.ts +27 -0
  47. package/src/core/footer/sheet-insets.test.ts +27 -0
  48. package/src/core/footer/sheet-insets.ts +30 -0
  49. package/src/core/geometry/clamp-height.test.ts +25 -0
  50. package/src/core/geometry/clamp-height.ts +12 -0
  51. package/src/core/geometry/closed-height.test.ts +37 -0
  52. package/src/core/geometry/closed-height.ts +29 -0
  53. package/src/core/geometry/detached-frame.test.ts +50 -0
  54. package/src/core/geometry/detached-frame.ts +49 -0
  55. package/src/core/geometry/position.test.ts +21 -0
  56. package/src/core/geometry/position.ts +13 -0
  57. package/src/core/geometry/surface-height.test.ts +30 -0
  58. package/src/core/geometry/surface-height.ts +22 -0
  59. package/src/core/haptic/crossed-detent.test.ts +43 -0
  60. package/src/core/haptic/crossed-detent.ts +33 -0
  61. package/src/core/index.ts +92 -0
  62. package/src/core/intent/layout-ready.test.ts +37 -0
  63. package/src/core/intent/layout-ready.ts +24 -0
  64. package/src/core/intent/resolve-intent.test.ts +163 -0
  65. package/src/core/intent/resolve-intent.ts +98 -0
  66. package/src/core/keyboard/container-layout-guard.test.ts +32 -0
  67. package/src/core/keyboard/container-layout-guard.ts +31 -0
  68. package/src/core/keyboard/content-area.test.ts +34 -0
  69. package/src/core/keyboard/content-area.ts +26 -0
  70. package/src/core/keyboard/keyboard-lift.test.ts +54 -0
  71. package/src/core/keyboard/keyboard-lift.ts +47 -0
  72. package/src/core/keyboard/keyboard-owner.test.ts +35 -0
  73. package/src/core/keyboard/keyboard-owner.ts +25 -0
  74. package/src/core/keyboard/keyboard-ownership.test.ts +69 -0
  75. package/src/core/keyboard/keyboard-ownership.ts +39 -0
  76. package/src/core/keyboard/keyboard-reset-guard.test.ts +33 -0
  77. package/src/core/keyboard/keyboard-reset-guard.ts +33 -0
  78. package/src/core/keyboard/keyboard-step.test.ts +66 -0
  79. package/src/core/keyboard/keyboard-step.ts +53 -0
  80. package/src/core/machine/define-sheet-machine.test.ts +187 -0
  81. package/src/core/machine/define-sheet-machine.ts +35 -0
  82. package/src/core/machine/index.ts +23 -0
  83. package/src/core/machine/machine.types.ts +80 -0
  84. package/src/core/machine/step-transition.test.ts +46 -0
  85. package/src/core/machine/step-transition.ts +59 -0
  86. package/src/core/machine/transition.test.ts +51 -0
  87. package/src/core/machine/transition.ts +58 -0
  88. package/src/core/result.test.ts +15 -0
  89. package/src/core/result.ts +20 -0
  90. package/src/core/scroll/scroll-lock.test.ts +40 -0
  91. package/src/core/scroll/scroll-lock.ts +35 -0
  92. package/src/core/scroll/scroll-pan.test.ts +99 -0
  93. package/src/core/scroll/scroll-pan.ts +96 -0
  94. package/src/core/sheet.types.ts +93 -0
  95. package/src/display-name.test.ts +181 -0
  96. package/src/docs.test.ts +45 -0
  97. package/src/flat-worklet.test.ts +130 -0
  98. package/src/gesture/AGENTS.md +71 -0
  99. package/src/gesture/gesture.types.ts +37 -0
  100. package/src/gesture/use-scroll-lock.ts +162 -0
  101. package/src/gesture/use-sheet-pan.ts +250 -0
  102. package/src/index.ts +127 -0
  103. package/src/keyboard/AGENTS.md +84 -0
  104. package/src/keyboard/use-bottom-sheet-text-input.ts +79 -0
  105. package/src/keyboard/use-keyboard-animation-guard.ts +59 -0
  106. package/src/keyboard/use-sheet-keyboard.ts +163 -0
  107. package/src/layout/AGENTS.md +52 -0
  108. package/src/layout/use-container-layout.ts +63 -0
  109. package/src/layout/use-measure-height.ts +40 -0
  110. package/src/lib/AGENTS.md +35 -0
  111. package/src/lib/compose-refs.ts +37 -0
  112. package/src/lib/merge-props.ts +42 -0
  113. package/src/lib/slot.tsx +44 -0
  114. package/src/lib/use-controllable-state.ts +45 -0
  115. package/src/no-classname.test.ts +27 -0
  116. package/src/portal/AGENTS.md +101 -0
  117. package/src/portal/bottom-sheet-host.tsx +67 -0
  118. package/src/portal/bottom-sheet-provider.tsx +33 -0
  119. package/src/portal/host.context.tsx +25 -0
  120. package/src/portal/sheet-registry.context.tsx +124 -0
  121. package/src/portal/sheet-registry.test.ts +87 -0
  122. package/src/portal/sheet-registry.ts +78 -0
  123. package/src/purity.test.ts +90 -0
  124. package/src/scrollable/AGENTS.md +123 -0
  125. package/src/scrollable/bottom-sheet-flat-list.tsx +15 -0
  126. package/src/scrollable/bottom-sheet-scroll-view.tsx +15 -0
  127. package/src/scrollable/bottom-sheet-section-list.tsx +22 -0
  128. package/src/scrollable/create-bottom-sheet-scrollable.tsx +248 -0
  129. package/src/scrollable/scrollable.types.ts +76 -0
  130. package/src/source-tree.test.ts +33 -0
  131. package/src/state/AGENTS.md +63 -0
  132. package/src/state/state.types.ts +134 -0
  133. package/src/state/use-detents.ts +43 -0
  134. package/src/state/use-sheet-geometry.ts +178 -0
  135. package/src/state/use-sheet-intents.ts +144 -0
  136. package/src/state/use-sheet-state.ts +157 -0
  137. package/src/steps/AGENTS.md +100 -0
  138. package/src/steps/bottom-sheet-step.tsx +61 -0
  139. package/src/steps/bottom-sheet-steps.tsx +215 -0
  140. package/src/steps/steps.context.tsx +73 -0
  141. package/src/steps/steps.types.ts +61 -0
  142. package/src/steps/use-sheet-machine.ts +70 -0
  143. package/src/steps/use-sheet-step.ts +29 -0
@@ -0,0 +1,45 @@
1
+ import { type ReactElement, useCallback } from "react";
2
+ import { type GestureResponderEvent, Pressable } from "react-native";
3
+ import { Slot } from "../lib/slot";
4
+ import { useBottomSheet } from "./bottom-sheet.context";
5
+ import type { BottomSheetCloseProps } from "./bottom-sheet.types";
6
+
7
+ /**
8
+ * The dismiss control. A `Pressable` on its own; with `asChild` it hands
9
+ * `onPress` to the child, the way `Trigger` does and for the same reason.
10
+ *
11
+ * Closing goes through the same path a swipe-down and an overlay press do, so
12
+ * a caller has one `onOpenChange` to watch rather than three.
13
+ */
14
+ export function BottomSheetClose({
15
+ asChild = false,
16
+ children,
17
+ onPress,
18
+ accessibilityLabel = "Close",
19
+ ...props
20
+ }: BottomSheetCloseProps): ReactElement {
21
+ const { close } = useBottomSheet();
22
+
23
+ const handlePress = useCallback(
24
+ (event: GestureResponderEvent) => {
25
+ close();
26
+ onPress?.(event);
27
+ },
28
+ [close, onPress]
29
+ );
30
+
31
+ if (asChild) {
32
+ return (
33
+ <Slot accessibilityLabel={accessibilityLabel} onPress={handlePress} {...props}>
34
+ {children}
35
+ </Slot>
36
+ );
37
+ }
38
+
39
+ return (
40
+ <Pressable accessibilityLabel={accessibilityLabel} accessibilityRole="button" onPress={handlePress} {...props}>
41
+ {children}
42
+ </Pressable>
43
+ );
44
+ }
45
+ BottomSheetClose.displayName = "DelacourBottomSheet.BottomSheet.Close";
@@ -0,0 +1,79 @@
1
+ import { type ReactElement, useEffect } from "react";
2
+ import { StyleSheet } from "react-native";
3
+ import Animated, { useAnimatedStyle } from "react-native-reanimated";
4
+ import { detachedFrame } from "../core";
5
+ import { useBottomSheet, useBottomSheetInternal } from "./bottom-sheet.context";
6
+ import type { BottomSheetContainerProps } from "./bottom-sheet.types";
7
+
8
+ /**
9
+ * The panel — the surface that moves.
10
+ *
11
+ * It fills the frame and is translated down by the geometry's `position`, so
12
+ * `height` pixels of it show above the resting bottom line; over-dragged past
13
+ * the top it never uncovers its own bottom edge, because it is as tall as the
14
+ * frame. `Background`, `Handle` and `Content` are written inside it in flow
15
+ * order; the background is an absolute fill, the handle and the body stack.
16
+ *
17
+ * **It is not `accessible`.** An accessible container collapses its whole
18
+ * subtree into one element on iOS, and every field, button and line of copy
19
+ * inside becomes unreachable to VoiceOver. Instead the panel is a modal view
20
+ * while open under an overlay, so a screen reader stays inside it, and its
21
+ * descendants are hidden while it is closed, so the app behind it is what a
22
+ * screen reader finds. The title labels it.
23
+ *
24
+ * A container written without a `Handle` reports a handle height of zero on
25
+ * mount, so a handle-less sheet is not a sheet that never becomes ready.
26
+ */
27
+ export function BottomSheetContainer({ children, style, ref, ...props }: BottomSheetContainerProps): ReactElement {
28
+ const { geometry, state, hasOverlay, handleMounted, titleId, detached } = useBottomSheetInternal();
29
+ const { isOpen } = useBottomSheet();
30
+ const position = geometry.position;
31
+ const containerWidth = state.containerWidth;
32
+ const config = state.config;
33
+
34
+ useEffect(() => {
35
+ if (!handleMounted.current && state.handleHeight.value < 0) state.handleHeight.value = 0;
36
+ }, [handleMounted, state]);
37
+
38
+ // A detached card is inset from the frame's sides by `detachedFrame`; an
39
+ // attached panel spans it. Both on the UI thread, off the measured width,
40
+ // so a rotation re-derives the inset with no render. The panel stays as
41
+ // tall as the frame either way, so it is `box-none` when detached: the
42
+ // surface, the handle and the body take their touches, and a touch in the
43
+ // gap under the card — where only the transparent panel is — falls through
44
+ // to the overlay behind it.
45
+ const animatedStyle = useAnimatedStyle(() => {
46
+ const options = config.value.detached;
47
+ if (options === null || !(containerWidth.value > 0)) {
48
+ return { left: 0, right: 0, width: undefined, transform: [{ translateY: position.value }] };
49
+ }
50
+ const frame = detachedFrame({
51
+ containerWidth: containerWidth.value,
52
+ horizontalMargin: options.horizontalMargin,
53
+ bottomOffset: options.bottomOffset,
54
+ bottomInset: config.value.bottomInset,
55
+ });
56
+ return { left: frame.left, right: undefined, width: frame.width, transform: [{ translateY: position.value }] };
57
+ });
58
+
59
+ return (
60
+ <Animated.View
61
+ accessibilityElementsHidden={!isOpen}
62
+ accessibilityLabelledBy={titleId}
63
+ accessibilityViewIsModal={isOpen && hasOverlay}
64
+ importantForAccessibility={isOpen ? "auto" : "no-hide-descendants"}
65
+ pointerEvents={detached === null ? "auto" : "box-none"}
66
+ ref={ref}
67
+ style={[styles.panel, detached === null ? null : styles.detached, style, animatedStyle]}
68
+ {...props}
69
+ >
70
+ {children}
71
+ </Animated.View>
72
+ );
73
+ }
74
+ BottomSheetContainer.displayName = "DelacourBottomSheet.BottomSheet.Container";
75
+
76
+ const styles = StyleSheet.create({
77
+ panel: { bottom: 0, flexDirection: "column", left: 0, position: "absolute", right: 0, top: 0 },
78
+ detached: { overflow: "visible" },
79
+ });
@@ -0,0 +1,102 @@
1
+ import { type ReactElement, useCallback } from "react";
2
+ import { type LayoutChangeEvent, StyleSheet, View } from "react-native";
3
+ import { GestureDetector } from "react-native-gesture-handler";
4
+ import Animated, { useAnimatedStyle } from "react-native-reanimated";
5
+ import { useMeasureHeight } from "../layout/use-measure-height";
6
+ import { useBottomSheetInternal } from "./bottom-sheet.context";
7
+ import type { BottomSheetContentProps } from "./bottom-sheet.types";
8
+
9
+ /**
10
+ * Before the first layout there is no content area to clamp to, and a
11
+ * `maxHeight` of zero would clip the body while it measures itself. Tall
12
+ * enough that no sheet reaches it.
13
+ */
14
+ const UNCLAMPED = 100_000;
15
+
16
+ /**
17
+ * The static body — what a caller writes the title, the copy and the controls
18
+ * into.
19
+ *
20
+ * Three boxes. The outermost is the content pan's detector and the clip: its
21
+ * `maxHeight` follows the geometry's `bodyClip` on the UI thread, so under a
22
+ * sticky footer nothing of the body is drawn below the footer's top edge, at
23
+ * rest or while the sheet is dragged below its detent and the footer holds
24
+ * the screen's bottom. The middle box is the layout: `contentArea` plus the
25
+ * `bodyInset` the body reserves at its end, which reaches the sheet's bottom
26
+ * line, and it never reflows during a snap between detents. The innermost is
27
+ * the caller's — it takes the `style` — and its measured height is what a
28
+ * sheet sized to its content grows to. Yoga lays the inner box out at its
29
+ * natural height whatever the outer clamps say, which is what makes the
30
+ * measurement honest.
31
+ *
32
+ * A trailing spacer the height of the body's inset — the sticky footer, band
33
+ * included, or the band alone — keeps the last line of content above either,
34
+ * and `footerGap` adds to it above a footer.
35
+ */
36
+ export function BottomSheetContent({
37
+ children,
38
+ style,
39
+ ref,
40
+ footerGap = 0,
41
+ ...props
42
+ }: BottomSheetContentProps): ReactElement {
43
+ const { state, geometry, pans, detached, contentHeightSource } = useBottomSheetInternal();
44
+ const measure = useMeasureHeight(state.contentHeight);
45
+ // A `Steps` body drives `contentHeight` itself; its layout events here
46
+ // would be the stack mid-animation, a frame stale.
47
+ const onLayout = useCallback(
48
+ (event: LayoutChangeEvent) => {
49
+ if (contentHeightSource.current === "steps") return;
50
+ measure(event);
51
+ },
52
+ [contentHeightSource, measure]
53
+ );
54
+ const { contentArea, bodyInset, bodyClip, layoutReady, surfaceHeight } = geometry;
55
+ const isDetached = detached !== null;
56
+
57
+ // A detached card's surface is only as tall as the sheet, so its body is
58
+ // also held to what the surface shows, or the opening animation would draw
59
+ // the body under the card. The surface keeps its detent's height while the
60
+ // card slides closed, so the body slides with it, unchanged.
61
+ const clip = useAnimatedStyle(() => {
62
+ if (!layoutReady.value) return { maxHeight: UNCLAMPED };
63
+ let height = bodyClip.value;
64
+ if (isDetached) {
65
+ const shown = surfaceHeight.value - state.handleHeight.value;
66
+ height = Math.min(height, shown > 0 ? shown : 0);
67
+ }
68
+ return { maxHeight: height };
69
+ });
70
+
71
+ // A ceiling, so the body keeps its natural height under it — except under
72
+ // `fillParent`, where the body is the space above the keyboard and a flex
73
+ // child inside it is meant to shrink to fit.
74
+ const layout = useAnimatedStyle(() => {
75
+ const area = layoutReady.value ? contentArea.value + bodyInset.value : UNCLAMPED;
76
+ return state.config.value.keyboardBehavior === "fillParent" && layoutReady.value
77
+ ? { height: area, maxHeight: area }
78
+ : { height: undefined, maxHeight: area };
79
+ });
80
+
81
+ const spacer = useAnimatedStyle(() => ({
82
+ height: bodyInset.value + (state.config.value.hasFooter ? footerGap : 0),
83
+ }));
84
+
85
+ return (
86
+ <GestureDetector gesture={pans.content}>
87
+ <Animated.View style={[styles.clip, clip]}>
88
+ <Animated.View style={layout}>
89
+ <View onLayout={onLayout} ref={ref} style={style} {...props}>
90
+ {children}
91
+ </View>
92
+ <Animated.View style={spacer} />
93
+ </Animated.View>
94
+ </Animated.View>
95
+ </GestureDetector>
96
+ );
97
+ }
98
+ BottomSheetContent.displayName = "DelacourBottomSheet.BottomSheet.Content";
99
+
100
+ const styles = StyleSheet.create({
101
+ clip: { overflow: "hidden" },
102
+ });
@@ -0,0 +1,33 @@
1
+ import type { ReactElement } from "react";
2
+ import { Text } from "react-native";
3
+ import { Slot } from "../lib/slot";
4
+ import { useBottomSheetInternal } from "./bottom-sheet.context";
5
+ import type { BottomSheetDescriptionProps } from "./bottom-sheet.types";
6
+
7
+ /**
8
+ * Supporting copy under the title. Publishes a `nativeID` for the same reason
9
+ * `Title` does, so a skin can describe the panel by it. A `Text` on its own;
10
+ * with `asChild`, the id lands on the child.
11
+ */
12
+ export function BottomSheetDescription({
13
+ asChild = false,
14
+ children,
15
+ ...props
16
+ }: BottomSheetDescriptionProps): ReactElement {
17
+ const { descriptionId } = useBottomSheetInternal();
18
+
19
+ if (asChild) {
20
+ return (
21
+ <Slot nativeID={descriptionId} {...props}>
22
+ {children}
23
+ </Slot>
24
+ );
25
+ }
26
+
27
+ return (
28
+ <Text nativeID={descriptionId} {...props}>
29
+ {children}
30
+ </Text>
31
+ );
32
+ }
33
+ BottomSheetDescription.displayName = "DelacourBottomSheet.BottomSheet.Description";
@@ -0,0 +1,97 @@
1
+ import { type ReactElement, useLayoutEffect, useMemo } from "react";
2
+ import { StyleSheet, View, type ViewStyle } from "react-native";
3
+ import Animated, { useAnimatedStyle } from "react-native-reanimated";
4
+ import { bottomBand } from "../core";
5
+ import { useMeasureHeight } from "../layout/use-measure-height";
6
+ import { useBottomSheetInternal } from "./bottom-sheet.context";
7
+ import type { BottomSheetFooterProps } from "./bottom-sheet.types";
8
+
9
+ /**
10
+ * The footer — a row that stays put while the body scrolls and rides the
11
+ * keyboard up by transform.
12
+ *
13
+ * Sticky (the default), it is absolutely positioned at the top of the panel
14
+ * and translated to the geometry's `footerTop`, which the core proves is
15
+ * constant for the whole of a keyboard animation: the keyboard's pixels come
16
+ * off the lift in step with `progress`, so the footer's bottom lands on the
17
+ * keyboard's top edge without ever moving. Its one view — the one that takes
18
+ * `style` and `padding` — pads through the resting safe-area band as well, so
19
+ * the surface a caller styles reaches the sheet's bottom line and its
20
+ * background covers the band: while the sheet is dragged below its detent the
21
+ * footer holds the screen's bottom edge and the body slides down behind it,
22
+ * and a transparent band would show every line that passed. The box is
23
+ * measured less the band into `footerContentHeight`, which the dynamic detent
24
+ * counts. The band is constant on purpose — the footer's translate does the
25
+ * collapsing, and padding that shrank as well would move the footer twice.
26
+ *
27
+ * The bottom padding is composed on the JS side from the flattened style, so a
28
+ * `paddingBottom`, `paddingVertical` or `padding` a caller writes still
29
+ * applies above the band; one given as a string cannot be added to and is
30
+ * read as zero.
31
+ *
32
+ * Mounting tells the root there is a footer — synchronously, so the flag is
33
+ * in the config before any measurement lands — and the layout then waits
34
+ * for the footer's own measurement before the first open, while the body
35
+ * reserves a trailing space its height. Written inside `Container`, after
36
+ * the body.
37
+ *
38
+ * `sticky={false}` is a plain `View` for a footer that should scroll with
39
+ * the content; write it inside the body then.
40
+ */
41
+ export function BottomSheetFooter({
42
+ children,
43
+ style,
44
+ ref,
45
+ sticky = true,
46
+ padding,
47
+ ...props
48
+ }: BottomSheetFooterProps): ReactElement {
49
+ const { state, geometry, setHasFooter, detached, bottomInset } = useBottomSheetInternal();
50
+ const band = bottomBand(detached !== null, bottomInset);
51
+ const onLayout = useMeasureHeight(state.footerContentHeight, band);
52
+ const { footerTop } = geometry;
53
+
54
+ // A layout effect, not a passive one: the flag has to reach the root's
55
+ // config before the handle and the content report their heights, or the
56
+ // first open resolves on a detent with no footer in it and the mount
57
+ // animation, already running, keeps the corrected list from applying.
58
+ useLayoutEffect(() => {
59
+ if (!sticky) return;
60
+ setHasFooter(true);
61
+ return () => setHasFooter(false);
62
+ }, [sticky, setHasFooter]);
63
+
64
+ const translate = useAnimatedStyle(() => ({
65
+ transform: [{ translateY: footerTop.value }],
66
+ }));
67
+
68
+ const inner = useMemo<ViewStyle>(() => {
69
+ const flat = StyleSheet.flatten([padding === undefined ? null : { padding }, style]) ?? {};
70
+ return flat;
71
+ }, [padding, style]);
72
+ const surface = useMemo<ViewStyle>(() => {
73
+ if (!sticky) return inner;
74
+ const own = inner.paddingBottom ?? inner.paddingVertical ?? inner.padding ?? 0;
75
+ return { ...inner, paddingBottom: (typeof own === "number" ? own : 0) + band };
76
+ }, [inner, sticky, band]);
77
+
78
+ if (!sticky) {
79
+ return (
80
+ <View ref={ref} style={surface} {...props}>
81
+ {children}
82
+ </View>
83
+ );
84
+ }
85
+ return (
86
+ <Animated.View pointerEvents="box-none" style={[styles.sticky, translate]}>
87
+ <View onLayout={onLayout} ref={ref} style={surface} {...props}>
88
+ {children}
89
+ </View>
90
+ </Animated.View>
91
+ );
92
+ }
93
+ BottomSheetFooter.displayName = "DelacourBottomSheet.BottomSheet.Footer";
94
+
95
+ const styles = StyleSheet.create({
96
+ sticky: { left: 0, position: "absolute", right: 0, top: 0, zIndex: 1 },
97
+ });
@@ -0,0 +1,72 @@
1
+ import { type ReactElement, useCallback, useEffect } from "react";
2
+ import { type AccessibilityActionEvent, View } from "react-native";
3
+ import { GestureDetector } from "react-native-gesture-handler";
4
+ import { useMeasureHeight } from "../layout/use-measure-height";
5
+ import { useBottomSheet, useBottomSheetInternal } from "./bottom-sheet.context";
6
+ import type { BottomSheetHandleProps } from "./bottom-sheet.types";
7
+
8
+ const ACTIONS = [{ name: "increment" }, { name: "decrement" }, { name: "escape" }] as const;
9
+
10
+ /**
11
+ * The grabber's row, and the sheet's one adjustable accessibility element.
12
+ *
13
+ * It draws nothing of its own — the skin puts the pill inside it — but it
14
+ * owns the handle pan and reports its height into the layout, so a sheet
15
+ * sized to its content counts it. A handle that is never written is a handle
16
+ * of height zero; see `Container`.
17
+ *
18
+ * For a screen reader it is the sheet: `adjustable`, with the detent as its
19
+ * value, increment and decrement moving one detent either way, and the escape
20
+ * gesture closing. Nothing else in the sheet has to know about detents.
21
+ */
22
+ export function BottomSheetHandle({
23
+ style,
24
+ ref,
25
+ enablePanningGesture,
26
+ accessibilityLabel = "Bottom sheet",
27
+ ...props
28
+ }: BottomSheetHandleProps): ReactElement {
29
+ const { state, pans, handleMounted, enableHandlePanningGesture } = useBottomSheetInternal();
30
+ const { index, detentCount: count, snapToIndex, close } = useBottomSheet();
31
+ const onLayout = useMeasureHeight(state.handleHeight);
32
+
33
+ useEffect(() => {
34
+ handleMounted.current = true;
35
+ return () => {
36
+ handleMounted.current = false;
37
+ };
38
+ }, [handleMounted]);
39
+
40
+ const onAccessibilityAction = useCallback(
41
+ (event: AccessibilityActionEvent) => {
42
+ const action = event.nativeEvent.actionName;
43
+ if (action === "increment") snapToIndex(index + 1);
44
+ else if (action === "decrement") snapToIndex(index - 1);
45
+ else if (action === "escape") close();
46
+ },
47
+ [index, snapToIndex, close]
48
+ );
49
+
50
+ // The pan is shared with every handle written in this sheet, and a gesture
51
+ // builder mutates in place, so a per-handle opt-out leaves the detector out
52
+ // rather than flipping a flag on the shared object.
53
+ const enabled = enablePanningGesture ?? enableHandlePanningGesture;
54
+ const row = (
55
+ <View
56
+ accessibilityActions={ACTIONS}
57
+ accessibilityLabel={accessibilityLabel}
58
+ accessibilityRole="adjustable"
59
+ accessibilityValue={{ text: index < 0 ? "Closed" : `Detent ${index + 1} of ${count}` }}
60
+ accessible
61
+ onAccessibilityAction={onAccessibilityAction}
62
+ onAccessibilityEscape={close}
63
+ onLayout={onLayout}
64
+ ref={ref}
65
+ style={style}
66
+ {...props}
67
+ />
68
+ );
69
+ if (!enabled) return row;
70
+ return <GestureDetector gesture={pans.handle}>{row}</GestureDetector>;
71
+ }
72
+ BottomSheetHandle.displayName = "DelacourBottomSheet.BottomSheet.Handle";
@@ -0,0 +1,91 @@
1
+ import { type ReactElement, useCallback, useEffect } from "react";
2
+ import { Pressable, StyleSheet, type ViewProps } from "react-native";
3
+ import Animated, { useAnimatedProps, useAnimatedStyle } from "react-native-reanimated";
4
+ import { backdropInteractive, backdropOpacity } from "../core";
5
+ import { useBottomSheet, useBottomSheetInternal } from "./bottom-sheet.context";
6
+ import type { BottomSheetOverlayProps } from "./bottom-sheet.types";
7
+
8
+ /**
9
+ * The scrim over the app behind the sheet.
10
+ *
11
+ * Its opacity follows the keyboard-free index on the UI thread, so a keyboard
12
+ * lift never dims the app and a higher detent is no darker. Omit it and the
13
+ * sheet has no scrim at all.
14
+ *
15
+ * **It is a React Native `Pressable`, never a Gesture Handler tap, and it is
16
+ * written before the panel.** Gesture Handler resolves a touch between
17
+ * competing gestures, so a full-screen tap gesture behind the sheet wins a
18
+ * touch on a `TextInput`, which has no gesture of its own, and the field never
19
+ * focuses. Native hit-testing resolves a touch by view order instead: the
20
+ * panel is the later sibling, so a touch on it never reaches this view, and a
21
+ * touch beside it does. Nothing here has to know where the sheet is.
22
+ *
23
+ * While the index is at or below `disappearsOnIndex` the view takes no
24
+ * touches and is hidden from assistive technology, so a closed sheet's scrim
25
+ * is not a transparent wall over the app.
26
+ */
27
+ export function BottomSheetOverlay({
28
+ appearsOnIndex = 0,
29
+ disappearsOnIndex = -1,
30
+ opacity = 1,
31
+ pressBehavior = "close",
32
+ enableTouchThrough = false,
33
+ accessibilityLabel = "Close",
34
+ onPress,
35
+ style,
36
+ ref,
37
+ ...props
38
+ }: BottomSheetOverlayProps): ReactElement {
39
+ const { geometry, setHasOverlay, stepOverride } = useBottomSheetInternal();
40
+ const { isOpen, close, collapse, snapToIndex } = useBottomSheet();
41
+ const index = geometry.index;
42
+
43
+ useEffect(() => {
44
+ setHasOverlay(true);
45
+ return () => setHasOverlay(false);
46
+ }, [setHasOverlay]);
47
+
48
+ const animatedStyle = useAnimatedStyle(() => ({
49
+ opacity: backdropOpacity(index.value, disappearsOnIndex, appearsOnIndex, opacity),
50
+ }));
51
+
52
+ const animatedProps = useAnimatedProps<ViewProps>(() => ({
53
+ pointerEvents: !enableTouchThrough && backdropInteractive(index.value, disappearsOnIndex) ? "auto" : "none",
54
+ }));
55
+
56
+ // A step that is not dismissible takes the press away for as long as it is current.
57
+ const behavior = stepOverride?.dismissible === false ? "none" : pressBehavior;
58
+ const handlePress = useCallback(() => {
59
+ onPress?.();
60
+ if (behavior === "close") close();
61
+ else if (behavior === "collapse") collapse();
62
+ else if (typeof behavior === "number") snapToIndex(behavior);
63
+ }, [onPress, behavior, close, collapse, snapToIndex]);
64
+
65
+ const pressable = behavior !== "none";
66
+
67
+ return (
68
+ <Animated.View
69
+ accessibilityElementsHidden={!isOpen}
70
+ animatedProps={animatedProps}
71
+ importantForAccessibility={isOpen ? "auto" : "no-hide-descendants"}
72
+ ref={ref}
73
+ style={[StyleSheet.absoluteFill, style, animatedStyle]}
74
+ {...props}
75
+ >
76
+ <Pressable
77
+ accessibilityLabel={accessibilityLabel}
78
+ accessibilityRole={pressable ? "button" : undefined}
79
+ accessible={pressable}
80
+ disabled={!pressable}
81
+ onPress={handlePress}
82
+ style={styles.fill}
83
+ />
84
+ </Animated.View>
85
+ );
86
+ }
87
+ BottomSheetOverlay.displayName = "DelacourBottomSheet.BottomSheet.Overlay";
88
+
89
+ const styles = StyleSheet.create({
90
+ fill: { flex: 1 },
91
+ });
@@ -0,0 +1,96 @@
1
+ import { type ReactElement, useEffect, useMemo } from "react";
2
+ import { StyleSheet, View } from "react-native";
3
+ import { Portal } from "react-native-teleport";
4
+ import { composeRefs } from "../lib/compose-refs";
5
+ import { useOptionalBottomSheetHostName } from "../portal/host.context";
6
+ import { zIndexOf } from "../portal/sheet-registry";
7
+ import { useOptionalSheetRegistry } from "../portal/sheet-registry.context";
8
+ import { useBottomSheetInternal } from "./bottom-sheet.context";
9
+ import type { BottomSheetPortalProps } from "./bottom-sheet.types";
10
+
11
+ /**
12
+ * Everything drawn over the app once the sheet is open, inside the frame the
13
+ * sheet measures itself against.
14
+ *
15
+ * **One implementation, two destinations.** With a `BottomSheetProvider`
16
+ * above and no `inline`, the frame is written inside a teleport `Portal`
17
+ * bound to the nearest `BottomSheet.Host` — `root` under a bare provider, or
18
+ * the host a native modal screen wrote last — and the native view moves
19
+ * there while the React tree stays put, so every context the trigger's
20
+ * screen provides still reaches the sheet. Without a provider, or with
21
+ * `inline`, the frame renders where it is written: an absolute fill of the
22
+ * nearest positioned ancestor.
23
+ *
24
+ * The frame is `box-none`: it takes no touch of its own, so a closed sheet
25
+ * leaves the app under it fully interactive. `overflow: hidden` keeps a
26
+ * closed panel — translated to the frame's full height — from drawing under
27
+ * the parent's edge; a detached frame is `overflow: visible`, so nothing of a
28
+ * card sliding out through the gap is cut at any line but the screen's own. The `zIndex` comes from the registry: sheets in one host
29
+ * are siblings, and the later open is the later number.
30
+ *
31
+ * Children mount while the sheet is presented, and stay mounted with
32
+ * `keepMounted` or with `unmountOnClose` off — which is the default for an
33
+ * inline sheet, a persistent drawer. While presented the sheet is in the
34
+ * registry, in its host, and `stackBehavior: "replace"` closes the others
35
+ * there.
36
+ */
37
+ export function BottomSheetPortal({
38
+ children,
39
+ style,
40
+ ref,
41
+ inline = false,
42
+ hostName,
43
+ unmountOnClose,
44
+ }: BottomSheetPortalProps): ReactElement | null {
45
+ const { presented, keepMounted, topInset, containerLayout, sheetId, stackBehavior, detached } =
46
+ useBottomSheetInternal();
47
+ const registry = useOptionalSheetRegistry();
48
+ const nearest = useOptionalBottomSheetHostName();
49
+ const frameRef = useMemo(() => composeRefs(containerLayout.ref, ref), [containerLayout.ref, ref]);
50
+
51
+ const teleported = !inline && nearest !== null;
52
+ const host = hostName ?? nearest ?? "root";
53
+ // An inline sheet is a host of its own: nothing replaces it, and it is always its own top.
54
+ const hostKey = teleported ? host : `inline:${sheetId}`;
55
+ const unmount = keepMounted ? false : (unmountOnClose ?? teleported);
56
+ const mounted = presented || !unmount;
57
+
58
+ // Keyed on the two stable callbacks, never on the registry object: its
59
+ // identity carries the state that `present` itself changes, and an effect
60
+ // keyed on it would present, re-render, dismiss and present without end.
61
+ const present = registry?.present;
62
+ const dismissed = registry?.dismissed;
63
+ useEffect(() => {
64
+ if (!presented || present === undefined || dismissed === undefined) return;
65
+ present(sheetId, hostKey, stackBehavior);
66
+ return () => dismissed(sheetId);
67
+ }, [presented, present, dismissed, sheetId, hostKey, stackBehavior]);
68
+
69
+ const zIndex = registry === null ? 0 : zIndexOf(registry.state, sheetId);
70
+
71
+ if (!mounted) return null;
72
+
73
+ const frame = (
74
+ <View
75
+ onLayout={containerLayout.onLayout}
76
+ pointerEvents="box-none"
77
+ ref={frameRef}
78
+ style={[styles.frame, detached === null ? null : styles.frameDetached, { top: topInset, zIndex }, style]}
79
+ >
80
+ {children}
81
+ </View>
82
+ );
83
+ if (!teleported) return frame;
84
+
85
+ return (
86
+ <Portal hostName={host} style={[StyleSheet.absoluteFill, { zIndex }]}>
87
+ {frame}
88
+ </Portal>
89
+ );
90
+ }
91
+ BottomSheetPortal.displayName = "DelacourBottomSheet.BottomSheet.Portal";
92
+
93
+ const styles = StyleSheet.create({
94
+ frame: { bottom: 0, left: 0, overflow: "hidden", position: "absolute", right: 0 },
95
+ frameDetached: { overflow: "visible" },
96
+ });
@@ -0,0 +1,36 @@
1
+ import { type ReactElement, useCallback, useMemo } from "react";
2
+ import { type BlurEvent, type FocusEvent, TextInput } from "react-native";
3
+ import { useBottomSheetTextInput } from "../keyboard/use-bottom-sheet-text-input";
4
+ import { composeRefs } from "../lib/compose-refs";
5
+ import type { BottomSheetTextInputProps } from "./bottom-sheet.types";
6
+
7
+ /**
8
+ * React Native's `TextInput`, registered with the sheet.
9
+ *
10
+ * `useBottomSheetTextInput` over a plain `TextInput`, for the case with no
11
+ * skin in front of it: the keyboard it raises is the sheet's under any
12
+ * `keyboardScope`, including `registered`. A field of your own takes the
13
+ * hook instead and keeps its component.
14
+ */
15
+ export function BottomSheetTextInput({ ref, onFocus, onBlur, ...props }: BottomSheetTextInputProps): ReactElement {
16
+ const handlers = useBottomSheetTextInput();
17
+ const composed = useMemo(() => composeRefs<TextInput | null>(ref, handlers.ref), [ref, handlers.ref]);
18
+
19
+ const handleFocus = useCallback(
20
+ (event: FocusEvent) => {
21
+ handlers.onFocus();
22
+ onFocus?.(event);
23
+ },
24
+ [handlers.onFocus, onFocus]
25
+ );
26
+ const handleBlur = useCallback(
27
+ (event: BlurEvent) => {
28
+ handlers.onBlur();
29
+ onBlur?.(event);
30
+ },
31
+ [handlers.onBlur, onBlur]
32
+ );
33
+
34
+ return <TextInput onBlur={handleBlur} onFocus={handleFocus} ref={composed} {...props} />;
35
+ }
36
+ BottomSheetTextInput.displayName = "DelacourBottomSheet.BottomSheet.TextInput";