@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,26 @@
1
+ import type { ReactElement, ReactNode } from "react";
2
+ import type { TextProps } from "react-native";
3
+ import { Text } from "../text";
4
+ import { screenVariants } from "./screen.variants";
5
+
6
+ export type ScreenNavbarSubtitleProps = Omit<TextProps, "children"> & {
7
+ className?: string;
8
+ children?: ReactNode;
9
+ };
10
+
11
+ /**
12
+ * The navbar's secondary line — a step down in scale, on the muted token.
13
+ *
14
+ * Follows `Screen.Navbar.Title`'s rule: a string child is wrapped in a `Text`,
15
+ * any other node passes straight through.
16
+ */
17
+ export function ScreenNavbarSubtitle({ className, children, ...props }: ScreenNavbarSubtitleProps): ReactElement {
18
+ if (typeof children !== "string") return <>{children}</>;
19
+
20
+ return (
21
+ <Text className={screenVariants().navbarSubtitle({ className })} numberOfLines={1} {...props}>
22
+ {children}
23
+ </Text>
24
+ );
25
+ }
26
+ ScreenNavbarSubtitle.displayName = "DelacourUI.Screen.Navbar.Subtitle";
@@ -0,0 +1,31 @@
1
+ import type { ReactElement, ReactNode } from "react";
2
+ import type { TextProps } from "react-native";
3
+ import { Text } from "../text";
4
+ import { screenVariants } from "./screen.variants";
5
+
6
+ export type ScreenNavbarTitleProps = Omit<TextProps, "children"> & {
7
+ className?: string;
8
+ children?: ReactNode;
9
+ };
10
+
11
+ /**
12
+ * The navbar's primary line.
13
+ *
14
+ * A string child is wrapped in a `Text`; any other node is passed straight
15
+ * through, so a title can be an avatar row or a dropdown trigger without this
16
+ * component second-guessing it. React Native crashes on bare text outside a
17
+ * `<Text>`, which is why the string case cannot simply be forwarded too — the
18
+ * same rule `Button` and `ListGroup.Item` follow.
19
+ *
20
+ * Carries its own colour: a `View` does not cascade one to a `Text` descendant.
21
+ */
22
+ export function ScreenNavbarTitle({ className, children, ...props }: ScreenNavbarTitleProps): ReactElement {
23
+ if (typeof children !== "string") return <>{children}</>;
24
+
25
+ return (
26
+ <Text className={screenVariants().navbarTitle({ className })} numberOfLines={1} {...props}>
27
+ {children}
28
+ </Text>
29
+ );
30
+ }
31
+ ScreenNavbarTitle.displayName = "DelacourUI.Screen.Navbar.Title";
@@ -0,0 +1,154 @@
1
+ import { type ReactElement, type ReactNode, useCallback, useEffect } from "react";
2
+ import { type LayoutChangeEvent, View } from "react-native";
3
+ import { useSafeAreaInsets } from "react-native-safe-area-context";
4
+ import { useScreenPart } from "./screen.context";
5
+ import type { ScreenPlacementProps } from "./screen.types";
6
+ import { screenVariants } from "./screen.variants";
7
+ import { ScreenNavbarBackButton } from "./screen-navbar-back-button";
8
+ import { ScreenNavbarBackground } from "./screen-navbar-background";
9
+ import { ScreenNavbarSubtitle } from "./screen-navbar-subtitle";
10
+ import { ScreenNavbarTitle } from "./screen-navbar-title";
11
+
12
+ export type ScreenNavbarProps = ScreenPlacementProps & {
13
+ /** Leading content — a back button, a title, an avatar row. */
14
+ children?: ReactNode;
15
+ /** Trailing content, laid out in a row against the right edge. */
16
+ actions?: ReactNode;
17
+ /**
18
+ * Content centred across the full width, independent of what the leading and
19
+ * trailing slots take. Absolutely positioned, so a long title on one side
20
+ * cannot push it off centre.
21
+ */
22
+ center?: ReactNode;
23
+ /** Classes for the navbar's opaque backing, to tint or blur it. */
24
+ backgroundClassName?: string;
25
+ /**
26
+ * Ramp the bottom hairline in as the screen scrolls, instead of drawing it at
27
+ * rest.
28
+ *
29
+ * Off by default: a screen whose content starts flush against the bar wants
30
+ * the line from the first frame. Turn it on for a header that should read as
31
+ * undivided until the content moves — which only says anything on a screen
32
+ * that scrolls, since the value it reads is published by the scrollable.
33
+ *
34
+ * @default false
35
+ */
36
+ fadeBorderOnScroll?: boolean;
37
+ };
38
+
39
+ /**
40
+ * The bar pinned to the top of a screen.
41
+ *
42
+ * Measures its own height — safe-area band included — into the screen context,
43
+ * so every scrollable reserves exactly what it covers rather than each screen
44
+ * guessing a number. That measurement is the reason this cannot be a standalone
45
+ * component: it only means anything inside a `<Screen>`.
46
+ *
47
+ * `overlay` by default, so content scrolls under it and the hairline appearing
48
+ * on scroll says there is more above. `placement="static"` puts it in the flow
49
+ * instead, for a screen whose content should never pass beneath it.
50
+ *
51
+ * The bottom hairline is drawn at rest. `fadeBorderOnScroll` ramps it in with
52
+ * the content instead, for a header that should read as undivided until the
53
+ * screen moves.
54
+ *
55
+ * Every layout box is `pointerEvents="box-none"`, so the bar itself never
56
+ * swallows a tap meant for the content beneath — only its actual controls do.
57
+ * The centre slot in particular spans the full width and would otherwise block
58
+ * both the leading and trailing controls.
59
+ *
60
+ * Actions are composed rather than configured: pass whatever the screen needs.
61
+ * A `Button` already covers the icon-button case, so there is no `Navbar.Action`
62
+ * restating its loading, sizing and haptics vocabulary.
63
+ *
64
+ * @example
65
+ * <Screen.Navbar>
66
+ * <Screen.Navbar.BackButton onPress={() => router.back()}>
67
+ * <Screen.Navbar.Title>Settings</Screen.Navbar.Title>
68
+ * </Screen.Navbar.BackButton>
69
+ * </Screen.Navbar>
70
+ *
71
+ * @example
72
+ * <Screen.Navbar
73
+ * actions={
74
+ * <Button accessibilityLabel="Search" size="icon-sm" variant="secondary" onPress={search}>
75
+ * <Icon icon={IconSearch} />
76
+ * </Button>
77
+ * }
78
+ * center={<Screen.Navbar.Title>Inbox</Screen.Navbar.Title>}
79
+ * placement="static"
80
+ * />
81
+ */
82
+ function ScreenNavbarRoot({
83
+ placement = "overlay",
84
+ className,
85
+ backgroundClassName,
86
+ fadeBorderOnScroll = false,
87
+ children,
88
+ actions,
89
+ center,
90
+ onLayout,
91
+ ...props
92
+ }: ScreenNavbarProps): ReactElement {
93
+ const { navbar } = useScreenPart("Screen.Navbar");
94
+ // Not uniwind's `pt-safe`: that compiles to env(safe-area-inset-top), which
95
+ // resolves to zero on React Native and would silently draw the bar over the
96
+ // status bar. The hook is also what the footer reads, so the two agree.
97
+ const { top } = useSafeAreaInsets();
98
+
99
+ const handleLayout = useCallback(
100
+ (event: LayoutChangeEvent) => {
101
+ onLayout?.(event);
102
+ navbar.height.value = event.nativeEvent.layout.height;
103
+ },
104
+ [navbar, onLayout]
105
+ );
106
+
107
+ useEffect(() => {
108
+ navbar.placement.value = placement;
109
+ }, [placement, navbar.placement]);
110
+
111
+ return (
112
+ <View
113
+ className={screenVariants({ placement }).navbar({ className })}
114
+ onLayout={handleLayout}
115
+ pointerEvents="box-none"
116
+ {...props}
117
+ >
118
+ <ScreenNavbarBackground className={backgroundClassName} fadeOnScroll={fadeBorderOnScroll} />
119
+ <View pointerEvents="box-none" style={{ paddingTop: top }}>
120
+ <View className={screenVariants().navbarRow()} pointerEvents="box-none">
121
+ <View className={screenVariants().navbarStart()} pointerEvents="box-none">
122
+ {children}
123
+ </View>
124
+ {actions ? (
125
+ <View className={screenVariants().navbarActions()} pointerEvents="box-none">
126
+ {actions}
127
+ </View>
128
+ ) : null}
129
+ {center ? (
130
+ <View className={screenVariants().navbarCenter()} pointerEvents="box-none">
131
+ {center}
132
+ </View>
133
+ ) : null}
134
+ </View>
135
+ </View>
136
+ </View>
137
+ );
138
+ }
139
+
140
+ /**
141
+ * The bar pinned to the top of a screen, with its title and back button slots.
142
+ *
143
+ * Reached as `Screen.Navbar`; see {@link ScreenNavbarRoot} for the layout and
144
+ * the measurement it publishes.
145
+ */
146
+ export const ScreenNavbar = Object.assign(ScreenNavbarRoot, {
147
+ /** The primary line. A string child is wrapped in a `Text`; any other node passes through. */
148
+ Title: ScreenNavbarTitle,
149
+ /** The secondary line, a step down in scale and on the muted token. */
150
+ Subtitle: ScreenNavbarSubtitle,
151
+ /** The leading control. Takes an `onPress` — this library wires no router of its own. */
152
+ BackButton: ScreenNavbarBackButton,
153
+ displayName: "DelacourUI.Screen.Navbar",
154
+ });
@@ -0,0 +1,38 @@
1
+ import type { ReactElement } from "react";
2
+ import { View, type ViewProps } from "react-native";
3
+ import { ScreenProvider, useScreenContext } from "./screen.context";
4
+ import { screenVariants } from "./screen.variants";
5
+
6
+ export type ScreenRootProps = ViewProps & {
7
+ /**
8
+ * Paint the screen's layout layers in debug colours — the footer's bands, a
9
+ * scrollable's spacers, a chat list's composer clearance.
10
+ *
11
+ * Opt-in per screen and not gated behind `__DEV__`, so a layout can still be
12
+ * inspected on a release build where a reserve only goes wrong.
13
+ */
14
+ debug?: boolean;
15
+ className?: string;
16
+ };
17
+
18
+ /**
19
+ * The root box of a screen, and the provider its parts measure themselves into.
20
+ *
21
+ * Lives in its own module rather than in `screen.tsx` because `Screen.Loading`
22
+ * and `Screen.Error` are whole screens — they render this — and importing
23
+ * `./screen` from either would close a cycle through the `Object.assign` that
24
+ * names them. See AGENTS.md rule 3.
25
+ */
26
+ export function ScreenRoot({ debug = false, className, children, ...props }: ScreenRootProps): ReactElement {
27
+ // A nested screen keeps reporting to the outer one rather than starting a
28
+ // second, unread set of measurements.
29
+ const parentContext = useScreenContext();
30
+
31
+ return (
32
+ <ScreenProvider debug={debug} parentContext={parentContext}>
33
+ <View className={screenVariants().root({ className })} {...props}>
34
+ {children}
35
+ </View>
36
+ </ScreenProvider>
37
+ );
38
+ }
@@ -0,0 +1,116 @@
1
+ import { forwardRef, type ReactElement, type Ref } from "react";
2
+ import type { ScrollViewProps } from "react-native";
3
+ import { KeyboardAwareScrollView, type KeyboardAwareScrollViewRef } from "react-native-keyboard-controller";
4
+ import Animated from "react-native-reanimated";
5
+ import { withUniwind } from "uniwind";
6
+ import { cn } from "../../lib/cn";
7
+ import type { ScreenScrollableProps, ScreenScrollViewRef } from "./screen.types";
8
+ import { screenVariants } from "./screen.variants";
9
+ import { useScreenFooterKeyboardClearance, useScreenScrollInsets } from "./use-screen-scroll-insets";
10
+
11
+ // `contentContainerClassName` only resolves on a uniwind-wrapped component, and
12
+ // the wrapper has to be built at module scope — in render it would mint a new
13
+ // component type every frame and remount the whole scroll view.
14
+ const StyledKeyboardAwareScrollView = withUniwind(KeyboardAwareScrollView);
15
+
16
+ export type ScreenScrollAreaProps = ScrollViewProps &
17
+ ScreenScrollableProps & {
18
+ /**
19
+ * Scroll the focused `TextInput` clear of the keyboard, rather than only
20
+ * reserving space for it. Turn this on for form screens. Off by default —
21
+ * it costs a second scroll driver that a read-only screen never needs.
22
+ */
23
+ keyboardAware?: boolean;
24
+ };
25
+
26
+ /**
27
+ * A scrolling region that keeps its content clear of the screen's chrome.
28
+ *
29
+ * The navbar and footer reserves are spacer views at either end of the content
30
+ * rather than padding on the content container, so both can animate on the UI
31
+ * thread as the chrome measures itself and the keyboard moves.
32
+ *
33
+ * The ref is a `ScreenScrollViewRef`, for a caller that has to move the scroll
34
+ * view — jumping to an offset, or driving it from a gesture on the UI thread.
35
+ * One type covers both engines: `keyboardAware` swaps in
36
+ * `KeyboardAwareScrollView`, whose ref is a SUPERSET of this one — it adds
37
+ * `assureFocusedInputVisible` to the same scroll view instance — so what a
38
+ * caller receives always satisfies what it declared.
39
+ *
40
+ * @example
41
+ * <Screen.ScrollArea contentContainerClassName="gap-4 px-5">
42
+ * {rows}
43
+ * </Screen.ScrollArea>
44
+ *
45
+ * @example
46
+ * <Screen.ScrollArea keyboardAware contentContainerClassName="gap-4 px-5">
47
+ * <Form />
48
+ * </Screen.ScrollArea>
49
+ */
50
+ export const ScreenScrollArea = forwardRef<ScreenScrollViewRef, ScreenScrollAreaProps>(function ScreenScrollAreaRender(
51
+ { header, className, contentContainerClassName, keyboardAware = false, children, ...props },
52
+ ref
53
+ ): ReactElement {
54
+ const { scrollHandler, insetTopAnimatedStyle, insetBottomAnimatedStyle } = useScreenScrollInsets(
55
+ keyboardAware ? "keyboard-aware" : "standard"
56
+ );
57
+ // A sticky footer rides the keyboard, so the focused field has to clear the
58
+ // footer too — not just the keyboard. Deliberately not the chat list's
59
+ // occupancy: the footer's safe-area band is parked behind the keyboard by the
60
+ // sticky shift, so counting it here would overshoot.
61
+ const footerClearance = useScreenFooterKeyboardClearance();
62
+
63
+ const content = (
64
+ <>
65
+ <Animated.View style={insetTopAnimatedStyle} />
66
+ {header}
67
+ {children}
68
+ <Animated.View style={insetBottomAnimatedStyle} />
69
+ </>
70
+ );
71
+ const contentContainer = cn(screenVariants().scrollContent({ className: "py-0" }), contentContainerClassName);
72
+
73
+ // Two branches rather than one `keyboardAware ? A : B` component variable.
74
+ // A variable holding either component types its `ref` as the two refs'
75
+ // intersection, which is neither of them, so `forwardRef` could not name a
76
+ // single instance type at all — and the branch is what lets each engine
77
+ // receive the ref that is actually its own.
78
+ //
79
+ // The cast in that branch is variance, not a guess: `RefObject.current` is
80
+ // mutable and therefore invariant, so TypeScript rejects the narrower ref
81
+ // even though the value flowing back into it is a `KeyboardAwareScrollViewRef`
82
+ // — which IS a `ScreenScrollViewRef` plus one method.
83
+ if (keyboardAware) {
84
+ return (
85
+ <StyledKeyboardAwareScrollView
86
+ className={className}
87
+ keyboardShouldPersistTaps="handled"
88
+ onScroll={scrollHandler}
89
+ showsHorizontalScrollIndicator={false}
90
+ showsVerticalScrollIndicator={false}
91
+ bottomOffset={footerClearance}
92
+ {...props}
93
+ ref={ref as Ref<KeyboardAwareScrollViewRef>}
94
+ contentContainerClassName={contentContainer}
95
+ >
96
+ {content}
97
+ </StyledKeyboardAwareScrollView>
98
+ );
99
+ }
100
+
101
+ return (
102
+ <Animated.ScrollView
103
+ className={className}
104
+ keyboardShouldPersistTaps="handled"
105
+ onScroll={scrollHandler}
106
+ showsHorizontalScrollIndicator={false}
107
+ showsVerticalScrollIndicator={false}
108
+ {...props}
109
+ ref={ref}
110
+ contentContainerClassName={contentContainer}
111
+ >
112
+ {content}
113
+ </Animated.ScrollView>
114
+ );
115
+ });
116
+ ScreenScrollArea.displayName = "DelacourUI.Screen.ScrollArea";
@@ -0,0 +1,186 @@
1
+ import { LinearGradient } from "expo-linear-gradient";
2
+ import type { ReactElement } from "react";
3
+ import { StyleSheet, View, type ViewProps } from "react-native";
4
+ import Animated, { interpolate, useAnimatedStyle } from "react-native-reanimated";
5
+ import { useThemeColor } from "../../hooks/use-theme-color";
6
+ import { transparentOf } from "../../lib/color";
7
+ import { useScreenPart } from "./screen.context";
8
+
9
+ /** How tall the fade is, in points, when a caller names no size. */
10
+ const SCROLL_SHADOW_SIZE = 28;
11
+ /**
12
+ * How far the scrollable travels before a fade is fully in, as a share of `size`.
13
+ *
14
+ * A quarter, so the fade arrives while the first row is still leaving rather
15
+ * than trailing it: matching the ramp to the fade's own height reads as the
16
+ * shadow lagging the finger.
17
+ */
18
+ const SCROLL_SHADOW_RAMP = 0.25;
19
+
20
+ /** Which ends of the scrollable fade. */
21
+ export const SCROLL_SHADOW_EDGES = ["top", "bottom", "both"] as const;
22
+ export type ScreenScrollShadowEdge = (typeof SCROLL_SHADOW_EDGES)[number];
23
+
24
+ /** What the fade measures its position from. */
25
+ export const SCROLL_SHADOW_ANCHORS = ["screen", "parent"] as const;
26
+ export type ScreenScrollShadowAnchor = (typeof SCROLL_SHADOW_ANCHORS)[number];
27
+
28
+ export type ScreenScrollShadowProps = Omit<ViewProps, "children" | "pointerEvents"> & {
29
+ /** How tall the fade is, in points. @default SCROLL_SHADOW_SIZE */
30
+ size?: number;
31
+ /** Which ends fade. @default "both" */
32
+ edges?: ScreenScrollShadowEdge;
33
+ /**
34
+ * What the fade positions itself against.
35
+ *
36
+ * `screen` measures from the navbar and footer, which is right whenever this
37
+ * is a sibling of the body. `parent` pins it to its container instead, for a
38
+ * caller that has already placed it — chrome drawing this behind itself,
39
+ * because paint order is source order and nothing outside a navigator can
40
+ * slot between the pages it renders and the bar it renders over them.
41
+ *
42
+ * @default "screen"
43
+ */
44
+ anchor?: ScreenScrollShadowAnchor;
45
+ /**
46
+ * The colour the content fades into.
47
+ *
48
+ * Defaults to the `background` token, which is what a screen paints. Name
49
+ * another when the shadow sits over a different surface — a `card`, say —
50
+ * because a fade into the wrong colour reads as a grey band rather than as
51
+ * the content running out.
52
+ */
53
+ color?: string;
54
+ /**
55
+ * Points of SOLID colour above the gradient, for chrome floating over the body.
56
+ *
57
+ * Not an offset — the fade still begins at the navbar. This is the band the
58
+ * floating chrome occupies, painted opaque so content is already gone by the
59
+ * time it reaches the chrome, with the gradient below it doing the dissolving.
60
+ * Offsetting the fade past the chrome instead leaves content crisp behind
61
+ * whatever parts of it are transparent, and then cuts abruptly where the fade
62
+ * finally starts — which is the whole artefact this is here to avoid.
63
+ */
64
+ coverTop?: number;
65
+ /** Points of solid colour below the gradient, for chrome floating at the end. */
66
+ coverBottom?: number;
67
+ className?: string;
68
+ };
69
+
70
+ /**
71
+ * A fade at each end of the screen's scrollable, for content that runs past it.
72
+ *
73
+ * The problem it solves is that a scrollable with a hard edge looks finished. A
74
+ * row cut exactly in half by the bottom of the viewport reads as a layout bug;
75
+ * the same row fading out reads as more to come. That signal matters most under
76
+ * chrome that floats over the content, where the cut lands mid-component and
77
+ * there is no bar edge to explain it.
78
+ *
79
+ * **It is a sibling of the scrollable, not a wrapper around it.** The screen
80
+ * context already publishes `scrollY`, `contentHeight` and `layoutHeight` from
81
+ * whichever body is mounted, so this reads the numbers rather than intercepting
82
+ * them. Wrapping would mean cloning the child to attach an `onScroll`, and every
83
+ * scrollable this package ships already has one of its own — the reserve
84
+ * animations in `use-screen-scroll-insets` are driven by it — so a second writer
85
+ * would take a handler that is already spoken for.
86
+ *
87
+ * It follows that this works over any of them: `ScrollArea`, `FlatList`,
88
+ * `SectionList`, `LegendList` and `ChatList` all publish to the same context.
89
+ *
90
+ * **It places itself against the screen's own chrome.** The top fade starts at
91
+ * the navbar's measured height and the bottom above the footer's, both read from
92
+ * the same context, so it is correct under either placement without a caller
93
+ * measuring anything.
94
+ *
95
+ * **`coverTop` extends the band, it does not move it.** Chrome floating between
96
+ * the navbar and the body — a tab bar, say — wants the content already gone
97
+ * where it passes behind, so the band is solid for those points and only then
98
+ * begins to fade. Pushing the whole fade below the chrome instead leaves content
99
+ * crisp behind whatever parts of it are transparent, and cuts abruptly where the
100
+ * fade finally starts.
101
+ *
102
+ * **Each end fades only when there is something past it.** The top is out at
103
+ * rest and arrives over the first `size` points of travel; the bottom is there
104
+ * from the start and leaves over the last `size`. Content shorter than its
105
+ * viewport never scrolls, so `maxScroll` is zero and both stay out — a fade over
106
+ * a short page would be an edge that promises content that does not exist.
107
+ *
108
+ * **The far stop is the near colour at zero alpha, never `transparent`.** The
109
+ * keyword is transparent BLACK, so interpolating toward it drags every stop
110
+ * between through grey — a dark bloom over a light ground and a milky one over
111
+ * a dark. `transparentOf` takes the alpha off the colour instead, and this
112
+ * declines to draw at all rather than fall back to the keyword.
113
+ *
114
+ * @example
115
+ * <Screen>
116
+ * <Screen.ScrollArea>{rows}</Screen.ScrollArea>
117
+ * <Screen.ScrollShadow />
118
+ * </Screen>
119
+ *
120
+ * @example
121
+ * // Under a bar floating over the content.
122
+ * <Screen.ScrollShadow insetTop={barHeight} />
123
+ */
124
+ export function ScreenScrollShadow({
125
+ size = SCROLL_SHADOW_SIZE,
126
+ edges = "both",
127
+ anchor = "screen",
128
+ color,
129
+ coverTop = 0,
130
+ coverBottom = 0,
131
+ className,
132
+ style,
133
+ ...props
134
+ }: ScreenScrollShadowProps): ReactElement {
135
+ const { scrollY, contentHeight, layoutHeight, navbar, footer } = useScreenPart("Screen.ScrollShadow");
136
+ const background = useThemeColor("background");
137
+ const paint = color ?? background;
138
+ const faded = transparentOf(paint);
139
+ const ramp = size * SCROLL_SHADOW_RAMP;
140
+
141
+ // The chrome's height is part of the position, not something a caller adds.
142
+ // This fills the whole screen, so a fade placed at zero would sit behind the
143
+ // navbar under a static one and under an overlay one alike — visible in
144
+ // neither, which is a component that silently does nothing.
145
+ const topStyle = useAnimatedStyle(() => ({
146
+ opacity: interpolate(scrollY.value, [0, ramp], [0, 1], "clamp"),
147
+ top: anchor === "parent" ? 0 : navbar.height.value,
148
+ }));
149
+
150
+ const bottomStyle = useAnimatedStyle(() => {
151
+ const maxScroll = contentHeight.value - layoutHeight.value;
152
+ const bottom = anchor === "parent" ? 0 : footer.height.value + footer.overlayHeight.value;
153
+ if (maxScroll <= 0) return { bottom, opacity: 0 };
154
+
155
+ return { bottom, opacity: interpolate(scrollY.value, [maxScroll - ramp, maxScroll], [1, 0], "clamp") };
156
+ });
157
+
158
+ // Without a colour to fade from there is no honest gradient to draw, and the
159
+ // keyword `transparent` is not a substitute — see `transparentOf`.
160
+ if (!(paint && faded)) return <View />;
161
+
162
+ return (
163
+ <View className={className} pointerEvents="none" style={[StyleSheet.absoluteFill, style]} {...props}>
164
+ {edges === "bottom" ? null : (
165
+ <Animated.View style={[{ height: coverTop + size, left: 0, position: "absolute", right: 0 }, topStyle]}>
166
+ <LinearGradient
167
+ colors={[paint, paint, faded]}
168
+ locations={[0, coverTop / (coverTop + size), 1]}
169
+ style={StyleSheet.absoluteFill}
170
+ />
171
+ </Animated.View>
172
+ )}
173
+
174
+ {edges === "top" ? null : (
175
+ <Animated.View style={[{ height: coverBottom + size, left: 0, position: "absolute", right: 0 }, bottomStyle]}>
176
+ <LinearGradient
177
+ colors={[faded, paint, paint]}
178
+ locations={[0, size / (coverBottom + size), 1]}
179
+ style={StyleSheet.absoluteFill}
180
+ />
181
+ </Animated.View>
182
+ )}
183
+ </View>
184
+ );
185
+ }
186
+ ScreenScrollShadow.displayName = "DelacourUI.Screen.ScrollShadow";
@@ -0,0 +1,81 @@
1
+ import { type ForwardedRef, forwardRef, type ReactElement, type RefAttributes, useMemo } from "react";
2
+ import { type DefaultSectionT, SectionList, type SectionListProps, 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 `sections` and `renderItem` still check against each other.
12
+ const AnimatedSectionList = Animated.createAnimatedComponent(SectionList) as unknown as typeof SectionList;
13
+
14
+ export type ScreenSectionListProps<ItemT, SectionT = DefaultSectionT> = Omit<
15
+ SectionListProps<ItemT, SectionT>,
16
+ "children"
17
+ > &
18
+ ScreenScrollableProps;
19
+
20
+ /**
21
+ * The component's own type, restated so the generics survive `forwardRef`.
22
+ *
23
+ * Same reasoning as `ScreenFlatListComponent`: `forwardRef` erases type
24
+ * parameters, so `sections` and `renderItem` would check against `unknown`.
25
+ */
26
+ type ScreenSectionListComponent = (<ItemT, SectionT = DefaultSectionT>(
27
+ props: ScreenSectionListProps<ItemT, SectionT> & RefAttributes<SectionList<ItemT, SectionT>>
28
+ ) => ReactElement) & { displayName?: string };
29
+
30
+ /**
31
+ * A sectioned virtualised list that keeps its content clear of the screen's
32
+ * chrome. `Screen.FlatList`'s behaviour, with sticky section headers.
33
+ *
34
+ * The ref is the list itself, for `scrollToLocation` and the rest.
35
+ */
36
+ export const ScreenSectionList = forwardRef(function ScreenSectionListRender<ItemT, SectionT = DefaultSectionT>(
37
+ {
38
+ header,
39
+ contentContainerClassName,
40
+ ListHeaderComponent: ListHeaderComponentProp,
41
+ ListFooterComponent: ListFooterComponentProp,
42
+ ...props
43
+ }: ScreenSectionListProps<ItemT, SectionT>,
44
+ ref: ForwardedRef<SectionList<ItemT, SectionT>>
45
+ ): ReactElement {
46
+ const { scrollHandler, insetTopAnimatedStyle, insetBottomAnimatedStyle } = useScreenScrollInsets("standard");
47
+
48
+ const ListHeaderComponent = useMemo<ReactElement>(
49
+ () => (
50
+ <>
51
+ <Animated.View style={insetTopAnimatedStyle} />
52
+ {header}
53
+ {resolveListComponent(ListHeaderComponentProp)}
54
+ </>
55
+ ),
56
+ [header, ListHeaderComponentProp, insetTopAnimatedStyle]
57
+ );
58
+
59
+ const ListFooterComponent = useMemo<ReactElement>(
60
+ () => (
61
+ <View>
62
+ {resolveListComponent(ListFooterComponentProp)}
63
+ <Animated.View style={insetBottomAnimatedStyle} />
64
+ </View>
65
+ ),
66
+ [ListFooterComponentProp, insetBottomAnimatedStyle]
67
+ );
68
+
69
+ return (
70
+ <AnimatedSectionList
71
+ showsVerticalScrollIndicator={false}
72
+ {...props}
73
+ contentContainerClassName={cn(screenVariants().scrollContent(), contentContainerClassName)}
74
+ ListFooterComponent={ListFooterComponent}
75
+ ListHeaderComponent={ListHeaderComponent}
76
+ onScroll={scrollHandler}
77
+ ref={ref}
78
+ />
79
+ );
80
+ }) as unknown as ScreenSectionListComponent;
81
+ ScreenSectionList.displayName = "DelacourUI.Screen.SectionList";