@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,111 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { ICON_SIZE_TOKENS } from "../../styles/tokens";
3
+ import {
4
+ ICON_FALLBACK_COLOR,
5
+ ICON_FALLBACK_SIZE_CLASS,
6
+ ICON_SIZES,
7
+ iconVariants,
8
+ isIconSize,
9
+ resolveIconSizeClass,
10
+ } from "./icon.variants";
11
+
12
+ /**
13
+ * Position of a class string's `size-icon-*` token on the shared icon scale.
14
+ *
15
+ * Compares by step rather than by points so a test says what it means — that
16
+ * one icon is a step larger than another — and does not have to be edited when
17
+ * a token's value is retuned in `tokens.css`. `tokens.test.ts` is what keeps
18
+ * this array ordered.
19
+ */
20
+ function iconStep(cls: string): number {
21
+ const token = cls.match(/\bsize-(icon-[\w-]+)\b/)?.[1];
22
+ return ICON_SIZE_TOKENS.indexOf(token as (typeof ICON_SIZE_TOKENS)[number]);
23
+ }
24
+
25
+ describe("iconVariants", () => {
26
+ test("gives every named size a distinct token, increasing with it", () => {
27
+ const steps = ICON_SIZES.map((size) => iconStep(iconVariants({ size })));
28
+ expect(steps).not.toContain(-1);
29
+ expect(new Set(steps).size).toBe(ICON_SIZES.length);
30
+ expect([...steps]).toEqual([...steps].sort((a, b) => a - b));
31
+ });
32
+
33
+ // The whole point of the shared scale: a named size has to name a token, not
34
+ // a raw utility, or Spinner cannot line up with it.
35
+ test("names a token from the shared icon scale at every size", () => {
36
+ for (const size of ICON_SIZES) {
37
+ expect(iconVariants({ size })).toBe(`size-icon-${size}`);
38
+ }
39
+ });
40
+
41
+ // An icon takes a colour value rather than a class, so the size axis must not
42
+ // smuggle one in. See ICON_FALLBACK_COLOR and useThemeColor.
43
+ test("carries size only, never colour", () => {
44
+ for (const size of ICON_SIZES) {
45
+ expect(iconVariants({ size })).toMatch(/\bsize-/);
46
+ expect(iconVariants({ size })).not.toMatch(/\b(text|bg|border)-/);
47
+ }
48
+ });
49
+
50
+ // A default would emit from inside this call, ahead of an inherited class in
51
+ // the merge, and the fallback would then beat the enclosing component.
52
+ test("emits nothing without a size, so the fallback can lose to context", () => {
53
+ expect(iconVariants() ?? "").not.toMatch(/\bsize-/);
54
+ });
55
+ });
56
+
57
+ describe("isIconSize", () => {
58
+ test("separates a named size from an edge length", () => {
59
+ for (const size of ICON_SIZES) {
60
+ expect(isIconSize(size)).toBe(true);
61
+ }
62
+ expect(isIconSize(18)).toBe(false);
63
+ expect(isIconSize(undefined)).toBe(false);
64
+ });
65
+ });
66
+
67
+ describe("resolveIconSizeClass", () => {
68
+ test("falls back when there is nothing to go on", () => {
69
+ expect(resolveIconSizeClass({})).toBe(ICON_FALLBACK_SIZE_CLASS);
70
+ });
71
+
72
+ test("an inherited class beats the fallback", () => {
73
+ expect(resolveIconSizeClass({ inherited: "size-icon-xs" })).toBe("size-icon-xs");
74
+ });
75
+
76
+ test("a named size beats an inherited class", () => {
77
+ expect(resolveIconSizeClass({ inherited: "size-icon-xs", size: "xl" })).toBe("size-icon-xl");
78
+ });
79
+
80
+ test("a className beats a named size", () => {
81
+ expect(resolveIconSizeClass({ className: "size-7", size: "xs" })).toBe("size-7");
82
+ });
83
+
84
+ test("a className beats an inherited class", () => {
85
+ expect(resolveIconSizeClass({ className: "size-7", inherited: "size-icon-sm" })).toBe("size-7");
86
+ });
87
+
88
+ test("resolves to exactly one size utility, whatever it was given", () => {
89
+ const cls = resolveIconSizeClass({ className: "size-7", inherited: "size-icon-xl", size: "xs" });
90
+ expect(cls.split(" ").filter((name) => name.startsWith("size-"))).toHaveLength(1);
91
+ });
92
+
93
+ // A numeric size is not a class — it goes straight to the glyph's prop, and
94
+ // uniwind then skips its mapping because that prop is already defined. The
95
+ // chain is still built and still passed; nothing reads it.
96
+ test("a numeric size leaves the chain untouched", () => {
97
+ expect(resolveIconSizeClass({ size: 18 })).toBe(ICON_FALLBACK_SIZE_CLASS);
98
+ expect(resolveIconSizeClass({ inherited: "size-icon-sm", size: 18 })).toBe("size-icon-sm");
99
+ });
100
+
101
+ test("carries through a className that is not a size", () => {
102
+ expect(resolveIconSizeClass({ className: "opacity-50" })).toContain("opacity-50");
103
+ expect(resolveIconSizeClass({ className: "opacity-50" })).toContain(ICON_FALLBACK_SIZE_CLASS);
104
+ });
105
+ });
106
+
107
+ describe("ICON_FALLBACK_COLOR", () => {
108
+ test("names a token the theme always defines", () => {
109
+ expect(ICON_FALLBACK_COLOR).toBe("foreground");
110
+ });
111
+ });
@@ -0,0 +1,79 @@
1
+ import { cn } from "../../lib/cn";
2
+ import { tv } from "../../lib/tv";
3
+
4
+ export const ICON_SIZES = ["xs", "sm", "md", "lg", "xl", "2xl"] as const;
5
+
6
+ export type IconSize = (typeof ICON_SIZES)[number];
7
+
8
+ /**
9
+ * Edge length for each named icon size, as a token class rather than a number.
10
+ *
11
+ * The steps come from the `--spacing-icon-*` scale in `tokens.css`, which
12
+ * `Spinner` names identically — so `size="md"` is the same edge length in both
13
+ * and one can replace the other without the layout moving. A control's own size
14
+ * axis indexes into the same scale rather than restating a number.
15
+ *
16
+ * A className can never size a Central Icon through a style. `CentralIconBase`
17
+ * spreads its props onto `<Svg>` *before* its own `width`/`height`, and
18
+ * `Svg.render` then pushes the width/height-derived styles onto the root last,
19
+ * where they beat anything a className contributed. So the size has to arrive as
20
+ * the `size` **prop** — `Icon` runs this class through `withUniwind` to recover
21
+ * the width and hands that to the glyph. See `icon.tsx`.
22
+ *
23
+ * No `defaultVariants` on purpose. The fallback has to *lose* to an inherited
24
+ * class, and a default here would emit from inside this same call, ahead of it
25
+ * in the merge. {@link resolveIconSizeClass} orders the sources instead.
26
+ *
27
+ * Free of React Native imports so it stays unit-testable — `bun test` cannot
28
+ * parse React Native's Flow-typed source. See AGENTS.md.
29
+ */
30
+ export const iconVariants = tv({
31
+ variants: {
32
+ size: {
33
+ xs: "size-icon-xs",
34
+ sm: "size-icon-sm",
35
+ md: "size-icon-md",
36
+ lg: "size-icon-lg",
37
+ xl: "size-icon-xl",
38
+ "2xl": "size-icon-2xl",
39
+ },
40
+ },
41
+ });
42
+
43
+ /** Edge length used with no `size`, no `className` and nothing to inherit — 20pt. */
44
+ export const ICON_FALLBACK_SIZE_CLASS = "size-icon-lg";
45
+
46
+ /** Theme token used with no `color` prop and nothing to inherit from. */
47
+ export const ICON_FALLBACK_COLOR = "foreground";
48
+
49
+ /** Whether a size is one of the named steps rather than an edge length in points. */
50
+ export function isIconSize(size: IconSize | number | undefined): size is IconSize {
51
+ return typeof size === "string";
52
+ }
53
+
54
+ /**
55
+ * The class chain that sizes an icon, weakest source first: the fallback, the
56
+ * enclosing component's class, this icon's named size, then the caller's own
57
+ * className. `cn` resolves the conflicts, so the last `size-*` wins.
58
+ *
59
+ * A numeric `size` is deliberately absent. It is not a class — it goes straight
60
+ * to the glyph's prop, and uniwind then skips its mapping because that prop is
61
+ * already defined. That skip *is* the precedence rule.
62
+ *
63
+ * Note for callers overriding through `className`: use `size-*`, not `w-*` with
64
+ * `h-*`. tailwind-merge conflicts `size` into `w`/`h` but not the reverse, so a
65
+ * trailing `w-6` will not clear a leading `size-5`.
66
+ *
67
+ * Pure, so the whole ladder is reachable from `bun test`. See AGENTS.md.
68
+ */
69
+ export function resolveIconSizeClass({
70
+ size,
71
+ inherited,
72
+ className,
73
+ }: {
74
+ size?: IconSize | number;
75
+ inherited?: string;
76
+ className?: string;
77
+ }): string {
78
+ return cn(ICON_FALLBACK_SIZE_CLASS, inherited, isIconSize(size) ? iconVariants({ size }) : undefined, className);
79
+ }
@@ -0,0 +1,11 @@
1
+ export { Icon, type IconComponent, type IconProps } from "./icon";
2
+ export { type IconDefaults, IconDefaultsProvider, useIconDefaults } from "./icon.context";
3
+ export {
4
+ ICON_FALLBACK_COLOR,
5
+ ICON_FALLBACK_SIZE_CLASS,
6
+ ICON_SIZES,
7
+ type IconSize,
8
+ iconVariants,
9
+ isIconSize,
10
+ resolveIconSizeClass,
11
+ } from "./icon.variants";
@@ -0,0 +1,126 @@
1
+ # Input
2
+
3
+ A text field, and the box that can hold content beside it. Root plus
4
+ `Input.Group` and its two decorators, `Prefix` and `Suffix`.
5
+
6
+ `import { Input } from "@delacour/react-native-ui/input";`
7
+
8
+ ## Files
9
+
10
+ | File | What it holds |
11
+ | --- | --- |
12
+ | `index.ts` | → `@delacour/react-native-ui/input` |
13
+ | `input.tsx` | Root + the `Object.assign` compound surface |
14
+ | `input-group.tsx` | `Input.Group`, plus its own nested surface |
15
+ | `input-group-decorator.tsx` | The shared body behind both decorators |
16
+ | `input-group-prefix.tsx` | `Input.Group.Prefix` |
17
+ | `input-group-suffix.tsx` | `Input.Group.Suffix` |
18
+ | `input.context.tsx` | `InputGroupProvider`, `useInputGroup()`, `useInputGroupContext()`, `useInputGroupPart()` |
19
+ | `input.types.ts` | Prop types shared by two or more parts |
20
+ | `input.variants.ts` | Pure `tv()` slots + resolvers, no RN imports |
21
+ | `input.variants.test.ts` | |
22
+
23
+ ## Design
24
+
25
+ - **Variants**: `primary`, `secondary`. **Sizes**: `sm`, `md`, `lg` — the box
26
+ height, the value's type scale and a decorator's icon step, on one axis.
27
+ - **The box is one slot with two homes, and that is the whole design.** The
28
+ `root` slot of `inputVariants` lands on the `TextInput` when a field stands
29
+ alone and on `Input.Group`'s row when it does not. `resolveInputFieldClass` is
30
+ the decision, and it is pure, so `input.variants.test.ts` sweeps the entire
31
+ matrix and asserts that every chrome utility a lone field wears is present on
32
+ the group's row. A grouped field is therefore the *same* box rather than a
33
+ similar one. **Do not give `Input.Group` a border, background or height of its
34
+ own** — a second class string is a second thing that can drift, and the drift
35
+ would be a one-pixel difference nobody notices until it is shipped.
36
+ - **The group owns the axes, because it owns the box.** `variant`, `size`,
37
+ `isInvalid` and `isDisabled` live on `Input.Group`, and an `Input` inside one
38
+ reads them from context — the same way a [`ListGroup.Item`](../list-group/AGENTS.md) takes no `variant`.
39
+ The field's own copies of those props are ignored while it is grouped. One box,
40
+ one set of axes; two would be two answers to the same question.
41
+ - **The two state axes have three sources, and the nearest wins:**
42
+ `Input.Group` → the `Input`'s own prop → the enclosing [`Field`](../field/AGENTS.md). An `Input`
43
+ inside `<Field isInvalid>` goes `destructive` with nothing said at the call site, and
44
+ `<Input isInvalid={false} />` opts that one control out. `Input.Group` reads
45
+ the `Field` too, or a decorated field inside an invalid one would stay calm
46
+ while its label went red. Both are `??` chains, so an explicit `false` is a
47
+ value rather than an absence — the rule `pressedScale` already follows.
48
+ - **Uniwind bridges the three colour *props*, so rule 7 is untouched.** A
49
+ `TextInput`'s placeholder, caret and selection take a colour value, not a
50
+ style, and uniwind's own `TextInput` — which is what a plain
51
+ `import { TextInput } from "react-native"` resolves to, via its Metro
52
+ resolver — accepts a className for each and compiles it to `styles.accentColor`.
53
+ So this component wraps nothing in `withUniwind`, and the `Icon` carve-out
54
+ stays spent exactly once.
55
+ - **Those classNames must be `accent-*` utilities.** `accent-muted-foreground`,
56
+ never `text-muted-foreground`. Uniwind reads only `accentColor` off the
57
+ compiled class, so anything else resolves to nothing: it warns once in
58
+ development and leaves the prop undefined, which renders as the platform
59
+ default rather than as an error. The defaults live in `input.variants.ts`.
60
+ - **`placeholderTextColorClassName` is `Omit`ed from `InputProps`.** Uniwind's
61
+ name and ours would otherwise both reach the same colour, and a caller setting
62
+ one while the component set the other is a bug with no error attached.
63
+ `selectionColorClassName` keeps uniwind's name because it already is the name
64
+ this package would have chosen; only its default is supplied here.
65
+ - **Focus is React state, not a `focus:` class.** Uniwind's `TextInput` does
66
+ track its own focus, and `focus:border-ring` would work on a lone field — and
67
+ do nothing at all for the box `Input.Group` draws around a grouped one, since a
68
+ `View` cannot see a sibling's focus. One state, read by an `isFocused` variant,
69
+ keeps the two identical and puts the decision somewhere `bun test` can reach.
70
+ - **Invalid outranks focus.** A field that went grey the moment it was tapped
71
+ would drop the only signal it has that its value is wrong, exactly while the
72
+ value is being corrected. The border, the caret and the decorators all stay
73
+ `destructive`.
74
+ - **A multiline field turns its height into a floor**, and the row aligns to the
75
+ top with it — centred decorators would drift down the side of a paragraph
76
+ instead of sitting on its first line. `py-0` on the single-line branch is
77
+ load-bearing on Android, where the platform's own vertical padding would
78
+ otherwise push the value off centre inside a fixed height.
79
+ - **Prefix and suffix share one `decorator` slot and one implementation.** They
80
+ are the same box in different places — the row's `gap` is what separates
81
+ them — so a second identical slot would only be a second thing to keep in step.
82
+ `input-group-decorator.tsx` is the shared leaf; the two part files name it.
83
+ - **A decorator wraps bare text in a `Text`.** `<Input.Group.Prefix>$</...>` is
84
+ the shortest thing anyone will write and React Native cannot render a string
85
+ outside a `<Text>`, so it would crash. Consecutive strings collapse into one —
86
+ the same rule, and the same reason, as [`Button`](../button/AGENTS.md).
87
+ - **Pressing the group focuses the field.** A lone field is its own tap target
88
+ edge to edge; a grouped one only covers the middle of the box, so the group is
89
+ a `Pressable` with `feedback="none"` whose press focuses the field through the
90
+ ref it shares on context. A `Button` inside a decorator still receives its own
91
+ press.
92
+ - **There is no `Input.Label`, `Input.Description` or `Input.ErrorMessage`.**
93
+ [`Text.Label`](../text/AGENTS.md) and `Text.Caption` already are those, and a label defined twice is
94
+ a type scale that can drift. `apps/playground`'s `/input/form` is what the
95
+ trade looks like at a call site.
96
+ - **`--spacing-input-*` is its own scale**, matching `--spacing-button-*` in
97
+ value and not in name — this is the case [Sizing](../../../AGENTS.md#sizing)
98
+ anticipated. A token test asserts the two stay level, so either can be retuned
99
+ without silently dragging the other along.
100
+
101
+ ## Joining a Button.Group
102
+
103
+ A field can sit inside a [`Button.Group`](../button/AGENTS.md#buttongroup) and
104
+ join the run, so `<Button.Group><Input /><Button>Go</Button></Button.Group>`
105
+ reads as one control rather than two boxes touching.
106
+
107
+ - **A joined field draws the *group's* corner, not its own.** Alone it takes the
108
+ generic ramp — `rounded-lg`, stepping down to `rounded-md` at `sm` — because a
109
+ field and the button beside it are meant to be retunable apart, which is the
110
+ same reason `--spacing-input-*` exists rather than the field reading
111
+ `h-button-md`. In a group that independence is exactly wrong: a run capped by
112
+ a field at one end and a button at the other would draw two different arcs.
113
+ The group owns the shape of its run, so a joined field switches to
114
+ `--radius-button-*`. `input.variants.test.ts` asserts the two emit identical
115
+ corner classes cell for cell, so the copies cannot drift.
116
+ - **A field nested in an `Input.Group` never consults the member axes.** That
117
+ row owns the box, and `resolveInputFieldClass` already drops the `root` slot
118
+ entirely when the field is grouped — so the corner and the seam land on the
119
+ row, once, exactly as they land on a lone field. The two are threaded through
120
+ the same `InputBoxState` for that reason: one struct, one box, wherever it
121
+ ends up.
122
+ - **`size` comes from the enclosing group outright; `isDisabled` stays a
123
+ fallback.** Controls of different heights do not join, so the group's size
124
+ wins. Disabled is the outermost rung of an already-three-deep ladder
125
+ (`Input.Group` → own prop → `Field` → `Button.Group`), so a single field in a
126
+ run can still be the only one disabled.
@@ -0,0 +1,27 @@
1
+ export { Input, type InputProps } from "./input";
2
+ export {
3
+ type InputGroupContextValue,
4
+ InputGroupProvider,
5
+ useInputGroup,
6
+ useInputGroupContext,
7
+ } from "./input.context";
8
+ export type { InputSlotProps } from "./input.types";
9
+ export {
10
+ INPUT_DECORATOR_ICON_TOKEN,
11
+ INPUT_INVALID_DECORATOR_ICON_TOKEN,
12
+ INPUT_INVALID_SELECTION_ACCENT_CLASS,
13
+ INPUT_PLACEHOLDER_ACCENT_CLASS,
14
+ INPUT_SELECTION_ACCENT_CLASS,
15
+ INPUT_SIZES,
16
+ INPUT_VARIANTS,
17
+ type InputBoxState,
18
+ type InputSize,
19
+ type InputVariant,
20
+ type InputVariantProps,
21
+ inputVariants,
22
+ resolveInputFieldClass,
23
+ resolveInputGroupClass,
24
+ resolvePlaceholderAccentClass,
25
+ resolveSelectionAccentClass,
26
+ } from "./input.variants";
27
+ export type { InputGroupProps } from "./input-group";
@@ -0,0 +1,84 @@
1
+ import { Children, type ReactElement, type ReactNode, useMemo } from "react";
2
+ import { View } from "react-native";
3
+ import { IconDefaultsProvider } from "../icon";
4
+ import { Text } from "../text";
5
+ import { TextClassProvider } from "../text/text.context";
6
+ import { useInputGroupPart } from "./input.context";
7
+ import type { InputSlotProps } from "./input.types";
8
+ import { INPUT_DECORATOR_ICON_TOKEN, INPUT_INVALID_DECORATOR_ICON_TOKEN, inputVariants } from "./input.variants";
9
+
10
+ /**
11
+ * The shared implementation behind `Input.Group.Prefix` and `.Suffix`.
12
+ *
13
+ * The two are the same box in different places — the row's `gap` is what puts
14
+ * them either side of the field, not a class of their own — so they read one
15
+ * `decorator` slot and share this file. Duplicating {@link wrapTextChildren}
16
+ * into two part files to keep them apart is exactly what this leaf avoids.
17
+ *
18
+ * Not a part itself: it takes the caller-facing name so the error thrown
19
+ * outside a group names `Input.Group.Prefix` rather than something private.
20
+ */
21
+ export function InputGroupDecorator({
22
+ part,
23
+ className,
24
+ children,
25
+ ...props
26
+ }: InputSlotProps & { part: string }): ReactElement {
27
+ const { isInvalid, size } = useInputGroupPart(part);
28
+ const slots = inputVariants({ isInvalid, size });
29
+
30
+ const iconClassName = slots.decoratorIcon();
31
+ const iconDefaults = useMemo(
32
+ () => ({
33
+ className: iconClassName,
34
+ color: isInvalid ? INPUT_INVALID_DECORATOR_ICON_TOKEN : INPUT_DECORATOR_ICON_TOKEN,
35
+ }),
36
+ [iconClassName, isInvalid]
37
+ );
38
+
39
+ return (
40
+ <View className={slots.decorator({ className })} {...props}>
41
+ <IconDefaultsProvider value={iconDefaults}>
42
+ <TextClassProvider value={slots.decoratorText()}>{wrapTextChildren(children)}</TextClassProvider>
43
+ </IconDefaultsProvider>
44
+ </View>
45
+ );
46
+ }
47
+ InputGroupDecorator.displayName = "DelacourUI.Input.Group.Decorator";
48
+
49
+ /**
50
+ * Wraps bare text children in a `Text`.
51
+ *
52
+ * A decorator renders a `View`, and React Native cannot render a string outside
53
+ * a `<Text>` — so `<Input.Group.Prefix>$</Input.Group.Prefix>`, which is the
54
+ * shortest thing anyone will write, would otherwise crash. Consecutive strings
55
+ * and numbers are collected into one `Text` rather than one each, so a
56
+ * `{currency}{symbol}` pair is a single affix instead of two spaced apart by
57
+ * nothing.
58
+ *
59
+ * The wrapped text needs no className: the decorator has already published the
60
+ * affix treatment through `TextClassProvider`.
61
+ */
62
+ function wrapTextChildren(children: ReactNode): ReactNode {
63
+ const items = Children.toArray(children);
64
+ const output: ReactNode[] = [];
65
+ let run: (string | number)[] = [];
66
+
67
+ const flushRun = () => {
68
+ if (run.length === 0) return;
69
+ output.push(<Text key={`affix-${output.length}`}>{run.join("")}</Text>);
70
+ run = [];
71
+ };
72
+
73
+ for (const child of items) {
74
+ if (typeof child === "string" || typeof child === "number") {
75
+ run.push(child);
76
+ continue;
77
+ }
78
+ flushRun();
79
+ output.push(child);
80
+ }
81
+ flushRun();
82
+
83
+ return output;
84
+ }
@@ -0,0 +1,16 @@
1
+ import type { ReactElement } from "react";
2
+ import type { InputSlotProps } from "./input.types";
3
+ import { InputGroupDecorator } from "./input-group-decorator";
4
+
5
+ /**
6
+ * Leading content inside the field's own box — an icon, a text affix, a control.
7
+ *
8
+ * An `Icon` needs nothing said at the call site: it inherits the field's icon
9
+ * step and a muted colour from the decorator's `IconDefaultsProvider`, and turns
10
+ * destructive with the group when the value is invalid. A bare string is wrapped in a
11
+ * `Text` that inherits the same affix treatment.
12
+ */
13
+ export function InputGroupPrefix(props: InputSlotProps): ReactElement {
14
+ return <InputGroupDecorator part="Input.Group.Prefix" {...props} />;
15
+ }
16
+ InputGroupPrefix.displayName = "DelacourUI.Input.Group.Prefix";
@@ -0,0 +1,15 @@
1
+ import type { ReactElement } from "react";
2
+ import type { InputSlotProps } from "./input.types";
3
+ import { InputGroupDecorator } from "./input-group-decorator";
4
+
5
+ /**
6
+ * Trailing content inside the field's own box — an icon, a text affix, a control.
7
+ *
8
+ * Draws nothing of its own, unlike `ListGroup.ItemSuffix`: a row in a list has a
9
+ * default chevron because it always navigates, whereas a field's trailing slot
10
+ * has no one thing it usually holds.
11
+ */
12
+ export function InputGroupSuffix(props: InputSlotProps): ReactElement {
13
+ return <InputGroupDecorator part="Input.Group.Suffix" {...props} />;
14
+ }
15
+ InputGroupSuffix.displayName = "DelacourUI.Input.Group.Suffix";
@@ -0,0 +1,140 @@
1
+ import { type ReactElement, type ReactNode, useCallback, useMemo, useRef, useState } from "react";
2
+ import type { TextInput } from "react-native";
3
+ import { useButtonGroupItemContext } from "../button/button.context";
4
+ import { resolveButtonSizeStep } from "../button/button.variants";
5
+ import { useFieldContext } from "../field/field.context";
6
+ import { Pressable } from "../pressable";
7
+ import { type InputGroupContextValue, InputGroupProvider } from "./input.context";
8
+ import { type InputSize, type InputVariant, resolveInputGroupClass } from "./input.variants";
9
+ import { InputGroupPrefix } from "./input-group-prefix";
10
+ import { InputGroupSuffix } from "./input-group-suffix";
11
+
12
+ export type InputGroupProps = {
13
+ /** Visual variant of the box. Read by the `Input` inside it. */
14
+ variant?: InputVariant;
15
+ /** Size of the box, the field's type scale and a decorator's icon. */
16
+ size?: InputSize;
17
+ /**
18
+ * Reports an invalid value: the border, the caret and the decorators all turn
19
+ * destructive. Inherited from an enclosing `Field` when it is not given.
20
+ */
21
+ isInvalid?: boolean;
22
+ /**
23
+ * Disables the field inside and fades the whole box. Inherited from an
24
+ * enclosing `Field` when it is not given.
25
+ */
26
+ isDisabled?: boolean;
27
+ className?: string;
28
+ children?: ReactNode;
29
+ };
30
+
31
+ function InputGroupRoot({
32
+ variant = "primary",
33
+ size = "md",
34
+ isInvalid,
35
+ isDisabled,
36
+ className,
37
+ children,
38
+ }: InputGroupProps): ReactElement {
39
+ const fieldRef = useRef<TextInput | null>(null);
40
+ const field = useFieldContext();
41
+ const [isFocused, setFocused] = useState(false);
42
+
43
+ // An enclosing `Field` is the outermost source, so it is the last fallback:
44
+ // a group inside an invalid field turns destructive with it, and a group that
45
+ // names its own state overrides that. These are deliberately not defaulted
46
+ // in the destructure — `false` there would swallow the field before it was
47
+ // ever consulted.
48
+ const resolvedIsInvalid = isInvalid ?? field?.isInvalid ?? false;
49
+
50
+ // An enclosing `Button.Group` owns the run's shape, so a grouped field draws
51
+ // the group's corner and overlaps its neighbour instead of drawing its own.
52
+ // `size` comes from the group outright — controls of different heights do
53
+ // not join — while `isDisabled` stays a fallback, so one member of a group
54
+ // can still be the only one disabled.
55
+ const member = useButtonGroupItemContext();
56
+ const resolvedSize = member ? resolveButtonSizeStep(member.size) : size;
57
+ const resolvedIsMemberDisabled = isDisabled ?? field?.isDisabled ?? member?.isDisabled ?? false;
58
+
59
+ const context = useMemo<InputGroupContextValue>(
60
+ () => ({
61
+ fieldRef,
62
+ isDisabled: resolvedIsMemberDisabled,
63
+ isFocused,
64
+ isInvalid: resolvedIsInvalid,
65
+ setFocused,
66
+ size: resolvedSize,
67
+ variant,
68
+ }),
69
+ [resolvedIsMemberDisabled, isFocused, resolvedIsInvalid, resolvedSize, variant]
70
+ );
71
+
72
+ // A lone field is its own tap target edge to edge. Inside a group the field
73
+ // only covers the middle of the box, so the gutter and the space around a
74
+ // decorator have to hand the press on rather than swallow it.
75
+ const focusField = useCallback(() => fieldRef.current?.focus(), []);
76
+
77
+ return (
78
+ <InputGroupProvider value={context}>
79
+ <Pressable
80
+ accessible={false}
81
+ className={resolveInputGroupClass({
82
+ className,
83
+ groupPosition: member?.position ?? "none",
84
+ isDisabled: resolvedIsMemberDisabled,
85
+ isFocused,
86
+ isInvalid: resolvedIsInvalid,
87
+ isSeamed: member?.isSeamed ?? false,
88
+ orientation: member?.orientation ?? "horizontal",
89
+ size: resolvedSize,
90
+ variant,
91
+ })}
92
+ disabled={resolvedIsMemberDisabled}
93
+ feedback="none"
94
+ onPress={focusField}
95
+ >
96
+ {children}
97
+ </Pressable>
98
+ </InputGroupProvider>
99
+ );
100
+ }
101
+
102
+ /**
103
+ * A field with content inside its box — a leading icon, a trailing control, a
104
+ * text affix, or all three.
105
+ *
106
+ * The box is the same one a lone `Input` draws. It is not a copy: both read the
107
+ * one `root` slot of `inputVariants`, which lands on the `TextInput` when a
108
+ * field stands alone and on this row when it does not. A grouped field is
109
+ * therefore indistinguishable from an ungrouped one by construction, and
110
+ * `input.variants.test.ts` pins that as a property rather than a comment.
111
+ *
112
+ * The group owns the box, so it owns the axes that draw one — `variant`, `size`,
113
+ * `isInvalid`, `isDisabled` all live here and the `Input` inside reads them from
114
+ * context. One box, one set of axes.
115
+ *
116
+ * Pressing the box focuses the field, so the gutter behaves the way a lone
117
+ * field's does. A control inside a decorator still receives its own press.
118
+ *
119
+ * @example
120
+ * <Input.Group>
121
+ * <Input.Group.Prefix>
122
+ * <Icon icon={IconMagnifyingGlass} />
123
+ * </Input.Group.Prefix>
124
+ * <Input placeholder="Search" />
125
+ * </Input.Group>
126
+ *
127
+ * @example
128
+ * <Input.Group size="lg">
129
+ * <Input.Group.Prefix>$</Input.Group.Prefix>
130
+ * <Input inputMode="decimal" placeholder="0.00" />
131
+ * <Input.Group.Suffix>NZD</Input.Group.Suffix>
132
+ * </Input.Group>
133
+ */
134
+ export const InputGroup = Object.assign(InputGroupRoot, {
135
+ /** Leading content inside the box. An `Icon` or a bare string needs nothing else. */
136
+ Prefix: InputGroupPrefix,
137
+ /** Trailing content inside the box — a clear button, a unit, a reveal toggle. */
138
+ Suffix: InputGroupSuffix,
139
+ displayName: "DelacourUI.Input.Group",
140
+ });