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

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 +192 -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 +435 -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 +232 -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 +245 -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 +148 -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 +95 -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 +276 -0
  43. package/src/components/bottom-sheet/bottom-sheet.variants.ts +199 -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 +100 -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 +33 -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 +78 -0
  108. package/src/components/field/field.tsx +144 -0
  109. package/src/components/field/field.types.ts +15 -0
  110. package/src/components/field/field.variants.test.ts +216 -0
  111. package/src/components/field/field.variants.ts +136 -0
  112. package/src/components/field/index.ts +21 -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 +64 -0
  144. package/src/components/pressable/index.ts +9 -0
  145. package/src/components/pressable/pressable.tsx +256 -0
  146. package/src/components/pressable/pressable.variants.test.ts +99 -0
  147. package/src/components/pressable/pressable.variants.ts +63 -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 +247 -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 +581 -0
  159. package/src/components/radio/radio.variants.ts +272 -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 +48 -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 +161 -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 +804 -0
  202. package/src/components/slider/slider.variants.ts +607 -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 +286 -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 +672 -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,88 @@
1
+ import { type ForwardedRef, forwardRef, type ReactElement, type RefAttributes, useMemo } from "react";
2
+ import { FlatList, type FlatListProps, View } from "react-native";
3
+ import Animated from "react-native-reanimated";
4
+ import { cn } from "../../lib/cn";
5
+ import type { ScreenScrollableProps } from "./screen.types";
6
+ import { screenVariants } from "./screen.variants";
7
+ import { resolveListComponent } from "./screen-list-component";
8
+ import { useScreenScrollInsets } from "./use-screen-scroll-insets";
9
+
10
+ // `Animated.createAnimatedComponent` erases the generic parameters; the recast
11
+ // puts them back so `data` and `renderItem` still check against each other.
12
+ const AnimatedFlatList = Animated.createAnimatedComponent(FlatList) as unknown as typeof FlatList;
13
+
14
+ export type ScreenFlatListProps<ItemT> = Omit<FlatListProps<ItemT>, "children"> & ScreenScrollableProps;
15
+
16
+ /**
17
+ * The component's own type, restated so `<ItemT>` survives `forwardRef`.
18
+ *
19
+ * `forwardRef` erases type parameters — its result is a
20
+ * `ForwardRefExoticComponent` over one concrete instantiation — so `data` and
21
+ * `renderItem` would check against `unknown` and stop constraining each other.
22
+ * Writing the signature out is the same move `StyledAnimatedLegendListComponent`
23
+ * makes for `withUniwind` in `screen-legend-list`, and the `displayName` member
24
+ * is what keeps the trailing assignment legal after the cast.
25
+ */
26
+ type ScreenFlatListComponent = (<ItemT>(
27
+ props: ScreenFlatListProps<ItemT> & RefAttributes<FlatList<ItemT>>
28
+ ) => ReactElement) & { displayName?: string };
29
+
30
+ /**
31
+ * A virtualised list that keeps its content clear of the screen's chrome.
32
+ *
33
+ * The reserves ride on `ListHeaderComponent` / `ListFooterComponent` rather
34
+ * than on the content container's padding, because a virtualised list measures
35
+ * its own content and a padded container would put the spacer outside what it
36
+ * measures.
37
+ *
38
+ * A caller's own header and footer components still render — they are composed
39
+ * inside the screen's, not replaced by them.
40
+ *
41
+ * The ref is the list itself, for `scrollToIndex`, `scrollToOffset` and the rest.
42
+ */
43
+ export const ScreenFlatList = forwardRef(function ScreenFlatListRender<ItemT>(
44
+ {
45
+ header,
46
+ contentContainerClassName,
47
+ ListHeaderComponent: ListHeaderComponentProp,
48
+ ListFooterComponent: ListFooterComponentProp,
49
+ ...props
50
+ }: ScreenFlatListProps<ItemT>,
51
+ ref: ForwardedRef<FlatList<ItemT>>
52
+ ): ReactElement {
53
+ const { scrollHandler, insetTopAnimatedStyle, insetBottomAnimatedStyle } = useScreenScrollInsets("standard");
54
+
55
+ const ListHeaderComponent = useMemo<ReactElement>(
56
+ () => (
57
+ <>
58
+ <Animated.View style={insetTopAnimatedStyle} />
59
+ {header}
60
+ {resolveListComponent(ListHeaderComponentProp)}
61
+ </>
62
+ ),
63
+ [header, ListHeaderComponentProp, insetTopAnimatedStyle]
64
+ );
65
+
66
+ const ListFooterComponent = useMemo<ReactElement>(
67
+ () => (
68
+ <View>
69
+ {resolveListComponent(ListFooterComponentProp)}
70
+ <Animated.View style={insetBottomAnimatedStyle} />
71
+ </View>
72
+ ),
73
+ [ListFooterComponentProp, insetBottomAnimatedStyle]
74
+ );
75
+
76
+ return (
77
+ <AnimatedFlatList
78
+ showsVerticalScrollIndicator={false}
79
+ {...props}
80
+ contentContainerClassName={cn(screenVariants().scrollContent(), contentContainerClassName)}
81
+ ListFooterComponent={ListFooterComponent}
82
+ ListHeaderComponent={ListHeaderComponent}
83
+ onScroll={scrollHandler}
84
+ ref={ref}
85
+ />
86
+ );
87
+ }) as unknown as ScreenFlatListComponent;
88
+ ScreenFlatList.displayName = "DelacourUI.Screen.FlatList";
@@ -0,0 +1,60 @@
1
+ import { memo, type ReactElement } from "react";
2
+ import { View } from "react-native";
3
+ import Animated, { useAnimatedStyle } from "react-native-reanimated";
4
+ import { useScreenDebug, useScreenPart } from "./screen.context";
5
+ import { resolveFooterBorderOpacity, type ScreenPlacement, screenVariants } from "./screen.variants";
6
+ import { SCREEN_DEBUG_COLORS } from "./screen-debug";
7
+
8
+ export type ScreenFooterBackgroundProps = {
9
+ placement: ScreenPlacement;
10
+ /** Fade the top hairline out as the content runs out, instead of drawing it at rest. */
11
+ fadeOnScroll?: boolean;
12
+ };
13
+
14
+ /**
15
+ * The footer's backing and its top hairline.
16
+ *
17
+ * Both are opaque only when the footer is `static`. A static footer is chrome:
18
+ * it takes its own space, and the content above must not show through when the
19
+ * keyboard lifts it. An `overlay` footer floats over content that deliberately
20
+ * scrolls under it, so it gets neither.
21
+ *
22
+ * Rendered INSIDE `KeyboardStickyView` so it travels with the translation. On
23
+ * the positioned outer view it would stay behind while the buttons moved.
24
+ *
25
+ * Separated from the footer itself because it reads `scrollY`, which changes
26
+ * every frame: memoised and isolated, the animated style re-runs on the UI
27
+ * thread without the footer's children re-rendering with it — the same reason
28
+ * `ScreenNavbarBackground` is its own component.
29
+ */
30
+ export const ScreenFooterBackground = memo(function ScreenFooterBackground({
31
+ placement,
32
+ fadeOnScroll = false,
33
+ }: ScreenFooterBackgroundProps): ReactElement {
34
+ const { scrollY, contentHeight, layoutHeight } = useScreenPart("Screen.Footer");
35
+ const debug = useScreenDebug();
36
+ const slots = screenVariants({ placement });
37
+
38
+ const borderStyle = useAnimatedStyle(
39
+ () => ({
40
+ opacity: resolveFooterBorderOpacity({
41
+ contentHeight: contentHeight.value,
42
+ fadeOnScroll,
43
+ layoutHeight: layoutHeight.value,
44
+ scrollY: scrollY.value,
45
+ }),
46
+ }),
47
+ [fadeOnScroll]
48
+ );
49
+
50
+ return (
51
+ <View
52
+ className={slots.footerBackground()}
53
+ pointerEvents="none"
54
+ style={debug ? { backgroundColor: SCREEN_DEBUG_COLORS.footerSticky } : undefined}
55
+ >
56
+ <Animated.View className={slots.footerBorder()} style={borderStyle} />
57
+ </View>
58
+ );
59
+ });
60
+ ScreenFooterBackground.displayName = "DelacourUI.Screen.Footer.Background";
@@ -0,0 +1,170 @@
1
+ import { type ReactElement, useCallback, useEffect, useRef, useState } from "react";
2
+ import { type LayoutChangeEvent, View } from "react-native";
3
+ import { KeyboardStickyView, useKeyboardState } from "react-native-keyboard-controller";
4
+ import { useSafeAreaInsets } from "react-native-safe-area-context";
5
+ import { useKeyboardAnimationGuard } from "../../hooks/use-keyboard-state-sync";
6
+ import { useScreenDebug, useScreenPart } from "./screen.context";
7
+ import type { ScreenPlacementProps } from "./screen.types";
8
+ import { SCREEN_FLOATING_BOTTOM_GAP, SCREEN_FOOTER_PADDING, screenVariants } from "./screen.variants";
9
+ import { SCREEN_DEBUG_COLORS } from "./screen-debug";
10
+ import { ScreenFooterBackground } from "./screen-footer-background";
11
+
12
+ export type ScreenFooterProps = ScreenPlacementProps & {
13
+ /**
14
+ * Ride the keyboard, so the footer stays directly above it. Off by default —
15
+ * a footer holding a submit button wants to stay put, while one holding a
16
+ * composer does not.
17
+ */
18
+ sticky?: boolean;
19
+ /**
20
+ * Fade the top hairline out as the content runs out, instead of drawing it at
21
+ * rest.
22
+ *
23
+ * The mirror of `Screen.Navbar`'s `fadeBorderOnScroll`, and off by default
24
+ * for the same reason: the line separates chrome from content, and content
25
+ * sitting right against the footer wants it from the first frame. Turn it on
26
+ * for a footer that should read as joined to the content once there is
27
+ * nothing left to scroll to.
28
+ *
29
+ * Only applies to a `static` footer — an overlay one draws no hairline.
30
+ *
31
+ * @default false
32
+ */
33
+ fadeBorderOnScroll?: boolean;
34
+ /**
35
+ * Whether the enclosing screen is the focused one. Defaults to `true`.
36
+ *
37
+ * A sticky footer on a backgrounded screen would otherwise follow a keyboard
38
+ * opened on the screen in front of it. This library takes no navigation
39
+ * dependency, so an app that has one passes its own signal — expo-router and
40
+ * React Navigation both expose `useIsFocused()`.
41
+ */
42
+ isFocused?: boolean;
43
+ };
44
+
45
+ /**
46
+ * A pinned region at the bottom of a screen, optionally riding the keyboard.
47
+ *
48
+ * Measures its CONTENT height — excluding its own padding and the safe-area
49
+ * band — into the screen context, so a scrollable can reserve exactly what the
50
+ * footer covers. `footerOccupancy` in `screen.variants` adds the rest back; the
51
+ * two must agree, which is why the padding is applied from the same constants
52
+ * rather than as a class.
53
+ *
54
+ * The VERTICAL padding is a style rather than a class for that reason alone. A
55
+ * class is unreadable from JS, so the reserve would have to restate the number
56
+ * and the two could drift — the failure being a list whose last row hides under the
57
+ * footer only on some devices.
58
+ *
59
+ * Four nested boxes, each with one job:
60
+ * 1. the positioned outer view, which the caller's `className` reaches;
61
+ * 2. `KeyboardStickyView`, which translates and carries the footer's padding;
62
+ * 3. the backing and its top hairline, filling the sticky view behind the rest;
63
+ * 4. the measured content box, whose height reaches the screen context.
64
+ *
65
+ * The backing lives INSIDE the sticky view, not on the positioned outer one, so
66
+ * it travels with the translation. A static footer lifted over the content by
67
+ * the keyboard would otherwise let that content show straight through it — the
68
+ * buttons legible only where they happened to overlap blank space.
69
+ *
70
+ * The sticky view sits *inside* the positioned view rather than being it, so
71
+ * this file needs no `withUniwind` wrapper — it takes a `style` and never a
72
+ * `className`. See AGENTS.md rule 6.
73
+ *
74
+ * @example
75
+ * <Screen.Footer>
76
+ * <Button onPress={save}>Save</Button>
77
+ * </Screen.Footer>
78
+ *
79
+ * @example
80
+ * <Screen.Footer sticky>
81
+ * <MessageComposer />
82
+ * </Screen.Footer>
83
+ */
84
+ export function ScreenFooter({
85
+ placement = "overlay",
86
+ sticky = false,
87
+ fadeBorderOnScroll = false,
88
+ isFocused = true,
89
+ className,
90
+ children,
91
+ style,
92
+ ...props
93
+ }: ScreenFooterProps): ReactElement {
94
+ const { footer } = useScreenPart("Screen.Footer");
95
+ const slots = screenVariants({ placement });
96
+ const { bottom } = useSafeAreaInsets();
97
+ const keyboardState = useKeyboardState();
98
+ const debug = useScreenDebug();
99
+ // The shared keyboard values are a global with no per-screen reset, so a
100
+ // footer can mount into a state left "open" by a keyboard that vanished
101
+ // without a `keyboardWillHide` — and then float a keyboard-height off the
102
+ // bottom of a screen with no keyboard on it. Repair on mount; a no-op
103
+ // whenever an input is genuinely focused, which is what keeps the
104
+ // `isKeyboardOpen` hand-off below working.
105
+ useKeyboardAnimationGuard();
106
+
107
+ // Sticking before the first measure would translate a zero-height box.
108
+ const [hasMeasured, setHasMeasured] = useState(false);
109
+ // A JS ref, never a SharedValue read in a memoized closure: React Compiler
110
+ // hoists such loads into memo-cache comparisons that run during render,
111
+ // which is a Reanimated strict-mode violation.
112
+ const hasCapturedBaselineRef = useRef(false);
113
+
114
+ // A screen navigated away from while its keyboard is still up has to keep
115
+ // reacting, or its footer snaps down behind the outgoing transition.
116
+ const isEnabled = sticky && hasMeasured && (isFocused || keyboardState.isVisible);
117
+
118
+ const handleContentLayout = useCallback(
119
+ (event: LayoutChangeEvent) => {
120
+ const { height } = event.nativeEvent.layout;
121
+ setHasMeasured(true);
122
+ footer.height.value = height;
123
+ if (!hasCapturedBaselineRef.current) {
124
+ hasCapturedBaselineRef.current = true;
125
+ footer.initialHeight.value = height;
126
+ }
127
+ },
128
+ [footer]
129
+ );
130
+
131
+ useEffect(() => {
132
+ footer.placement.value = placement;
133
+ }, [placement, footer.placement]);
134
+
135
+ useEffect(() => {
136
+ return () => {
137
+ footer.height.value = 0;
138
+ footer.initialHeight.value = 0;
139
+ hasCapturedBaselineRef.current = false;
140
+ };
141
+ }, [footer.height, footer.initialHeight]);
142
+
143
+ return (
144
+ <View className={slots.footer({ className })} style={style} {...props}>
145
+ <KeyboardStickyView
146
+ enabled={isEnabled}
147
+ // Shift by the inset rather than animating the padding away: the
148
+ // footer's safe-area band is meaningless once the keyboard covers it,
149
+ // and animating padding leaves a visible gap between the two
150
+ // mid-transition.
151
+ offset={{ closed: 0, opened: bottom }}
152
+ style={{ paddingBottom: SCREEN_FLOATING_BOTTOM_GAP, paddingTop: SCREEN_FOOTER_PADDING }}
153
+ >
154
+ <ScreenFooterBackground fadeOnScroll={fadeBorderOnScroll} placement={placement} />
155
+ <View
156
+ style={[{ paddingBottom: bottom }, debug ? { backgroundColor: SCREEN_DEBUG_COLORS.footerSafeArea } : null]}
157
+ >
158
+ <View
159
+ className={slots.footerContent()}
160
+ onLayout={handleContentLayout}
161
+ style={debug ? { backgroundColor: SCREEN_DEBUG_COLORS.footerContent } : undefined}
162
+ >
163
+ {children}
164
+ </View>
165
+ </View>
166
+ </KeyboardStickyView>
167
+ </View>
168
+ );
169
+ }
170
+ ScreenFooter.displayName = "DelacourUI.Screen.Footer";
@@ -0,0 +1,36 @@
1
+ import type { ReactElement } from "react";
2
+ import { View } from "react-native";
3
+ import { useSafeAreaInsets } from "react-native-safe-area-context";
4
+ import type { ScreenInsetProps } from "./screen.types";
5
+ import { resolveScreenEdgePadding, screenVariants } from "./screen.variants";
6
+
7
+ export type ScreenHeaderProps = ScreenInsetProps;
8
+
9
+ /**
10
+ * A titled block at the top of a screen's content.
11
+ *
12
+ * Carries no gutter and no vertical padding: the scrollable it sits in already
13
+ * has both, and a header adding its own would double them at every call site.
14
+ * What is left is a semantic block with its own `gap-1` and optional safe-area
15
+ * insets.
16
+ *
17
+ * Two nested views rather than one: the safe-area padding and anything the
18
+ * caller adds are different concerns, and collapsing them would make
19
+ * `insets={["top"]}` silently replace that padding instead of adding to it —
20
+ * the failure the rest of this component was fixed for.
21
+ *
22
+ * Scrolls with the content — it is passed to a scrollable's `header` prop, not
23
+ * pinned. `Screen.Navbar` is the pinned one.
24
+ */
25
+ export function ScreenHeader({ insets, className, children, style, ...props }: ScreenHeaderProps): ReactElement {
26
+ const safeArea = useSafeAreaInsets();
27
+
28
+ return (
29
+ <View style={resolveScreenEdgePadding(insets, safeArea)}>
30
+ <View className={screenVariants().header({ className })} style={style} {...props}>
31
+ {children}
32
+ </View>
33
+ </View>
34
+ );
35
+ }
36
+ ScreenHeader.displayName = "DelacourUI.Screen.Header";
@@ -0,0 +1,105 @@
1
+ import type { LegendListRef } from "@legendapp/list/react-native";
2
+ import { AnimatedLegendList, type AnimatedLegendListProps } from "@legendapp/list/reanimated";
3
+ import { type ForwardedRef, forwardRef, type ReactElement, type Ref, type RefAttributes, useMemo } from "react";
4
+ import { View } from "react-native";
5
+ import Animated from "react-native-reanimated";
6
+ import { withUniwind } from "uniwind";
7
+ import { cn } from "../../lib/cn";
8
+ import type { ScreenScrollableProps } from "./screen.types";
9
+ import { screenVariants } from "./screen.variants";
10
+ import { resolveListComponent } from "./screen-list-component";
11
+ import { useScreenScrollInsets } from "./use-screen-scroll-insets";
12
+
13
+ /**
14
+ * The class props `withUniwind` adds, restated so the generic survives.
15
+ *
16
+ * `withUniwind`'s own return type maps over a component's props and collapses
17
+ * `<ItemT>` in the process, which would leave `data` and `renderItem` checking
18
+ * against `unknown`. Writing the signature out keeps inference and still
19
+ * declares exactly the two props the wrapper adds.
20
+ */
21
+ type StyledAnimatedLegendListComponent = <ItemT>(
22
+ props: AnimatedLegendListProps<ItemT> & {
23
+ ref?: Ref<LegendListRef>;
24
+ className?: string;
25
+ contentContainerClassName?: string;
26
+ }
27
+ ) => ReactElement | null;
28
+
29
+ // Third-party, so `className` needs the wrapper — and it has to be built at
30
+ // module scope or every render mints a new component type and remounts the list.
31
+ const StyledAnimatedLegendList = withUniwind(AnimatedLegendList) as unknown as StyledAnimatedLegendListComponent;
32
+
33
+ export type ScreenLegendListProps<ItemT> = Omit<AnimatedLegendListProps<ItemT>, "renderScrollComponent"> &
34
+ ScreenScrollableProps;
35
+
36
+ /**
37
+ * The component's own type, restated so `<ItemT>` survives `forwardRef`.
38
+ *
39
+ * Same reasoning as `StyledAnimatedLegendListComponent` above, one layer out:
40
+ * `forwardRef` erases type parameters, so `data` and `renderItem` would check
41
+ * against `unknown`.
42
+ */
43
+ type ScreenLegendListComponent = (<ItemT>(
44
+ props: ScreenLegendListProps<ItemT> & RefAttributes<LegendListRef>
45
+ ) => ReactElement) & { displayName?: string };
46
+
47
+ /**
48
+ * A recycling list that keeps its content clear of the screen's chrome.
49
+ *
50
+ * `Screen.FlatList`'s behaviour on LegendList's recycling engine, for a long or
51
+ * heterogeneous list. Same navbar, footer and keyboard reserves — they come
52
+ * from the shared `useScreenScrollInsets`, so the three lists cannot drift.
53
+ *
54
+ * For a chat, reach for `Screen.ChatList` instead: it adds the composer
55
+ * clearance and the keyboard lift that a conversation needs and this one has no
56
+ * opinion about.
57
+ *
58
+ * The ref is a `LegendListRef`.
59
+ */
60
+ export const ScreenLegendList = forwardRef(function ScreenLegendListRender<ItemT>(
61
+ {
62
+ header,
63
+ contentContainerClassName,
64
+ ListHeaderComponent: ListHeaderComponentProp,
65
+ ListFooterComponent: ListFooterComponentProp,
66
+ ...props
67
+ }: ScreenLegendListProps<ItemT>,
68
+ ref: ForwardedRef<LegendListRef>
69
+ ): ReactElement {
70
+ const { scrollHandler, insetTopAnimatedStyle, insetBottomAnimatedStyle } = useScreenScrollInsets("standard");
71
+
72
+ const ListHeaderComponent = useMemo<ReactElement>(
73
+ () => (
74
+ <>
75
+ <Animated.View style={insetTopAnimatedStyle} />
76
+ {header}
77
+ {resolveListComponent(ListHeaderComponentProp)}
78
+ </>
79
+ ),
80
+ [header, ListHeaderComponentProp, insetTopAnimatedStyle]
81
+ );
82
+
83
+ const ListFooterComponent = useMemo<ReactElement>(
84
+ () => (
85
+ <View>
86
+ {resolveListComponent(ListFooterComponentProp)}
87
+ <Animated.View style={insetBottomAnimatedStyle} />
88
+ </View>
89
+ ),
90
+ [ListFooterComponentProp, insetBottomAnimatedStyle]
91
+ );
92
+
93
+ return (
94
+ <StyledAnimatedLegendList
95
+ showsVerticalScrollIndicator={false}
96
+ {...(props as AnimatedLegendListProps<ItemT>)}
97
+ contentContainerClassName={cn(screenVariants().scrollContent(), contentContainerClassName)}
98
+ ListFooterComponent={ListFooterComponent}
99
+ ListHeaderComponent={ListHeaderComponent}
100
+ onScroll={scrollHandler}
101
+ ref={ref}
102
+ />
103
+ );
104
+ }) as unknown as ScreenLegendListComponent;
105
+ ScreenLegendList.displayName = "DelacourUI.Screen.LegendList";
@@ -0,0 +1,23 @@
1
+ import { type ComponentType, isValidElement, type ReactElement, type ReactNode } from "react";
2
+
3
+ /** What React Native's lists accept for `ListHeaderComponent` / `ListFooterComponent`. */
4
+ export type ListComponent = ComponentType<unknown> | ReactElement | null | undefined;
5
+
6
+ /**
7
+ * Renders a list's header or footer prop as a node, whichever form it took.
8
+ *
9
+ * React Native accepts a component type *or* an element for these props, and a
10
+ * Screen scrollable has to compose the caller's into its own spacers — which
11
+ * only works with a node. Calling a component type as a function would break
12
+ * its hooks, so it is rendered as an element instead.
13
+ *
14
+ * Shared by every Screen list, so it lives in a leaf of its own rather than in
15
+ * whichever list happened to need it first.
16
+ */
17
+ export function resolveListComponent(component: ListComponent): ReactNode {
18
+ if (component == null) return null;
19
+ if (isValidElement(component)) return component;
20
+
21
+ const Component = component as ComponentType<unknown>;
22
+ return <Component />;
23
+ }
@@ -0,0 +1,50 @@
1
+ import type { ReactElement } from "react";
2
+ import { View } from "react-native";
3
+ import { Spinner } from "../spinner";
4
+ import { screenVariants } from "./screen.variants";
5
+ import { ScreenContent } from "./screen-content";
6
+ import { ScreenNavbar } from "./screen-navbar";
7
+ import { ScreenRoot } from "./screen-root";
8
+
9
+ export type ScreenLoadingProps = {
10
+ /** Title for the navbar, if the screen shows one. */
11
+ title?: string;
12
+ /** Back handler. Omit it and the navbar renders without a back control. */
13
+ onBack?: () => void;
14
+ /** Show the navbar at all. On by default, so the frame does not shift when the content arrives. */
15
+ showNavbar?: boolean;
16
+ };
17
+
18
+ /**
19
+ * A whole screen showing a spinner — the placeholder a route renders while its
20
+ * data is in flight.
21
+ *
22
+ * Keeps the navbar by default so the frame does not jump when the real content
23
+ * replaces it, and so the user can still go back out of a slow load.
24
+ *
25
+ * @example
26
+ * if (isPending) return <Screen.Loading onBack={() => router.back()} title="Settings" />;
27
+ */
28
+ export function ScreenLoading({ title, onBack, showNavbar = true }: ScreenLoadingProps): ReactElement {
29
+ return (
30
+ <ScreenRoot>
31
+ {showNavbar ? (
32
+ <ScreenNavbar>
33
+ {onBack ? (
34
+ <ScreenNavbar.BackButton onPress={onBack}>
35
+ {title ? <ScreenNavbar.Title>{title}</ScreenNavbar.Title> : null}
36
+ </ScreenNavbar.BackButton>
37
+ ) : title ? (
38
+ <ScreenNavbar.Title>{title}</ScreenNavbar.Title>
39
+ ) : null}
40
+ </ScreenNavbar>
41
+ ) : null}
42
+ <ScreenContent>
43
+ <View className={screenVariants().loading()}>
44
+ <Spinner size="xl" />
45
+ </View>
46
+ </ScreenContent>
47
+ </ScreenRoot>
48
+ );
49
+ }
50
+ ScreenLoading.displayName = "DelacourUI.Screen.Loading";
@@ -0,0 +1,79 @@
1
+ import type { ReactElement } from "react";
2
+ import { IconChevronLeft, IconCrossSmall } from "../../icons/central";
3
+ import { Icon, IconDefaultsProvider } from "../icon";
4
+ import { Pressable, type PressableProps } from "../pressable";
5
+ import { screenVariants } from "./screen.variants";
6
+
7
+ /** Which affordance the control reads as: going back, or closing a modal. */
8
+ export const SCREEN_BACK_BUTTON_GLYPHS = ["chevron", "close"] as const;
9
+
10
+ export type ScreenBackButtonGlyph = (typeof SCREEN_BACK_BUTTON_GLYPHS)[number];
11
+
12
+ export type ScreenNavbarBackButtonProps = Omit<PressableProps, "onPress"> & {
13
+ /** `chevron` for a push, `close` for a modal. */
14
+ glyph?: ScreenBackButtonGlyph;
15
+ /**
16
+ * What going back does. Required: this library takes no navigation
17
+ * dependency, so the screen wires its own router — `() => router.back()`.
18
+ */
19
+ onPress: () => void;
20
+ /**
21
+ * Runs before `onPress`. Returning `false` cancels the navigation, for an
22
+ * unsaved-changes prompt.
23
+ */
24
+ onBeforeBack?: () => boolean;
25
+ };
26
+
27
+ const GLYPH: Record<ScreenBackButtonGlyph, typeof IconChevronLeft> = {
28
+ chevron: IconChevronLeft,
29
+ close: IconCrossSmall,
30
+ };
31
+
32
+ /**
33
+ * The navbar's leading control.
34
+ *
35
+ * Takes an `onPress` rather than calling a router itself. `native-ui` has no
36
+ * navigation dependency and should not gain one for a chevron — the screen
37
+ * already knows how it was pushed, and an app on a different router still gets
38
+ * to use this.
39
+ *
40
+ * Children render beside the glyph, so a title can share the control's tap
41
+ * target — which is what makes the whole "‹ Settings" row pressable rather than
42
+ * just the chevron.
43
+ *
44
+ * @example
45
+ * <Screen.Navbar.BackButton onPress={() => router.back()}>
46
+ * <Screen.Navbar.Title>Settings</Screen.Navbar.Title>
47
+ * </Screen.Navbar.BackButton>
48
+ */
49
+ export function ScreenNavbarBackButton({
50
+ glyph = "chevron",
51
+ onPress,
52
+ onBeforeBack,
53
+ className,
54
+ children,
55
+ accessibilityLabel = "Back",
56
+ ...props
57
+ }: ScreenNavbarBackButtonProps): ReactElement {
58
+ function handlePress(): void {
59
+ if (onBeforeBack && !onBeforeBack()) return;
60
+ onPress();
61
+ }
62
+
63
+ return (
64
+ <Pressable
65
+ accessibilityLabel={accessibilityLabel}
66
+ accessibilityRole="button"
67
+ className={screenVariants().backButton({ className })}
68
+ feedback="fade"
69
+ onPress={handlePress}
70
+ {...props}
71
+ >
72
+ <IconDefaultsProvider value={{ className: "size-icon-lg", color: "foreground" }}>
73
+ <Icon icon={GLYPH[glyph]} />
74
+ {children}
75
+ </IconDefaultsProvider>
76
+ </Pressable>
77
+ );
78
+ }
79
+ ScreenNavbarBackButton.displayName = "DelacourUI.Screen.Navbar.BackButton";
@@ -0,0 +1,45 @@
1
+ import { memo, type ReactElement } from "react";
2
+ import { View } from "react-native";
3
+ import Animated, { useAnimatedStyle } from "react-native-reanimated";
4
+ import { useScreenPart } from "./screen.context";
5
+ import { resolveNavbarBorderOpacity, screenVariants } from "./screen.variants";
6
+
7
+ export type ScreenNavbarBackgroundProps = {
8
+ className?: string;
9
+ /** Ramp the hairline in as the screen scrolls instead of drawing it at rest. */
10
+ fadeOnScroll?: boolean;
11
+ };
12
+
13
+ /**
14
+ * The navbar's opaque backing, and its bottom hairline.
15
+ *
16
+ * Separated from the navbar itself because it reads `scrollY`, which changes
17
+ * every frame: memoised and isolated, the animated style re-runs on the UI
18
+ * thread without the navbar's children re-rendering with it.
19
+ *
20
+ * The line is drawn at rest by default. `fadeOnScroll` ramps it in over the
21
+ * first {@link SCREEN_BORDER_FADE_DISTANCE} points instead, for a header
22
+ * that should read as undivided until the content moves.
23
+ *
24
+ * The fill is a CLASS rather than an inline style. An inline `backgroundColor`
25
+ * beats the style uniwind resolves from a className, which is what would make a
26
+ * caller's `backgroundClassName` silently inert.
27
+ */
28
+ export const ScreenNavbarBackground = memo(function ScreenNavbarBackground({
29
+ className,
30
+ fadeOnScroll = false,
31
+ }: ScreenNavbarBackgroundProps): ReactElement {
32
+ const { scrollY } = useScreenPart("Screen.Navbar");
33
+
34
+ const borderStyle = useAnimatedStyle(
35
+ () => ({ opacity: resolveNavbarBorderOpacity(scrollY.value, fadeOnScroll) }),
36
+ [fadeOnScroll]
37
+ );
38
+
39
+ return (
40
+ <View className={screenVariants().navbarBackground({ className })} pointerEvents="none">
41
+ <Animated.View className={screenVariants().navbarBorder()} style={borderStyle} />
42
+ </View>
43
+ );
44
+ });
45
+ ScreenNavbarBackground.displayName = "DelacourUI.Screen.Navbar.Background";