@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,240 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { TEXT_COLORS } from "../text/text.variants";
3
+ import {
4
+ FIELD_LEGEND_VARIANTS,
5
+ FIELD_ORIENTATIONS,
6
+ FIELD_TEXT_PARTS,
7
+ type FieldTextPart,
8
+ fieldVariants,
9
+ resolveFieldInteractive,
10
+ resolveFieldLabelText,
11
+ resolveFieldTextColor,
12
+ } from "./field.variants";
13
+
14
+ /**
15
+ * A slot's class string, with `tv`'s empty-slot `undefined` flattened.
16
+ *
17
+ * A slot that emits nothing returns `undefined` rather than `""`, which is
18
+ * correct — these parts carry state only, so most are empty most of the time —
19
+ * but it makes a bare `not.toMatch()` throw rather than pass.
20
+ */
21
+ function cls(value: string | undefined): string {
22
+ return value ?? "";
23
+ }
24
+
25
+ /** The gap step a class string sets — `gap-1.5` yields 1.5. */
26
+ function gapStep(cls: string): number | undefined {
27
+ const value = cls.match(/\bgap-(\d+(?:\.\d+)?)\b/)?.[1];
28
+ return value === undefined ? undefined : Number(value);
29
+ }
30
+
31
+ /** Slots that land on a `View`, which cannot cascade colour to a `Text`. */
32
+ const VIEW_SLOTS = ["root", "set", "group", "content", "separator"] as const;
33
+
34
+ /** Slots handed to a `Text`, whose type scale comes from the preset, not from here. */
35
+ const TEXT_SLOTS = ["label", "description", "error"] as const;
36
+
37
+ describe("the root slot", () => {
38
+ test("lays a field out on one axis or the other, never both", () => {
39
+ expect(fieldVariants({ orientation: "vertical" }).root()).toContain("flex-col");
40
+ expect(fieldVariants({ orientation: "vertical" }).root()).not.toContain("flex-row");
41
+ expect(fieldVariants({ orientation: "horizontal" }).root()).toContain("flex-row");
42
+ expect(fieldVariants({ orientation: "horizontal" }).root()).not.toContain("flex-col");
43
+ });
44
+
45
+ test("centres a horizontal field on its control", () => {
46
+ expect(fieldVariants({ orientation: "horizontal" }).root()).toContain("items-center");
47
+ });
48
+
49
+ // The control belongs at the far edge, and the root pushes it there rather
50
+ // than the label growing into the space — a label that grew would stretch
51
+ // vertically the moment it moved inside a `Field.Content` column.
52
+ test("pushes a horizontal field's control to the far edge", () => {
53
+ expect(fieldVariants({ orientation: "horizontal" }).root()).toContain("justify-between");
54
+ expect(fieldVariants({ orientation: "vertical" }).root()).not.toContain("justify-between");
55
+ });
56
+
57
+ test("stacks vertically by default", () => {
58
+ expect(fieldVariants().root()).toBe(fieldVariants({ orientation: "vertical" }).root());
59
+ });
60
+
61
+ test("merges an incoming className last", () => {
62
+ expect(fieldVariants().root({ className: "mb-6" })).toContain("mb-6");
63
+ expect(fieldVariants().root({ className: "gap-8" })).not.toContain("gap-1.5");
64
+ });
65
+ });
66
+
67
+ // A React Native `View` does not cascade colour to a `Text` descendant, so no
68
+ // slot worn by one may carry a colour. See AGENTS.md rule 1.
69
+ describe("the slots that land on a View", () => {
70
+ test("carry no text colour", () => {
71
+ for (const orientation of FIELD_ORIENTATIONS) {
72
+ for (const isInvalid of [false, true]) {
73
+ for (const isDisabled of [false, true]) {
74
+ const slots = fieldVariants({ isDisabled, isInvalid, orientation });
75
+ for (const slot of VIEW_SLOTS) {
76
+ expect(cls(slots[slot]())).not.toMatch(/\btext-/);
77
+ }
78
+ }
79
+ }
80
+ }
81
+ });
82
+ });
83
+
84
+ // The whole reason four different gaps exist: a field's own parts have to read
85
+ // as one thing, and two fields as two. Pinning the ordering rather than each
86
+ // number keeps the test meaningful when the spacing is retuned.
87
+ describe("the gap ladder", () => {
88
+ test("tightens inward, from field to field down to label and description", () => {
89
+ const slots = fieldVariants();
90
+ const content = gapStep(slots.content());
91
+ const root = gapStep(slots.root());
92
+ const set = gapStep(slots.set());
93
+ const group = gapStep(slots.group());
94
+
95
+ for (const gap of [content, root, set, group]) {
96
+ expect(gap).toBeDefined();
97
+ }
98
+ expect(content as number).toBeLessThan(root as number);
99
+ expect(root as number).toBeLessThan(set as number);
100
+ expect(set as number).toBeLessThanOrEqual(group as number);
101
+ });
102
+ });
103
+
104
+ describe("a disabled field", () => {
105
+ test("fades the label", () => {
106
+ expect(cls(fieldVariants({ isDisabled: true }).label())).toContain("opacity-50");
107
+ expect(cls(fieldVariants({ isDisabled: false }).label())).not.toContain("opacity-50");
108
+ });
109
+
110
+ // The control fades itself, and a description dimmed on top of an already
111
+ // dimmed control reads as two problems rather than one state.
112
+ test("leaves the description and the error alone", () => {
113
+ expect(cls(fieldVariants({ isDisabled: true }).description())).not.toContain("opacity-50");
114
+ expect(cls(fieldVariants({ isDisabled: true }).error())).not.toContain("opacity-50");
115
+ });
116
+ });
117
+
118
+ // The type scale belongs to the `Text` preset each part renders. A size or a
119
+ // weight here would be a second definition of `Text.Label` that could drift
120
+ // from it — the reason `Input` ships no label part at all. See AGENTS.md.
121
+ describe("the slots handed to a Text", () => {
122
+ test("restate no type scale", () => {
123
+ for (const isInvalid of [false, true]) {
124
+ for (const isDisabled of [false, true]) {
125
+ const slots = fieldVariants({ isDisabled, isInvalid });
126
+ for (const slot of TEXT_SLOTS) {
127
+ expect(cls(slots[slot]())).not.toMatch(/\btext-(xs|sm|base|lg|xl|2xl|3xl)\b/);
128
+ expect(cls(slots[slot]())).not.toMatch(/\bfont-(normal|medium|semibold|bold)\b/);
129
+ }
130
+ }
131
+ }
132
+ });
133
+
134
+ test("carry no colour either, since that rides the preset's own axis", () => {
135
+ for (const isInvalid of [false, true]) {
136
+ const slots = fieldVariants({ isInvalid });
137
+ for (const slot of TEXT_SLOTS) {
138
+ expect(cls(slots[slot]())).not.toMatch(/\btext-\w+-?\w*\b/);
139
+ }
140
+ }
141
+ });
142
+ });
143
+
144
+ describe("the legend", () => {
145
+ test("gives its two variants different treatments", () => {
146
+ const seen = new Set(FIELD_LEGEND_VARIANTS.map((variant) => cls(fieldVariants({ variant }).legend())));
147
+ expect(seen.size).toBe(FIELD_LEGEND_VARIANTS.length);
148
+ });
149
+
150
+ test("merges an incoming className last", () => {
151
+ expect(cls(fieldVariants().legend({ className: "mb-2" }))).toContain("mb-2");
152
+ });
153
+ });
154
+
155
+ describe("the separator", () => {
156
+ test("lays its rules out in a row", () => {
157
+ expect(fieldVariants().separator()).toContain("flex-row");
158
+ expect(fieldVariants().separator()).toContain("items-center");
159
+ });
160
+
161
+ // Each rule takes half the leftover width so a centred label sits between
162
+ // them, rather than one rule being positioned behind an opaque label.
163
+ test("lets each rule take the space the label does not", () => {
164
+ expect(fieldVariants().separatorLine()).toContain("flex-1");
165
+ });
166
+ });
167
+
168
+ describe("resolveFieldTextColor", () => {
169
+ test("names a colour the Text component actually has, or nothing", () => {
170
+ for (const part of FIELD_TEXT_PARTS) {
171
+ for (const isInvalid of [false, true]) {
172
+ const color = resolveFieldTextColor(part, isInvalid);
173
+ if (color !== undefined) {
174
+ expect(TEXT_COLORS).toContain(color);
175
+ }
176
+ }
177
+ }
178
+ });
179
+
180
+ // The label turns with the control it names; the description stays muted so
181
+ // the error is the one line that changed.
182
+ test("reddens the label only while the field is invalid", () => {
183
+ expect(resolveFieldTextColor("label", true)).toBe("destructive");
184
+ expect(resolveFieldTextColor("label", false)).toBeUndefined();
185
+ });
186
+
187
+ test("leaves the description on its preset's own colour in both states", () => {
188
+ for (const isInvalid of [false, true]) {
189
+ expect(resolveFieldTextColor("description", isInvalid)).toBeUndefined();
190
+ }
191
+ });
192
+
193
+ test("keeps the error destructive even outside an invalid field", () => {
194
+ for (const isInvalid of [false, true]) {
195
+ expect(resolveFieldTextColor("error", isInvalid)).toBe("destructive");
196
+ }
197
+ });
198
+
199
+ test("covers every part it declares", () => {
200
+ for (const part of FIELD_TEXT_PARTS) {
201
+ expect(() => resolveFieldTextColor(part as FieldTextPart, true)).not.toThrow();
202
+ }
203
+ });
204
+ });
205
+
206
+ describe("resolveFieldInteractive", () => {
207
+ test("leaves a field of static text inert", () => {
208
+ // No control has offered a press, so the row stays a View. Mounting a
209
+ // gesture detector under every label and description in a form would
210
+ // announce static text as something you can activate.
211
+ expect(resolveFieldInteractive(null)).toBe(false);
212
+ });
213
+
214
+ test("hands the row to a control that offered one", () => {
215
+ expect(resolveFieldInteractive(() => undefined)).toBe(true);
216
+ });
217
+ });
218
+
219
+ describe("resolveFieldLabelText", () => {
220
+ test("hands a string label through, trimmed", () => {
221
+ expect(resolveFieldLabelText("Volume")).toBe("Volume");
222
+ expect(resolveFieldLabelText(" Volume\n")).toBe("Volume");
223
+ });
224
+
225
+ test("reads a number the way a screen reader would", () => {
226
+ expect(resolveFieldLabelText(42)).toBe("42");
227
+ });
228
+
229
+ // A label made of elements has no text this side of a render, and an empty
230
+ // string is not a name — both leave the control to name itself.
231
+ test("offers nothing for an element, an empty string, or no children at all", () => {
232
+ expect(resolveFieldLabelText("")).toBeNull();
233
+ expect(resolveFieldLabelText(" ")).toBeNull();
234
+ expect(resolveFieldLabelText(undefined)).toBeNull();
235
+ expect(resolveFieldLabelText(null)).toBeNull();
236
+ expect(resolveFieldLabelText(false)).toBeNull();
237
+ expect(resolveFieldLabelText({ type: "Text", props: {} })).toBeNull();
238
+ expect(resolveFieldLabelText(["a", "b"])).toBeNull();
239
+ });
240
+ });
@@ -0,0 +1,156 @@
1
+ import type { VariantProps } from "tailwind-variants";
2
+ import { tv } from "../../lib/tv";
3
+ import type { TextColor } from "../text/text.variants";
4
+
5
+ export const FIELD_ORIENTATIONS = ["vertical", "horizontal"] as const;
6
+
7
+ export const FIELD_LEGEND_VARIANTS = ["legend", "label"] as const;
8
+
9
+ /** The parts that render a `Text` and therefore need a colour rather than a class. */
10
+ export const FIELD_TEXT_PARTS = ["label", "description", "error"] as const;
11
+
12
+ export type FieldOrientation = (typeof FIELD_ORIENTATIONS)[number];
13
+ export type FieldLegendVariant = (typeof FIELD_LEGEND_VARIANTS)[number];
14
+ export type FieldTextPart = (typeof FIELD_TEXT_PARTS)[number];
15
+
16
+ /**
17
+ * Layout for a form field and the structures that hold one.
18
+ *
19
+ * One slotted `tv()` rather than a call per part, so the spacing ladder is
20
+ * declared in a single place. That ladder is the component: `content` groups a
21
+ * label with its description, `root` groups a field's parts, `set` groups a
22
+ * legend with its fields, and `group` separates one field from the next. Each
23
+ * step is looser than the one inside it, which is what makes a field read as one
24
+ * thing and two fields as two — `field.variants.test.ts` pins the ordering
25
+ * rather than the numbers, so the spacing can be retuned without the test
26
+ * becoming a transcript of it.
27
+ *
28
+ * **No slot here carries a type scale or a colour.** The text parts render the
29
+ * `Text` presets — `Text.Label`, `Text.Caption` — and pass a colour through
30
+ * {@link resolveFieldTextColor}, so the scale lives in exactly one place. A
31
+ * `text-sm font-medium` written here would be a second definition of
32
+ * `Text.Label` that could drift from it, which is the reason `Input` ships no
33
+ * label part of its own. The slots hold state and layout only.
34
+ *
35
+ * The `View` slots hold no `text-*` at all: a React Native `View` does not
36
+ * cascade colour to a `Text` descendant. See AGENTS.md rule 1.
37
+ *
38
+ * Free of React Native imports so it stays unit-testable — `bun test` cannot
39
+ * parse React Native's Flow-typed source. See AGENTS.md.
40
+ */
41
+ export const fieldVariants = tv({
42
+ slots: {
43
+ root: "w-full gap-1.5",
44
+ /** A legend, its description and the fields under them. */
45
+ set: "w-full gap-4",
46
+ /** Field to field — the loosest step, so two fields never read as one. */
47
+ group: "w-full gap-5",
48
+ /** A label and its description, as one block beside a control. */
49
+ content: "min-w-0 flex-1 gap-0.5",
50
+ label: "",
51
+ description: "",
52
+ error: "",
53
+ legend: "",
54
+ /** The row a separator's rules and its optional label share. */
55
+ separator: "w-full flex-row items-center gap-3",
56
+ /** Each rule takes the width the label does not. */
57
+ separatorLine: "flex-1",
58
+ },
59
+ variants: {
60
+ orientation: {
61
+ vertical: { root: "flex-col" },
62
+ // `justify-between` is what pushes the control to the far edge, and it
63
+ // is the root's job rather than the label's. The web kit grows the label
64
+ // instead — `*:data-[slot=field-label]:flex-auto` — which needs a child
65
+ // selector uniwind does not have, and would stretch the label vertically
66
+ // once it sat inside a `Field.Content` column rather than beside it.
67
+ horizontal: { root: "flex-row items-center justify-between" },
68
+ },
69
+ variant: {
70
+ legend: { legend: "font-medium" },
71
+ label: { legend: "" },
72
+ },
73
+ // The empty `false` branches are load-bearing typing, not placeholders.
74
+ // `tv` derives the prop type from the declared keys, so a map with only
75
+ // `true` types the prop as `true` rather than `boolean`. See the note in
76
+ // button.variants.ts.
77
+ isInvalid: { true: {}, false: {} },
78
+ // Only the label fades. The control dims itself, and a description
79
+ // dimmed on top of an already dimmed control reads as two problems
80
+ // rather than one state.
81
+ isDisabled: { true: { label: "opacity-50" }, false: {} },
82
+ },
83
+ defaultVariants: {
84
+ orientation: "vertical",
85
+ variant: "legend",
86
+ isInvalid: false,
87
+ isDisabled: false,
88
+ },
89
+ });
90
+
91
+ /**
92
+ * The `Text` colour a text part takes, given the field's state.
93
+ *
94
+ * Returning `undefined` is meaningful rather than lazy: `Text`'s `color` axis
95
+ * emits nothing when it is not named, so the part falls through to its preset's
96
+ * own colour — `text-foreground` for a label, `text-muted-foreground` for a
97
+ * description. That is what lets a part say "leave it alone" without knowing
98
+ * which token its preset chose.
99
+ *
100
+ * The label turns with the control it names, so the pair reads as one state. The
101
+ * description stays muted in both, so the error is the one line that appeared.
102
+ * The error is destructive even outside an invalid field, because an error message is
103
+ * never the calm case.
104
+ *
105
+ * Pure, so the whole matrix is reachable from `bun test`. See AGENTS.md.
106
+ */
107
+ export function resolveFieldTextColor(part: FieldTextPart, isInvalid: boolean): TextColor | undefined {
108
+ switch (part) {
109
+ case "label":
110
+ return isInvalid ? "destructive" : undefined;
111
+ case "error":
112
+ return "destructive";
113
+ default:
114
+ return undefined;
115
+ }
116
+ }
117
+
118
+ /**
119
+ * Whether the field's row should render as a `Pressable` rather than a `View`.
120
+ *
121
+ * Only once a control inside has offered a press. A field is layout until then,
122
+ * and mounting a `GestureDetector` regardless would put one under every label
123
+ * and description in a form — the same thing `Badge` refuses to do for a list of
124
+ * fifty tags, and for the same reason: assistive technology would announce a row
125
+ * of static text as something you can activate.
126
+ *
127
+ * A type predicate rather than a plain boolean, so the branch that renders the
128
+ * `Pressable` gets a non-null `onPress` out of the same check that decided it.
129
+ *
130
+ * Pure, so the decision is reachable from `bun test`. See AGENTS.md.
131
+ */
132
+ export function resolveFieldInteractive(press: (() => void) | null): press is () => void {
133
+ return press !== null;
134
+ }
135
+
136
+ /**
137
+ * The text a `Field.Label` can lend to the control beside it as its accessible
138
+ * name, or `null` when it has none to lend.
139
+ *
140
+ * Only a bare string or number qualifies. A label built from elements has no
141
+ * text this side of a render, and guessing at one would name a control after
142
+ * the wrong thing — an icon's key, say. Whitespace is trimmed and an empty
143
+ * string is an absence, because a screen reader handed `""` reads nothing at
144
+ * all, which is worse than reading the value alone.
145
+ *
146
+ * React Native has no `<label for>`; this is the association, done by hand
147
+ * through the field's context. Pure, so `bun test` reaches it. See AGENTS.md.
148
+ */
149
+ export function resolveFieldLabelText(children: unknown): string | null {
150
+ if (typeof children === "number") return String(children);
151
+ if (typeof children !== "string") return null;
152
+ const text = children.trim();
153
+ return text === "" ? null : text;
154
+ }
155
+
156
+ export type FieldVariantProps = VariantProps<typeof fieldVariants>;
@@ -0,0 +1,22 @@
1
+ export { Field, type FieldProps } from "./field";
2
+ export { type FieldContextValue, FieldProvider, useField, useFieldContext } from "./field.context";
3
+ export type { FieldTextProps } from "./field.types";
4
+ export {
5
+ FIELD_LEGEND_VARIANTS,
6
+ FIELD_ORIENTATIONS,
7
+ FIELD_TEXT_PARTS,
8
+ type FieldLegendVariant,
9
+ type FieldOrientation,
10
+ type FieldTextPart,
11
+ type FieldVariantProps,
12
+ fieldVariants,
13
+ resolveFieldInteractive,
14
+ resolveFieldLabelText,
15
+ resolveFieldTextColor,
16
+ } from "./field.variants";
17
+ export type { FieldContentProps } from "./field-content";
18
+ export type { FieldErrorProps } from "./field-error";
19
+ export type { FieldGroupProps } from "./field-group";
20
+ export type { FieldLegendProps } from "./field-legend";
21
+ export type { FieldSeparatorProps } from "./field-separator";
22
+ export type { FieldSetProps } from "./field-set";
@@ -0,0 +1,104 @@
1
+ # Icon
2
+
3
+ Renders a Central Icon at a theme-aware size and colour. Pattern A in
4
+ [The three component patterns](../../../AGENTS.md#the-three-component-patterns) —
5
+ a styled wrapper with no state and no compound surface, which also reads
6
+ `IconDefaultsProvider` so a parent can set size and colour for its whole subtree
7
+ instead of every call site repeating them.
8
+
9
+ `import { Icon } from "@delacour/react-native-ui/icon";`
10
+
11
+ ## Files
12
+
13
+ | File | What it holds |
14
+ | --- | --- |
15
+ | `index.ts` | → `@delacour/react-native-ui/icon` |
16
+ | `icon.tsx` | `Icon`, the `IconGlyph` proxy and the one `withUniwind` wrapper (see rule 7) — internal below `Icon` |
17
+ | `icon.context.tsx` | `IconDefaults`, `IconDefaultsProvider`, `useIconDefaults()` |
18
+ | `icon.variants.ts` | Pure `tv()` + the size-class ladder, no RN imports |
19
+ | `icon.variants.test.ts` | |
20
+
21
+ ## Design
22
+
23
+ - **Props**: `icon` — required, a Central Icon component; `size` — a named step
24
+ or an edge length in points; `color` — a theme colour token, a CSS variable
25
+ name or a literal; `className` — a `size-*` utility. It extends `SvgProps`
26
+ with `color`, `width` and `height` removed, because those three are the axes
27
+ `Icon` owns: `color` is resolved through the theme, and width and height
28
+ arrive as the glyph's `size` prop rather than as props of their own.
29
+ - **Sizes**: `xs`, `sm`, `md`, `lg`, `xl`, `2xl` — 14/16/18/20/24/32pt, held as
30
+ `size-icon-*` token classes rather than numbers, plus a numeric escape hatch.
31
+ With no `size`, no `className` and nothing to inherit the fallback is
32
+ `size-icon-lg`, 20pt, on the `foreground` token.
33
+ - **Precedence is a five-source ladder** and it is shared with `Spinner`, so it
34
+ is documented once in the package doc — see
35
+ [Sizing](../../../AGENTS.md#sizing) for the table and for why a numeric size
36
+ can never become a class. `resolveIconSizeClass` builds the first four sources
37
+ as one `cn()` chain, so the last `size-*` wins; a numeric `size` bypasses the
38
+ chain entirely.
39
+ - **Overriding through `className` uses `size-*`, not `w-*` with `h-*`.**
40
+ tailwind-merge conflicts `size` into `w`/`h` but not the reverse, so a trailing
41
+ `w-6` will not clear a leading `size-5`.
42
+ - **Size and colour are inherited, not passed.** `IconDefaultsProvider` supplies
43
+ the class and the token that an unstyled `Icon` in its subtree adopts, and
44
+ `useIconDefaults()` reads the nearest one — `null` outside a provider. This is
45
+ what lets an icon be *composed into* a component rather than passed as a prop:
46
+ `<Button><Icon icon={IconPlus} /><Button.Label>Add</Button.Label></Button>`
47
+ sizes and tints the icon for the button's variant, with no `startIcon` prop and
48
+ no colour repeated at the call site. An explicit `size` or `color` on the icon
49
+ still wins.
50
+ - **The components that publish it** are
51
+ [Button](../button/AGENTS.md), `Badge`, `Switch.Content`, `Switch.Thumb`,
52
+ `Tabs.Trigger`, `Accordion.Trigger`, `Accordion.Indicator`,
53
+ `Input.Group`'s decorators, `ListGroup.ItemPrefix`, `Screen.Navbar.BackButton`
54
+ and [Spinner](../spinner/AGENTS.md) — each carrying its own step on the shared
55
+ scale and its own variant's foreground token.
56
+ - **An icon's size is a class; an icon's colour is a token.** The asymmetry is
57
+ deliberate — a colour class cannot express a literal like `#EC4899` or reach an
58
+ SVG paint prop, so `color` is resolved through the active theme by
59
+ `useThemeColor` instead. See [Theming](../../../AGENTS.md#theming).
60
+ - **`iconVariants` has no `defaultVariants`, on purpose.** The fall-through *is*
61
+ the feature: a default would emit from inside that same `tv()` call, ahead of
62
+ an inherited class in the merge, and the fallback would then beat the enclosing
63
+ component. An unnamed axis must reach the inherited provider rather than be
64
+ answered locally, so `resolveIconSizeClass` orders the sources instead. The
65
+ variant test pins it — without a size, `iconVariants()` emits no `size-*` at
66
+ all.
67
+ - **The `withUniwind` wrapper is forced, not stylistic.** A className cannot size
68
+ a Central Icon through a style: `CentralIconBase` spreads its props onto
69
+ `<Svg>` *before* setting its own `width`/`height`, and `react-native-svg`'s
70
+ `Svg.render()` then merges `{...style, ...props}` and pushes the
71
+ width/height-derived styles onto the root **last**, so a `size-4` that resolved
72
+ to `style.width` is overridden every single time. `icon.tsx` runs the class
73
+ through `withUniwind` in manual mode to recover the width and hands that number
74
+ to the glyph's `size` prop.
75
+ - **The wrapper goes on a proxy that takes the glyph as data.** `IconGlyph`
76
+ renders whatever component it is handed, so a *single* `withUniwind` covers the
77
+ whole two-thousand-icon set instead of one per glyph — still one component
78
+ wrapped once, in one file. It also drops the `className` that sized it, because
79
+ `withManualUniwind` spreads the original props straight through and
80
+ `CentralIconBase` would forward a stray `className` onto the native SVG view,
81
+ where nothing interprets it.
82
+ - **`withUniwind` is called at module scope.** In render it would mint a new
83
+ component type every frame and remount the icon.
84
+ - **This is the single sanctioned carve-out from rule 7, and it is already
85
+ spent.** Do not wrap a Central Icon directly, do not wrap the proxy anywhere
86
+ else, and do not add a second wrapper for `Svg`.
87
+ - **`Icon` and `Spinner` share one scale.** `SPINNER_SIZES` *is* `ICON_SIZES`, so
88
+ `size="md"` is the same edge length in both and one can stand in for the other
89
+ with nothing moving — which is what makes a button's loading swap free. See
90
+ [Spinner](../spinner/AGENTS.md).
91
+ - **Central Icons only**, via `@delacour/react-native-ui/icons/central` — never Lucide,
92
+ Hugeicons, or anything else. That is rule 5.
93
+ - **`icon.variants.ts` is free of React Native imports** so the whole ladder is
94
+ reachable from `bun test` — `resolveIconSizeClass`, `isIconSize` and
95
+ `iconVariants` are all pure. See [Testing](../../../AGENTS.md#testing).
96
+
97
+ ## Known gaps
98
+
99
+ - **`Icon` is the only component with no playground route.** Step 5 of
100
+ [Adding a component](../../../AGENTS.md#adding-a-component) requires
101
+ `apps/playground/src/app/(components)/{name}.tsx` plus a row on the index, and
102
+ exempts only a component with nothing to render — `DelacourProvider`. `Icon`
103
+ renders, so it does not qualify for that exemption; it is verified today only
104
+ indirectly, through the glyphs on the other routes.
@@ -0,0 +1,29 @@
1
+ import { createContext, type ReactElement, type ReactNode, use } from "react";
2
+
3
+ export type IconDefaults = {
4
+ /** Classes an unstyled `Icon` in this subtree adopts, e.g. `size-4.5`. */
5
+ className: string;
6
+ /** Theme colour token, e.g. `primary-foreground`. */
7
+ color: string;
8
+ };
9
+
10
+ const IconDefaultsContext = createContext<IconDefaults | null>(null);
11
+
12
+ /**
13
+ * Supplies the size and colour that unstyled `Icon`s in this subtree adopt.
14
+ *
15
+ * This is what lets an icon be *composed into* a component rather than passed
16
+ * as a prop: `<Button><Icon icon={IconPlus} /><Button.Label>Add</Button.Label></Button>`
17
+ * sizes and tints the icon for the button's variant, with no `startIcon` prop
18
+ * and no colour repeated at the call site. An explicit `size` or `color` on the
19
+ * icon still wins.
20
+ */
21
+ export function IconDefaultsProvider({ value, children }: { value: IconDefaults; children: ReactNode }): ReactElement {
22
+ return <IconDefaultsContext value={value}>{children}</IconDefaultsContext>;
23
+ }
24
+ IconDefaultsProvider.displayName = "DelacourUI.Icon.DefaultsProvider";
25
+
26
+ /** Icon defaults from the nearest provider, or null outside one. */
27
+ export function useIconDefaults(): IconDefaults | null {
28
+ return use(IconDefaultsContext);
29
+ }
@@ -0,0 +1,92 @@
1
+ import type { ComponentType, ReactElement } from "react";
2
+ import type { SvgProps } from "react-native-svg";
3
+ import { withUniwind } from "uniwind";
4
+ import { useThemeColor } from "../../hooks/use-theme-color";
5
+ import { useIconDefaults } from "./icon.context";
6
+ import { ICON_FALLBACK_COLOR, type IconSize, isIconSize, resolveIconSizeClass } from "./icon.variants";
7
+
8
+ export type IconComponent = ComponentType<SvgProps & { size?: number | string }>;
9
+
10
+ export type IconProps = Omit<SvgProps, "color" | "width" | "height"> & {
11
+ /** An icon component from `@delacour/react-native-ui/icons/central`. */
12
+ icon: IconComponent;
13
+ /** A named size, or an edge length in points. A number beats `className`. */
14
+ size?: IconSize | number;
15
+ /**
16
+ * A theme colour token (`foreground`, `muted-foreground`, …) or a CSS
17
+ * variable name. Defaults to the enclosing component's icon colour.
18
+ */
19
+ color?: string;
20
+ /** A `size-*` utility. Beats a named `size`, loses to a numeric one. */
21
+ className?: string;
22
+ };
23
+
24
+ type IconGlyphProps = Omit<SvgProps, "color" | "width" | "height"> & {
25
+ glyph: IconComponent;
26
+ size?: number | string;
27
+ color?: string;
28
+ className?: string;
29
+ };
30
+
31
+ /**
32
+ * Renders the glyph and drops the className that sized it.
33
+ *
34
+ * This one indirection is what keeps the whole Central Icons set to a *single*
35
+ * `withUniwind` wrapper: the wrapper goes on this component, which takes the
36
+ * glyph as data, rather than on each of the two thousand icon components.
37
+ * AGENTS.md rule 7 caps a component at one wrapper in one file — this is it.
38
+ *
39
+ * The className must not reach the glyph. `withManualUniwind` spreads the
40
+ * original props straight through, and `CentralIconBase` would forward a stray
41
+ * `className` onto the native SVG view, where nothing interprets it.
42
+ */
43
+ function IconGlyph({ glyph: Glyph, className: _sizedBy, ...props }: IconGlyphProps): ReactElement {
44
+ return <Glyph {...props} />;
45
+ }
46
+ IconGlyph.displayName = "DelacourUI.Icon.Glyph";
47
+
48
+ /**
49
+ * Turns a `size-*` class into the glyph's `size` **prop**.
50
+ *
51
+ * A className cannot size an SVG through a style: `CentralIconBase` spreads its
52
+ * props onto `<Svg>` before its own `width`/`height`, and `Svg.render` pushes
53
+ * the width/height-derived styles on last, beating anything a className set.
54
+ *
55
+ * `withManualUniwind` reads `props.className`, compiles it, and writes
56
+ * `styles.width` to `size` — but skips the mapping entirely when `size` is
57
+ * already defined. That skip *is* the precedence rule: a numeric `size` from the
58
+ * caller wins and the class is ignored.
59
+ *
60
+ * Created once at module scope. Calling `withUniwind` inside a render would mint
61
+ * a new component type every frame and remount the icon.
62
+ */
63
+ const StyledIconGlyph = withUniwind(IconGlyph, {
64
+ size: { fromClassName: "className", styleProperty: "width" },
65
+ });
66
+
67
+ /**
68
+ * Renders a Central Icon at a theme-aware size and colour.
69
+ *
70
+ * An icon's size is a class and an icon's colour is a token — a class cannot
71
+ * express a literal like `#EC4899` or reach an SVG paint prop, so colour is
72
+ * resolved through the active theme instead. See AGENTS.md.
73
+ *
74
+ * Both fall back to the nearest {@link IconDefaultsProvider}, so an icon
75
+ * composed into a Button matches that button's size and variant without being
76
+ * told to. An explicit `size` or `color` still wins.
77
+ */
78
+ export function Icon({ icon, size, color, className, ...props }: IconProps): ReactElement {
79
+ const defaults = useIconDefaults();
80
+ const resolvedColor = useThemeColor(color ?? defaults?.color ?? ICON_FALLBACK_COLOR);
81
+
82
+ return (
83
+ <StyledIconGlyph
84
+ className={resolveIconSizeClass({ className, inherited: defaults?.className, size })}
85
+ color={resolvedColor}
86
+ glyph={icon}
87
+ size={isIconSize(size) ? undefined : size}
88
+ {...props}
89
+ />
90
+ );
91
+ }
92
+ Icon.displayName = "DelacourUI.Icon";