@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,282 @@
1
+ import type { VariantProps } from "tailwind-variants";
2
+ import { cn } from "../../lib/cn";
3
+ import { tv } from "../../lib/tv";
4
+
5
+ /**
6
+ * The named text roles, block presets first and largest first.
7
+ *
8
+ * Order carries meaning for the block half — the tests read it to assert the
9
+ * type scale descends — so a new block role goes in at its step rather than on
10
+ * the end.
11
+ */
12
+ export const TEXT_VARIANTS = [
13
+ "display",
14
+ "title",
15
+ "header",
16
+ "subheader",
17
+ "paragraph",
18
+ "label",
19
+ "caption",
20
+ "overline",
21
+ "strong",
22
+ "emphasis",
23
+ "link",
24
+ "code",
25
+ ] as const;
26
+
27
+ /**
28
+ * Presets that emit only a delta — a weight, a slant, a decoration, a family —
29
+ * and inherit size, colour and weight from the `Text` they sit inside.
30
+ *
31
+ * This is what makes `<Text.Paragraph>Hi <Text.Strong>there</Text.Strong></Text.Paragraph>`
32
+ * come out at the paragraph's size and colour instead of snapping back to a
33
+ * base of its own.
34
+ */
35
+ export const TEXT_INLINE_VARIANTS = ["strong", "emphasis", "link", "code"] as const;
36
+
37
+ export const TEXT_SIZES = ["xs", "sm", "md", "lg", "xl", "2xl", "3xl"] as const;
38
+ export const TEXT_WEIGHTS = ["normal", "medium", "semibold", "bold"] as const;
39
+ export const TEXT_COLORS = ["default", "muted", "destructive", "success", "warning", "info"] as const;
40
+ export const TEXT_ALIGNS = ["left", "center", "right"] as const;
41
+ export const TEXT_TRANSFORMS = ["none", "uppercase", "lowercase", "capitalize"] as const;
42
+
43
+ export type TextVariant = (typeof TEXT_VARIANTS)[number];
44
+ export type TextInlineVariant = (typeof TEXT_INLINE_VARIANTS)[number];
45
+ export type TextSize = (typeof TEXT_SIZES)[number];
46
+ export type TextWeight = (typeof TEXT_WEIGHTS)[number];
47
+ export type TextColor = (typeof TEXT_COLORS)[number];
48
+ export type TextAlign = (typeof TEXT_ALIGNS)[number];
49
+ export type TextTransform = (typeof TEXT_TRANSFORMS)[number];
50
+
51
+ /**
52
+ * The treatment a `Text` falls back to on every axis nothing else names.
53
+ *
54
+ * First in {@link resolveTextClass}'s chain and therefore the weakest source, so
55
+ * it never fights an inherited class or a named axis. It is applied
56
+ * unconditionally — including inside a nested `Text` — and that is deliberate.
57
+ *
58
+ * For a nested `Text` it is a no-op: the parent's resolved class came out of
59
+ * this same function and already names all three axes, so tailwind-merge drops
60
+ * every class here. It only does work under a *partial* provider — a `Button`
61
+ * publishing nothing but `text-primary-foreground`, say. Guarding it behind "am
62
+ * I nested" would leave that `Text` with a colour and no size at all, silently
63
+ * collapsing to React Native's own 14pt default.
64
+ *
65
+ * It names the three axes React Native inherits natively and cannot default
66
+ * sensibly on its own — weight, size, colour — plus the family.
67
+ *
68
+ * `font-sans` is what makes the typeface themeable at all: it resolves
69
+ * `--font-sans`, which `theme.css` declares per platform and an app may retune
70
+ * at runtime. Without it a `Text` carries no `fontFamily` and the platform
71
+ * default stands however the variable is set. It is FIRST in the chain, so
72
+ * `Text.Code`'s `font-mono` and the headings' `font-heading` — emitted later by
73
+ * `textVariants`, in the same tailwind-merge group — both still win.
74
+ */
75
+ export const TEXT_BASE_CLASS = "font-sans font-normal text-base text-foreground";
76
+
77
+ /**
78
+ * Cap on OS font scaling.
79
+ *
80
+ * Accessibility text sizes are respected — `allowFontScaling` is left at React
81
+ * Native's default — but the library's fixed-height chrome cannot grow with
82
+ * them: `h-button-sm|md|lg` are hard 36/44/52pt and `h-navbar-row` is 56pt, so
83
+ * an uncapped multiplier clips a label rather than enlarging it.
84
+ */
85
+ export const TEXT_MAX_FONT_SIZE_MULTIPLIER = 1.4;
86
+
87
+ /**
88
+ * Styling for one piece of text, on six independent axes.
89
+ *
90
+ * ## Order is the design
91
+ *
92
+ * `tv` emits variants in the order their keys are declared here, then the
93
+ * compound variants, then the caller's `className`. `variant` is FIRST because
94
+ * a preset is the weakest named source: `<Text.Header size="sm">` has to come
95
+ * out at 14pt. The axes after it touch different tailwind-merge groups, so
96
+ * their order relative to each other means nothing.
97
+ *
98
+ * ## No `defaultVariants`, on purpose
99
+ *
100
+ * A `defaultVariants: { size: "md" }` would emit `text-base` from THIS call,
101
+ * after `variant` and therefore ahead of it in the merge — every preset's size
102
+ * would be overwritten by the default. More importantly an axis the caller did
103
+ * not name has to emit *nothing*, so it falls through to the class the
104
+ * enclosing `Text` published. That is what reproduces React Native's own
105
+ * cascade. `iconVariants` avoids the same trap for the same reason; the
106
+ * fallback lives in {@link TEXT_BASE_CLASS} instead.
107
+ *
108
+ * ## A variant names type, never a line height
109
+ *
110
+ * tailwind-merge lists `leading` among `font-size`'s conflicting groups, so a
111
+ * `text-lg` from the `size` axis silently strips a `leading-6` written beside a
112
+ * `text-base` here — no error, just a paragraph that loses its leading the
113
+ * moment someone resizes it. Tailwind v4's `--text-*--line-height` companions
114
+ * survive `tokens.css`'s size-only overrides, so every step already carries a
115
+ * paired leading. If prose ever needs its own, add a `--text-paragraph` /
116
+ * `--text-paragraph--line-height` *pair* in `tokens.css` and register the
117
+ * bespoke name in `tokens.ts` — one utility carrying both.
118
+ *
119
+ * Free of React Native imports so it stays unit-testable — `bun test` cannot
120
+ * parse React Native's Flow-typed source. See AGENTS.md.
121
+ */
122
+ export const textVariants = tv({
123
+ variants: {
124
+ variant: {
125
+ // The three block headings, and the only presets that read
126
+ // `--font-heading`. It defaults to the same family as `--font-sans`,
127
+ // so pairing a display face with body text is opt-in for an app
128
+ // rather than something this package decides.
129
+ display: "font-heading font-bold text-3xl text-foreground",
130
+ title: "font-heading font-bold text-2xl text-foreground",
131
+ header: "font-heading font-semibold text-xl text-foreground",
132
+ // A step down from the title it sits under, not a fourth heading
133
+ // level — which is why it is muted and carries no weight of its own.
134
+ subheader: "text-lg text-muted-foreground",
135
+ paragraph: "text-base text-foreground",
136
+ label: "font-medium text-sm text-foreground",
137
+ caption: "text-sm text-muted-foreground",
138
+ overline: "font-semibold text-muted-foreground text-xs uppercase tracking-wide",
139
+ // The four below emit a delta and nothing else. See TEXT_INLINE_VARIANTS.
140
+ strong: "font-semibold",
141
+ emphasis: "italic",
142
+ // `text-info`, never `text-primary`. `--primary` is the primary
143
+ // SURFACE — #262626 light, #f5f5f5 dark — so a link painted with it
144
+ // would be indistinguishable from the foreground it sits in.
145
+ link: "text-info underline",
146
+ code: "bg-muted font-mono",
147
+ },
148
+ // The empty branches are load-bearing typing, not placeholders: a map with
149
+ // only `true` types the prop as `true` rather than `boolean`. The compound
150
+ // below matches without them — `tv` compares against `defaultVariants` plus
151
+ // props and never reads this map. See the note in button.variants.ts.
152
+ isNested: { true: "", false: "" },
153
+ size: {
154
+ xs: "text-xs",
155
+ sm: "text-sm",
156
+ // `md` rather than Tailwind's own `base`, so one step name means "the
157
+ // default step" across Icon, Spinner, Button, ListGroup and Text. The
158
+ // token underneath is still `--text-base`.
159
+ md: "text-base",
160
+ lg: "text-lg",
161
+ xl: "text-xl",
162
+ "2xl": "text-2xl",
163
+ "3xl": "text-3xl",
164
+ },
165
+ weight: {
166
+ normal: "font-normal",
167
+ medium: "font-medium",
168
+ semibold: "font-semibold",
169
+ bold: "font-bold",
170
+ },
171
+ // Page-level text colours only. Text drawn ON a coloured surface is that
172
+ // surface component's job — BUTTON_FOREGROUND_TOKEN already maps a variant
173
+ // to its foreground, and a second copy of that map here could drift from
174
+ // it. A caller on a coloured surface writes the utility directly.
175
+ color: {
176
+ default: "text-foreground",
177
+ muted: "text-muted-foreground",
178
+ destructive: "text-destructive",
179
+ success: "text-success",
180
+ warning: "text-warning",
181
+ info: "text-info",
182
+ },
183
+ // `left`/`right`, never `start`/`end`: React Native's `textAlign` accepts
184
+ // auto | left | right | center | justify and nothing else, so Tailwind v4's
185
+ // logical-property utilities would resolve to a value RN rejects.
186
+ align: { left: "text-left", center: "text-center", right: "text-right" },
187
+ transform: {
188
+ // `normal-case` rather than an empty string, so `transform="none"` can
189
+ // actually clear an inherited or preset `uppercase`.
190
+ none: "normal-case",
191
+ uppercase: "uppercase",
192
+ lowercase: "lowercase",
193
+ capitalize: "capitalize",
194
+ },
195
+ },
196
+ compoundVariants: [
197
+ // A standalone `Text.Code` is an ordinary Yoga box and takes padding. A
198
+ // NESTED one is a run inside the platform text engine — iOS lays it out as
199
+ // an NSAttributedString, Android as a Span — and both ignore padding,
200
+ // margin and border radius on an inner `<Text>` outright. Only the
201
+ // background survives, drawn tight to the glyphs with square corners.
202
+ { variant: "code", isNested: false, class: "rounded-md px-1.5 py-0.5" },
203
+ ],
204
+ });
205
+
206
+ /** Whether a preset emits only a delta and inherits the rest from the `Text` above it. */
207
+ export function isInlineTextVariant(variant: TextVariant): boolean {
208
+ return (TEXT_INLINE_VARIANTS as readonly string[]).includes(variant);
209
+ }
210
+
211
+ /**
212
+ * Whether a `Text` with these children can hold a nested `Text` at all.
213
+ *
214
+ * A string or number child has no descendant component, so nothing below could
215
+ * read the context — publishing it would render a provider element per label
216
+ * for the most-instantiated component in the library. `false` here is provable
217
+ * rather than a guess, which is what makes skipping the provider safe.
218
+ *
219
+ * Deliberately typed on `unknown` rather than `ReactNode`: keeping this module
220
+ * free of even a type import is what lets `bun test` reach it.
221
+ */
222
+ export function canNestText(children: unknown): boolean {
223
+ return typeof children !== "string" && typeof children !== "number";
224
+ }
225
+
226
+ /**
227
+ * The class chain that styles one piece of text, weakest source first.
228
+ *
229
+ * | Source | How it wins |
230
+ * | --- | --- |
231
+ * | {@link TEXT_BASE_CLASS} | first in the chain |
232
+ * | the class inherited from the enclosing `Text` or `TextClassProvider` | second |
233
+ * | this `Text`'s own named axes | third |
234
+ * | the caller's `className` | last |
235
+ *
236
+ * This is what replaces React Native's native text inheritance. Natively a
237
+ * nested `<Text>` inherits the parent's *style object* and overrides only the
238
+ * keys it sets; here every `Text` compiles its own className, so the parent's
239
+ * resolved class has to be threaded through and beaten per-axis rather than
240
+ * wholesale. The chain does that: an axis the child names wins, an axis it
241
+ * leaves alone falls through.
242
+ *
243
+ * The property that makes nesting correct is a fixpoint — for every `x` this
244
+ * function can produce, `resolveTextClass({ inherited: x })` is exactly `x`, at
245
+ * any depth. The tests assert it across the matrix.
246
+ *
247
+ * Precedence is the ladder `Icon` and `Spinner` already follow, so a `Text`
248
+ * composed into a `Button` matches it without being told to. See
249
+ * `resolveIconSizeClass`.
250
+ *
251
+ * Pure, so the whole ladder is reachable from `bun test`. See AGENTS.md.
252
+ */
253
+ export function resolveTextClass({
254
+ align,
255
+ className,
256
+ color,
257
+ inherited,
258
+ isNested = false,
259
+ size,
260
+ transform,
261
+ variant,
262
+ weight,
263
+ }: {
264
+ align?: TextAlign;
265
+ className?: string;
266
+ color?: TextColor;
267
+ inherited?: string;
268
+ isNested?: boolean;
269
+ size?: TextSize;
270
+ transform?: TextTransform;
271
+ variant?: TextVariant;
272
+ weight?: TextWeight;
273
+ }): string {
274
+ return cn(
275
+ TEXT_BASE_CLASS,
276
+ inherited,
277
+ textVariants({ align, color, isNested, size, transform, variant, weight }),
278
+ className
279
+ );
280
+ }
281
+
282
+ export type TextVariantProps = VariantProps<typeof textVariants>;
@@ -0,0 +1,145 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { readdirSync, readFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+
5
+ const SRC = import.meta.dirname;
6
+
7
+ /**
8
+ * Every component in the package must carry a `DelacourUI.`-prefixed displayName.
9
+ *
10
+ * React DevTools reads `displayName || name`, so without one a tree row, an error
11
+ * stack and a profiler entry all read the private symbol — `ScreenNavbarTitle`
12
+ * rather than `DelacourUI.Screen.Navbar.Title`. `bun test` cannot render React
13
+ * Native, so the convention is checked against the source text instead: this file
14
+ * reads the `.tsx` tree off disk and imports nothing from it.
15
+ *
16
+ * The corpus spans every file at once rather than checking each in isolation,
17
+ * because `ScreenRoot` is declared in `screen-root.tsx` and named by the
18
+ * `Object.assign` in `screen.tsx` — see components/screen/AGENTS.md for why the
19
+ * two cannot share a file.
20
+ */
21
+
22
+ /** Every `.tsx` under `src/`, as a path → source map. */
23
+ function sources(): Map<string, string> {
24
+ const found = new Map<string, string>();
25
+ const walk = (dir: string): void => {
26
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
27
+ const path = join(dir, entry.name);
28
+ if (entry.isDirectory()) walk(path);
29
+ else if (entry.name.endsWith(".tsx")) found.set(path, readFileSync(path, "utf-8"));
30
+ }
31
+ };
32
+ walk(SRC);
33
+ return found;
34
+ }
35
+
36
+ const SOURCES = sources();
37
+
38
+ /**
39
+ * A PascalCase `function` declaration, or a `memo(…)` / `forwardRef(…)` const.
40
+ *
41
+ * Those are the only three shapes a component takes here: the package has no
42
+ * arrow-function components, and every PascalCase function declaration in it
43
+ * returns `ReactElement`, `ReactElement | null` or `null`. A PascalCase helper
44
+ * would be a false positive — write helpers in camelCase, as the package already
45
+ * does (`wrapTextChildren`, `withDividers`, `resolveListComponent`).
46
+ *
47
+ * A `forwardRef` component's own render function is a function EXPRESSION on the
48
+ * same line as the call, so it never starts a line and is not counted twice —
49
+ * which is why the screen scrollables name themselves once, on the const.
50
+ */
51
+ const DECLARATIONS = [
52
+ /^(?:export )?function ([A-Z][A-Za-z0-9]*)\s*[<(]/gm,
53
+ /^(?:export )?const ([A-Z][A-Za-z0-9]*) = memo\(/gm,
54
+ /^(?:export )?const ([A-Z][A-Za-z0-9]*) = forwardRef[<(]/gm,
55
+ ];
56
+
57
+ /** The trailing form on a part, and the `Object.assign` form on a root. */
58
+ const BINDINGS = [
59
+ /^([A-Z][A-Za-z0-9]*)\.displayName = "([^"]*)";/gm,
60
+ /Object\.assign\(\s*([A-Z][A-Za-z0-9]*)\s*,\s*\{[\s\S]*?displayName: "([^"]*)"/gm,
61
+ ];
62
+
63
+ /** Component name → the file it is declared in. */
64
+ function declared(): Map<string, string> {
65
+ const found = new Map<string, string>();
66
+ for (const [path, source] of SOURCES) {
67
+ for (const pattern of DECLARATIONS) {
68
+ for (const [, name] of source.matchAll(pattern)) found.set(name, path);
69
+ }
70
+ }
71
+ return found;
72
+ }
73
+
74
+ /** Component name → every displayName bound to it, across the whole tree. */
75
+ function bound(): Map<string, string[]> {
76
+ const found = new Map<string, string[]>();
77
+ for (const source of SOURCES.values()) {
78
+ for (const pattern of BINDINGS) {
79
+ for (const [, name, value] of source.matchAll(pattern)) {
80
+ found.set(name, [...(found.get(name) ?? []), value]);
81
+ }
82
+ }
83
+ }
84
+ return found;
85
+ }
86
+
87
+ const DECLARED = declared();
88
+ const BOUND = bound();
89
+
90
+ /** `DelacourUI` plus one segment per step down the public API. */
91
+ const SHAPE = /^DelacourUI(\.[A-Z][A-Za-z0-9]*)+$/;
92
+
93
+ describe("displayName", () => {
94
+ // A walker that matched nothing would let every assertion below pass on an
95
+ // empty set, so the suite would stay green while the convention rotted.
96
+ test("finds the component tree", () => {
97
+ expect(SOURCES.size).toBeGreaterThan(50);
98
+ expect(DECLARED.size).toBeGreaterThan(80);
99
+ });
100
+
101
+ test("every component has one", () => {
102
+ const missing = [...DECLARED]
103
+ .filter(([name]) => !BOUND.has(name))
104
+ .map(([name, path]) => `${name} (${path.slice(SRC.length + 1)})`);
105
+ expect(missing).toEqual([]);
106
+ });
107
+
108
+ // Two bindings on one component is a rename that only landed in one place.
109
+ test("no component has two", () => {
110
+ const doubled = [...BOUND].filter(([, values]) => values.length > 1).map(([name]) => name);
111
+ expect(doubled).toEqual([]);
112
+ });
113
+
114
+ test("every name is a DelacourUI path", () => {
115
+ const wrong = [...BOUND]
116
+ .filter(([, values]) => values.some((value) => !SHAPE.test(value)))
117
+ .map(([name, values]) => `${name} = ${values[0]}`);
118
+ expect(wrong).toEqual([]);
119
+ });
120
+
121
+ // Two components answering to one name is a copy-pasted part file.
122
+ test("no two components share a name", () => {
123
+ const seen = new Map<string, string>();
124
+ const clashes: string[] = [];
125
+ for (const [name, values] of BOUND) {
126
+ for (const value of values) {
127
+ const owner = seen.get(value);
128
+ if (owner) clashes.push(`${value}: ${owner} and ${name}`);
129
+ else seen.set(value, name);
130
+ }
131
+ }
132
+ expect(clashes).toEqual([]);
133
+ });
134
+
135
+ // A part's name is its path in the public API, so its root's name is a prefix
136
+ // of it. This is what keeps `Badge.Label` from drifting to `Badge.Text`.
137
+ test("every name descends from a component that exists", () => {
138
+ const names = new Set([...BOUND.values()].flat());
139
+ const orphans = [...names].filter((name) => {
140
+ const parent = name.slice(0, name.lastIndexOf("."));
141
+ return parent !== "DelacourUI" && !names.has(parent);
142
+ });
143
+ expect(orphans).toEqual([]);
144
+ });
145
+ });
@@ -0,0 +1,98 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+
5
+ const SRC = import.meta.dirname;
6
+ const COMPONENTS = join(SRC, "components");
7
+ const PACKAGE_DOC = join(SRC, "..", "AGENTS.md");
8
+ const PACKAGE_JSON = join(SRC, "..", "package.json");
9
+
10
+ /**
11
+ * Every component folder must carry an `AGENTS.md`, and the package doc must
12
+ * index it.
13
+ *
14
+ * `Radio` shipped exported, tested and rendered in the playground, and stayed
15
+ * undocumented for fifteen commits — while nine bullets in other sections
16
+ * already pointed at rules nobody had written down. Nothing caught it, because
17
+ * nothing was looking. This looks.
18
+ *
19
+ * The index table is the second assertion for a reason: a folder can hold an
20
+ * `AGENTS.md` nobody can find. A component absent from the table is a component
21
+ * an agent reaches only by already knowing it exists, which is the same failure
22
+ * one step later.
23
+ *
24
+ * Like `display-name.test.ts` and `styles/tokens.test.ts`, this reads the tree as
25
+ * **text** and imports nothing from it. That is the move available whenever a
26
+ * convention is real but no renderer can check it — and `bun test` cannot render
27
+ * React Native at all.
28
+ */
29
+
30
+ /** Every directory under `src/components/`, by folder name. */
31
+ function componentFolders(): string[] {
32
+ return readdirSync(COMPONENTS, { withFileTypes: true })
33
+ .filter((entry) => entry.isDirectory())
34
+ .map((entry) => entry.name)
35
+ .sort();
36
+ }
37
+
38
+ /**
39
+ * The folder names the package doc's index table lists.
40
+ *
41
+ * Matched on the export subpath rather than the heading, because the two are not
42
+ * the same word: `provider` is `DelacourProvider` and `list-group` is
43
+ * `ListGroup`. The subpath is the unambiguous key, and it is the string a
44
+ * consumer actually types.
45
+ *
46
+ * The package name is read from `package.json` rather than written out here, so
47
+ * a rename cannot leave this matching a name nobody publishes any more. It did
48
+ * once: the literal was a regex, escaped slashes and all, so the rename that
49
+ * swept every other mention of the old name walked straight past it and every
50
+ * component read as undocumented.
51
+ */
52
+ function indexedFolders(): string[] {
53
+ const doc = readFileSync(PACKAGE_DOC, "utf-8");
54
+ const { name } = JSON.parse(readFileSync(PACKAGE_JSON, "utf-8")) as { name: string };
55
+ const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
56
+ const rows = doc.matchAll(new RegExp(`\\[\`?${escaped}/([a-z-]+)\`?\\]|\`${escaped}/([a-z-]+)\``, "g"));
57
+ return [...new Set([...rows].map(([, a, b]) => a ?? b))].sort();
58
+ }
59
+
60
+ const FOLDERS = componentFolders();
61
+ const INDEXED = indexedFolders();
62
+
63
+ describe("component docs", () => {
64
+ // A walker that found nothing would let every assertion below pass on an
65
+ // empty set, so the suite would stay green while the convention rotted.
66
+ test("finds the component tree", () => {
67
+ expect(FOLDERS.length).toBeGreaterThan(15);
68
+ });
69
+
70
+ test("every component folder has an AGENTS.md", () => {
71
+ const missing = FOLDERS.filter((name) => !existsSync(join(COMPONENTS, name, "AGENTS.md")));
72
+ expect(missing).toEqual([]);
73
+ });
74
+
75
+ // An empty file passes the check above and helps nobody.
76
+ test("no component doc is a stub", () => {
77
+ const stubs = FOLDERS.filter((name) => {
78
+ const path = join(COMPONENTS, name, "AGENTS.md");
79
+ if (!existsSync(path)) return false;
80
+ const doc = readFileSync(path, "utf-8");
81
+ return !doc.startsWith("# ") || doc.split("\n").length < 10;
82
+ });
83
+ expect(stubs).toEqual([]);
84
+ });
85
+
86
+ // This is the assertion that would have caught Radio.
87
+ test("every component is named in the package doc", () => {
88
+ const unlisted = FOLDERS.filter((name) => !INDEXED.includes(name));
89
+ expect(unlisted).toEqual([]);
90
+ });
91
+
92
+ // A row naming a folder that no longer exists is a rename that landed in one
93
+ // place, and it sends a reader to a path that will not resolve.
94
+ test("the package doc names no component that does not exist", () => {
95
+ const orphans = INDEXED.filter((name) => !FOLDERS.includes(name));
96
+ expect(orphans).toEqual([]);
97
+ });
98
+ });
@@ -0,0 +1,58 @@
1
+ import { DarkTheme, DefaultTheme, ThemeProvider } from "expo-router";
2
+ import { type ReactElement, type ReactNode, useMemo } from "react";
3
+ import { useNavigationTheme } from "../hooks/use-navigation-theme";
4
+
5
+ export type NavigationThemeProps = {
6
+ children: ReactNode;
7
+ };
8
+
9
+ /**
10
+ * Hands the navigator this library's theme colours.
11
+ *
12
+ * Mount it around a `Stack`, `Tabs` or `Drawer`, inside `DelacourProvider`.
13
+ *
14
+ * Not decoration. `expo-router` mounts its `NavigationContainer` with no
15
+ * `theme`, so React Navigation's light default stands however dark the app is —
16
+ * and on iOS the native stack hands `colors.background` to the
17
+ * `UINavigationController`'s view, which is the slab visible between the cards
18
+ * during a push and at their rounded corners. That layer is set from the theme
19
+ * with no escape hatch, so a `contentStyle` in `screenOptions` cannot reach it.
20
+ *
21
+ * `ThemeProvider` comes from `expo-router`, which vendors React Navigation:
22
+ * `@react-navigation/native` is not a package this monorepo can resolve.
23
+ *
24
+ * The framework's own base theme is spread underneath rather than replaced,
25
+ * because `ReactNavigation.Theme` also carries `fonts`, whose shape is
26
+ * platform-specific — restating it here would be a second definition able to
27
+ * drift from the one React Navigation actually ships. Only the colours this
28
+ * library owns are overridden, and `DarkTheme`'s `rgb(1, 1, 1)` background is
29
+ * exactly the kind of near-miss that makes overriding worth doing.
30
+ *
31
+ * Re-renders on a theme change and nothing else, so keep it below the
32
+ * providers — they have no reason to remount when the palette swaps.
33
+ *
34
+ * @example
35
+ * // app/_layout.tsx
36
+ * import { DelacourProvider } from "@delacour/react-native-ui/provider";
37
+ * import { NavigationTheme } from "@delacour/react-native-ui/expo/navigation-theme";
38
+ * import { Stack } from "expo-router";
39
+ *
40
+ * export default function RootLayout() {
41
+ * return (
42
+ * <DelacourProvider>
43
+ * <NavigationTheme>
44
+ * <Stack screenOptions={{ headerShown: false }} />
45
+ * </NavigationTheme>
46
+ * </DelacourProvider>
47
+ * );
48
+ * }
49
+ */
50
+ export function NavigationTheme({ children }: NavigationThemeProps): ReactElement {
51
+ const { dark, colors } = useNavigationTheme();
52
+ const base = dark ? DarkTheme : DefaultTheme;
53
+
54
+ const value = useMemo(() => ({ ...base, colors: { ...base.colors, ...colors }, dark }), [base, colors, dark]);
55
+
56
+ return <ThemeProvider value={value}>{children}</ThemeProvider>;
57
+ }
58
+ NavigationTheme.displayName = "DelacourUI.NavigationTheme";
@@ -0,0 +1,45 @@
1
+ import { useCallback, useRef, useState } from "react";
2
+
3
+ type UseControllableStateOptions<T> = {
4
+ /** Controlled value. When not `undefined` the hook defers to the caller. */
5
+ value?: T;
6
+ /** Initial value used while uncontrolled. */
7
+ defaultValue: T;
8
+ onChange?: (value: T) => void;
9
+ };
10
+
11
+ /**
12
+ * Supports both controlled and uncontrolled usage from one hook.
13
+ *
14
+ * While `value` is undefined the state is held internally; once a value is
15
+ * passed the caller owns it and the setter only reports upward. Which mode is
16
+ * in play is locked in on first render — switching later is a caller bug and
17
+ * warns in development rather than silently changing behaviour.
18
+ */
19
+ export function useControllableState<T>({
20
+ value,
21
+ defaultValue,
22
+ onChange,
23
+ }: UseControllableStateOptions<T>): [T, (next: T) => void] {
24
+ const isControlled = value !== undefined;
25
+ const wasControlled = useRef(isControlled);
26
+ const [uncontrolled, setUncontrolled] = useState<T>(defaultValue);
27
+
28
+ if (process.env.NODE_ENV !== "production" && wasControlled.current !== isControlled) {
29
+ console.warn(
30
+ `useControllableState: switched from ${wasControlled.current ? "controlled" : "uncontrolled"} to ` +
31
+ `${isControlled ? "controlled" : "uncontrolled"}. Pick one for the lifetime of the component.`
32
+ );
33
+ wasControlled.current = isControlled;
34
+ }
35
+
36
+ const setValue = useCallback(
37
+ (next: T) => {
38
+ if (!isControlled) setUncontrolled(next);
39
+ onChange?.(next);
40
+ },
41
+ [isControlled, onChange]
42
+ );
43
+
44
+ return [isControlled ? value : uncontrolled, setValue];
45
+ }