@delacour/react-native-ui 0.1.0-alpha.20260925053522

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 (273) hide show
  1. package/README.md +113 -0
  2. package/package.json +125 -0
  3. package/src/components/accordion/AGENTS.md +193 -0
  4. package/src/components/accordion/accordion-content.tsx +125 -0
  5. package/src/components/accordion/accordion-description.tsx +17 -0
  6. package/src/components/accordion/accordion-indicator.tsx +104 -0
  7. package/src/components/accordion/accordion-item.tsx +78 -0
  8. package/src/components/accordion/accordion-title.tsx +18 -0
  9. package/src/components/accordion/accordion-trigger.tsx +148 -0
  10. package/src/components/accordion/accordion.context.tsx +153 -0
  11. package/src/components/accordion/accordion.tsx +253 -0
  12. package/src/components/accordion/accordion.types.ts +11 -0
  13. package/src/components/accordion/accordion.variants.test.ts +434 -0
  14. package/src/components/accordion/accordion.variants.ts +358 -0
  15. package/src/components/accordion/index.ts +45 -0
  16. package/src/components/badge/AGENTS.md +83 -0
  17. package/src/components/badge/badge-close-button.tsx +48 -0
  18. package/src/components/badge/badge-end-content.tsx +15 -0
  19. package/src/components/badge/badge-label.tsx +24 -0
  20. package/src/components/badge/badge-start-content.tsx +16 -0
  21. package/src/components/badge/badge.context.tsx +64 -0
  22. package/src/components/badge/badge.tsx +192 -0
  23. package/src/components/badge/badge.types.ts +10 -0
  24. package/src/components/badge/badge.variants.test.ts +269 -0
  25. package/src/components/badge/badge.variants.ts +238 -0
  26. package/src/components/badge/index.ts +17 -0
  27. package/src/components/bottom-sheet/AGENTS.md +242 -0
  28. package/src/components/bottom-sheet/bottom-sheet-background.tsx +35 -0
  29. package/src/components/bottom-sheet/bottom-sheet-close.tsx +60 -0
  30. package/src/components/bottom-sheet/bottom-sheet-container.tsx +246 -0
  31. package/src/components/bottom-sheet/bottom-sheet-content.tsx +66 -0
  32. package/src/components/bottom-sheet/bottom-sheet-description.tsx +29 -0
  33. package/src/components/bottom-sheet/bottom-sheet-footer.tsx +150 -0
  34. package/src/components/bottom-sheet/bottom-sheet-handle.tsx +47 -0
  35. package/src/components/bottom-sheet/bottom-sheet-overlay.tsx +180 -0
  36. package/src/components/bottom-sheet/bottom-sheet-portal.tsx +72 -0
  37. package/src/components/bottom-sheet/bottom-sheet-scroll-view.tsx +105 -0
  38. package/src/components/bottom-sheet/bottom-sheet-title.tsx +24 -0
  39. package/src/components/bottom-sheet/bottom-sheet-trigger.tsx +73 -0
  40. package/src/components/bottom-sheet/bottom-sheet.context.tsx +135 -0
  41. package/src/components/bottom-sheet/bottom-sheet.tsx +125 -0
  42. package/src/components/bottom-sheet/bottom-sheet.variants.test.ts +293 -0
  43. package/src/components/bottom-sheet/bottom-sheet.variants.ts +220 -0
  44. package/src/components/bottom-sheet/index.ts +40 -0
  45. package/src/components/bottom-sheet/use-bottom-sheet-input.ts +125 -0
  46. package/src/components/button/AGENTS.md +217 -0
  47. package/src/components/button/button-end-content.tsx +15 -0
  48. package/src/components/button/button-group-separator.tsx +37 -0
  49. package/src/components/button/button-group-text.tsx +101 -0
  50. package/src/components/button/button-group.tsx +158 -0
  51. package/src/components/button/button-label.tsx +24 -0
  52. package/src/components/button/button-start-content.tsx +15 -0
  53. package/src/components/button/button.context.tsx +187 -0
  54. package/src/components/button/button.tsx +273 -0
  55. package/src/components/button/button.types.ts +10 -0
  56. package/src/components/button/button.variants.test.ts +811 -0
  57. package/src/components/button/button.variants.ts +480 -0
  58. package/src/components/button/index.ts +50 -0
  59. package/src/components/chart/AGENTS.md +210 -0
  60. package/src/components/chart/chart-area.tsx +93 -0
  61. package/src/components/chart/chart-bar.tsx +103 -0
  62. package/src/components/chart/chart-bars.tsx +75 -0
  63. package/src/components/chart/chart-candlestick.tsx +60 -0
  64. package/src/components/chart/chart-grid.tsx +33 -0
  65. package/src/components/chart/chart-legend.tsx +41 -0
  66. package/src/components/chart/chart-line.tsx +50 -0
  67. package/src/components/chart/chart-scatter.tsx +35 -0
  68. package/src/components/chart/chart-tooltip-dot.tsx +70 -0
  69. package/src/components/chart/chart-tooltip-x.tsx +121 -0
  70. package/src/components/chart/chart-tooltip-y.tsx +55 -0
  71. package/src/components/chart/chart-tooltip.tsx +133 -0
  72. package/src/components/chart/chart-x-axis.tsx +27 -0
  73. package/src/components/chart/chart-y-axis.tsx +21 -0
  74. package/src/components/chart/chart.context.tsx +127 -0
  75. package/src/components/chart/chart.tsx +448 -0
  76. package/src/components/chart/chart.types.ts +108 -0
  77. package/src/components/chart/chart.variants.test.ts +893 -0
  78. package/src/components/chart/chart.variants.ts +726 -0
  79. package/src/components/chart/index.ts +103 -0
  80. package/src/components/chart/pie-chart-center.tsx +40 -0
  81. package/src/components/chart/pie-chart-label.tsx +56 -0
  82. package/src/components/chart/pie-chart-slice.tsx +45 -0
  83. package/src/components/chart/pie-chart-tooltip.tsx +85 -0
  84. package/src/components/chart/pie-chart.context.tsx +77 -0
  85. package/src/components/chart/pie-chart.tsx +247 -0
  86. package/src/components/chart/use-chart-font.ts +25 -0
  87. package/src/components/chart/use-chart-palette.ts +38 -0
  88. package/src/components/checkbox/AGENTS.md +185 -0
  89. package/src/components/checkbox/checkbox-box.tsx +152 -0
  90. package/src/components/checkbox/checkbox-group.tsx +107 -0
  91. package/src/components/checkbox/checkbox-label.tsx +34 -0
  92. package/src/components/checkbox/checkbox.context.tsx +136 -0
  93. package/src/components/checkbox/checkbox.tsx +258 -0
  94. package/src/components/checkbox/checkbox.types.ts +14 -0
  95. package/src/components/checkbox/checkbox.variants.test.ts +634 -0
  96. package/src/components/checkbox/checkbox.variants.ts +484 -0
  97. package/src/components/checkbox/index.ts +42 -0
  98. package/src/components/field/AGENTS.md +112 -0
  99. package/src/components/field/field-content.tsx +24 -0
  100. package/src/components/field/field-description.tsx +29 -0
  101. package/src/components/field/field-error.tsx +61 -0
  102. package/src/components/field/field-group.tsx +22 -0
  103. package/src/components/field/field-label.tsx +47 -0
  104. package/src/components/field/field-legend.tsx +34 -0
  105. package/src/components/field/field-separator.tsx +81 -0
  106. package/src/components/field/field-set.tsx +22 -0
  107. package/src/components/field/field.context.tsx +95 -0
  108. package/src/components/field/field.tsx +157 -0
  109. package/src/components/field/field.types.ts +15 -0
  110. package/src/components/field/field.variants.test.ts +240 -0
  111. package/src/components/field/field.variants.ts +156 -0
  112. package/src/components/field/index.ts +22 -0
  113. package/src/components/icon/AGENTS.md +104 -0
  114. package/src/components/icon/icon.context.tsx +29 -0
  115. package/src/components/icon/icon.tsx +92 -0
  116. package/src/components/icon/icon.variants.test.ts +111 -0
  117. package/src/components/icon/icon.variants.ts +79 -0
  118. package/src/components/icon/index.ts +11 -0
  119. package/src/components/input/AGENTS.md +126 -0
  120. package/src/components/input/index.ts +27 -0
  121. package/src/components/input/input-group-decorator.tsx +84 -0
  122. package/src/components/input/input-group-prefix.tsx +16 -0
  123. package/src/components/input/input-group-suffix.tsx +15 -0
  124. package/src/components/input/input-group.tsx +140 -0
  125. package/src/components/input/input.context.tsx +87 -0
  126. package/src/components/input/input.tsx +184 -0
  127. package/src/components/input/input.types.ts +10 -0
  128. package/src/components/input/input.variants.test.ts +369 -0
  129. package/src/components/input/input.variants.ts +329 -0
  130. package/src/components/list-group/AGENTS.md +67 -0
  131. package/src/components/list-group/index.ts +20 -0
  132. package/src/components/list-group/list-group-item-content.tsx +10 -0
  133. package/src/components/list-group/list-group-item-description.tsx +12 -0
  134. package/src/components/list-group/list-group-item-prefix.tsx +30 -0
  135. package/src/components/list-group/list-group-item-suffix.tsx +55 -0
  136. package/src/components/list-group/list-group-item-title.tsx +17 -0
  137. package/src/components/list-group/list-group-item.tsx +87 -0
  138. package/src/components/list-group/list-group.context.tsx +65 -0
  139. package/src/components/list-group/list-group.tsx +116 -0
  140. package/src/components/list-group/list-group.types.ts +14 -0
  141. package/src/components/list-group/list-group.variants.test.ts +223 -0
  142. package/src/components/list-group/list-group.variants.ts +107 -0
  143. package/src/components/pressable/AGENTS.md +74 -0
  144. package/src/components/pressable/index.ts +9 -0
  145. package/src/components/pressable/pressable.tsx +261 -0
  146. package/src/components/pressable/pressable.variants.test.ts +128 -0
  147. package/src/components/pressable/pressable.variants.ts +80 -0
  148. package/src/components/provider/AGENTS.md +90 -0
  149. package/src/components/provider/index.ts +1 -0
  150. package/src/components/provider/provider.tsx +105 -0
  151. package/src/components/radio/AGENTS.md +245 -0
  152. package/src/components/radio/index.ts +35 -0
  153. package/src/components/radio/radio-group.tsx +126 -0
  154. package/src/components/radio/radio-indicator.tsx +91 -0
  155. package/src/components/radio/radio-label.tsx +39 -0
  156. package/src/components/radio/radio.context.tsx +132 -0
  157. package/src/components/radio/radio.tsx +215 -0
  158. package/src/components/radio/radio.variants.test.ts +580 -0
  159. package/src/components/radio/radio.variants.ts +271 -0
  160. package/src/components/screen/AGENTS.md +287 -0
  161. package/src/components/screen/index.ts +85 -0
  162. package/src/components/screen/screen-chat-list.tsx +485 -0
  163. package/src/components/screen/screen-content.tsx +69 -0
  164. package/src/components/screen/screen-debug.ts +43 -0
  165. package/src/components/screen/screen-error.tsx +61 -0
  166. package/src/components/screen/screen-flat-list.tsx +88 -0
  167. package/src/components/screen/screen-footer-background.tsx +60 -0
  168. package/src/components/screen/screen-footer.tsx +170 -0
  169. package/src/components/screen/screen-header.tsx +36 -0
  170. package/src/components/screen/screen-legend-list.tsx +105 -0
  171. package/src/components/screen/screen-list-component.tsx +23 -0
  172. package/src/components/screen/screen-loading.tsx +50 -0
  173. package/src/components/screen/screen-navbar-back-button.tsx +79 -0
  174. package/src/components/screen/screen-navbar-background.tsx +45 -0
  175. package/src/components/screen/screen-navbar-subtitle.tsx +26 -0
  176. package/src/components/screen/screen-navbar-title.tsx +31 -0
  177. package/src/components/screen/screen-navbar.tsx +154 -0
  178. package/src/components/screen/screen-root.tsx +38 -0
  179. package/src/components/screen/screen-scroll-area.tsx +116 -0
  180. package/src/components/screen/screen-scroll-shadow.tsx +186 -0
  181. package/src/components/screen/screen-section-list.tsx +81 -0
  182. package/src/components/screen/screen-view.tsx +59 -0
  183. package/src/components/screen/screen.context.tsx +167 -0
  184. package/src/components/screen/screen.tsx +114 -0
  185. package/src/components/screen/screen.types.ts +66 -0
  186. package/src/components/screen/screen.variants.test.ts +602 -0
  187. package/src/components/screen/screen.variants.ts +475 -0
  188. package/src/components/screen/use-screen-scroll-insets.ts +218 -0
  189. package/src/components/separator/AGENTS.md +30 -0
  190. package/src/components/separator/index.ts +8 -0
  191. package/src/components/separator/separator.tsx +85 -0
  192. package/src/components/slider/AGENTS.md +274 -0
  193. package/src/components/slider/index.ts +50 -0
  194. package/src/components/slider/slider-fill.tsx +68 -0
  195. package/src/components/slider/slider-output.tsx +55 -0
  196. package/src/components/slider/slider-thumb.tsx +193 -0
  197. package/src/components/slider/slider-track.tsx +233 -0
  198. package/src/components/slider/slider.context.tsx +118 -0
  199. package/src/components/slider/slider.tsx +321 -0
  200. package/src/components/slider/slider.types.ts +26 -0
  201. package/src/components/slider/slider.variants.test.ts +856 -0
  202. package/src/components/slider/slider.variants.ts +661 -0
  203. package/src/components/spinner/AGENTS.md +73 -0
  204. package/src/components/spinner/index.ts +17 -0
  205. package/src/components/spinner/spinner-arc.tsx +90 -0
  206. package/src/components/spinner/spinner-content.tsx +63 -0
  207. package/src/components/spinner/spinner.context.tsx +52 -0
  208. package/src/components/spinner/spinner.tsx +128 -0
  209. package/src/components/spinner/spinner.variants.test.ts +273 -0
  210. package/src/components/spinner/spinner.variants.ts +187 -0
  211. package/src/components/switch/AGENTS.md +213 -0
  212. package/src/components/switch/index.ts +42 -0
  213. package/src/components/switch/switch-content.tsx +111 -0
  214. package/src/components/switch/switch-end-content.tsx +18 -0
  215. package/src/components/switch/switch-start-content.tsx +20 -0
  216. package/src/components/switch/switch-thumb.tsx +102 -0
  217. package/src/components/switch/switch.context.tsx +79 -0
  218. package/src/components/switch/switch.tsx +423 -0
  219. package/src/components/switch/switch.types.ts +14 -0
  220. package/src/components/switch/switch.variants.test.ts +570 -0
  221. package/src/components/switch/switch.variants.ts +511 -0
  222. package/src/components/tabs/AGENTS.md +287 -0
  223. package/src/components/tabs/index.ts +69 -0
  224. package/src/components/tabs/tabs-content.tsx +65 -0
  225. package/src/components/tabs/tabs-indicator.tsx +97 -0
  226. package/src/components/tabs/tabs-label.tsx +62 -0
  227. package/src/components/tabs/tabs-list.tsx +139 -0
  228. package/src/components/tabs/tabs-pager.tsx +59 -0
  229. package/src/components/tabs/tabs-scroll-view.tsx +171 -0
  230. package/src/components/tabs/tabs-separator.tsx +73 -0
  231. package/src/components/tabs/tabs-trigger.tsx +210 -0
  232. package/src/components/tabs/tabs.context.tsx +294 -0
  233. package/src/components/tabs/tabs.tsx +435 -0
  234. package/src/components/tabs/tabs.types.ts +13 -0
  235. package/src/components/tabs/tabs.variants.test.ts +1020 -0
  236. package/src/components/tabs/tabs.variants.ts +670 -0
  237. package/src/components/text/AGENTS.md +95 -0
  238. package/src/components/text/index.ts +25 -0
  239. package/src/components/text/text.context.tsx +60 -0
  240. package/src/components/text/text.tsx +251 -0
  241. package/src/components/text/text.variants.test.ts +422 -0
  242. package/src/components/text/text.variants.ts +282 -0
  243. package/src/display-name.test.ts +145 -0
  244. package/src/docs.test.ts +98 -0
  245. package/src/expo/navigation-theme.tsx +58 -0
  246. package/src/hooks/use-controllable-state.ts +45 -0
  247. package/src/hooks/use-keyboard-state-sync.tsx +147 -0
  248. package/src/hooks/use-navigation-theme.ts +78 -0
  249. package/src/hooks/use-theme-color.ts +44 -0
  250. package/src/icons/central.ts +1 -0
  251. package/src/lib/cn.test.ts +136 -0
  252. package/src/lib/cn.ts +29 -0
  253. package/src/lib/color.test.ts +80 -0
  254. package/src/lib/color.ts +79 -0
  255. package/src/lib/compose-refs.test.ts +64 -0
  256. package/src/lib/compose-refs.ts +37 -0
  257. package/src/lib/keyboard-animation.test.ts +33 -0
  258. package/src/lib/keyboard-animation.ts +28 -0
  259. package/src/lib/merge-props.test.ts +82 -0
  260. package/src/lib/merge-props.ts +47 -0
  261. package/src/lib/navigation-theme.test.ts +49 -0
  262. package/src/lib/navigation-theme.ts +53 -0
  263. package/src/lib/slot.tsx +45 -0
  264. package/src/lib/tv.ts +20 -0
  265. package/src/styles/base.css +2 -0
  266. package/src/styles/geometry.test.ts +117 -0
  267. package/src/styles/index.css +3 -0
  268. package/src/styles/theme-tokens.test.ts +252 -0
  269. package/src/styles/theme.css +447 -0
  270. package/src/styles/tokens.css +132 -0
  271. package/src/styles/tokens.test.ts +181 -0
  272. package/src/styles/tokens.ts +86 -0
  273. package/src/uniwind-env.d.ts +1 -0
