@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,134 @@
1
+ import type { SharedValue } from "react-native-reanimated";
2
+ import type {
3
+ AnimationSource,
4
+ DetachedOptions,
5
+ DetentSpec,
6
+ GestureSource,
7
+ KeyboardBehavior,
8
+ KeyboardBlurBehavior,
9
+ KeyboardScope,
10
+ ScrollableType,
11
+ SheetIntent,
12
+ SheetState,
13
+ } from "../core";
14
+
15
+ /** Whether an animation is running. Numeric so the UI thread never serialises a string. */
16
+ export const ANIM_STATUS = {
17
+ IDLE: 0,
18
+ RUNNING: 1,
19
+ } as const;
20
+
21
+ export type AnimStatus = (typeof ANIM_STATUS)[keyof typeof ANIM_STATUS];
22
+
23
+ /**
24
+ * The root's props as the UI thread needs them, mirrored into one shared value
25
+ * so a worklet reads a snapshot rather than a dozen captured primitives that
26
+ * would each rebuild the closure when they change.
27
+ */
28
+ export type SheetWorkletConfig = {
29
+ dynamicSizing: boolean;
30
+ maxDynamicContentSize: number | undefined;
31
+ hasFooter: boolean;
32
+ bottomInset: number;
33
+ detached: DetachedOptions | null;
34
+ enablePanDownToClose: boolean;
35
+ enableOverDrag: boolean;
36
+ overDragResistanceFactor: number;
37
+ initialIndex: number;
38
+ animateOnMount: boolean;
39
+ keyboardBehavior: KeyboardBehavior;
40
+ keyboardBlurBehavior: KeyboardBlurBehavior;
41
+ keyboardScope: KeyboardScope;
42
+ enableBlurKeyboardOnGesture: boolean;
43
+ };
44
+
45
+ /**
46
+ * Every raw shared value the sheet owns.
47
+ *
48
+ * Measurements start at `UNMEASURED` (`-1`); `base` is the only animated
49
+ * value; everything else is written by a gesture, a layout callback or the JS
50
+ * thread, and read by the derived geometry.
51
+ */
52
+ export type SheetSharedState = {
53
+ /** The frame the sheet lives in, `top: topInset` to the host's bottom. */
54
+ containerHeight: SharedValue<number>;
55
+ containerWidth: SharedValue<number>;
56
+ /** Window bottom minus frame bottom — what a keyboard has to clear before it overlaps. */
57
+ containerBottomOffset: SharedValue<number>;
58
+ handleHeight: SharedValue<number>;
59
+ contentHeight: SharedValue<number>;
60
+ footerContentHeight: SharedValue<number>;
61
+ /** The keyboard-free height. The only value an animation writes. */
62
+ base: SharedValue<number>;
63
+ /** The settled index, `-1` closed. Written on settle, never mid-flight. */
64
+ currentIndex: SharedValue<number>;
65
+ animStatus: SharedValue<AnimStatus>;
66
+ animSource: SharedValue<AnimationSource>;
67
+ animTarget: SharedValue<number>;
68
+ gestureSource: SharedValue<GestureSource>;
69
+ /** The scrollable's live offset, written by its scroll handler; negative while bounced past the top. */
70
+ scrollOffsetY: SharedValue<number>;
71
+ /** Where the scrollable is held while the sheet is below its highest detent. */
72
+ scrollLockedAt: SharedValue<number>;
73
+ /** Which scrollable is the body, `NONE` for static content. Set by `createBottomSheetScrollable` on focus. */
74
+ scrollableType: SharedValue<ScrollableType>;
75
+ keyboardOwned: SharedValue<boolean>;
76
+ /** keyboard-controller's `progress`, `0` until BSHEET-3 wires the keyboard. */
77
+ keyboardProgress: SharedValue<number>;
78
+ /** The keyboard's height inside the container, positive, `0` until BSHEET-3. */
79
+ keyboardHeight: SharedValue<number>;
80
+ /** The pending request from the JS thread, `null` once resolved. */
81
+ intent: SharedValue<SheetIntent | null>;
82
+ /** Whether the next open is the mount open — reported as `"mount"` and skipped by `animateOnMount: false`. */
83
+ mountPending: SharedValue<boolean>;
84
+ /** The `snapPoints` prop, string-keyed so identity churn is harmless. */
85
+ detentSpec: SharedValue<readonly DetentSpec[]>;
86
+ config: SharedValue<SheetWorkletConfig>;
87
+ };
88
+
89
+ /**
90
+ * Every number derived from the raw state, each a `useDerivedValue` over the
91
+ * core's pure functions. Consumers read; nothing writes.
92
+ */
93
+ export type SheetGeometry = {
94
+ restingBottom: SharedValue<number>;
95
+ closedHeight: SharedValue<number>;
96
+ /** The height the sheet may occupy; what a `%` detent resolves against. */
97
+ maxHeight: SharedValue<number>;
98
+ /** The safe-area band an attached sheet reserves. */
99
+ band: SharedValue<number>;
100
+ bandNow: SharedValue<number>;
101
+ /** Ascending, unique, the dynamic detent included when sizing to content. */
102
+ detents: SharedValue<readonly number[]>;
103
+ /** The last detent, or the closed height when there is none yet. */
104
+ highest: SharedValue<number>;
105
+ keyboardLift: SharedValue<number>;
106
+ /** `base + keyboardLift`, clamped. What the view shows. */
107
+ height: SharedValue<number>;
108
+ /** `translateY` for the panel. */
109
+ position: SharedValue<number>;
110
+ /** Continuous index over `base`, `-1` closed. The overlay follows this. */
111
+ index: SharedValue<number>;
112
+ sheetState: SharedValue<SheetState>;
113
+ layoutReady: SharedValue<boolean>;
114
+ /** What the body may fill above the footer or the band, sized against its detent. */
115
+ contentArea: SharedValue<number>;
116
+ footerHeight: SharedValue<number>;
117
+ footerTop: SharedValue<number>;
118
+ /** What trails the body: the footer, band included, or the band alone. The body reserves it at its end. */
119
+ bodyInset: SharedValue<number>;
120
+ /**
121
+ * How tall the body's clipping box is: `contentArea + bodyInset` — the body
122
+ * is laid out to the sheet's bottom line — held under a sticky footer to
123
+ * the footer's live top edge, so nothing of the body shows below it while
124
+ * the sheet is lower than its detent.
125
+ */
126
+ bodyClip: SharedValue<number>;
127
+ /**
128
+ * How tall a detached card's surface is: `height` between the detents, the
129
+ * first detent's height below them and the last's above, so a close, a
130
+ * rubber-band and an over-drag move the card as a rigid body. Equal to
131
+ * `height` when attached.
132
+ */
133
+ surfaceHeight: SharedValue<number>;
134
+ };
@@ -0,0 +1,43 @@
1
+ import { type SharedValue, useDerivedValue } from "react-native-reanimated";
2
+ import { dynamicDetent, normalizeDetents } from "../core";
3
+ import type { SheetSharedState } from "./state.types";
4
+
5
+ /**
6
+ * The ascending, unique detents, recomputed on the UI thread whenever the spec,
7
+ * the container or a measurement changes.
8
+ *
9
+ * The dynamic detent joins the list only once the handle and the content have
10
+ * both reported — before that a sheet sized to its content has no detent at
11
+ * all, and `layoutReady` keeps an open intent waiting rather than animating to
12
+ * a guess. An explicit detent equal to the dynamic one is one detent, not two.
13
+ */
14
+ export function useDetents(
15
+ state: SheetSharedState,
16
+ maxHeight: SharedValue<number>,
17
+ band: SharedValue<number>
18
+ ): SharedValue<readonly number[]> {
19
+ return useDerivedValue<readonly number[]>(() => {
20
+ const available = maxHeight.value;
21
+ const config = state.config.value;
22
+ const detents = normalizeDetents(state.detentSpec.value, available);
23
+
24
+ if (!config.dynamicSizing || available <= 0) return detents;
25
+ if (state.handleHeight.value < 0 || state.contentHeight.value < 0) return detents;
26
+ if (config.hasFooter && state.footerContentHeight.value < 0) return detents;
27
+
28
+ const dynamic = dynamicDetent({
29
+ handleHeight: state.handleHeight.value,
30
+ contentHeight: state.contentHeight.value,
31
+ footerContentHeight: state.footerContentHeight.value,
32
+ band: band.value,
33
+ hasFooter: config.hasFooter,
34
+ available,
35
+ maxDynamicContentSize: config.maxDynamicContentSize,
36
+ });
37
+ if (detents.indexOf(dynamic) !== -1) return detents;
38
+
39
+ const merged = [...detents, dynamic];
40
+ merged.sort((a, b) => a - b);
41
+ return merged;
42
+ });
43
+ }
@@ -0,0 +1,178 @@
1
+ import { useMemo } from "react";
2
+ import { useDerivedValue } from "react-native-reanimated";
3
+ import {
4
+ availableHeight,
5
+ bandNow,
6
+ bodyClip,
7
+ bodyInset,
8
+ bottomBand,
9
+ clampHeight,
10
+ closedHeight,
11
+ contentArea,
12
+ footerHeight,
13
+ footerTop,
14
+ indexForHeight,
15
+ isLayoutReady,
16
+ keyboardLift,
17
+ positionFor,
18
+ restingBottom,
19
+ sheetState,
20
+ surfaceHeight,
21
+ } from "../core";
22
+ import type { SheetGeometry, SheetSharedState } from "./state.types";
23
+ import { useDetents } from "./use-detents";
24
+
25
+ /**
26
+ * Where an unmeasured panel goes: far enough down that no frame of it shows
27
+ * before the container has reported a height.
28
+ */
29
+ const OFFSCREEN = 100_000;
30
+
31
+ /**
32
+ * Every derived number, as `useDerivedValue`s over the core's pure functions.
33
+ *
34
+ * Each hook is one formula from `core/AGENTS.md`, in dependency order, and none
35
+ * of them writes anything: a gesture, an animation or a layout callback writes
36
+ * the raw state and this re-derives. The keyboard values are real derivations
37
+ * over `keyboardHeight` and `keyboardProgress`, which BSHEET-3 writes; until
38
+ * then both are `0` and every keyboard term is inert.
39
+ */
40
+ export function useSheetGeometry(state: SheetSharedState): SheetGeometry {
41
+ const resting = useDerivedValue(() => {
42
+ const detached = state.config.value.detached;
43
+ return restingBottom(detached !== null, detached?.bottomOffset ?? 0, state.config.value.bottomInset);
44
+ });
45
+ const closed = useDerivedValue(() => closedHeight(resting.value));
46
+ const maxHeight = useDerivedValue(() => availableHeight(state.containerHeight.value, resting.value));
47
+ const band = useDerivedValue(() => bottomBand(state.config.value.detached !== null, state.config.value.bottomInset));
48
+ const bandCurrent = useDerivedValue(() => bandNow(band.value, state.keyboardProgress.value));
49
+ const detents = useDetents(state, maxHeight, band);
50
+ const highest = useDerivedValue(() => {
51
+ const list = detents.value;
52
+ return list.length > 0 ? (list[list.length - 1] as number) : closed.value;
53
+ });
54
+ // The keyboard the sheet answers to: owned, and under a behaviour that does
55
+ // anything about it. `none` neither lifts nor shrinks the body.
56
+ const keyboardEffective = useDerivedValue(() =>
57
+ state.keyboardOwned.value && state.config.value.keyboardBehavior !== "none" ? state.keyboardHeight.value : 0
58
+ );
59
+ const lift = useDerivedValue(() =>
60
+ keyboardLift({
61
+ keyboardHeight: keyboardEffective.value,
62
+ progress: state.keyboardProgress.value,
63
+ band: band.value,
64
+ behavior: state.config.value.keyboardBehavior,
65
+ gapBelow: resting.value,
66
+ })
67
+ );
68
+ const height = useDerivedValue(() => clampHeight(state.base.value + lift.value, closed.value, maxHeight.value));
69
+ const position = useDerivedValue(() => {
70
+ const container = state.containerHeight.value;
71
+ if (!(container > 0)) return OFFSCREEN;
72
+ return positionFor(container, resting.value, height.value);
73
+ });
74
+ const index = useDerivedValue(() => indexForHeight(state.base.value, detents.value, closed.value));
75
+ const sheetStateValue = useDerivedValue(() =>
76
+ sheetState(state.base.value, height.value, detents.value, closed.value, maxHeight.value)
77
+ );
78
+ const layoutReady = useDerivedValue(() =>
79
+ isLayoutReady({
80
+ containerHeight: state.containerHeight.value,
81
+ handleHeight: state.handleHeight.value,
82
+ contentHeight: state.contentHeight.value,
83
+ footerContentHeight: state.footerContentHeight.value,
84
+ dynamicSizing: state.config.value.dynamicSizing,
85
+ hasFooter: state.config.value.hasFooter,
86
+ })
87
+ );
88
+ const footer = useDerivedValue(() =>
89
+ footerHeight(state.config.value.hasFooter, state.footerContentHeight.value, bandCurrent.value)
90
+ );
91
+ // The body is sized against the highest detent rather than `base`, so it
92
+ // never reflows during a snap between detents — but a `fillParent` snap
93
+ // takes `base` above every detent, and the body follows it up.
94
+ const area = useDerivedValue(() => {
95
+ const sheetHeight = Math.min(maxHeight.value, Math.max(highest.value, state.base.value) + lift.value);
96
+ return contentArea({
97
+ sheetHeight,
98
+ handleHeight: state.handleHeight.value,
99
+ footerHeight: footer.value,
100
+ keyboardHeight: keyboardEffective.value,
101
+ trailingBand: state.config.value.hasFooter ? 0 : bandCurrent.value,
102
+ });
103
+ });
104
+ const top = useDerivedValue(() =>
105
+ footerTop({
106
+ sheetHeight: height.value,
107
+ keyboardHeight: keyboardEffective.value,
108
+ footerContentHeight: state.footerContentHeight.value,
109
+ band: bandCurrent.value,
110
+ })
111
+ );
112
+ // The body is laid out to the sheet's bottom line and reserves `inset` at
113
+ // its end; the clip is what of it may show — under a footer, only what is
114
+ // above the footer's live top edge.
115
+ const inset = useDerivedValue(() => bodyInset(state.config.value.hasFooter, footer.value, bandCurrent.value));
116
+ const clip = useDerivedValue(() =>
117
+ bodyClip({
118
+ contentArea: area.value,
119
+ inset: inset.value,
120
+ hasFooter: state.config.value.hasFooter,
121
+ sheetHeight: height.value,
122
+ handleHeight: state.handleHeight.value,
123
+ keyboardHeight: keyboardEffective.value,
124
+ })
125
+ );
126
+
127
+ const surface = useDerivedValue(() => {
128
+ if (state.config.value.detached === null) return height.value;
129
+ const list = detents.value;
130
+ const lowest = list.length > 0 ? (list[0] as number) : 0;
131
+ return surfaceHeight(height.value, lowest, highest.value);
132
+ });
133
+
134
+ return useMemo<SheetGeometry>(
135
+ () => ({
136
+ restingBottom: resting,
137
+ closedHeight: closed,
138
+ maxHeight,
139
+ band,
140
+ bandNow: bandCurrent,
141
+ detents,
142
+ highest,
143
+ keyboardLift: lift,
144
+ height,
145
+ position,
146
+ index,
147
+ sheetState: sheetStateValue,
148
+ layoutReady,
149
+ contentArea: area,
150
+ footerHeight: footer,
151
+ footerTop: top,
152
+ bodyInset: inset,
153
+ bodyClip: clip,
154
+ surfaceHeight: surface,
155
+ }),
156
+ [
157
+ resting,
158
+ closed,
159
+ maxHeight,
160
+ band,
161
+ bandCurrent,
162
+ detents,
163
+ highest,
164
+ lift,
165
+ height,
166
+ position,
167
+ index,
168
+ sheetStateValue,
169
+ layoutReady,
170
+ area,
171
+ footer,
172
+ top,
173
+ inset,
174
+ clip,
175
+ surface,
176
+ ]
177
+ );
178
+ }
@@ -0,0 +1,144 @@
1
+ import { useCallback, useMemo, useRef } from "react";
2
+ import { useAnimatedReaction } from "react-native-reanimated";
3
+ import type { AnimateTo, JumpTo } from "../animation/animation.types";
4
+ import { GESTURE_SOURCE, heightForIndex, indexForHeight, resolveIntent, type SheetIntent } from "../core";
5
+ import { ANIM_STATUS, type SheetGeometry, type SheetSharedState } from "./state.types";
6
+
7
+ /** `Omit` over each member of a union, rather than over the union's common keys. */
8
+ type DistributiveOmit<T, K extends keyof T> = T extends unknown ? Omit<T, K> : never;
9
+
10
+ /** Every request the JS thread can make, minus the `id` the dispatcher stamps on it. */
11
+ export type IntentRequest = DistributiveOmit<SheetIntent, "id">;
12
+
13
+ export type SheetIntentDispatch = (request: IntentRequest) => void;
14
+
15
+ /**
16
+ * The JS thread's handle on the queue.
17
+ *
18
+ * `lastKind` is the kind of the most recent request, tracked on the JS side.
19
+ * A JS write to a shared value is queued to the UI thread, so reading
20
+ * `state.intent.value` straight after a `dispatch` still returns what was
21
+ * there before — the root once read `null` that way, queued an `open` on top
22
+ * of a ref's `expand`, and the sheet landed on the wrong detent. `clear`
23
+ * writes `null` in order after whatever was queued, which is what a controlled
24
+ * `isOpen: false` needs to cancel an open still waiting on layout.
25
+ */
26
+ export type SheetIntents = {
27
+ dispatch: SheetIntentDispatch;
28
+ clear: () => void;
29
+ lastKind: () => IntentRequest["kind"] | null;
30
+ };
31
+
32
+ /**
33
+ * Intents replace present/dismiss.
34
+ *
35
+ * The JS thread never animates. It writes a request into `state.intent`, and a
36
+ * reaction keyed on the request's `id` and on `layoutReady` runs
37
+ * `resolveIntent` on the UI thread: a close while closed is nothing, an open
38
+ * before the first layout stays queued until the measurements land, a
39
+ * `forceClose` jumps. Two identical requests in a row both run, because each
40
+ * carries a fresh `id`.
41
+ *
42
+ * The first open a sheet resolves is its mount: reported as `"mount"`, and a
43
+ * jump rather than an animation when `animateOnMount` is off. `mountPending`
44
+ * is the flag, and it clears on that first resolution whatever kind it was.
45
+ *
46
+ * A second reaction handles the detents moving under a settled sheet — dynamic
47
+ * content growing, a `snapPoints` change — by animating to the same index in
48
+ * the new list, and a container resize by jumping there instead. Neither runs
49
+ * while a gesture or an animation owns `base`.
50
+ */
51
+ export function useSheetIntents(
52
+ state: SheetSharedState,
53
+ geometry: SheetGeometry,
54
+ animateTo: AnimateTo,
55
+ jumpTo: JumpTo
56
+ ): SheetIntents {
57
+ const nextId = useRef(0);
58
+ const last = useRef<IntentRequest["kind"] | null>(null);
59
+
60
+ const dispatch = useCallback<SheetIntentDispatch>(
61
+ (request) => {
62
+ nextId.current += 1;
63
+ last.current = request.kind;
64
+ state.intent.value = { ...request, id: nextId.current } as SheetIntent;
65
+ },
66
+ [state]
67
+ );
68
+
69
+ const clear = useCallback(() => {
70
+ last.current = null;
71
+ state.intent.value = null;
72
+ }, [state]);
73
+
74
+ const lastKind = useCallback(() => last.current, []);
75
+
76
+ useAnimatedReaction(
77
+ () => ({ id: state.intent.value?.id ?? 0, ready: geometry.layoutReady.value }),
78
+ (current, previous) => {
79
+ if (previous !== null && current.id === previous.id && current.ready === previous.ready) return;
80
+ const intent = state.intent.value;
81
+ if (intent === null) return;
82
+
83
+ // Mid-animation the sheet is where it is going, not where it settled:
84
+ // a close during the open animation has to see an open sheet.
85
+ const effectiveIndex =
86
+ state.animStatus.value === ANIM_STATUS.RUNNING
87
+ ? Math.round(indexForHeight(state.animTarget.value, geometry.detents.value, geometry.closedHeight.value))
88
+ : state.currentIndex.value;
89
+ const resolution = resolveIntent(
90
+ {
91
+ currentIndex: effectiveIndex,
92
+ base: state.base.value,
93
+ layoutReady: current.ready,
94
+ detents: geometry.detents.value,
95
+ closedHeight: geometry.closedHeight.value,
96
+ maxHeight: geometry.maxHeight.value,
97
+ initialIndex: state.config.value.initialIndex,
98
+ },
99
+ intent
100
+ );
101
+
102
+ if (resolution === null) {
103
+ state.intent.value = null;
104
+ return;
105
+ }
106
+ if (resolution.action === "wait") return;
107
+ state.intent.value = null;
108
+
109
+ const mounting = state.mountPending.value && intent.kind === "open";
110
+ state.mountPending.value = false;
111
+ const source = mounting ? "mount" : "user";
112
+ const jump = resolution.action === "jump" || (mounting && !state.config.value.animateOnMount);
113
+ if (jump) jumpTo(resolution.target, source);
114
+ else animateTo(resolution.target, source, 0);
115
+ },
116
+ [state, geometry, animateTo, jumpTo]
117
+ );
118
+
119
+ useAnimatedReaction(
120
+ () => ({ detents: geometry.detents.value.join(","), container: state.containerHeight.value }),
121
+ (current, previous) => {
122
+ if (previous === null || (current.detents === previous.detents && current.container === previous.container)) {
123
+ return;
124
+ }
125
+ const busy = state.animStatus.value !== ANIM_STATUS.IDLE || state.gestureSource.value !== GESTURE_SOURCE.NONE;
126
+ const index = state.currentIndex.value;
127
+ if (busy || index < 0) return;
128
+
129
+ const detents = geometry.detents.value;
130
+ if (detents.length === 0) return;
131
+ const target = heightForIndex(Math.min(index, detents.length - 1), detents, geometry.closedHeight.value);
132
+ if (Math.abs(target - state.base.value) < 0.5) return;
133
+
134
+ if (current.container !== previous.container) {
135
+ jumpTo(target, "container");
136
+ return;
137
+ }
138
+ animateTo(target, "snapPoints", 0);
139
+ },
140
+ [state, geometry, animateTo, jumpTo]
141
+ );
142
+
143
+ return useMemo<SheetIntents>(() => ({ dispatch, clear, lastKind }), [dispatch, clear, lastKind]);
144
+ }
@@ -0,0 +1,157 @@
1
+ import { useEffect, useMemo } from "react";
2
+ import { useSharedValue } from "react-native-reanimated";
3
+ import {
4
+ type AnimationSource,
5
+ CLOSED_INDEX,
6
+ type DetentSpec,
7
+ GESTURE_SOURCE,
8
+ SCROLLABLE_TYPE,
9
+ type ScrollableType,
10
+ type SheetIntent,
11
+ UNMEASURED,
12
+ } from "../core";
13
+ import { ANIM_STATUS, type AnimStatus, type SheetSharedState, type SheetWorkletConfig } from "./state.types";
14
+
15
+ /**
16
+ * Allocates the raw shared values, once per sheet, and keeps `detentSpec` and
17
+ * `config` in step with the props.
18
+ *
19
+ * `detentSpec` is written when its **serialisation** changes, not its identity,
20
+ * so a `snapPoints={["40%", "85%"]}` literal in a render body costs nothing —
21
+ * the array identity churn that made the library this replaces re-derive on
22
+ * every render is simply not observed.
23
+ */
24
+ export function useSheetState(snapPoints: readonly DetentSpec[], config: SheetWorkletConfig): SheetSharedState {
25
+ const containerHeight = useSharedValue(UNMEASURED);
26
+ const containerWidth = useSharedValue(UNMEASURED);
27
+ const containerBottomOffset = useSharedValue(0);
28
+ const handleHeight = useSharedValue(UNMEASURED);
29
+ const contentHeight = useSharedValue(UNMEASURED);
30
+ const footerContentHeight = useSharedValue(UNMEASURED);
31
+ const base = useSharedValue(0);
32
+ const currentIndex = useSharedValue(CLOSED_INDEX);
33
+ const animStatus = useSharedValue<AnimStatus>(ANIM_STATUS.IDLE);
34
+ const animSource = useSharedValue<AnimationSource>("mount");
35
+ const animTarget = useSharedValue(0);
36
+ const gestureSource = useSharedValue<(typeof GESTURE_SOURCE)[keyof typeof GESTURE_SOURCE]>(GESTURE_SOURCE.NONE);
37
+ const scrollOffsetY = useSharedValue(0);
38
+ const scrollLockedAt = useSharedValue(0);
39
+ const scrollableType = useSharedValue<ScrollableType>(SCROLLABLE_TYPE.NONE);
40
+ const keyboardOwned = useSharedValue(false);
41
+ const keyboardProgress = useSharedValue(0);
42
+ const keyboardHeight = useSharedValue(0);
43
+ const intent = useSharedValue<SheetIntent | null>(null);
44
+ const mountPending = useSharedValue(true);
45
+ const detentSpec = useSharedValue<readonly DetentSpec[]>(snapPoints);
46
+ const configValue = useSharedValue<SheetWorkletConfig>(config);
47
+
48
+ const specKey = snapPoints.join("|");
49
+ // biome-ignore lint/correctness/useExhaustiveDependencies: keyed on the serialisation on purpose; the array is only read when it changes
50
+ useEffect(() => {
51
+ detentSpec.value = snapPoints;
52
+ }, [specKey, detentSpec]);
53
+
54
+ const {
55
+ dynamicSizing,
56
+ maxDynamicContentSize,
57
+ hasFooter,
58
+ bottomInset,
59
+ detached,
60
+ enablePanDownToClose,
61
+ enableOverDrag,
62
+ overDragResistanceFactor,
63
+ initialIndex,
64
+ animateOnMount,
65
+ keyboardBehavior,
66
+ keyboardBlurBehavior,
67
+ keyboardScope,
68
+ enableBlurKeyboardOnGesture,
69
+ } = config;
70
+ const detachedKey = detached === null ? "" : `${detached.horizontalMargin}/${detached.bottomOffset}`;
71
+ // biome-ignore lint/correctness/useExhaustiveDependencies: `detached` is keyed by its two numbers, not its identity
72
+ useEffect(() => {
73
+ configValue.value = {
74
+ dynamicSizing,
75
+ maxDynamicContentSize,
76
+ hasFooter,
77
+ bottomInset,
78
+ detached,
79
+ enablePanDownToClose,
80
+ enableOverDrag,
81
+ overDragResistanceFactor,
82
+ initialIndex,
83
+ animateOnMount,
84
+ keyboardBehavior,
85
+ keyboardBlurBehavior,
86
+ keyboardScope,
87
+ enableBlurKeyboardOnGesture,
88
+ };
89
+ }, [
90
+ configValue,
91
+ dynamicSizing,
92
+ maxDynamicContentSize,
93
+ hasFooter,
94
+ bottomInset,
95
+ detachedKey,
96
+ enablePanDownToClose,
97
+ enableOverDrag,
98
+ overDragResistanceFactor,
99
+ initialIndex,
100
+ animateOnMount,
101
+ keyboardBehavior,
102
+ keyboardBlurBehavior,
103
+ keyboardScope,
104
+ enableBlurKeyboardOnGesture,
105
+ ]);
106
+
107
+ return useMemo<SheetSharedState>(
108
+ () => ({
109
+ containerHeight,
110
+ containerWidth,
111
+ containerBottomOffset,
112
+ handleHeight,
113
+ contentHeight,
114
+ footerContentHeight,
115
+ base,
116
+ currentIndex,
117
+ animStatus,
118
+ animSource,
119
+ animTarget,
120
+ gestureSource,
121
+ scrollOffsetY,
122
+ scrollLockedAt,
123
+ scrollableType,
124
+ keyboardOwned,
125
+ keyboardProgress,
126
+ keyboardHeight,
127
+ intent,
128
+ mountPending,
129
+ detentSpec,
130
+ config: configValue,
131
+ }),
132
+ [
133
+ containerHeight,
134
+ containerWidth,
135
+ containerBottomOffset,
136
+ handleHeight,
137
+ contentHeight,
138
+ footerContentHeight,
139
+ base,
140
+ currentIndex,
141
+ animStatus,
142
+ animSource,
143
+ animTarget,
144
+ gestureSource,
145
+ scrollOffsetY,
146
+ scrollLockedAt,
147
+ scrollableType,
148
+ keyboardOwned,
149
+ keyboardProgress,
150
+ keyboardHeight,
151
+ intent,
152
+ mountPending,
153
+ detentSpec,
154
+ configValue,
155
+ ]
156
+ );
157
+ }