@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,100 @@
1
+ # steps
2
+
3
+ `BottomSheet.Steps` and `BottomSheet.Step` — a body whose contents change
4
+ with a machine, and whose height glides between them — plus the hooks that
5
+ run the machine and read it.
6
+
7
+ ## Files
8
+
9
+ | Path | What |
10
+ | --- | --- |
11
+ | `steps.types.ts` | `SheetStepController` (a snapshot plus `send`, `can`, `matches`, `reset`, `direction`), `UseSheetMachineOptions`, the two parts' props |
12
+ | `use-sheet-machine.ts` | `useSheetMachine(machine, { onTransition?, onRejected? })` — the machine as React state |
13
+ | `steps.context.tsx` | `SheetStepContext` (the controller, untyped at the boundary) and `StepsLayoutContext` (what `Steps` tells each `Step`) |
14
+ | `use-sheet-step.ts` | `useSheetStep<S, C, E>()` / `useOptionalSheetStep()` — the controller with its generics asserted back on |
15
+ | `bottom-sheet-steps.tsx` | The body: `Content` with an animated-height stack inside, the `contentHeight` write, the per-step override, the reset on close |
16
+ | `bottom-sheet-step.tsx` | One step: absolutely positioned, measured while current, framed by `stepFrame` |
17
+
18
+ The machine itself — `defineSheetMachine`, `transition`, `stepFrame`,
19
+ `stepOverride` — is pure and lives in `core/machine`.
20
+
21
+ ## The model
22
+
23
+ ```tsx
24
+ const machine = defineSheetMachine<Step, Context, Event>({ initial: "details", context, states: { … } });
25
+
26
+ function Form() {
27
+ const controller = useSheetMachine(machine);
28
+ return (
29
+ <BottomSheet>
30
+ <BottomSheet.Portal>
31
+ <BottomSheet.Overlay />
32
+ <BottomSheet.Container>
33
+ <BottomSheet.Handle />
34
+ <BottomSheet.Steps controller transition="crossfade">
35
+ <BottomSheet.Step name="details">…</BottomSheet.Step>
36
+ <BottomSheet.Step name="confirm">…</BottomSheet.Step>
37
+ </BottomSheet.Steps>
38
+ <BottomSheet.Footer><Actions /></BottomSheet.Footer>
39
+ </BottomSheet.Container>
40
+ </BottomSheet.Portal>
41
+ </BottomSheet>
42
+ );
43
+ }
44
+
45
+ function Actions() {
46
+ const { send, can, matches } = useSheetStep<Step, Context, Event>();
47
+ …
48
+ }
49
+ ```
50
+
51
+ - **`useSheetMachine` is `useState` over `machine.transition`, not
52
+ `useReducer`.** A reducer has to be pure and React may run it twice;
53
+ `onRejected` is a side effect a form wants exactly once. The latest snapshot
54
+ is also kept in a ref so two `send`s in one tick chain. `direction` is
55
+ `machine.directionOf(from, to)` of the last change of step, and a `reset`
56
+ reads as `back`.
57
+ - **`Steps` is `Content` with a stack inside.** It renders `Content` itself,
58
+ so the content pan, the `contentArea` clamp, the detached clamp and the
59
+ footer spacer are all the same code; the stack is an `Animated.View` whose
60
+ height is a shared value. Only the current step and, during a change, the
61
+ one leaving are rendered; each is absolutely positioned across the top of
62
+ the stack, measured by its `onLayout` while current.
63
+ - **The height glides because the detent moves once.** When the current step
64
+ measures, `Steps` writes the number to the sheet's `contentHeight` straight
65
+ away and springs the stack's height to it. The dynamic detent re-derives on
66
+ the UI thread, the root's detent-change reaction (`state/use-sheet-intents.ts`)
67
+ animates `base` to the same index in the new list with the sheet's own
68
+ animation, and — because the stack's spring is resolved from the same
69
+ config — the panel's top edge and the body's bottom edge move together. A
70
+ sheet resting on an explicit detent does not move at all. If the sheet was
71
+ busy when the detent moved (a step change mid-open), the stack animation's
72
+ completion nudges `base` onto its detent.
73
+ - **`Content` stops measuring.** The internal context's
74
+ `contentHeightSource` reads `steps` while a `Steps` body is mounted, and
75
+ `Content`'s `onLayout` returns early; its layout events would otherwise be
76
+ the stack mid-animation, a frame stale. The unmount reset to `UNMEASURED`
77
+ still runs, so the next open waits for the first step to measure again.
78
+ - **A step's `snapPoints` replace the root's, and turn dynamic sizing off,
79
+ while it is current.** A step that names its detents is sized by them.
80
+ `dismissible: false` switches off pan-down-to-close and the overlay's press
81
+ for that step — `Close` and the ref still work. Both go through
82
+ `setStepOverride` on the root (`components/bottom-sheet.tsx`), because the
83
+ overlay is not a descendant of the body.
84
+ - **Transitions are `stepFrame` over one `progress`.** `crossfade` fades
85
+ the two across each other, `slide` moves them a container-width apart along
86
+ `direction`, `none` swaps them. `progress` runs `0 → 1` on the incoming
87
+ step's first layout, with the same animation as the height, and the outgoing
88
+ step is dropped when it completes.
89
+ - **`resetOnClose` resets from two places.** The `isOpen` flip, for a body
90
+ that stays mounted (inline, `keepMounted`), and the unmount cleanup, for one
91
+ the portal takes down — a close settles, the root sets `isOpen` false and
92
+ `presented` false in one render, and only the cleanup runs.
93
+ - **`useSheetStep` reaches a `Footer`.** The footer is written beside the
94
+ body, not inside it, so `Steps` also registers its controller with the root
95
+ through `setStepController`; the hook reads the context first and the
96
+ registration second. Anywhere inside the `BottomSheet` works once the body
97
+ has mounted.
98
+ - **Vertical padding goes on the `Step`, not on `Steps`.** `Steps`'s `style`
99
+ lands on `Content`'s inner view around the stack, and the sheet's height is
100
+ what the step measures — padding around the stack is not counted.
@@ -0,0 +1,61 @@
1
+ import type { ReactElement } from "react";
2
+ import { StyleSheet } from "react-native";
3
+ import Animated, { useAnimatedStyle } from "react-native-reanimated";
4
+ import { stepFrame } from "../core";
5
+ import { useStepsLayout } from "./steps.context";
6
+ import type { BottomSheetStepProps } from "./steps.types";
7
+
8
+ /**
9
+ * One step's body. Rendered by `Steps` only while it is the current step or
10
+ * the one on its way out, so a step that is neither costs nothing.
11
+ *
12
+ * Absolutely positioned across the top of the stack, so two steps can overlap
13
+ * during a change and neither pushes the other down; the stack's own height
14
+ * is animated by `Steps` from what the current step measures here. Its frame
15
+ * — opacity and horizontal offset — is `stepFrame` over the shared `progress`,
16
+ * on the UI thread.
17
+ */
18
+ export function BottomSheetStep<S extends string>({
19
+ name,
20
+ children,
21
+ style,
22
+ ref,
23
+ onLayout,
24
+ ...props
25
+ }: BottomSheetStepProps<S>): ReactElement | null {
26
+ const { active, outgoing, direction, transition, progress, width, onActiveLayout } = useStepsLayout();
27
+ const role = name === active ? "incoming" : name === outgoing ? "outgoing" : null;
28
+
29
+ const frame = useAnimatedStyle(() => {
30
+ if (role === null) return { opacity: 0, transform: [{ translateX: 0 }] };
31
+ const { opacity, translateX } = stepFrame(transition, role, direction, progress.value, width.value);
32
+ return { opacity, transform: [{ translateX }] };
33
+ });
34
+
35
+ if (role === null) return null;
36
+
37
+ const measure =
38
+ role === "incoming"
39
+ ? (event: Parameters<typeof onActiveLayout>[0]): void => {
40
+ onActiveLayout(event);
41
+ onLayout?.(event);
42
+ }
43
+ : onLayout;
44
+
45
+ return (
46
+ <Animated.View
47
+ onLayout={measure}
48
+ pointerEvents={role === "incoming" ? "auto" : "none"}
49
+ ref={ref}
50
+ style={[styles.step, style, frame]}
51
+ {...props}
52
+ >
53
+ {children}
54
+ </Animated.View>
55
+ );
56
+ }
57
+ BottomSheetStep.displayName = "DelacourBottomSheet.BottomSheet.Step";
58
+
59
+ const styles = StyleSheet.create({
60
+ step: { left: 0, position: "absolute", right: 0, top: 0 },
61
+ });
@@ -0,0 +1,215 @@
1
+ import { type ReactElement, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
2
+ import { type LayoutChangeEvent, Platform, StyleSheet } from "react-native";
3
+ import Animated, { useAnimatedStyle, useSharedValue, withSpring, withTiming } from "react-native-reanimated";
4
+ import { scheduleOnRN } from "react-native-worklets";
5
+ import { type ReanimatedAnimation, toReanimated } from "../animation/resolve-animation";
6
+ import { useBottomSheet, useBottomSheetInternal } from "../components/bottom-sheet.context";
7
+ import { BottomSheetContent } from "../components/bottom-sheet-content";
8
+ import {
9
+ GESTURE_SOURCE,
10
+ heightForIndex,
11
+ type SheetEvent,
12
+ type SheetStepDirection,
13
+ selectAnimation,
14
+ stepOverride,
15
+ UNMEASURED,
16
+ } from "../core";
17
+ import { ANIM_STATUS } from "../state/state.types";
18
+ import { SheetStepContext, StepsLayoutContext, type StepsLayoutValue } from "./steps.context";
19
+ import type { BottomSheetStepsProps, SheetStepController } from "./steps.types";
20
+
21
+ type Stage<S extends string> = { active: S; outgoing: S | null; direction: SheetStepDirection };
22
+
23
+ /** Hands `value` to a spring or a timing, with `complete` on the UI thread once it lands. */
24
+ function animateWith(animation: ReanimatedAnimation, value: number, complete: (finished?: boolean) => void): number {
25
+ if (animation.type === "spring") return withSpring(value, animation.config, complete);
26
+ return withTiming(value, animation.config, complete);
27
+ }
28
+
29
+ /**
30
+ * A multi-step body: the `Content` of a sheet whose contents change with a
31
+ * machine.
32
+ *
33
+ * It is `Content` with a stack inside — each `Step` is absolutely positioned
34
+ * across the top of the stack and only the current step and, during a change,
35
+ * the one leaving are rendered. The stack's height is a shared value animated
36
+ * to whatever the current step measures, and that same measurement is written
37
+ * to the sheet's `contentHeight` the moment it lands, so the dynamic detent
38
+ * moves once and the root's detent-change reaction animates `base` there with
39
+ * the same spring the body is using: the panel's top edge and the body's
40
+ * bottom edge glide together, and a sheet resting on an explicit detent does
41
+ * not move at all. Should the sheet have been busy when the detent moved — a
42
+ * step change mid-open — the height animation's completion nudges it onto its
43
+ * detent.
44
+ *
45
+ * `Content`'s own measurement is switched off while this is the body, or its
46
+ * layout events would race the animated height with stale numbers.
47
+ *
48
+ * The step the machine is on decides two things about the sheet: its
49
+ * `snapPoints` replace the root's while it is current (and dynamic sizing is
50
+ * off for it — a step that names its detents is sized by them), and
51
+ * `dismissible: false` disables pan-down-to-close and the overlay's press.
52
+ * Both go through the root, since the overlay is not a descendant of the body.
53
+ *
54
+ * The controller is provided to every descendant through `SheetStepContext`,
55
+ * and registered with the root so a `Footer` written beside this body reaches
56
+ * it through `useSheetStep` too.
57
+ */
58
+ export function BottomSheetSteps<S extends string, C, E extends SheetEvent>({
59
+ controller,
60
+ transition = "crossfade",
61
+ animation,
62
+ resetOnClose = true,
63
+ footerGap,
64
+ children,
65
+ style,
66
+ ref,
67
+ ...props
68
+ }: BottomSheetStepsProps<S, C, E>): ReactElement {
69
+ const internal = useBottomSheetInternal();
70
+ const { state, geometry, animateTo, setContentHeightSource, setStepOverride, setStepController } = internal;
71
+ const { isOpen } = useBottomSheet();
72
+ const stepHeight = useSharedValue(UNMEASURED);
73
+ const progress = useSharedValue(1);
74
+
75
+ const [stage, setStage] = useState<Stage<S>>({ active: controller.value, outgoing: null, direction: "forward" });
76
+ const changePending = useRef(false);
77
+ const lastHeight = useRef(UNMEASURED);
78
+
79
+ // Derived during render, so the incoming step mounts in the same commit the
80
+ // machine moved in and its first layout is the one that starts the change.
81
+ if (controller.value !== stage.active) {
82
+ changePending.current = true;
83
+ setStage({
84
+ active: controller.value,
85
+ outgoing: transition === "none" ? null : stage.active,
86
+ direction: controller.direction,
87
+ });
88
+ }
89
+
90
+ const animationKey = JSON.stringify(animation ?? internal.animation ?? null);
91
+ // biome-ignore lint/correctness/useExhaustiveDependencies: the animation is keyed by its serialisation
92
+ const resolved = useMemo(
93
+ () => toReanimated(selectAnimation(animation ?? internal.animation, Platform.OS, internal.overrideReduceMotion)),
94
+ [animationKey, internal.overrideReduceMotion]
95
+ );
96
+
97
+ useLayoutEffect(() => {
98
+ setContentHeightSource("steps");
99
+ return () => setContentHeightSource("content");
100
+ }, [setContentHeightSource]);
101
+
102
+ const { machine, value } = controller;
103
+ useLayoutEffect(() => {
104
+ setStepOverride(stepOverride(machine.nodeOf(value)));
105
+ }, [machine, value, setStepOverride]);
106
+ useEffect(() => () => setStepOverride(null), [setStepOverride]);
107
+
108
+ // The ref during render, so a `Footer` rendered after this body in the
109
+ // same pass reads the controller; the state from an effect, to re-render
110
+ // anything that did not.
111
+ const untyped = controller as unknown as SheetStepController<string, unknown, SheetEvent>;
112
+ internal.stepControllerRef.current = untyped;
113
+ useLayoutEffect(() => {
114
+ setStepController(untyped);
115
+ }, [untyped, setStepController]);
116
+ useEffect(
117
+ () => () => {
118
+ internal.stepControllerRef.current = null;
119
+ setStepController(null);
120
+ },
121
+ [internal.stepControllerRef, setStepController]
122
+ );
123
+
124
+ // A close resets the machine: through the `isOpen` flip for a body that
125
+ // stays mounted, and through unmount for one the portal takes down.
126
+ const latest = useRef({ controller, resetOnClose });
127
+ latest.current = { controller, resetOnClose };
128
+ useEffect(() => {
129
+ if (isOpen || !latest.current.resetOnClose) return;
130
+ latest.current.controller.reset();
131
+ }, [isOpen]);
132
+ useEffect(
133
+ () => () => {
134
+ if (latest.current.resetOnClose) latest.current.controller.reset();
135
+ },
136
+ []
137
+ );
138
+
139
+ const finishChange = useCallback(() => {
140
+ setStage((current) => (current.outgoing === null ? current : { ...current, outgoing: null }));
141
+ }, []);
142
+
143
+ const onActiveLayout = useCallback(
144
+ (event: LayoutChangeEvent) => {
145
+ const height = event.nativeEvent.layout.height;
146
+ const changing = changePending.current;
147
+ changePending.current = false;
148
+ if (lastHeight.current === height && !changing) return;
149
+ const first = lastHeight.current < 0;
150
+ lastHeight.current = height;
151
+ state.contentHeight.value = height;
152
+ if (first) {
153
+ stepHeight.value = height;
154
+ return;
155
+ }
156
+
157
+ const settle = (finished?: boolean): void => {
158
+ "worklet";
159
+ if (finished !== true) return;
160
+ const busy = state.animStatus.value !== ANIM_STATUS.IDLE || state.gestureSource.value !== GESTURE_SOURCE.NONE;
161
+ const index = state.currentIndex.value;
162
+ const detents = geometry.detents.value;
163
+ if (busy || index < 0 || detents.length === 0) return;
164
+ const target = heightForIndex(Math.min(index, detents.length - 1), detents, geometry.closedHeight.value);
165
+ if (Math.abs(target - state.base.value) > 0.5) animateTo(target, "snapPoints", 0);
166
+ };
167
+ stepHeight.value = animateWith(resolved, height, settle);
168
+
169
+ if (!changing) return;
170
+ if (transition === "none") {
171
+ progress.value = 1;
172
+ return;
173
+ }
174
+ const complete = (finished?: boolean): void => {
175
+ "worklet";
176
+ if (finished === true) scheduleOnRN(finishChange);
177
+ };
178
+ progress.value = 0;
179
+ progress.value = animateWith(resolved, 1, complete);
180
+ },
181
+ [state, geometry, animateTo, resolved, transition, stepHeight, progress, finishChange]
182
+ );
183
+
184
+ const stack = useAnimatedStyle(() => ({
185
+ height: stepHeight.value >= 0 ? stepHeight.value : undefined,
186
+ }));
187
+
188
+ const layoutValue = useMemo<StepsLayoutValue>(
189
+ () => ({
190
+ active: stage.active,
191
+ outgoing: stage.outgoing,
192
+ direction: stage.direction,
193
+ transition,
194
+ progress,
195
+ width: state.containerWidth,
196
+ onActiveLayout,
197
+ }),
198
+ [stage, transition, progress, state.containerWidth, onActiveLayout]
199
+ );
200
+
201
+ return (
202
+ <SheetStepContext.Provider value={untyped}>
203
+ <StepsLayoutContext.Provider value={layoutValue}>
204
+ <BottomSheetContent footerGap={footerGap} ref={ref} style={style} {...props}>
205
+ <Animated.View style={[styles.stack, stack]}>{children}</Animated.View>
206
+ </BottomSheetContent>
207
+ </StepsLayoutContext.Provider>
208
+ </SheetStepContext.Provider>
209
+ );
210
+ }
211
+ BottomSheetSteps.displayName = "DelacourBottomSheet.BottomSheet.Steps";
212
+
213
+ const styles = StyleSheet.create({
214
+ stack: { overflow: "hidden", width: "100%" },
215
+ });
@@ -0,0 +1,73 @@
1
+ import { createContext, useContext } from "react";
2
+ import type { LayoutChangeEvent } from "react-native";
3
+ import type { SharedValue } from "react-native-reanimated";
4
+ import { useOptionalBottomSheetInternal } from "../components/bottom-sheet.context";
5
+ import type { SheetEvent, SheetStepDirection, SheetStepTransition } from "../core";
6
+ import type { SheetStepController } from "./steps.types";
7
+
8
+ const OUTSIDE = (hook: string): Error =>
9
+ new Error(
10
+ `[@delacour/react-native-bottom-sheet] ${hook} outside <BottomSheet.Steps>. A step reads its controller from context.`
11
+ );
12
+
13
+ /**
14
+ * The controller `BottomSheet.Steps` was given, for every descendant — a
15
+ * step's own buttons, a title that names the step. A `Footer` written beside
16
+ * the body rather than inside it is not a descendant, so `Steps` also
17
+ * registers the controller with the root and `useSheetStep` falls back to
18
+ * that: anywhere inside the `BottomSheet` works once the body has mounted.
19
+ *
20
+ * Typed loosely at the context boundary: a provider cannot carry the machine's
21
+ * generics through `createContext`, so `useSheetStep<S, C, E>()` asserts them
22
+ * back at the call site, the way a typed selector would.
23
+ */
24
+ export const SheetStepContext = createContext<SheetStepController<string, unknown, SheetEvent> | null>(null);
25
+ SheetStepContext.displayName = "DelacourBottomSheet.StepContext";
26
+
27
+ /** Untyped at the boundary; `use-sheet-step.ts` puts the machine's generics back. */
28
+ export function useSheetStep(): SheetStepController<string, unknown, SheetEvent> {
29
+ const value = useContext(SheetStepContext);
30
+ const registered = useOptionalBottomSheetInternal()?.stepControllerRef.current ?? null;
31
+ const controller = value ?? registered;
32
+ if (controller === null) throw OUTSIDE("useSheetStep");
33
+ return controller;
34
+ }
35
+
36
+ export function useOptionalSheetStep(): SheetStepController<string, unknown, SheetEvent> | null {
37
+ const value = useContext(SheetStepContext);
38
+ const registered = useOptionalBottomSheetInternal()?.stepControllerRef.current ?? null;
39
+ return value ?? registered;
40
+ }
41
+
42
+ /**
43
+ * What `Steps` tells each `Step`: whether it is the one arriving or the one
44
+ * leaving, how far the change has run, and where to report its height.
45
+ * Internal to the folder.
46
+ */
47
+ export type StepsLayoutValue = {
48
+ /** The step the machine is on. */
49
+ active: string;
50
+ /** The step on its way out during a transition, `null` otherwise. */
51
+ outgoing: string | null;
52
+ direction: SheetStepDirection;
53
+ transition: SheetStepTransition;
54
+ /** `0` at the start of a change, `1` once the incoming step is fully in. */
55
+ progress: SharedValue<number>;
56
+ /** The frame's width, for `slide`. */
57
+ width: SharedValue<number>;
58
+ /** The active step's measured height, from its `onLayout`. */
59
+ onActiveLayout: (event: LayoutChangeEvent) => void;
60
+ };
61
+
62
+ export const StepsLayoutContext = createContext<StepsLayoutValue | null>(null);
63
+ StepsLayoutContext.displayName = "DelacourBottomSheet.StepsLayoutContext";
64
+
65
+ export function useStepsLayout(): StepsLayoutValue {
66
+ const value = useContext(StepsLayoutContext);
67
+ if (value === null) throw OUTSIDE("BottomSheet.Step");
68
+ return value;
69
+ }
70
+
71
+ export function useOptionalStepsLayout(): StepsLayoutValue | null {
72
+ return useContext(StepsLayoutContext);
73
+ }
@@ -0,0 +1,61 @@
1
+ import type { ReactNode, Ref } from "react";
2
+ import type { View, ViewProps } from "react-native";
3
+ import type { SheetAnimation } from "../animation/animation.types";
4
+ import type {
5
+ SheetEvent,
6
+ SheetMachine,
7
+ SheetMachineSnapshot,
8
+ SheetStepDirection,
9
+ SheetStepTransition,
10
+ SheetTransitionError,
11
+ } from "../core";
12
+
13
+ /**
14
+ * What `useSheetMachine` returns and `BottomSheet.Steps` takes.
15
+ *
16
+ * A snapshot plus the verbs: `send` an event, ask whether one `can` be sent,
17
+ * test the current step with `matches`. `direction` is which way the last
18
+ * change of step read, for the transition the body animates. An XState
19
+ * adapter is any object with this shape — nothing checks its identity.
20
+ */
21
+ export type SheetStepController<S extends string, C, E extends SheetEvent> = SheetMachineSnapshot<S, C> & {
22
+ /** The machine the controller runs, for `nodeOf` and `steps`. */
23
+ machine: SheetMachine<S, C, E>;
24
+ /** The steps in declaration order. */
25
+ steps: readonly S[];
26
+ /** Which way the last change of step went. `forward` until one happens. */
27
+ direction: SheetStepDirection;
28
+ send: (event: E) => void;
29
+ /** Whether `send` would move — for disabling a Next button. */
30
+ can: (event: E) => boolean;
31
+ matches: (step: S) => boolean;
32
+ /** Back to `machine.initial`. `Steps` calls it after a close when `resetOnClose`. */
33
+ reset: () => void;
34
+ };
35
+
36
+ export type UseSheetMachineOptions<S extends string, C, E extends SheetEvent> = {
37
+ /** A transition succeeded: the snapshot it left and the one it reached. */
38
+ onTransition?: (from: SheetMachineSnapshot<S, C>, to: SheetMachineSnapshot<S, C>, event: E) => void;
39
+ /** A transition was refused. The snapshot is unchanged. */
40
+ onRejected?: (error: SheetTransitionError<S>, event: E) => void;
41
+ };
42
+
43
+ export type BottomSheetStepsProps<S extends string, C, E extends SheetEvent> = Omit<ViewProps, "children"> & {
44
+ children?: ReactNode;
45
+ ref?: Ref<View>;
46
+ controller: SheetStepController<S, C, E>;
47
+ /** How one step gives way to the next. @default "crossfade" */
48
+ transition?: SheetStepTransition;
49
+ /** The spring or timing for the height and the transition. The root's `animation` when omitted. */
50
+ animation?: SheetAnimation;
51
+ /** Send the controller back to its initial step once the sheet has closed. @default true */
52
+ resetOnClose?: boolean;
53
+ /** Extra space between the steps and a sticky footer. @default 0 */
54
+ footerGap?: number;
55
+ };
56
+
57
+ export type BottomSheetStepProps<S extends string = string> = ViewProps & {
58
+ ref?: Ref<View>;
59
+ /** The step this body belongs to — a key of the machine's `states`. */
60
+ name: S;
61
+ };
@@ -0,0 +1,70 @@
1
+ import { useCallback, useMemo, useRef, useState } from "react";
2
+ import type { SheetEvent, SheetMachine, SheetMachineSnapshot, SheetStepDirection } from "../core";
3
+ import type { SheetStepController, UseSheetMachineOptions } from "./steps.types";
4
+
5
+ type MachineState<S extends string, C> = { snapshot: SheetMachineSnapshot<S, C>; direction: SheetStepDirection };
6
+
7
+ /**
8
+ * Runs a sheet machine as React state.
9
+ *
10
+ * `send` is `machine.transition` over the latest snapshot: a success becomes
11
+ * the new state and `onTransition` hears about it, a refusal leaves the state
12
+ * alone and `onRejected` hears why. The latest snapshot lives in a ref as
13
+ * well as in state, so two `send`s in one tick chain rather than both reading
14
+ * the render they were made in.
15
+ *
16
+ * Not a `useReducer`, on purpose: a reducer has to be pure and React may run
17
+ * it twice, and `onRejected` is a side effect a caller wants exactly once.
18
+ *
19
+ * `can` and `matches` close over the rendered snapshot, not the ref, and are
20
+ * rebuilt with it: the React Compiler memoises `can({ type: "NEXT" })` on the
21
+ * identity of `can`, and a stable `can` reading a ref returned the answer
22
+ * from the render it was first called in — the Next button never unlocked.
23
+ * `send` and `reset` stay stable; they are actions, not questions.
24
+ */
25
+ export function useSheetMachine<S extends string, C, E extends SheetEvent>(
26
+ machine: SheetMachine<S, C, E>,
27
+ options: UseSheetMachineOptions<S, C, E> = {}
28
+ ): SheetStepController<S, C, E> {
29
+ const [state, setState] = useState<MachineState<S, C>>({ snapshot: machine.initial, direction: "forward" });
30
+ const latest = useRef(state);
31
+ const callbacks = useRef(options);
32
+ callbacks.current = options;
33
+
34
+ const send = useCallback(
35
+ (event: E) => {
36
+ const from = latest.current.snapshot;
37
+ const result = machine.transition(from, event);
38
+ if (!result.success) {
39
+ callbacks.current.onRejected?.(result.error, event);
40
+ return;
41
+ }
42
+ const to = result.data;
43
+ const direction = to.value === from.value ? latest.current.direction : machine.directionOf(from.value, to.value);
44
+ latest.current = { snapshot: to, direction };
45
+ setState(latest.current);
46
+ callbacks.current.onTransition?.(from, to, event);
47
+ },
48
+ [machine]
49
+ );
50
+
51
+ const reset = useCallback(() => {
52
+ latest.current = { snapshot: machine.initial, direction: "back" };
53
+ setState(latest.current);
54
+ }, [machine]);
55
+
56
+ const { snapshot, direction } = state;
57
+ return useMemo<SheetStepController<S, C, E>>(
58
+ () => ({
59
+ ...snapshot,
60
+ machine,
61
+ steps: machine.steps,
62
+ direction,
63
+ send,
64
+ can: (event) => machine.can(snapshot, event),
65
+ matches: (step) => snapshot.value === step,
66
+ reset,
67
+ }),
68
+ [snapshot, direction, machine, send, reset]
69
+ );
70
+ }
@@ -0,0 +1,29 @@
1
+ import type { SheetEvent } from "../core";
2
+ import { useOptionalSheetStep as useOptionalUntyped, useSheetStep as useUntyped } from "./steps.context";
3
+ import type { SheetStepController } from "./steps.types";
4
+
5
+ /**
6
+ * The controller of the nearest `BottomSheet.Steps` — or the one registered
7
+ * with the enclosing `BottomSheet`, for a part written beside the body such
8
+ * as a `Footer` — with the machine's generics asserted back on.
9
+ *
10
+ * A context cannot carry `<S, C, E>` through `createContext`, so the hook in
11
+ * `steps.context.tsx` returns the untyped controller and this one narrows it
12
+ * the way a typed selector would: `useSheetStep<Step, Context, Event>()` is
13
+ * the caller's promise that the enclosing `Steps` runs that machine.
14
+ */
15
+ export function useSheetStep<
16
+ S extends string = string,
17
+ C = unknown,
18
+ E extends SheetEvent = SheetEvent,
19
+ >(): SheetStepController<S, C, E> {
20
+ return useUntyped() as unknown as SheetStepController<S, C, E>;
21
+ }
22
+
23
+ export function useOptionalSheetStep<
24
+ S extends string = string,
25
+ C = unknown,
26
+ E extends SheetEvent = SheetEvent,
27
+ >(): SheetStepController<S, C, E> | null {
28
+ return useOptionalUntyped() as unknown as SheetStepController<S, C, E> | null;
29
+ }