@@ -0,0 +1,475 @@
1
+ import type { VariantProps } from "tailwind-variants";
2
+ import { tv } from "../../lib/tv";
3
+
4
+ /** Where a navbar or footer sits: over the content, or in the flow above and below it. */
5
+ export const SCREEN_PLACEMENTS = ["overlay", "static"] as const;
6
+
7
+ export type ScreenPlacement = (typeof SCREEN_PLACEMENTS)[number];
8
+
9
+ /**
10
+ * Safe-area edges a container can inset against.
11
+ *
12
+ * Structurally `Edge` from react-native-safe-area-context, restated here so
13
+ * this module keeps its promise of importing nothing — the whole reason it is
14
+ * reachable from `bun test`.
15
+ */
16
+ export const SCREEN_EDGES = ["top", "right", "bottom", "left"] as const;
17
+
18
+ export type ScreenEdge = (typeof SCREEN_EDGES)[number];
19
+
20
+ /**
21
+ * Styling for every part of a screen.
22
+ *
23
+ * One slotted `tv()` rather than a call per part, so `placement` — the axis the
24
+ * navbar and the footer both turn on — is declared once. The two read the same
25
+ * variant and get different classes from it, which is what keeps "overlay" from
26
+ * meaning `top-0` in one file and `bottom-0` in another.
27
+ *
28
+ * Colour lives on the text slots (`navbarTitle`, `navbarSubtitle`,
29
+ * `errorTitle`, `errorMessage`) and never on a container. A React Native `View`
30
+ * does not cascade colour to a `Text` descendant the way a DOM element does, so
31
+ * a `text-*` on `navbarRow` would simply do nothing — the tests assert it stays
32
+ * off.
33
+ *
34
+ * Safe-area padding is deliberately absent here. Uniwind's `pt-safe` and
35
+ * friends compile to `env(safe-area-inset-*)`, which resolves to zero on React
36
+ * Native — see {@link resolveScreenEdgePadding}.
37
+ *
38
+ * `navbarBackground` clips: the hairline it holds is positioned against the
39
+ * navbar's own box, and the safe-area band has to be painted too or the status
40
+ * bar shows content scrolling underneath it.
41
+ *
42
+ * Free of React Native imports so it stays unit-testable — `bun test` cannot
43
+ * parse React Native's Flow-typed source. See AGENTS.md.
44
+ */
45
+ export const screenVariants = tv({
46
+ slots: {
47
+ root: "flex-1 bg-background",
48
+ content: "flex-1 bg-background",
49
+ view: "flex-1",
50
+ /**
51
+ * The default padding inside a scrollable, on the token every other edge of
52
+ * the screen lines up with.
53
+ *
54
+ * The library owns it so a screen does not repeat it — twelve call sites
55
+ * writing `p-5`, `px-5`, `py-5` and `px-5 pt-4` by hand is how they drifted
56
+ * apart. A caller overrides per axis (`px-0` for full-bleed rows) and
57
+ * tailwind-merge resolves it, because `screen-gutter` is registered in
58
+ * `styles/tokens.ts`.
59
+ *
60
+ * On a virtualised list this is not a double count. Content-container
61
+ * padding wraps `ListHeaderComponent`, so the order down the screen is
62
+ * padding, then the navbar spacer, then the rows — and the spacer sits
63
+ * behind the overlaying navbar, leaving exactly one gutter of visible
64
+ * breathing room, the same as a scroll area.
65
+ */
66
+ scrollContent: "p-screen-gutter",
67
+ /**
68
+ * A titled block inside a scrollable, which already carries the gutter and
69
+ * the vertical rhythm — so the header adds neither, or every screen would
70
+ * need `px-0` to undo the second one.
71
+ *
72
+ * `gap-1` is its OWN rhythm, between a title and whatever sits under it,
73
+ * and it is deliberately not the empty string: `tv()` returns `undefined`
74
+ * for a slot with no classes at all, which reads as a bug at the call site
75
+ * and breaks any test that asserts on it.
76
+ */
77
+ header: "gap-1",
78
+ /**
79
+ * Above the content it overlays. The navbar is the FIRST child of a
80
+ * screen, so without a raised z-index a later sibling paints over it —
81
+ * the footer already wins by document order, which is what a footer
82
+ * should do.
83
+ */
84
+ navbar: "z-50",
85
+ navbarRow: "h-navbar-row flex-row items-center justify-between gap-2 px-screen-gutter",
86
+ navbarStart: "min-w-0 flex-1 flex-row items-center gap-2",
87
+ navbarActions: "flex-row items-center gap-2",
88
+ navbarCenter: "absolute top-0 right-0 bottom-0 left-0 flex-row items-center justify-center gap-2",
89
+ navbarBackground: "absolute top-0 right-0 bottom-0 left-0 overflow-hidden bg-background",
90
+ navbarBorder: "absolute right-0 bottom-0 left-0 h-px bg-border",
91
+ /**
92
+ * No text alignment of its own. In the `center` slot the flex row already
93
+ * centres it, so `text-center` was redundant there — and in the leading slot
94
+ * it actively fought a title stacked above a subtitle, centring one line in a
95
+ * column that should read left-aligned.
96
+ */
97
+ navbarTitle: "text-foreground text-lg font-semibold leading-tight",
98
+ navbarSubtitle: "text-muted-foreground text-sm",
99
+ backButton: "flex-row items-center gap-2",
100
+ footer: "",
101
+ /**
102
+ * The footer's own backing, filling the box that travels with the keyboard.
103
+ *
104
+ * Opaque only when the footer is `static`. A static footer is chrome: it
105
+ * takes its own space in the flow, and the content above it must not show
106
+ * through when the keyboard lifts it over that content. An `overlay` footer
107
+ * is the opposite — a surface floating above content that deliberately
108
+ * scrolls under it — so it stays transparent and whatever the caller puts
109
+ * inside brings its own background.
110
+ */
111
+ footerBackground: "absolute top-0 right-0 bottom-0 left-0",
112
+ /**
113
+ * The hairline along the footer's TOP edge — the mirror of the navbar's,
114
+ * which runs along its bottom. Drawn only for a `static` footer, since an
115
+ * overlay one floats and a rule across the screen above it would read as a
116
+ * divider belonging to the content.
117
+ */
118
+ footerBorder: "absolute top-0 right-0 left-0 h-px",
119
+ /**
120
+ * The footer's measured content box, and the owner of its HORIZONTAL
121
+ * padding.
122
+ *
123
+ * Horizontal lives here rather than in the sticky view's inline style so it
124
+ * can use the same gutter token as the content above — a footer button used
125
+ * to sit at 16 while the content sat at 20, on every screen with a footer.
126
+ * The vertical stays inline, because `footerOccupancy` has to add the same
127
+ * numbers to a height measured at runtime and a class is unreadable from
128
+ * JS. Only the axis that was wrong moved, so the occupancy maths is
129
+ * untouched.
130
+ */
131
+ footerContent: "gap-2 px-screen-gutter",
132
+ loading: "flex-1 items-center justify-center",
133
+ errorContent: "flex-1 items-center justify-center gap-3 px-screen-gutter",
134
+ errorTitle: "text-center font-semibold text-foreground text-lg",
135
+ errorMessage: "text-center text-muted-foreground text-sm",
136
+ },
137
+ variants: {
138
+ placement: {
139
+ overlay: {
140
+ navbar: "absolute top-0 right-0 left-0",
141
+ footer: "absolute right-0 bottom-0 left-0",
142
+ },
143
+ static: {
144
+ navbar: "relative",
145
+ footer: "relative",
146
+ footerBackground: "bg-background",
147
+ footerBorder: "bg-border",
148
+ },
149
+ },
150
+ },
151
+ defaultVariants: {
152
+ placement: "overlay",
153
+ },
154
+ });
155
+
156
+ /**
157
+ * Scroll distance over which a hairline fades, in points.
158
+ *
159
+ * One constant for both edges: the navbar's line and the footer's are the same
160
+ * idea at opposite ends of the content, and two numbers that should always
161
+ * agree are two numbers that can drift.
162
+ *
163
+ * Short on purpose. The line answers a yes/no question — is there content that
164
+ * way? — and a longer ramp reads as the border being slow rather than as a
165
+ * response.
166
+ */
167
+ export const SCREEN_BORDER_FADE_DISTANCE = 20;
168
+
169
+ /**
170
+ * Opacity of the navbar's bottom hairline.
171
+ *
172
+ * Drawn at full strength at rest unless `fadeOnScroll` is on, which is what
173
+ * makes an undivided header opt-in rather than the default — a screen whose
174
+ * content starts flush against the bar wants the line from the first frame, and
175
+ * used to re-add one by hand precisely because it faded.
176
+ *
177
+ * With `fadeOnScroll`, ramps 0 → 1 over the first
178
+ * {@link SCREEN_BORDER_FADE_DISTANCE} points and clamps at both ends —
179
+ * a rubber-banded overscroll reports a negative offset, which would otherwise
180
+ * drive the opacity below zero.
181
+ *
182
+ * Pure, so the whole ramp is reachable from `bun test`. Written out rather than
183
+ * calling Reanimated's `interpolate` so this module keeps its promise of
184
+ * importing nothing. See AGENTS.md.
185
+ */
186
+ export function resolveNavbarBorderOpacity(scrollY: number, fadeOnScroll: boolean): number {
187
+ "worklet";
188
+ if (!fadeOnScroll) return 1;
189
+ // The clamp is written out in both resolvers rather than shared. A module
190
+ // helper called from a worklet is not always captured into the UI runtime's
191
+ // closure, and the failure is a runtime "undefined is not a function" on the
192
+ // UI thread that no unit test sees — the JS thread resolves it fine.
193
+ return Math.min(1, Math.max(0, scrollY / SCREEN_BORDER_FADE_DISTANCE));
194
+ }
195
+
196
+ /**
197
+ * Opacity of the footer's top hairline.
198
+ *
199
+ * The mirror of {@link resolveNavbarBorderOpacity}, and deliberately not the
200
+ * same input. The navbar's line answers "is there content ABOVE?", so it reads
201
+ * the near end of the scroll; the footer's answers "is there content BELOW?",
202
+ * so it reads the far end and fades OUT as the content runs out. A footer line
203
+ * driven by `scrollY` would be brightest exactly where there is nothing left to
204
+ * scroll to.
205
+ *
206
+ * `contentHeight` and `layoutHeight` are published by the scrollable's own
207
+ * scroll events, so both are 0 until the first one arrives. Unmeasured is
208
+ * treated as "draw it": before any scroll a screen tall enough to scroll does
209
+ * have content below, and a screen too short to scroll never contradicts it.
210
+ *
211
+ * Pure, so the whole ramp is reachable from `bun test`. See AGENTS.md.
212
+ */
213
+ export function resolveFooterBorderOpacity(state: {
214
+ /** Live scroll offset. */
215
+ scrollY: number;
216
+ /** Total scrollable content height, or 0 before the first scroll event. */
217
+ contentHeight: number;
218
+ /** Viewport height, or 0 before the first scroll event. */
219
+ layoutHeight: number;
220
+ fadeOnScroll: boolean;
221
+ }): number {
222
+ "worklet";
223
+ if (!state.fadeOnScroll) return 1;
224
+ if (state.contentHeight <= 0) return 1;
225
+
226
+ const remaining = state.contentHeight - state.layoutHeight - state.scrollY;
227
+ // Written out rather than shared with the navbar's ramp — see the note there.
228
+ return Math.min(1, Math.max(0, remaining / SCREEN_BORDER_FADE_DISTANCE));
229
+ }
230
+
231
+ /** The four safe-area insets, as `useSafeAreaInsets()` reports them. */
232
+ export type ScreenEdgeInsets = { top: number; right: number; bottom: number; left: number };
233
+
234
+ /** Padding for a container insetting itself against some of the safe area. */
235
+ export type ScreenEdgePadding = {
236
+ paddingTop?: number;
237
+ paddingRight?: number;
238
+ paddingBottom?: number;
239
+ paddingLeft?: number;
240
+ };
241
+
242
+ /**
243
+ * Safe-area padding for a set of edges.
244
+ *
245
+ * Values from `useSafeAreaInsets()` rather than uniwind's `pt-safe` utilities,
246
+ * which compile to `env(safe-area-inset-*)` and resolve to **zero** on React
247
+ * Native — a silent no-op, which is the worst kind of wrong for a notch. The
248
+ * hook is also the same source the footer's occupancy maths reads, so a
249
+ * container's inset and the reserve computed against it cannot disagree.
250
+ *
251
+ * **Only the edges asked for appear.** An unrequested edge is absent, not `0`.
252
+ * A `0` here is not a harmless absence — it is a value, and it wins: uniwind
253
+ * puts a className's style first and the `style` prop second, and Yoga resolves
254
+ * a longhand edge ahead of the `padding` shorthand a class compiles to,
255
+ * treating `0` as defined. So four unconditional zeroes silently erased every
256
+ * side of a caller's `p-5`. Emitting a partial is half the fix; the other half
257
+ * is that a caller's className belongs on a different box entirely — see
258
+ * `screen-header.tsx`.
259
+ *
260
+ * Pure, so the whole matrix is reachable from `bun test`. See AGENTS.md.
261
+ */
262
+ export function resolveScreenEdgePadding(
263
+ edges: readonly ScreenEdge[] | undefined,
264
+ insets: ScreenEdgeInsets
265
+ ): ScreenEdgePadding {
266
+ const padding: ScreenEdgePadding = {};
267
+ if (!edges?.length) return padding;
268
+
269
+ if (edges.includes("top")) padding.paddingTop = insets.top;
270
+ if (edges.includes("right")) padding.paddingRight = insets.right;
271
+ if (edges.includes("bottom")) padding.paddingBottom = insets.bottom;
272
+ if (edges.includes("left")) padding.paddingLeft = insets.left;
273
+
274
+ return padding;
275
+ }
276
+
277
+ /*
278
+ * How much vertical space `Screen.Footer` takes away from a scrollable, kept
279
+ * here so the arithmetic is unit-testable and cannot drift from the footer's
280
+ * own render.
281
+ *
282
+ * There are TWO different numbers below and conflating them is a real bug —
283
+ * they differ by exactly the safe-area inset:
284
+ *
285
+ * - `footerOccupancy` — what the footer covers in LIST-CONTENT space.
286
+ * - `footerAboveKeyboard` — what it covers ABOVE AN OPEN KEYBOARD.
287
+ *
288
+ * ## Why occupancy is the same in both keyboard states
289
+ *
290
+ * For keyboard height `K`, safe-area inset `b`, measured content `m`, and the
291
+ * footer's own chrome `c = SCREEN_FOOTER_PADDING + SCREEN_FLOATING_BOTTOM_GAP`:
292
+ *
293
+ * - CLOSED: the footer's top edge sits `c + m + b` above the screen bottom, and
294
+ * the list's content bottom IS the screen bottom. Reserve = `c + m + b`.
295
+ * - OPEN: `KeyboardStickyView` translates by `-(K - b)` (its `offset.opened` is
296
+ * the inset), so the footer's `b` band ends up BEHIND the keyboard and its top
297
+ * edge is `keyboardTop - (c + m)`. The list lifts by `(K - b)`, because
298
+ * `KeyboardChatScrollView` scrolls by "distance (`keyboardHeight - offset`)
299
+ * instead of the full keyboard height" and `Screen.ChatList` passes the inset
300
+ * as that offset. Measured from the lifted content bottom, the footer's top is
301
+ * once again `c + m + b` away.
302
+ *
303
+ * So ONE static spacer is correct in both states: `b` is real padding while
304
+ * closed and list-lift compensation while open. Omitting it — on the theory that
305
+ * the sticky shift "discards" it — leaves every chat list short by the inset,
306
+ * correct to within 2pt on a 34pt-inset phone and 32pt wrong on a zero-inset
307
+ * one.
308
+ *
309
+ * `footerAboveKeyboard` is the exception, and only because it answers a
310
+ * different question: `KeyboardAwareScrollView`'s `bottomOffset` is the
311
+ * clearance between the FOCUSED INPUT and the keyboard's top edge, so the band
312
+ * already hidden behind the keyboard must not be counted.
313
+ *
314
+ * These stay numbers rather than joining the tokens in `tokens.css`. The footer
315
+ * applies them as an inline `style` precisely because `footerOccupancy` has to
316
+ * add the same band to a height measured at runtime, and a class is unreadable
317
+ * from JS. One constant driving both the render and the reserve is what stops a
318
+ * scrollable clearing the wrong distance.
319
+ */
320
+
321
+ /** `Screen.Footer`'s padding on its top and side edges. */
322
+ export const SCREEN_FOOTER_PADDING = 16;
323
+
324
+ /**
325
+ * Gap between the safe-area edge and a floating bottom surface.
326
+ *
327
+ * Separate from {@link SCREEN_FOOTER_PADDING} because the bottom edge is
328
+ * measured against whatever else can occupy that corner of the screen — a tab
329
+ * bar, a composer pill — and a few points of difference between them reads as
330
+ * the surface jumping on navigation.
331
+ */
332
+ export const SCREEN_FLOATING_BOTTOM_GAP = 12;
333
+
334
+ /** Breathing room between a chat's newest message and the composer above it. */
335
+ export const CHAT_COMPOSER_GAP = 16;
336
+
337
+ /**
338
+ * Vertical space the footer takes from a scrollable's content, in list-content
339
+ * space. Identical whether the keyboard is open or closed — see the block above.
340
+ *
341
+ * @param measuredContentHeight - `footer.height` / `footer.initialHeight`, what `Screen.Footer` measures
342
+ * @param safeAreaBottom - `useSafeAreaInsets().bottom`
343
+ */
344
+ export function footerOccupancy(measuredContentHeight: number, safeAreaBottom: number): number {
345
+ "worklet";
346
+ return measuredContentHeight + SCREEN_FOOTER_PADDING + SCREEN_FLOATING_BOTTOM_GAP + safeAreaBottom;
347
+ }
348
+
349
+ /**
350
+ * Vertical space the footer takes ABOVE AN OPEN KEYBOARD. Excludes the
351
+ * safe-area band, which the sticky shift parks behind the keyboard.
352
+ *
353
+ * For `KeyboardAwareScrollView`'s `bottomOffset` — clearance between the focused
354
+ * input and the keyboard top, not clearance in content space.
355
+ */
356
+ export function footerAboveKeyboard(measuredContentHeight: number): number {
357
+ "worklet";
358
+ return measuredContentHeight + SCREEN_FOOTER_PADDING + SCREEN_FLOATING_BOTTOM_GAP;
359
+ }
360
+
361
+ /** How a scrollable reserves space at its bottom edge. See {@link resolveScrollBottomInset}. */
362
+ export const SCREEN_SCROLL_INSET_MODES = ["standard", "keyboard-aware", "chat"] as const;
363
+
364
+ export type ScreenScrollInsetMode = (typeof SCREEN_SCROLL_INSET_MODES)[number];
365
+
366
+ /**
367
+ * The spacer height a scrollable needs below its content.
368
+ *
369
+ * Reserves the footer's OCCUPANCY, not its measured content height. The two
370
+ * differ by the footer's own chrome, so reserving the measured height alone
371
+ * leaves the last row sitting 28pt behind the footer's top edge — visible under
372
+ * `<Screen debug>` as a red band above the green one.
373
+ *
374
+ * A `static` footer needs no reserve at all: it sits in the flow below the
375
+ * scrollable and carries its own safe-area padding, so counting it here would
376
+ * strand an empty band the height of the footer.
377
+ *
378
+ * With no footer mounted the reserve is the bare safe-area band, which fades out
379
+ * as the keyboard covers it — the inset is meaningless behind a keyboard.
380
+ *
381
+ * Pure, so the whole matrix is reachable from `bun test`. See AGENTS.md.
382
+ */
383
+ export function resolveScrollBottomInset(state: {
384
+ mode: ScreenScrollInsetMode;
385
+ /** `footer.height` — content only, excluding the footer's padding and safe-area band. */
386
+ footerHeight: number;
387
+ footerPlacement: ScreenPlacement;
388
+ /** `useSafeAreaInsets().bottom`. */
389
+ safeAreaBottom: number;
390
+ /** 0 closed through 1 open. */
391
+ keyboardProgress: number;
392
+ /** Negative while the keyboard is open. */
393
+ keyboardHeight: number;
394
+ }): number {
395
+ "worklet";
396
+ const hasFooter = state.footerHeight > 0;
397
+
398
+ if (state.mode === "chat") {
399
+ // A chat list is LIFTED by the keyboard rather than padded for it, so no
400
+ // keyboard band belongs here — and the occupancy is correct in both
401
+ // keyboard states, which is why the safe-area inset is not faded either.
402
+ const reserve = hasFooter ? footerOccupancy(state.footerHeight, state.safeAreaBottom) : state.safeAreaBottom;
403
+ return reserve + CHAT_COMPOSER_GAP;
404
+ }
405
+
406
+ const fadingSafeArea = state.safeAreaBottom * (1 - state.keyboardProgress);
407
+ // `keyboardHeight` is negative while open, so the band is its negation —
408
+ // clamped, because negating a closed keyboard's 0 yields -0, and a -0 height
409
+ // reaching a native view is a surprise waiting to happen.
410
+ //
411
+ // `keyboard-aware` omits the band entirely: `KeyboardAwareScrollView` adds
412
+ // that padding itself, and adding it twice leaves a keyboard-sized gap.
413
+ const keyboardBand = state.mode === "keyboard-aware" ? 0 : Math.max(0, -state.keyboardHeight);
414
+
415
+ if (!hasFooter) return fadingSafeArea + keyboardBand;
416
+ if (state.footerPlacement === "static") return keyboardBand;
417
+
418
+ return footerOccupancy(state.footerHeight, fadingSafeArea) + keyboardBand;
419
+ }
420
+
421
+ /**
422
+ * The height of a scrollable's top spacer.
423
+ *
424
+ * Mirrors the top half of {@link resolveScreenViewPadding}, including the
425
+ * fallback that matters most: with NO navbar mounted the spacer is the raw
426
+ * safe-area inset, not zero. Without it a screen that skips the navbar runs its
427
+ * first row under the notch — the scroll area had no fallback while the static
428
+ * body did, so the two disagreed about the same screen.
429
+ *
430
+ * A `static` navbar contributes nothing: it already took its space in the flow.
431
+ *
432
+ * Pure, so the whole matrix is reachable from `bun test`. See AGENTS.md.
433
+ */
434
+ export function resolveScrollTopInset(state: {
435
+ navbarHeight: number;
436
+ navbarPlacement: ScreenPlacement;
437
+ safeAreaTop: number;
438
+ }): number {
439
+ "worklet";
440
+ if (state.navbarHeight <= 0) return state.safeAreaTop;
441
+ return state.navbarPlacement === "overlay" ? state.navbarHeight : 0;
442
+ }
443
+
444
+ /**
445
+ * The padding a non-scrolling body needs to clear the screen's chrome.
446
+ *
447
+ * Falls back to the raw safe-area inset on an edge with no chrome on it, so a
448
+ * screen with no navbar still clears the notch. A `static` navbar or footer
449
+ * contributes nothing: it already took its space in the flow.
450
+ *
451
+ * Pure, so the whole matrix is reachable from `bun test`. See AGENTS.md.
452
+ */
453
+ export function resolveScreenViewPadding(state: {
454
+ navbarHeight: number;
455
+ navbarPlacement: ScreenPlacement;
456
+ footerHeight: number;
457
+ footerPlacement: ScreenPlacement;
458
+ safeAreaTop: number;
459
+ safeAreaBottom: number;
460
+ }): { paddingTop: number; paddingBottom: number } {
461
+ "worklet";
462
+ const hasNavbar = state.navbarHeight > 0;
463
+ const paddingTop = hasNavbar ? (state.navbarPlacement === "overlay" ? state.navbarHeight : 0) : state.safeAreaTop;
464
+
465
+ const hasFooter = state.footerHeight > 0;
466
+ const paddingBottom = hasFooter
467
+ ? state.footerPlacement === "overlay"
468
+ ? footerOccupancy(state.footerHeight, state.safeAreaBottom)
469
+ : 0
470
+ : state.safeAreaBottom;
471
+
472
+ return { paddingBottom, paddingTop: paddingTop };
473
+ }
474
+
475
+ export type ScreenVariantProps = VariantProps<typeof screenVariants>;
@@ -0,0 +1,218 @@
1
+ import { useState } from "react";
2
+ import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller";
3
+ import {
4
+ type DerivedValue,
5
+ type SharedValue,
6
+ useAnimatedReaction,
7
+ useAnimatedScrollHandler,
8
+ useAnimatedStyle,
9
+ useDerivedValue,
10
+ } from "react-native-reanimated";
11
+ import { useSafeAreaInsets } from "react-native-safe-area-context";
12
+ import { scheduleOnRN } from "react-native-worklets";
13
+ import { useScreenPart } from "./screen.context";
14
+ import {
15
+ CHAT_COMPOSER_GAP,
16
+ footerAboveKeyboard,
17
+ footerOccupancy,
18
+ resolveScrollBottomInset,
19
+ resolveScrollTopInset,
20
+ type ScreenScrollInsetMode,
21
+ } from "./screen.variants";
22
+
23
+ /**
24
+ * The footer's collapsed baseline height, as JS state.
25
+ *
26
+ * The measurement arrives on the UI thread, so it is mirrored across rather
27
+ * than read directly — a `.value` captured in a memo or effect closure becomes
28
+ * a memo-cache dependency read during render once React Compiler is on, which
29
+ * Reanimated's strict mode rejects.
30
+ *
31
+ * A measured 0 means NOT MEASURED YET — mount, or the footer unmounting — and
32
+ * is ignored, so a caller's seed survives until a real height replaces it.
33
+ */
34
+ function useFooterBaselineHeight(estimatedHeight = 0): number {
35
+ const { footer } = useScreenPart("a Screen scrollable");
36
+ const [measured, setMeasured] = useState(() => Math.round(estimatedHeight));
37
+
38
+ useAnimatedReaction(
39
+ () => Math.round(footer.initialHeight.value),
40
+ (current, previous) => {
41
+ if (current <= 0 || current === previous) return;
42
+ scheduleOnRN(setMeasured, current);
43
+ },
44
+ []
45
+ );
46
+
47
+ return measured;
48
+ }
49
+
50
+ /**
51
+ * The shared value a footer's own content drives when it expands VISUALLY above
52
+ * its measured layout box.
53
+ *
54
+ * For a composer pill that grows upward without changing the height it
55
+ * occupies. Drive it as a discrete reservation — the full expanded height while
56
+ * open, 0 when closed — and set it BEFORE focusing the input, so a chat
57
+ * scrollable sees the right padding on the same frame it starts lifting. Do NOT
58
+ * interpolate it with keyboard progress: a mid-animation write races the lift
59
+ * and can overwrite it with a stale offset.
60
+ *
61
+ * Growth while the keyboard is already open is fine — those deltas are small.
62
+ * Reset it to 0 on unmount; `Screen.Footer` clears the measured heights but
63
+ * never this one, because it does not own it.
64
+ */
65
+ export function useScreenFooterOverlayHeight(): SharedValue<number> {
66
+ const { footer } = useScreenPart("useScreenFooterOverlayHeight");
67
+ return footer.overlayHeight;
68
+ }
69
+
70
+ /**
71
+ * Live composer clearance as a UI-thread value: the footer's full occupancy,
72
+ * plus whatever overlays above it, plus the breathing gap.
73
+ *
74
+ * Feed this to a chat list's own inset props so composer growth and keyboard
75
+ * motion land in the SAME frame. Routing it through React state instead lags
76
+ * the keyboard by a re-render, which reads as jank.
77
+ */
78
+ export function useChatComposerInset(): DerivedValue<number> {
79
+ const { footer } = useScreenPart("useChatComposerInset");
80
+ const { bottom } = useSafeAreaInsets();
81
+
82
+ return useDerivedValue(
83
+ () => footerOccupancy(footer.height.value, bottom) + footer.overlayHeight.value + CHAT_COMPOSER_GAP,
84
+ [bottom]
85
+ );
86
+ }
87
+
88
+ /**
89
+ * The composer's growth above its collapsed baseline, as a UI-thread value.
90
+ *
91
+ * A growing input extends the scroll range through this rather than through a
92
+ * re-render, so the list never re-lays-out mid-keystroke.
93
+ */
94
+ export function useChatComposerGrowthPadding(): DerivedValue<number> {
95
+ const { footer } = useScreenPart("useChatComposerGrowthPadding");
96
+
97
+ return useDerivedValue(
98
+ () => Math.max(0, footer.height.value - footer.initialHeight.value) + footer.overlayHeight.value,
99
+ []
100
+ );
101
+ }
102
+
103
+ export type { ScreenScrollInsetMode };
104
+
105
+ /** A chat list's static bottom clearance, split so each band is separately visible under `<Screen debug>`. */
106
+ export type ChatComposerSpacer = {
107
+ /** Exactly what the footer covers — the band whose edge must land on the footer's own. */
108
+ occupancy: number;
109
+ /** Breathing room between the newest message and the composer, above `occupancy`. */
110
+ gap: number;
111
+ /** `occupancy + gap` — the whole reserve. */
112
+ total: number;
113
+ };
114
+
115
+ /**
116
+ * The STATIC layout spacer a chat list reserves for the composer. Live growth
117
+ * and keyboard motion go through the UI-thread values above, never here.
118
+ *
119
+ * The safe-area band IS included: it is correct in both keyboard states, and
120
+ * the derivation is in `screen.variants`. Omitting it leaves every chat list
121
+ * short by exactly the inset.
122
+ *
123
+ * `estimatedFooterHeight` SEEDS the first render and is read once — later
124
+ * changes are ignored. Pass it whenever the composer's collapsed height is a
125
+ * known constant. The footer only publishes its real height a commit or two
126
+ * AFTER the list's first layout, and a virtualised list cannot always correct
127
+ * for that afterwards: once the footer has grown it reads the shifted offset as
128
+ * "the user scrolled away" and abandons its retarget. That is why the newest
129
+ * message hides under the composer only SOMETIMES. A correct first paint is the
130
+ * only reliable fix.
131
+ */
132
+ export function useChatComposerBaseSpacerHeight(estimatedFooterHeight = 0): ChatComposerSpacer {
133
+ const { bottom } = useSafeAreaInsets();
134
+ const measured = useFooterBaselineHeight(estimatedFooterHeight);
135
+ const occupancy = footerOccupancy(measured, bottom);
136
+
137
+ return { gap: CHAT_COMPOSER_GAP, occupancy, total: occupancy + CHAT_COMPOSER_GAP };
138
+ }
139
+
140
+ /**
141
+ * Clearance a keyboard-aware scrollable needs between its focused input and the
142
+ * keyboard's top edge.
143
+ *
144
+ * A sticky footer rides the keyboard, so the input has to clear the footer too,
145
+ * not just the keyboard. Deliberately NOT the full occupancy: the sticky shift
146
+ * parks the footer's safe-area band behind the keyboard, so counting it here
147
+ * would scroll the input further than it needs to go.
148
+ */
149
+ export function useScreenFooterKeyboardClearance(): number {
150
+ return footerAboveKeyboard(useFooterBaselineHeight()) + CHAT_COMPOSER_GAP;
151
+ }
152
+
153
+ /**
154
+ * The shared scroll reporting and top/bottom spacer styles every Screen
155
+ * scrollable is built on.
156
+ *
157
+ * One hook rather than a copy per list, so `Screen.ScrollArea`,
158
+ * `Screen.FlatList`, `Screen.SectionList` and the rest cannot drift on what
159
+ * "clear the navbar" means.
160
+ *
161
+ * Both insets are spacer HEIGHTS rather than content padding. A padded content
162
+ * container cannot animate on the UI thread, and the navbar's height is only
163
+ * known after it lays out — so a spacer view whose height is an animated style
164
+ * is what lets the reserve appear in the same frame as the measurement.
165
+ */
166
+ export function useScreenScrollInsets(mode: ScreenScrollInsetMode) {
167
+ const { navbar, footer, scrollY, contentHeight, layoutHeight } = useScreenPart("a Screen scrollable");
168
+ const { bottom, top } = useSafeAreaInsets();
169
+ const keyboard = useReanimatedKeyboardAnimation();
170
+
171
+ const scrollHandler = useAnimatedScrollHandler({
172
+ onScroll: (event) => {
173
+ scrollY.value = event.contentOffset.y;
174
+
175
+ if (event.contentSize.height !== contentHeight.value) {
176
+ contentHeight.value = event.contentSize.height;
177
+ }
178
+
179
+ if (event.layoutMeasurement.height !== layoutHeight.value) {
180
+ layoutHeight.value = event.layoutMeasurement.height;
181
+ }
182
+ },
183
+ });
184
+
185
+ const insetTopAnimatedStyle = useAnimatedStyle(() => {
186
+ return {
187
+ height: resolveScrollTopInset({
188
+ navbarHeight: navbar.height.value,
189
+ navbarPlacement: navbar.placement.value,
190
+ safeAreaTop: top,
191
+ }),
192
+ };
193
+ }, [top]);
194
+
195
+ const insetBottomAnimatedStyle = useAnimatedStyle(() => {
196
+ return {
197
+ height: resolveScrollBottomInset({
198
+ footerHeight: footer.height.value,
199
+ footerPlacement: footer.placement.value,
200
+ keyboardHeight: keyboard.height.value,
201
+ keyboardProgress: keyboard.progress.value,
202
+ mode,
203
+ safeAreaBottom: bottom,
204
+ }),
205
+ };
206
+ }, [mode, bottom]);
207
+
208
+ return { insetBottomAnimatedStyle, insetTopAnimatedStyle, scrollHandler };
209
+ }
210
+
211
+ /**
212
+ * The scroll handler and spacer styles a Screen scrollable is built from.
213
+ *
214
+ * Inferred rather than written out: `useAnimatedStyle`'s return type carries
215
+ * Reanimated's own internal branding, and restating it here by hand would be a
216
+ * second definition that can drift from the one Reanimated actually returns.
217
+ */
218
+ export type ScreenScrollInsets = ReturnType<typeof useScreenScrollInsets>;