@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,79 @@
1
+ import { type RefCallback, useCallback, useRef } from "react";
2
+ import { findNodeHandle, TextInput } from "react-native";
3
+ import { useOptionalBottomSheetInternal } from "../components/bottom-sheet.context";
4
+
5
+ /**
6
+ * `findNodeHandle` for a value React Native's own types do not admit.
7
+ *
8
+ * `TextInput.State.currentlyFocusedInput()` returns a Fabric host instance and
9
+ * the shipped signature still describes the pre-Fabric parameter; the runtime
10
+ * accepts it. One cast, in one place.
11
+ */
12
+ function nodeHandleOf(instance: unknown): number | null {
13
+ return findNodeHandle(instance as Parameters<typeof findNodeHandle>[0]);
14
+ }
15
+
16
+ export type BottomSheetTextInputHandlers = {
17
+ ref: RefCallback<TextInput | null>;
18
+ onFocus: () => void;
19
+ onBlur: () => void;
20
+ };
21
+
22
+ /**
23
+ * Registers a text field as the sheet's own, so the keyboard it raises is the
24
+ * sheet's to lift for whatever `keyboardScope` says.
25
+ *
26
+ * Spread the result onto any `TextInput` — yours, a skin's, a library's:
27
+ *
28
+ * ```tsx
29
+ * <TextInput {...useBottomSheetTextInput()} placeholder="Name" />
30
+ * ```
31
+ *
32
+ * `BottomSheet.TextInput` is this over React Native's `TextInput`, for the
33
+ * plain case. All three handlers matter: `ref` puts the field's native node
34
+ * in the sheet's registry, `onFocus` claims the keyboard, and `onBlur` gives
35
+ * it up — unless focus moved to another registered field in the same sheet,
36
+ * which React Native's own focus registry already knows. Without that check
37
+ * a tap from one field to the next would read as the keyboard closing and
38
+ * reopening, and the sheet would resize twice between two taps that never
39
+ * dismissed it.
40
+ *
41
+ * Outside a sheet the handlers are inert, so a form shared between a screen
42
+ * and a sheet needs no branch.
43
+ */
44
+ export function useBottomSheetTextInput(): BottomSheetTextInputHandlers {
45
+ const registry = useOptionalBottomSheetInternal()?.keyboard ?? null;
46
+ const registered = useRef<number | null>(null);
47
+
48
+ const ref = useCallback<RefCallback<TextInput | null>>(
49
+ (instance) => {
50
+ if (registry === null) return;
51
+ const previous = registered.current;
52
+ if (previous !== null) {
53
+ registry.nodes.current.delete(previous);
54
+ registered.current = null;
55
+ }
56
+ if (instance === null) return;
57
+ const node = nodeHandleOf(instance);
58
+ if (node === null) return;
59
+ registry.nodes.current.add(node);
60
+ registered.current = node;
61
+ },
62
+ [registry]
63
+ );
64
+
65
+ const onFocus = useCallback(() => {
66
+ if (registry === null) return;
67
+ registry.focused.value = true;
68
+ }, [registry]);
69
+
70
+ const onBlur = useCallback(() => {
71
+ if (registry === null) return;
72
+ const focused = TextInput.State.currentlyFocusedInput();
73
+ const node = focused == null ? null : nodeHandleOf(focused);
74
+ if (node !== null && registry.nodes.current.has(node)) return;
75
+ registry.focused.value = false;
76
+ }, [registry]);
77
+
78
+ return { ref, onFocus, onBlur };
79
+ }
@@ -0,0 +1,59 @@
1
+ import { useCallback, useEffect } from "react";
2
+ import { TextInput } from "react-native";
3
+ import { useKeyboardContext } from "react-native-keyboard-controller";
4
+ import type { SharedValue } from "react-native-reanimated";
5
+ import { scheduleOnUI } from "react-native-worklets";
6
+ import { shouldResetKeyboardAnimation } from "../core";
7
+
8
+ /** The one pair of animation values `KeyboardProvider` shares with the whole app. */
9
+ export type KeyboardAnimationValues = {
10
+ progress: SharedValue<number>;
11
+ height: SharedValue<number>;
12
+ };
13
+
14
+ /**
15
+ * Snaps `KeyboardProvider`'s shared progress and height back to closed when
16
+ * they are provably stale.
17
+ *
18
+ * The focus check stays on the JS thread — React Native's focus registry
19
+ * lives there, and it is the one signal keyboard-controller's own missed
20
+ * notifications cannot poison — and the read-and-write happens inside one
21
+ * worklet, so the decision is made against the UI thread's own values.
22
+ * A snap, never an animation: the keyboard is already gone.
23
+ */
24
+ export function reconcileKeyboardAnimation({ progress, height }: KeyboardAnimationValues): void {
25
+ if (TextInput.State.currentlyFocusedInput() != null) return;
26
+
27
+ scheduleOnUI(() => {
28
+ "worklet";
29
+ if (!shouldResetKeyboardAnimation({ hasFocusedInput: false, height: height.value, progress: progress.value })) {
30
+ return;
31
+ }
32
+ progress.value = 0;
33
+ height.value = 0;
34
+ });
35
+ }
36
+
37
+ /**
38
+ * Repairs a keyboard `KeyboardProvider` still believes is up.
39
+ *
40
+ * On iOS the provider's shared values are written by `keyboardWillShow` /
41
+ * `keyboardWillHide` alone, so any teardown that produces no `willHide` — a
42
+ * native-stack pop over a focused field, an interrupted interactive dismiss,
43
+ * an app suspend — leaves them pinned open, app-wide. A sheet mounted into
44
+ * that state would lift for a keyboard that is not there.
45
+ *
46
+ * Idempotent and cheap: a no-op whenever a field is genuinely focused. The
47
+ * sheet runs it every time its portal presents; a screen of your own can run
48
+ * it on mount the same way. The same decision as
49
+ * `@delacour/react-native-ui`'s `useKeyboardAnimationGuard`, over the same
50
+ * core function, so the two never disagree about what stale means.
51
+ */
52
+ export function useKeyboardAnimationGuard(): void {
53
+ const { reanimated } = useKeyboardContext();
54
+ const reconcile = useCallback(() => reconcileKeyboardAnimation(reanimated), [reanimated]);
55
+
56
+ useEffect(() => {
57
+ reconcile();
58
+ }, [reconcile]);
59
+ }
@@ -0,0 +1,163 @@
1
+ import { type RefObject, useEffect, useMemo, useRef } from "react";
2
+ import { useWindowDimensions } from "react-native";
3
+ import { useKeyboardContext, useReanimatedFocusedInput } from "react-native-keyboard-controller";
4
+ import { type SharedValue, useAnimatedReaction, useSharedValue } from "react-native-reanimated";
5
+ import type { AnimateTo } from "../animation/animation.types";
6
+ import {
7
+ CLOSED_INDEX,
8
+ GESTURE_SOURCE,
9
+ heightForIndex,
10
+ indexForHeight,
11
+ isInputInsideSheet,
12
+ keyboardInContainer,
13
+ keyboardStep,
14
+ resolveKeyboardOwner,
15
+ } from "../core";
16
+ import { ANIM_STATUS, type SheetGeometry, type SheetSharedState } from "../state/state.types";
17
+ import { reconcileKeyboardAnimation } from "./use-keyboard-animation-guard";
18
+
19
+ /**
20
+ * What `useBottomSheetTextInput` writes into: the native nodes of every
21
+ * registered field, and whether one of them holds focus.
22
+ */
23
+ export type SheetKeyboardRegistry = {
24
+ nodes: RefObject<Set<number>>;
25
+ focused: SharedValue<boolean>;
26
+ };
27
+
28
+ export type UseSheetKeyboardOptions = {
29
+ state: SheetSharedState;
30
+ geometry: SheetGeometry;
31
+ animateTo: AnimateTo;
32
+ /** Whether the portal's children are mounted; the stale-keyboard guard runs on each presentation. */
33
+ presented: boolean;
34
+ };
35
+
36
+ /**
37
+ * The keyboard, from keyboard-controller's shared values into the sheet's.
38
+ *
39
+ * Two reactions. The first is the writer: every frame the keyboard's `height`
40
+ * (negative, from the window's bottom edge) or `progress` moves, or the
41
+ * focused input's frame changes, it writes `keyboardHeight` (positive, within
42
+ * the container), `keyboardProgress` and `keyboardOwned`. Ownership goes
43
+ * through `resolveKeyboardOwner` — a registered field, or under `inside` a
44
+ * focused input whose frame overlaps the sheet, and sticky through a
45
+ * transition once claimed. Everything the geometry derives from those three
46
+ * — the lift, the body's area, the footer's place — follows on the UI thread
47
+ * with no further code here.
48
+ *
49
+ * The second is the behaviour. `interactive` and `none` need none: one lifts
50
+ * by derivation, the other ignores the keyboard. `extend` and `fillParent`
51
+ * snap — to the highest detent or the container — when an owned keyboard
52
+ * rises, and `keyboardBlurBehavior: "restore"` sends the sheet back to the
53
+ * detent it held when the keyboard leaves. `keyboardStep` in the core makes
54
+ * that call from two consecutive samples; this reaction only carries it out,
55
+ * and stays out of the way of a gesture that owns `base`.
56
+ */
57
+ export function useSheetKeyboard({
58
+ state,
59
+ geometry,
60
+ animateTo,
61
+ presented,
62
+ }: UseSheetKeyboardOptions): SheetKeyboardRegistry {
63
+ const { reanimated } = useKeyboardContext();
64
+ const { height, progress } = reanimated;
65
+ const { input } = useReanimatedFocusedInput();
66
+ const { height: windowHeight } = useWindowDimensions();
67
+ const nodes = useRef(new Set<number>());
68
+ const focused = useSharedValue(false);
69
+ const indexBefore = useSharedValue(CLOSED_INDEX);
70
+ const applied = useSharedValue(false);
71
+
72
+ useEffect(() => {
73
+ if (presented) reconcileKeyboardAnimation(reanimated);
74
+ }, [presented, reanimated]);
75
+
76
+ useAnimatedReaction(
77
+ () => ({
78
+ raw: height.value,
79
+ progress: progress.value,
80
+ layout: input.value,
81
+ registered: focused.value,
82
+ offset: state.containerBottomOffset.value,
83
+ container: state.containerHeight.value,
84
+ position: geometry.position.value,
85
+ }),
86
+ (current) => {
87
+ const keyboardHeight = keyboardInContainer(current.raw, current.offset);
88
+ const containerBottom = windowHeight - current.offset;
89
+ const layout = current.layout;
90
+ const inside =
91
+ layout !== null &&
92
+ current.container > 0 &&
93
+ isInputInsideSheet({
94
+ inputY: layout.layout.absoluteY,
95
+ inputHeight: layout.layout.height,
96
+ sheetTop: containerBottom - current.container + current.position,
97
+ containerBottom,
98
+ });
99
+ const owned = resolveKeyboardOwner({
100
+ scope: state.config.value.keyboardScope,
101
+ registeredFocused: current.registered,
102
+ inputInside: inside,
103
+ inputFocused: layout !== null,
104
+ progress: current.progress,
105
+ previousOwned: state.keyboardOwned.value,
106
+ });
107
+ if (state.keyboardHeight.value !== keyboardHeight) state.keyboardHeight.value = keyboardHeight;
108
+ if (state.keyboardProgress.value !== current.progress) state.keyboardProgress.value = current.progress;
109
+ if (state.keyboardOwned.value !== owned) state.keyboardOwned.value = owned;
110
+ },
111
+ [state, geometry, height, progress, input, focused, windowHeight]
112
+ );
113
+
114
+ useAnimatedReaction(
115
+ () => ({ progress: progress.value, owned: state.keyboardOwned.value }),
116
+ (current, previous) => {
117
+ if (previous === null) return;
118
+ const config = state.config.value;
119
+ const closed = geometry.closedHeight.value;
120
+ const running = state.animStatus.value === ANIM_STATUS.RUNNING;
121
+ const sheetOpen = running ? state.animTarget.value > closed : state.base.value > closed;
122
+
123
+ const step = keyboardStep({
124
+ behavior: config.keyboardBehavior,
125
+ blurBehavior: config.keyboardBlurBehavior,
126
+ progress: current.progress,
127
+ previousProgress: previous.progress,
128
+ owned: current.owned,
129
+ previousOwned: previous.owned,
130
+ applied: applied.value,
131
+ sheetOpen,
132
+ });
133
+ if (step === null) return;
134
+
135
+ // A finger on the sheet owns `base`; the release will settle it.
136
+ const dragging = state.gestureSource.value !== GESTURE_SOURCE.NONE;
137
+
138
+ if (step === "apply") {
139
+ if (dragging) return;
140
+ applied.value = true;
141
+ indexBefore.value = running
142
+ ? Math.round(indexForHeight(state.animTarget.value, geometry.detents.value, closed))
143
+ : state.currentIndex.value;
144
+ const target = config.keyboardBehavior === "fillParent" ? geometry.maxHeight.value : geometry.highest.value;
145
+ if (running || Math.abs(target - state.base.value) > 0.5) animateTo(target, "keyboard", 0);
146
+ return;
147
+ }
148
+
149
+ applied.value = false;
150
+ const index = indexBefore.value;
151
+ indexBefore.value = CLOSED_INDEX;
152
+ if (step !== "restore" || dragging || !sheetOpen || index < 0) return;
153
+
154
+ const detents = geometry.detents.value;
155
+ if (detents.length === 0) return;
156
+ const target = heightForIndex(Math.min(index, detents.length - 1), detents, closed);
157
+ if (Math.abs(target - state.base.value) > 0.5) animateTo(target, "keyboard", 0);
158
+ },
159
+ [state, geometry, animateTo, progress, applied, indexBefore]
160
+ );
161
+
162
+ return useMemo<SheetKeyboardRegistry>(() => ({ nodes, focused }), [focused]);
163
+ }
@@ -0,0 +1,52 @@
1
+ # layout
2
+
3
+ How the sheet learns the sizes the geometry needs.
4
+
5
+ ## Files
6
+
7
+ | Path | What |
8
+ | --- | --- |
9
+ | `use-measure-height.ts` | An `onLayout` that writes a view's height into a shared value, and resets it to `UNMEASURED` on unmount |
10
+ | `use-container-layout.ts` | The frame's `onLayout` and ref: height and width, plus `measureInWindow` for the offset between the frame's bottom and the window's |
11
+
12
+ ## What is measured
13
+
14
+ | Value | By | Read for |
15
+ | --- | --- | --- |
16
+ | `containerHeight`, `containerWidth` | the frame `Portal` renders | every detent, `position`, the detached frame |
17
+ | `containerBottomOffset` | the same frame, via `measureInWindow` | how much of a keyboard overlaps the sheet at all (BSHEET-3) |
18
+ | `handleHeight` | `Handle` | the dynamic detent, `contentArea` |
19
+ | `contentHeight` | `Content`'s inner box | the dynamic detent |
20
+ | `footerContentHeight` | `Footer`'s inner box (BSHEET-3) | the dynamic detent, `footerTop` |
21
+
22
+ Scrollables (BSHEET-4) feed `contentHeight` from `onContentSizeChange`
23
+ instead of a layout.
24
+
25
+ ## The reset is the point
26
+
27
+ A portal that unmounts its children on close takes the handle and the content
28
+ with it. If their heights stayed, `layoutReady` would be true for a sheet with
29
+ nothing in it and the next open would animate before the new content had
30
+ measured. Every measuring hook resets its value to `UNMEASURED` on unmount, so
31
+ the open goes back on the intent queue until the measurement lands again.
32
+
33
+ A handle that is never written is a handle of height zero: `Container` reports
34
+ that on mount when no `Handle` registered, so a handle-less sheet is not a
35
+ sheet that never becomes ready.
36
+
37
+ ## Container resize
38
+
39
+ The frame's height goes through `acceptContainerLayout`, which refuses
40
+ Android's adjustResize double-count while a keyboard is up. Inert until
41
+ BSHEET-3 writes `keyboardHeight`, and correct once it does. A height that is
42
+ accepted while the sheet is open makes the intent reaction jump the sheet to
43
+ the same index in the re-derived detents (`state/`).
44
+
45
+ ## No synchronous first read
46
+
47
+ The plan mentioned a Fabric `getBoundingClientRect` in a layout effect for a
48
+ synchronous first measurement. It is not here: the open intent waits on
49
+ `layoutReady` anyway, so the only thing a synchronous read would buy is one
50
+ frame off the first open, and it would buy it with a host-instance method the
51
+ type definitions do not yet name. Add it here, in these two hooks, if that
52
+ frame ever shows.
@@ -0,0 +1,63 @@
1
+ import { type RefObject, useCallback, useEffect, useRef } from "react";
2
+ import { Dimensions, type LayoutChangeEvent, type View } from "react-native";
3
+ import { acceptContainerLayout, UNMEASURED } from "../core";
4
+ import type { SheetSharedState } from "../state/state.types";
5
+
6
+ export type ContainerLayout = {
7
+ ref: RefObject<View | null>;
8
+ onLayout: (event: LayoutChangeEvent) => void;
9
+ };
10
+
11
+ /**
12
+ * Measures the frame every sheet renders around itself.
13
+ *
14
+ * `onLayout` gives the height and width; `measureInWindow` gives where the
15
+ * frame's bottom edge sits relative to the window's, which is what a keyboard
16
+ * has to clear before it overlaps the sheet — a frame inside a tab bar's
17
+ * screen ends above the window, and the keyboard's first pixels overlap
18
+ * nothing.
19
+ *
20
+ * The height goes through `acceptContainerLayout`, which refuses Android's
21
+ * adjustResize double-count while a keyboard is up. Inert until BSHEET-3
22
+ * writes `keyboardHeight`, and correct once it does.
23
+ *
24
+ * Unmounting resets every value to `UNMEASURED`, for the same reason
25
+ * `useMeasureHeight` does.
26
+ */
27
+ export function useContainerLayout(state: SheetSharedState): ContainerLayout {
28
+ const ref = useRef<View | null>(null);
29
+ const { containerHeight, containerWidth, containerBottomOffset, keyboardHeight, keyboardProgress } = state;
30
+
31
+ useEffect(
32
+ () => () => {
33
+ containerHeight.value = UNMEASURED;
34
+ containerWidth.value = UNMEASURED;
35
+ containerBottomOffset.value = 0;
36
+ },
37
+ [containerHeight, containerWidth, containerBottomOffset]
38
+ );
39
+
40
+ const onLayout = useCallback(
41
+ (event: LayoutChangeEvent) => {
42
+ const { height, width } = event.nativeEvent.layout;
43
+ if (containerWidth.value !== width) containerWidth.value = width;
44
+
45
+ const accepted = acceptContainerLayout({
46
+ prev: containerHeight.value,
47
+ next: height,
48
+ keyboardHeight: keyboardHeight.value,
49
+ progress: keyboardProgress.value,
50
+ });
51
+ if (accepted && containerHeight.value !== height) containerHeight.value = height;
52
+
53
+ ref.current?.measureInWindow((_x, y, _width, measuredHeight) => {
54
+ const offset = Dimensions.get("window").height - (y + measuredHeight);
55
+ const clamped = Number.isFinite(offset) && offset > 0 ? offset : 0;
56
+ if (containerBottomOffset.value !== clamped) containerBottomOffset.value = clamped;
57
+ });
58
+ },
59
+ [containerHeight, containerWidth, containerBottomOffset, keyboardHeight, keyboardProgress]
60
+ );
61
+
62
+ return { ref, onLayout };
63
+ }
@@ -0,0 +1,40 @@
1
+ import { useCallback, useEffect } from "react";
2
+ import type { LayoutChangeEvent } from "react-native";
3
+ import type { SharedValue } from "react-native-reanimated";
4
+ import { UNMEASURED } from "../core";
5
+
6
+ /**
7
+ * Feeds a view's height into a shared value from its `onLayout`, and resets
8
+ * it to `UNMEASURED` when the view goes away.
9
+ *
10
+ * The reset is the important half. A portal that unmounts its children on
11
+ * close takes the handle and the content with it; if their heights stayed,
12
+ * `layoutReady` would be true for a sheet with nothing in it and the next open
13
+ * would animate before the new content had measured. `-1` puts the open back
14
+ * on the queue until the measurement lands again.
15
+ *
16
+ * `subtract` takes a constant off every measurement, never below zero: the
17
+ * footer measures its styled box, which pads through the safe-area band, and
18
+ * records the box less the band.
19
+ *
20
+ * Written once per part rather than shared with the container, because a part
21
+ * only wants its own height; the container also wants its offset from the
22
+ * window's bottom.
23
+ */
24
+ export function useMeasureHeight(target: SharedValue<number>, subtract = 0): (event: LayoutChangeEvent) => void {
25
+ useEffect(
26
+ () => () => {
27
+ target.value = UNMEASURED;
28
+ },
29
+ [target]
30
+ );
31
+
32
+ return useCallback(
33
+ (event: LayoutChangeEvent) => {
34
+ const measured = event.nativeEvent.layout.height - subtract;
35
+ const height = measured > 0 ? measured : 0;
36
+ if (target.value !== height) target.value = height;
37
+ },
38
+ [target, subtract]
39
+ );
40
+ }
@@ -0,0 +1,35 @@
1
+ # lib
2
+
3
+ Four utilities copied from `@delacour/react-native-ui`, with the one thing
4
+ this package must not have removed.
5
+
6
+ ## Files
7
+
8
+ | Path | What |
9
+ | --- | --- |
10
+ | `slot.tsx` | `Slot` — renders its props into its single child; what backs every `asChild` |
11
+ | `merge-props.ts` | Child props win; event handlers chain slot-first; `style` arrays flatten with the child last |
12
+ | `compose-refs.ts` | Fans one node out to several refs, honouring React 19 cleanup returns |
13
+ | `use-controllable-state.ts` | Controlled or uncontrolled from one hook; the mode is locked in on first render |
14
+
15
+ ## What changed in the copy
16
+
17
+ `merge-props.ts` in the skin also merges class strings through its `cn`. This
18
+ package has no class strings to merge — `src/no-classname.test.ts` — so the
19
+ branch is gone and nothing here imports `cn`. Everything else is the same
20
+ file, and should stay the same file: a fix in one belongs in both.
21
+
22
+ ## Why copied rather than imported
23
+
24
+ `@delacour/react-native-ui` is the skin over this engine, and it depends on
25
+ the engine as an optional peer. The engine importing the skin's `lib` would
26
+ close that cycle, and a published package cannot reach into a sibling
27
+ workspace's `src` anyway. Four small files is the price of a clean direction.
28
+
29
+ ## Slot and animated styles
30
+
31
+ Do not pass a Reanimated style through `Slot`. React Native deep freezes style
32
+ props on a non-animated component in development, and Reanimated's effect then
33
+ throws trying to write to the frozen object. No part here does; a part that
34
+ needs to would render the child through an animated counterpart of its own
35
+ type, the way the skin's `Pressable` does.
@@ -0,0 +1,37 @@
1
+ import type { Ref } from "react";
2
+
3
+ type RefCleanup = () => void;
4
+
5
+ /**
6
+ * Fans a single node out to several refs.
7
+ *
8
+ * React 19 lets a callback ref return a cleanup function. When any of the given
9
+ * refs does, the composed ref returns a cleanup of its own that runs each child
10
+ * cleanup and nulls out every object ref.
11
+ */
12
+ export function composeRefs<T>(...refs: (Ref<T> | undefined)[]): (node: T | null) => RefCleanup | undefined {
13
+ return (node) => {
14
+ const cleanups: RefCleanup[] = [];
15
+
16
+ for (const ref of refs) {
17
+ if (!ref) continue;
18
+
19
+ if (typeof ref === "function") {
20
+ const result = ref(node);
21
+ if (typeof result === "function") cleanups.push(result);
22
+ continue;
23
+ }
24
+
25
+ ref.current = node;
26
+ }
27
+
28
+ if (cleanups.length === 0) return undefined;
29
+
30
+ return () => {
31
+ for (const cleanup of cleanups) cleanup();
32
+ for (const ref of refs) {
33
+ if (ref && typeof ref !== "function") ref.current = null;
34
+ }
35
+ };
36
+ };
37
+ }
@@ -0,0 +1,42 @@
1
+ type UnknownProps = Record<string, unknown>;
2
+
3
+ function isEventHandlerKey(key: string): boolean {
4
+ return key.startsWith("on") && key.length > 2 && key[2] === key[2]?.toUpperCase();
5
+ }
6
+
7
+ /**
8
+ * Merges a slot's own props with those of the element it renders into.
9
+ *
10
+ * Child props win, with two exceptions: event handlers are chained
11
+ * slot-first-then-child so neither side is silently dropped, and `style` is
12
+ * flattened into an array with the child last (React Native resolves style
13
+ * arrays left to right).
14
+ *
15
+ * The copy in `@delacour/react-native-ui` also merges class strings; this
16
+ * package has none to merge, which is the whole point of it.
17
+ */
18
+ export function mergeProps<T extends UnknownProps>(slotProps: UnknownProps, childProps: UnknownProps): T {
19
+ const merged: UnknownProps = { ...slotProps };
20
+
21
+ for (const key of Object.keys(childProps)) {
22
+ const slotValue = slotProps[key];
23
+ const childValue = childProps[key];
24
+
25
+ if (isEventHandlerKey(key) && typeof slotValue === "function" && typeof childValue === "function") {
26
+ merged[key] = (...args: unknown[]) => {
27
+ (slotValue as (...a: unknown[]) => unknown)(...args);
28
+ return (childValue as (...a: unknown[]) => unknown)(...args);
29
+ };
30
+ continue;
31
+ }
32
+
33
+ if (key === "style" && slotValue != null && childValue != null) {
34
+ merged[key] = [slotValue, childValue].flat();
35
+ continue;
36
+ }
37
+
38
+ merged[key] = childValue;
39
+ }
40
+
41
+ return merged as T;
42
+ }
@@ -0,0 +1,44 @@
1
+ import { Children, cloneElement, isValidElement, type ReactElement, type ReactNode, type Ref } from "react";
2
+ import { composeRefs } from "./compose-refs";
3
+ import { mergeProps } from "./merge-props";
4
+
5
+ type SlotProps = {
6
+ children?: ReactNode;
7
+ [key: string]: unknown;
8
+ };
9
+
10
+ type ChildProps = Record<string, unknown> & { ref?: Ref<unknown> };
11
+
12
+ /**
13
+ * Renders its props into its single child instead of emitting an element.
14
+ *
15
+ * This is what backs every `asChild` prop here: `<BottomSheet.Trigger asChild>`
16
+ * hands its `onPress` to the button inside it with no extra view in the tree.
17
+ * Props are combined by {@link mergeProps} and refs by {@link composeRefs}.
18
+ *
19
+ * React 19 exposes `ref` as an ordinary prop, so no `forwardRef` is involved.
20
+ *
21
+ * Do not pass a Reanimated style through this. React Native deep freezes style
22
+ * props on a non-animated component in development, and Reanimated's effect
23
+ * then throws trying to write to the frozen object.
24
+ */
25
+ export function Slot({ children, ...slotProps }: SlotProps): ReactElement {
26
+ const count = Children.count(children);
27
+
28
+ if (count !== 1) {
29
+ throw new Error(`Slot expects exactly one child element, received ${count}. Did you mean to omit \`asChild\`?`);
30
+ }
31
+
32
+ const child = Children.only(children);
33
+
34
+ if (!isValidElement(child)) {
35
+ throw new Error("Slot expects a single React element child; text and fragments cannot receive props.");
36
+ }
37
+
38
+ const childProps = child.props as ChildProps;
39
+ const merged = mergeProps<ChildProps>(slotProps, childProps);
40
+ merged.ref = composeRefs(slotProps.ref as Ref<unknown> | undefined, childProps.ref);
41
+
42
+ return cloneElement(child, merged);
43
+ }
44
+ Slot.displayName = "DelacourBottomSheet.Slot";
@@ -0,0 +1,45 @@
1
+ import { useCallback, useRef, useState } from "react";
2
+
3
+ type UseControllableStateOptions<T> = {
4
+ /** Controlled value. When not `undefined` the hook defers to the caller. */
5
+ value?: T;
6
+ /** Initial value used while uncontrolled. */
7
+ defaultValue: T;
8
+ onChange?: (value: T) => void;
9
+ };
10
+
11
+ /**
12
+ * Supports both controlled and uncontrolled usage from one hook.
13
+ *
14
+ * While `value` is undefined the state is held internally; once a value is
15
+ * passed the caller owns it and the setter only reports upward. Which mode is
16
+ * in play is locked in on first render — switching later is a caller bug and
17
+ * warns in development rather than silently changing behaviour.
18
+ */
19
+ export function useControllableState<T>({
20
+ value,
21
+ defaultValue,
22
+ onChange,
23
+ }: UseControllableStateOptions<T>): [T, (next: T) => void] {
24
+ const isControlled = value !== undefined;
25
+ const wasControlled = useRef(isControlled);
26
+ const [uncontrolled, setUncontrolled] = useState<T>(defaultValue);
27
+
28
+ if (process.env.NODE_ENV !== "production" && wasControlled.current !== isControlled) {
29
+ console.warn(
30
+ `useControllableState: switched from ${wasControlled.current ? "controlled" : "uncontrolled"} to ` +
31
+ `${isControlled ? "controlled" : "uncontrolled"}. Pick one for the lifetime of the component.`
32
+ );
33
+ wasControlled.current = isControlled;
34
+ }
35
+
36
+ const setValue = useCallback(
37
+ (next: T) => {
38
+ if (!isControlled) setUncontrolled(next);
39
+ onChange?.(next);
40
+ },
41
+ [isControlled, onChange]
42
+ );
43
+
44
+ return [isControlled ? value : uncontrolled, setValue];
45
+ }