@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,123 @@
1
+ # scrollable
2
+
3
+ A `ScrollView`, `FlatList` or `SectionList` as the sheet's body, and the
4
+ factory that makes one out of any animated scrollable.
5
+
6
+ ## Files
7
+
8
+ | Path | What |
9
+ | --- | --- |
10
+ | `scrollable.types.ts` | `BottomSheetScrollableProps` (`focusHook`), `FocusHook`, `ScrollableHandle`, the inner shape the wrapper is written against, and the restated generic component types |
11
+ | `create-bottom-sheet-scrollable.tsx` | `createBottomSheetScrollable(AnimatedComponent, type)` — the two detectors, the lock, the clip and the clamp, the trailing spacer, content size, registration |
12
+ | `bottom-sheet-scroll-view.tsx` | `BottomSheet.ScrollView` over `Animated.ScrollView` |
13
+ | `bottom-sheet-flat-list.tsx` | `BottomSheet.FlatList` over `Animated.FlatList`, generic in `ItemT` |
14
+ | `bottom-sheet-section-list.tsx` | `BottomSheet.SectionList` over a module-scope `createAnimatedComponent(SectionList)`, generic in `ItemT, SectionT` |
15
+
16
+ The UI-thread half — the scroll handler, the lock reaction and the animated
17
+ props — is `gesture/use-scroll-lock.ts`, beside the pan it cooperates with.
18
+ The numbers are `core/scroll/`: `shouldLockScroll`, `listDragHeight`,
19
+ `listOwnsRelease`, `scrollLockTarget`.
20
+
21
+ ## How a list and the sheet share a finger
22
+
23
+ The content pan and the list's native scroll are **simultaneous**: the list's
24
+ `Native` gesture is declared `simultaneousWithExternalGesture(pans.content)`,
25
+ so both see every touch. The rule for who moves is the sheet's state:
26
+
27
+ - **Below the highest detent — locked.** Every scroll event is answered with
28
+ `scrollTo(ref, 0, scrollLockedAt, false)` and the offset the pan reads is
29
+ pinned there, so the list consumes nothing and the pan moves the sheet. The
30
+ indicator is hidden, `bounces` is off and `decelerationRate` is `0`, so a
31
+ fling has no momentum for the lock to fight.
32
+ - **At the highest detent — unlocked.** The list scrolls. The pan treats the
33
+ offset the list *began* the gesture with as a budget the finger spends
34
+ downward before the sheet moves (`listDragHeight`): the list scrolls
35
+ one-for-one under the same finger, so it reaches its top exactly as the
36
+ budget runs out and the sheet takes over from there. Upward, the clamp at
37
+ `highest` means the list scrolls and bounces rather than the sheet
38
+ over-dragging.
39
+ - **A release while scrolled at the top** belongs to the list
40
+ (`listOwnsRelease`): the velocity is the list's momentum, not a snap. The
41
+ library this replaces snapped here and hopped a detent under interrupted
42
+ scrolls.
43
+
44
+ The budget is the offset at the *start* of the gesture, never the live one.
45
+ The live offset was tried first: the pan and the scroll event for one touch
46
+ move land in the same frame in no fixed order, and when the pan ran first the
47
+ sheet dipped a pixel, the lock engaged and the list froze where it was. A
48
+ fixed budget cannot race anything. A list held by the lock — scrolled, and
49
+ below the top — has no budget until the sheet reaches the top this gesture
50
+ (`listHeld` in `use-sheet-pan.ts`), the previous engine's
51
+ `isScrollablePositionLocked` under another name.
52
+
53
+ The lock's target is taken when the lock engages, by any path — a drag, a
54
+ `snapToIndex`, a keyboard — as `max(0, offset)`. A pull-down from the top
55
+ locks at `0`; a handle drag on a scrolled list holds the rows where they are.
56
+
57
+ ## Sizing
58
+
59
+ The list fills the body to the sheet's bottom line: its `maxHeight` follows
60
+ `contentArea + bodyInset` on the UI thread, exactly as `Content`'s layout box
61
+ does. What the footer or the safe-area band would cover is reserved *inside*
62
+ the content, as a trailing spacer — appended to a `ScrollView`'s children,
63
+ composed after the consumer's `ListFooterComponent` on a `FlatList` or
64
+ `SectionList` — the height of the geometry's `bodyInset`, so the rows scroll
65
+ under the footer or the band and the last row can still be brought fully clear
66
+ of them. The spacer is animated because the band collapses as the keyboard
67
+ rises, and `contentContainerStyle` cannot be; `scrollIndicatorInsets.bottom`
68
+ follows the same value through the lock's `animatedProps`, so the indicator
69
+ ends where the rows stop being visible. The outer view clips to `bodyClip`,
70
+ which under a footer is the footer's live top edge.
71
+
72
+ The list must not shrink to that clip. A React Native scrollable ships
73
+ `flexShrink: 1`, and Yoga shrank it to the wrapper — the footer's top — so
74
+ the spacer's clearance landed on top of the wrapper's and the last row stopped
75
+ a whole footer above the footer. `flexShrink: 0` on the list leaves
76
+ `maxHeight` as its only bound. A `marginBottom` of the footer's height was the
77
+ earlier answer and is gone: it ended the list above the footer with the rows
78
+ cut at that edge and an empty band under them.
79
+
80
+ `onContentSizeChange`, less the spacer's own measured height
81
+ (`scrollContentHeight`), writes `contentHeight`, which is the dynamic detent's
82
+ measurement. A `BottomSheet.ScrollView` of forty rows therefore needs neither
83
+ `snapPoints` nor `dynamicSizing={false}`: it sizes to its rows, capped by
84
+ `maxDynamicContentSize`, and scrolls inside that. The content size and the
85
+ spacer's layout arrive as two events from one commit; both are held in refs
86
+ and folded into a single write after the batch, because the first open
87
+ resolves on whatever `contentHeight` says first and the mount animation, once
88
+ running, ignores a corrected detent list — a frame of rows-plus-spacer would
89
+ open the sheet a footer too tall. `contentHeight` goes back to `UNMEASURED` on
90
+ unmount for the same reason `useMeasureHeight` resets it.
91
+
92
+ ## The factory's contract
93
+
94
+ The component handed to `createBottomSheetScrollable` must already be
95
+ animated, and must be made **once at module scope** — `Animated.ScrollView`,
96
+ `Animated.FlatList`, or `Animated.createAnimatedComponent(X)` in a module
97
+ body. The wrapper drives it with `animatedProps` and an animated `onScroll`;
98
+ a component created inside a render is a new type every frame and remounts
99
+ its subtree.
100
+
101
+ The wrapper is written once against the props every React Native scrollable
102
+ shares (`ScrollableInnerProps`) and passes the rest through. The built-ins
103
+ cast back to a generic signature so `data` and `renderItem` keep checking
104
+ against each other — the same move `Screen.FlatList` makes in
105
+ `@delacour/react-native-ui`.
106
+
107
+ `contentContainerStyle` reaches the list flattened to one object. A
108
+ virtualised list measures its content container, and an array style makes
109
+ that measurement lag the layout by a frame.
110
+
111
+ The consumer's `onScroll`, `onScrollBeginDrag`, `onScrollEndDrag` and
112
+ `onMomentumScrollEnd` are called on the JS thread through `scheduleOnRN` with
113
+ the event the worklet saw; they cannot be worklets of their own.
114
+
115
+ `focusHook` defaults to `useEffect`. Under React Navigation pass
116
+ `useFocusEffect`, and a list on a screen that is not showing withdraws from
117
+ the sheet.
118
+
119
+ ## Out of scope
120
+
121
+ Refresh control. `refreshControl` and `onRefresh` pass through untouched, but
122
+ a pull-to-refresh at the top of a locked list fights the sheet's own
123
+ pull-down, and no rule for that is written yet.
@@ -0,0 +1,15 @@
1
+ import Animated from "react-native-reanimated";
2
+ import { SCROLLABLE_TYPE } from "../core";
3
+ import { createBottomSheetScrollable } from "./create-bottom-sheet-scrollable";
4
+ import type { BottomSheetFlatListComponent } from "./scrollable.types";
5
+
6
+ /**
7
+ * A `FlatList` as the sheet's body. `Animated.FlatList` is Reanimated's own,
8
+ * made once at module scope; the cast restores `<ItemT>` so `data` and
9
+ * `renderItem` check against each other.
10
+ */
11
+ export const BottomSheetFlatList = createBottomSheetScrollable(
12
+ Animated.FlatList,
13
+ SCROLLABLE_TYPE.FLAT_LIST
14
+ ) as unknown as BottomSheetFlatListComponent;
15
+ BottomSheetFlatList.displayName = "DelacourBottomSheet.BottomSheet.FlatList";
@@ -0,0 +1,15 @@
1
+ import Animated from "react-native-reanimated";
2
+ import { SCROLLABLE_TYPE } from "../core";
3
+ import { createBottomSheetScrollable } from "./create-bottom-sheet-scrollable";
4
+ import type { BottomSheetScrollViewComponent } from "./scrollable.types";
5
+
6
+ /**
7
+ * A `ScrollView` as the sheet's body: locked below the highest detent, sized
8
+ * to its content when the sheet is dynamic. `Animated.ScrollView` is
9
+ * Reanimated's own, made once at module scope.
10
+ */
11
+ export const BottomSheetScrollView = createBottomSheetScrollable(
12
+ Animated.ScrollView,
13
+ SCROLLABLE_TYPE.SCROLL_VIEW
14
+ ) as unknown as BottomSheetScrollViewComponent;
15
+ BottomSheetScrollView.displayName = "DelacourBottomSheet.BottomSheet.ScrollView";
@@ -0,0 +1,22 @@
1
+ import { SectionList } from "react-native";
2
+ import Animated from "react-native-reanimated";
3
+ import { SCROLLABLE_TYPE } from "../core";
4
+ import { createBottomSheetScrollable } from "./create-bottom-sheet-scrollable";
5
+ import type { BottomSheetSectionListComponent } from "./scrollable.types";
6
+
7
+ /**
8
+ * Reanimated ships no `Animated.SectionList`, so this is the one built-in
9
+ * that calls `createAnimatedComponent` — once, here, at module scope, never
10
+ * inside a render.
11
+ */
12
+ const AnimatedSectionList = Animated.createAnimatedComponent(SectionList);
13
+
14
+ /**
15
+ * A `SectionList` as the sheet's body; the cast restores `<ItemT, SectionT>`
16
+ * so `sections` and `renderItem` check against each other.
17
+ */
18
+ export const BottomSheetSectionList = createBottomSheetScrollable(
19
+ AnimatedSectionList,
20
+ SCROLLABLE_TYPE.SECTION_LIST
21
+ ) as unknown as BottomSheetSectionListComponent;
22
+ BottomSheetSectionList.displayName = "DelacourBottomSheet.BottomSheet.SectionList";
@@ -0,0 +1,248 @@
1
+ import {
2
+ type ComponentType,
3
+ createElement,
4
+ isValidElement,
5
+ type ReactElement,
6
+ type Ref,
7
+ useCallback,
8
+ useEffect,
9
+ useMemo,
10
+ useRef,
11
+ } from "react";
12
+ import { type LayoutChangeEvent, StyleSheet } from "react-native";
13
+ import { Gesture, GestureDetector } from "react-native-gesture-handler";
14
+ import Animated, { useAnimatedRef, useAnimatedStyle } from "react-native-reanimated";
15
+ import { useBottomSheetInternal } from "../components/bottom-sheet.context";
16
+ import { SCROLLABLE_TYPE, type ScrollableType, scrollContentHeight, UNMEASURED } from "../core";
17
+ import { useScrollLock } from "../gesture/use-scroll-lock";
18
+ import { composeRefs } from "../lib/compose-refs";
19
+ import type {
20
+ BottomSheetScrollableProps,
21
+ ScrollableHandle,
22
+ ScrollableInnerComponent,
23
+ ScrollableInnerProps,
24
+ ScrollableListFooter,
25
+ } from "./scrollable.types";
26
+
27
+ /**
28
+ * Before the first layout there is no content area to clamp to, and a
29
+ * `maxHeight` of zero would clip the list while it measures itself. Tall
30
+ * enough that no sheet reaches it.
31
+ */
32
+ const UNCLAMPED = 100_000;
33
+
34
+ /** How often the list reports its offset, in milliseconds — every frame. */
35
+ const SCROLL_EVENT_THROTTLE = 16;
36
+
37
+ type ScrollableProps = ScrollableInnerProps & BottomSheetScrollableProps;
38
+
39
+ /** A consumer's `ListFooterComponent` — an element, a component type, or nothing — as an element. */
40
+ function footerElement(footer: ScrollableListFooter | undefined): ReactElement | null {
41
+ if (footer === undefined || footer === null) return null;
42
+ if (isValidElement(footer)) return footer;
43
+ return createElement(footer);
44
+ }
45
+
46
+ /**
47
+ * Wraps an animated scrollable as a sheet body.
48
+ *
49
+ * The component passed in must already be animated — `Animated.ScrollView`,
50
+ * `Animated.FlatList`, or the result of `Animated.createAnimatedComponent`
51
+ * made **once, at module scope** — because the wrapper drives it with
52
+ * `animatedProps` and an animated `onScroll`, and because a component created
53
+ * inside a render remounts its subtree every frame.
54
+ *
55
+ * What the wrapper does, top to bottom:
56
+ *
57
+ * - Two gesture detectors. The outer is the sheet's content pan, the same
58
+ * object `Content` uses; the inner is a `Native` gesture on the list itself,
59
+ * declared simultaneous with that pan and kept alive when the finger leaves
60
+ * the view. Both run at once, and the pan subtracts what the list consumed.
61
+ * - The scroll lock (`useScrollLock`). Below the highest detent the list is
62
+ * held where it was, so the pan moves the sheet; at the highest it scrolls,
63
+ * and a drag down at the top hands back to the sheet. The indicator hides,
64
+ * bounce switches off and momentum is cut while locked.
65
+ * - The sizing. The list fills the body to the sheet's bottom line —
66
+ * `contentArea + bodyInset` — and reserves the footer or the safe-area band
67
+ * as a trailing spacer *inside* its content, so the rows scroll under either
68
+ * and the last row can be brought fully clear of them. The spacer is
69
+ * animated: it collapses with the band as the keyboard rises. The indicator
70
+ * is inset by the same amount so it ends above the footer. The outer view
71
+ * clips to the geometry's `bodyClip`, so under a footer nothing of the list
72
+ * shows below the footer's top edge, at rest or while the sheet is dragged
73
+ * down; the list itself does not shrink to that clip (see `styles.list`).
74
+ * - Content size. `onContentSizeChange` less the spacer's own measured height
75
+ * feeds `contentHeight`, which is the dynamic detent's measurement: a
76
+ * `ScrollView` of forty rows needs no `snapPoints` and no
77
+ * `dynamicSizing={false}` — it sizes to its rows up to
78
+ * `maxDynamicContentSize`, then scrolls. The two layout events land in one
79
+ * batch and are folded into one write, so the detent never sees the rows
80
+ * plus the spacer for a frame — a first open resolved on that sum would
81
+ * animate to it and ignore the correction.
82
+ * - Registration. On focus the list tells the sheet what kind it is, and on
83
+ * blur it withdraws and the offset resets; `focusHook` is where a navigator's
84
+ * `useFocusEffect` goes.
85
+ *
86
+ * `contentContainerStyle` is flattened to one object before it reaches the
87
+ * list: a virtualised list measures its content container, and an array style
88
+ * makes that measurement lag a frame behind the layout.
89
+ *
90
+ * Refresh control is out of scope here — a `refreshControl` prop passes
91
+ * through untouched, but a pull-to-refresh at the top of a locked list fights
92
+ * the sheet's own pull-down, and no rule for that is written yet.
93
+ */
94
+ export function createBottomSheetScrollable<P extends object>(
95
+ Scrollable: ComponentType<P>,
96
+ type: ScrollableType
97
+ ): ComponentType<P & BottomSheetScrollableProps> {
98
+ // The wrapper reads and writes the props every React Native scrollable
99
+ // shares and passes the rest through, so it is written once against that
100
+ // common shape; the built-ins restate their own generic signatures.
101
+ const Inner = Scrollable as unknown as ScrollableInnerComponent;
102
+ const isScrollView = type === SCROLLABLE_TYPE.SCROLL_VIEW;
103
+
104
+ function BottomSheetScrollable({
105
+ focusHook = useEffect,
106
+ ref,
107
+ style,
108
+ contentContainerStyle,
109
+ onContentSizeChange,
110
+ onScroll,
111
+ onScrollBeginDrag,
112
+ onScrollEndDrag,
113
+ onMomentumScrollEnd,
114
+ showsVerticalScrollIndicator,
115
+ scrollIndicatorInsets,
116
+ bounces,
117
+ decelerationRate,
118
+ children,
119
+ ListFooterComponent,
120
+ ...rest
121
+ }: ScrollableProps): ReactElement {
122
+ const { state, geometry, pans, enableContentPanningGesture, setScrollableRef, removeScrollableRef } =
123
+ useBottomSheetInternal();
124
+ const { contentArea, bodyInset, bodyClip, layoutReady } = geometry;
125
+ const animatedRef = useAnimatedRef<ScrollableHandle>();
126
+
127
+ const { scrollHandler, animatedProps } = useScrollLock(state, geometry, animatedRef, {
128
+ enableContentPan: enableContentPanningGesture,
129
+ showsVerticalScrollIndicator,
130
+ scrollIndicatorInsets,
131
+ indicatorInset: bodyInset,
132
+ bounces,
133
+ decelerationRate,
134
+ listeners: { onScroll, onScrollBeginDrag, onScrollEndDrag, onMomentumScrollEnd },
135
+ });
136
+
137
+ const native = useMemo(
138
+ () => Gesture.Native().simultaneousWithExternalGesture(pans.content).shouldCancelWhenOutside(false),
139
+ [pans.content]
140
+ );
141
+
142
+ const composedRef = useMemo(
143
+ () => composeRefs<ScrollableHandle>(animatedRef as unknown as Ref<ScrollableHandle>, ref),
144
+ [animatedRef, ref]
145
+ );
146
+
147
+ const clip = useAnimatedStyle(() => ({
148
+ maxHeight: layoutReady.value ? bodyClip.value : UNCLAMPED,
149
+ }));
150
+ const clamp = useAnimatedStyle(() => ({
151
+ maxHeight: layoutReady.value ? contentArea.value + bodyInset.value : UNCLAMPED,
152
+ }));
153
+ const spacer = useAnimatedStyle(() => ({ height: bodyInset.value }));
154
+
155
+ // The content size and the spacer's height arrive as two layout events
156
+ // from the same commit; both are kept and one write follows the batch.
157
+ const contentSize = useRef<number | null>(null);
158
+ const spacerHeight = useRef<number | null>(null);
159
+ const flushQueued = useRef(false);
160
+ const flush = useCallback(() => {
161
+ flushQueued.current = false;
162
+ if (contentSize.current === null || spacerHeight.current === null) return;
163
+ const rows = scrollContentHeight(contentSize.current, spacerHeight.current);
164
+ if (state.contentHeight.value !== rows) state.contentHeight.value = rows;
165
+ }, [state]);
166
+ const queueFlush = useCallback(() => {
167
+ if (flushQueued.current) return;
168
+ flushQueued.current = true;
169
+ queueMicrotask(flush);
170
+ }, [flush]);
171
+
172
+ const handleContentSizeChange = useCallback(
173
+ (width: number, height: number) => {
174
+ contentSize.current = height;
175
+ queueFlush();
176
+ if (onContentSizeChange) onContentSizeChange(width, height);
177
+ },
178
+ [queueFlush, onContentSizeChange]
179
+ );
180
+ const handleSpacerLayout = useCallback(
181
+ (event: LayoutChangeEvent) => {
182
+ spacerHeight.current = event.nativeEvent.layout.height;
183
+ queueFlush();
184
+ },
185
+ [queueFlush]
186
+ );
187
+
188
+ useEffect(
189
+ () => () => {
190
+ state.contentHeight.value = UNMEASURED;
191
+ },
192
+ [state]
193
+ );
194
+
195
+ const register = useCallback(() => {
196
+ setScrollableRef(animatedRef, type);
197
+ return () => removeScrollableRef(animatedRef);
198
+ }, [setScrollableRef, removeScrollableRef, animatedRef]);
199
+ focusHook(register, [register]);
200
+
201
+ const trailing = <Animated.View onLayout={handleSpacerLayout} pointerEvents="none" style={spacer} />;
202
+ const consumerFooter = footerElement(ListFooterComponent);
203
+ const listFooter = isScrollView ? undefined : (
204
+ <>
205
+ {consumerFooter}
206
+ {trailing}
207
+ </>
208
+ );
209
+
210
+ return (
211
+ <GestureDetector gesture={pans.content}>
212
+ <Animated.View style={[styles.outer, clip]}>
213
+ <GestureDetector gesture={native}>
214
+ <Inner
215
+ {...rest}
216
+ animatedProps={animatedProps}
217
+ contentContainerStyle={StyleSheet.flatten(contentContainerStyle)}
218
+ keyboardDismissMode="interactive"
219
+ ListFooterComponent={listFooter}
220
+ onContentSizeChange={handleContentSizeChange}
221
+ onScroll={scrollHandler}
222
+ overScrollMode="never"
223
+ ref={composedRef}
224
+ scrollEventThrottle={SCROLL_EVENT_THROTTLE}
225
+ style={[styles.list, style, clamp]}
226
+ >
227
+ {isScrollView ? children : undefined}
228
+ {isScrollView ? trailing : undefined}
229
+ </Inner>
230
+ </GestureDetector>
231
+ </Animated.View>
232
+ </GestureDetector>
233
+ );
234
+ }
235
+ BottomSheetScrollable.displayName = "DelacourBottomSheet.BottomSheet.Scrollable";
236
+
237
+ return BottomSheetScrollable as unknown as ComponentType<P & BottomSheetScrollableProps>;
238
+ }
239
+
240
+ // A React Native scrollable ships `flexShrink: 1`, and Yoga would shrink it to
241
+ // the clipping wrapper — the footer's top edge — instead of letting it reach
242
+ // the sheet's bottom line, which puts the trailing spacer's clearance on top
243
+ // of the wrapper's and doubles it. `flexShrink: 0` leaves only `maxHeight` to
244
+ // bound it, and the wrapper alone does the clipping.
245
+ const styles = StyleSheet.create({
246
+ outer: { flexShrink: 1, overflow: "hidden" },
247
+ list: { flexShrink: 0 },
248
+ });
@@ -0,0 +1,76 @@
1
+ import type { Component, ComponentType, DependencyList, EffectCallback, ReactElement, Ref, RefAttributes } from "react";
2
+ import type { FlatList, FlatListProps, ScrollView, ScrollViewProps, SectionList, SectionListProps } from "react-native";
3
+ import type { AnimatedProps } from "react-native-reanimated";
4
+
5
+ /**
6
+ * The list a scrollable registers with its sheet. Kept for identity — the
7
+ * lock scrolls it through Reanimated's own animated ref — so the constraint is
8
+ * only that it is a component instance.
9
+ */
10
+ export type ScrollableHandle = Component;
11
+
12
+ /**
13
+ * The hook that runs the registration effect.
14
+ *
15
+ * `useEffect` registers on mount. Under React Navigation `useFocusEffect` has
16
+ * the same shape and registers when the screen gains focus, so two screens in
17
+ * a stack, each with a list in the same sheet, hand the sheet the one that is
18
+ * showing.
19
+ */
20
+ export type FocusHook = (effect: EffectCallback, deps?: DependencyList) => void;
21
+
22
+ /** What every `createBottomSheetScrollable` component adds on top of the list's own props. */
23
+ export type BottomSheetScrollableProps = {
24
+ /** @default useEffect */
25
+ focusHook?: FocusHook;
26
+ };
27
+
28
+ /** A list's `ListFooterComponent`, as the virtualised lists take it. */
29
+ export type ScrollableListFooter = ComponentType<unknown> | ReactElement | null;
30
+
31
+ /**
32
+ * The props the wrapper itself reads or writes, and that every React Native
33
+ * scrollable has. The generic list's remaining props pass through untouched.
34
+ * `ListFooterComponent` is the one list-only prop here: the wrapper appends
35
+ * its trailing spacer to it, where a `ScrollView` takes the spacer as a child.
36
+ */
37
+ export type ScrollableInnerProps = Omit<ScrollViewProps, "style"> & {
38
+ ref?: Ref<ScrollableHandle>;
39
+ style?: AnimatedProps<ScrollViewProps>["style"];
40
+ animatedProps?: Partial<ScrollViewProps> | readonly Partial<ScrollViewProps>[];
41
+ ListFooterComponent?: ScrollableListFooter;
42
+ };
43
+
44
+ /** The animated list a factory wraps — `Animated.ScrollView`, `Animated.FlatList`, or one made with `createAnimatedComponent`. */
45
+ export type ScrollableInnerComponent = ComponentType<ScrollableInnerProps>;
46
+
47
+ export type BottomSheetScrollViewProps = ScrollViewProps &
48
+ BottomSheetScrollableProps & {
49
+ ref?: Ref<ScrollView>;
50
+ };
51
+
52
+ export type BottomSheetFlatListProps<ItemT> = Omit<FlatListProps<ItemT>, "children"> & BottomSheetScrollableProps;
53
+
54
+ export type BottomSheetSectionListProps<ItemT, SectionT> = Omit<SectionListProps<ItemT, SectionT>, "children"> &
55
+ BottomSheetScrollableProps;
56
+
57
+ /**
58
+ * The generic component types, restated so `<ItemT>` survives the factory.
59
+ *
60
+ * `createBottomSheetScrollable` returns one concrete component, so `data` and
61
+ * `renderItem` would check against `unknown` and stop constraining each
62
+ * other. Writing the signature out is the same move
63
+ * `@delacour/react-native-ui`'s `Screen.FlatList` makes, and the `displayName`
64
+ * member is what keeps the trailing assignment legal after the cast.
65
+ */
66
+ export type BottomSheetFlatListComponent = (<ItemT>(
67
+ props: BottomSheetFlatListProps<ItemT> & RefAttributes<FlatList<ItemT>>
68
+ ) => ReactElement) & { displayName?: string };
69
+
70
+ export type BottomSheetSectionListComponent = (<ItemT, SectionT>(
71
+ props: BottomSheetSectionListProps<ItemT, SectionT> & RefAttributes<SectionList<ItemT, SectionT>>
72
+ ) => ReactElement) & { displayName?: string };
73
+
74
+ export type BottomSheetScrollViewComponent = ((props: BottomSheetScrollViewProps) => ReactElement) & {
75
+ displayName?: string;
76
+ };
@@ -0,0 +1,33 @@
1
+ import { readdirSync, readFileSync, statSync } from "node:fs";
2
+ import { join } from "node:path";
3
+
4
+ /** Every `.ts`/`.tsx` under `dir`, tests included unless `skipTests`. */
5
+ export function sourceFiles(dir: string, options: { skipTests?: boolean } = {}): string[] {
6
+ const found: string[] = [];
7
+ const walk = (current: string): void => {
8
+ for (const entry of readdirSync(current)) {
9
+ const path = join(current, entry);
10
+ if (statSync(path).isDirectory()) {
11
+ walk(path);
12
+ continue;
13
+ }
14
+ if (!/\.tsx?$/.test(entry)) continue;
15
+ if (options.skipTests && /\.test\.tsx?$/.test(entry)) continue;
16
+ found.push(path);
17
+ }
18
+ };
19
+ walk(dir);
20
+ return found;
21
+ }
22
+
23
+ export const SRC = import.meta.dir;
24
+ export const PACKAGE_ROOT = join(SRC, "..");
25
+
26
+ export function read(path: string): string {
27
+ return readFileSync(path, "utf8");
28
+ }
29
+
30
+ /** `path` relative to the package, for a failure message that can be acted on. */
31
+ export function relative(path: string): string {
32
+ return path.slice(PACKAGE_ROOT.length + 1);
33
+ }
@@ -0,0 +1,63 @@
1
+ # state
2
+
3
+ The shared values a sheet owns, the numbers derived from them, and the intent
4
+ queue that moves them.
5
+
6
+ ## Files
7
+
8
+ | Path | What |
9
+ | --- | --- |
10
+ | `state.types.ts` | `SheetSharedState` (the raw shared values), `SheetGeometry` (the derived ones), `SheetWorkletConfig` (the props a worklet reads), `ANIM_STATUS` |
11
+ | `use-sheet-state.ts` | Allocates the raw values once per sheet; mirrors `snapPoints` and the config into them, keyed on serialisation rather than identity |
12
+ | `use-detents.ts` | `detents` — `normalizeDetents` plus the dynamic detent once handle and content have measured |
13
+ | `use-sheet-geometry.ts` | Every formula in `core/AGENTS.md` as a `useDerivedValue`, in dependency order |
14
+ | `use-sheet-intents.ts` | The JS-thread `dispatch`, the reaction that resolves an intent on the UI thread, and the reaction that follows a detent change or a container resize |
15
+
16
+ ## The model
17
+
18
+ `base` is the only value an animation or a gesture writes. Everything a view
19
+ reads — `height`, `position`, `index`, `contentArea` — is derived from it and
20
+ from the measurements, on the UI thread, by the core's pure functions. Nothing
21
+ in this folder contains a formula; it contains `useDerivedValue(() =>
22
+ coreFunction(...))` and nothing else, which is what keeps the numbers
23
+ testable.
24
+
25
+ Measurements start at `UNMEASURED` (`-1`) and go back to it when the part that
26
+ reported them unmounts (`layout/`). `layoutReady` is derived from them, and an
27
+ `open` intent waits on it.
28
+
29
+ The keyboard values — `keyboardHeight`, `keyboardProgress`, `keyboardOwned` —
30
+ are allocated here and every keyboard term in the geometry is a real
31
+ derivation over them. BSHEET-3 writes them; until then they are `0`, `0` and
32
+ `false`, and every keyboard term is inert. `footerContentHeight` is the same:
33
+ allocated, derived over, and unmeasured until BSHEET-3 mounts a footer and the
34
+ root sets `hasFooter`.
35
+
36
+ ## Intents
37
+
38
+ `dispatch({ kind: "open" })` writes `state.intent`; a reaction keyed on the
39
+ intent's `id` and on `layoutReady` runs `resolveIntent` from the core and calls
40
+ `animateTo` or `jumpTo`. The JS thread never animates and never reads a
41
+ measurement to decide whether it may.
42
+
43
+ The resolver is told the sheet's **effective** index: the settled one, or the
44
+ running animation's target. Without that a `close()` during the open animation
45
+ would see a closed sheet, resolve to nothing, and let the sheet finish opening.
46
+
47
+ The first open a sheet resolves is reported as `"mount"` and, with
48
+ `animateOnMount: false`, jumps. `mountPending` is that flag.
49
+
50
+ ## Detents moving under a settled sheet
51
+
52
+ The second reaction watches the detent list and the container height. When
53
+ the list changes with the sheet idle and open, the sheet animates to the same
54
+ index in the new list — dynamic content growing, a `snapPoints` change. When
55
+ the container changed, it jumps there instead: a rotation is not something to
56
+ animate through. Neither runs while a gesture or an animation owns `base`.
57
+
58
+ ## Why the config is one shared value
59
+
60
+ A worklet that captured a dozen boolean props would rebuild every time one of
61
+ them changed, and a gesture captures the pan handlers. `config` is one object
62
+ written by one effect, and a worklet reads `state.config.value.x` at the
63
+ moment it needs it.