@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,580 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { readFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { ICON_SIZE_TOKENS } from "../../styles/tokens";
5
+ import { TEXT_SIZES } from "../text/text.variants";
6
+ import {
7
+ RADIO_DEFAULT_SIZE,
8
+ RADIO_DEFAULT_VARIANT,
9
+ RADIO_DOT_SPRING,
10
+ RADIO_LABEL_TEXT_SIZE,
11
+ RADIO_ORIENTATIONS,
12
+ RADIO_SIZES,
13
+ RADIO_VARIANTS,
14
+ radioVariants,
15
+ resolveIndicatorPlacement,
16
+ resolveRadioState,
17
+ shouldEmitSelection,
18
+ } from "./radio.variants";
19
+
20
+ const TOKENS_CSS = readFileSync(join(import.meta.dirname, "../../styles/tokens.css"), "utf-8");
21
+
22
+ /** Points behind a `--spacing-*` token, failing loudly rather than yielding NaN. */
23
+ function spacingPx(token: string): number {
24
+ const value = TOKENS_CSS.match(new RegExp(`--spacing-${token}:\\s*([\\d.]+)px;`))?.[1];
25
+ if (value === undefined) throw new Error(`tokens.css defines no --spacing-${token}`);
26
+ return Number(value);
27
+ }
28
+
29
+ /** Tailwind's own spacing scale, in points. `size-2` is eight. */
30
+ const SPACING_STEP_PX = 4;
31
+
32
+ /**
33
+ * A slot's class string, with `tv`'s empty-slot `undefined` flattened.
34
+ *
35
+ * A slot that emits nothing returns `undefined` rather than `""` — correct, but
36
+ * it makes a bare `not.toMatch()` throw rather than pass. The reader
37
+ * `field.variants.test.ts` already uses.
38
+ */
39
+ function cls(value: string | undefined): string {
40
+ return value ?? "";
41
+ }
42
+
43
+ /** The gap step a class string sets — `gap-2.5` yields 2.5. */
44
+ function gapStep(value: string): number {
45
+ return Number(value.match(/\bgap-(\d+(?:\.\d+)?)\b/)?.[1]);
46
+ }
47
+
48
+ /** The `min-h-*` step a class string sets — `min-h-11` yields 11. */
49
+ function minHeightStep(value: string): number {
50
+ return Number(value.match(/\bmin-h-(\d+(?:\.\d+)?)\b/)?.[1]);
51
+ }
52
+
53
+ /** The `size-*` step a class string sets, for a plain spacing step — `size-2` yields 2. */
54
+ function sizeStep(value: string): number {
55
+ return Number(value.match(/\bsize-(\d+(?:\.\d+)?)\b/)?.[1]);
56
+ }
57
+
58
+ /**
59
+ * Position of a slot's `size-icon-*` token on the shared icon scale.
60
+ *
61
+ * The helper `badge.variants.test.ts` and `input.variants.test.ts` already use —
62
+ * a radio's ring has to sit on the scale `Icon` and `Spinner` share rather than
63
+ * carry a private number.
64
+ */
65
+ function iconStep(value: string): number {
66
+ const token = value.match(/\bsize-(icon-[\w-]+)\b/)?.[1];
67
+ return ICON_SIZE_TOKENS.indexOf(token as (typeof ICON_SIZE_TOKENS)[number]);
68
+ }
69
+
70
+ /** Every combination of the axes that paint a radio, as `tv` props. */
71
+ function everyCell(): {
72
+ variant: (typeof RADIO_VARIANTS)[number];
73
+ size: (typeof RADIO_SIZES)[number];
74
+ isSelected: boolean;
75
+ isInvalid: boolean;
76
+ isDisabled: boolean;
77
+ }[] {
78
+ const cells = [];
79
+ for (const variant of RADIO_VARIANTS) {
80
+ for (const size of RADIO_SIZES) {
81
+ for (const isSelected of [false, true]) {
82
+ for (const isInvalid of [false, true]) {
83
+ for (const isDisabled of [false, true]) {
84
+ cells.push({ variant, size, isSelected, isInvalid, isDisabled });
85
+ }
86
+ }
87
+ }
88
+ }
89
+ }
90
+ return cells;
91
+ }
92
+
93
+ const CELLS = everyCell();
94
+
95
+ describe("the tokens.css reader", () => {
96
+ // The dot-fits-inside-the-ring assertion is only worth anything if the parse
97
+ // found real numbers.
98
+ test("finds the icon scale", () => {
99
+ for (const token of ICON_SIZE_TOKENS) {
100
+ expect(spacingPx(token)).toBeGreaterThan(0);
101
+ }
102
+ });
103
+ });
104
+
105
+ describe("the group slot", () => {
106
+ test("lays its radios out on one axis or the other, never both", () => {
107
+ expect(radioVariants({ orientation: "vertical" }).group()).toContain("flex-col");
108
+ expect(radioVariants({ orientation: "vertical" }).group()).not.toContain("flex-row");
109
+ expect(radioVariants({ orientation: "horizontal" }).group()).toContain("flex-row");
110
+ expect(radioVariants({ orientation: "horizontal" }).group()).not.toContain("flex-col");
111
+ });
112
+
113
+ test("gives every orientation a treatment of its own", () => {
114
+ const seen = new Set(RADIO_ORIENTATIONS.map((orientation) => radioVariants({ orientation }).group()));
115
+ expect(seen.size).toBe(RADIO_ORIENTATIONS.length);
116
+ });
117
+
118
+ test("stacks vertically by default", () => {
119
+ expect(radioVariants().group()).toBe(radioVariants({ orientation: "vertical" }).group());
120
+ });
121
+
122
+ // A horizontal group runs out of width long before it runs out of options.
123
+ test("wraps only when horizontal", () => {
124
+ expect(radioVariants({ orientation: "horizontal" }).group()).toContain("flex-wrap");
125
+ expect(radioVariants({ orientation: "vertical" }).group()).not.toContain("flex-wrap");
126
+ });
127
+
128
+ test("steps its gap with size", () => {
129
+ const gaps = RADIO_SIZES.map((size) => gapStep(radioVariants({ size }).group()));
130
+ expect(gaps.every(Number.isFinite)).toBe(true);
131
+ expect(new Set(gaps).size).toBe(RADIO_SIZES.length);
132
+ expect([...gaps]).toEqual([...gaps].sort((a, b) => a - b));
133
+ });
134
+
135
+ // A disabled group is N faded rows. Fading the box as well would compound
136
+ // `opacity-50` twice and land the whole group at a quarter opacity.
137
+ test("paints no state of its own", () => {
138
+ expect(radioVariants({ isDisabled: true }).group()).not.toContain("opacity-50");
139
+ expect(radioVariants({ isInvalid: true }).group()).toBe(radioVariants({ isInvalid: false }).group());
140
+ });
141
+
142
+ test("merges an incoming className last", () => {
143
+ expect(radioVariants().group({ className: "gap-8" })).toContain("gap-8");
144
+ expect(radioVariants().group({ className: "gap-8" })).not.toContain("gap-3");
145
+ });
146
+ });
147
+
148
+ describe("the root slot", () => {
149
+ // A radio row IS its own tap target, so unlike a Badge it wants the stretch
150
+ // it gets inside the group's flex-col. `self-start` would shrink the target
151
+ // to the width of the word "Yes".
152
+ test("never stops itself stretching, at any size", () => {
153
+ for (const cell of CELLS) {
154
+ expect(radioVariants(cell).root()).not.toContain("self-start");
155
+ }
156
+ });
157
+
158
+ // ...but it cannot take `w-full` either, or a horizontal group would give
159
+ // every radio the group's full width and blow the row apart.
160
+ test("never takes a width, so a horizontal group can size it by content", () => {
161
+ for (const cell of CELLS) {
162
+ expect(radioVariants(cell).root()).not.toMatch(/\bw-/);
163
+ }
164
+ });
165
+
166
+ // A floor, not a ceiling. `Text` respects OS font scaling, so a fixed height
167
+ // clips the label at a large accessibility step instead of growing with it.
168
+ test("floors its height rather than fixing it", () => {
169
+ for (const size of RADIO_SIZES) {
170
+ const root = radioVariants({ size }).root();
171
+ expect(minHeightStep(root)).toBeGreaterThan(0);
172
+ expect(root).not.toMatch(/(?<![\w-])h-\d/);
173
+ }
174
+ });
175
+
176
+ test("gives every size a distinct floor, increasing with it", () => {
177
+ const floors = RADIO_SIZES.map((size) => minHeightStep(radioVariants({ size }).root()));
178
+ expect(new Set(floors).size).toBe(RADIO_SIZES.length);
179
+ expect([...floors]).toEqual([...floors].sort((a, b) => a - b));
180
+ });
181
+
182
+ // The platform guideline is 44pt, and the default size has to clear it.
183
+ test("clears the platform hit target at the default size", () => {
184
+ expect(minHeightStep(radioVariants({ size: RADIO_DEFAULT_SIZE }).root()) * SPACING_STEP_PX).toBeGreaterThanOrEqual(
185
+ 44
186
+ );
187
+ });
188
+
189
+ test("steps its gap with size", () => {
190
+ const gaps = RADIO_SIZES.map((size) => gapStep(radioVariants({ size }).root()));
191
+ expect(gaps.every(Number.isFinite)).toBe(true);
192
+ expect([...gaps]).toEqual([...gaps].sort((a, b) => a - b));
193
+ });
194
+
195
+ // The fade lives on the ring and the label; an `opacity-50` on the root as
196
+ // well would compound with them and land at a quarter.
197
+ test("never carries an opacity, which would compound with its children's", () => {
198
+ for (const cell of CELLS) {
199
+ expect(radioVariants(cell).root()).not.toMatch(/\bopacity-/);
200
+ }
201
+ });
202
+
203
+ test("merges an incoming className last", () => {
204
+ expect(radioVariants().root({ className: "gap-8" })).toContain("gap-8");
205
+ expect(radioVariants().root({ className: "gap-8" })).not.toContain("gap-2.5");
206
+ });
207
+ });
208
+
209
+ describe("the indicator slot", () => {
210
+ test("is a circle in every cell", () => {
211
+ for (const cell of CELLS) {
212
+ expect(radioVariants(cell).indicator()).toContain("rounded-full");
213
+ }
214
+ });
215
+
216
+ // The border is always in the box, so selecting or switching variant only
217
+ // ever recolours it — it never resizes the ring by four points.
218
+ test("reserves its border in every cell", () => {
219
+ for (const cell of CELLS) {
220
+ expect(radioVariants(cell).indicator()).toMatch(/\bborder-2\b/);
221
+ }
222
+ });
223
+
224
+ test("names a border colour in every cell", () => {
225
+ for (const cell of CELLS) {
226
+ expect(radioVariants(cell).indicator()).toMatch(/\bborder-(?!2\b)[\w-]+/);
227
+ }
228
+ });
229
+
230
+ test("sizes from the scale Icon and Spinner share", () => {
231
+ const steps = RADIO_SIZES.map((size) => iconStep(radioVariants({ size }).indicator()));
232
+ expect(steps.every((step) => step >= 0)).toBe(true);
233
+ expect(new Set(steps).size).toBe(RADIO_SIZES.length);
234
+ expect([...steps]).toEqual([...steps].sort((a, b) => a - b));
235
+ });
236
+
237
+ // Every cell of the matrix has to be reachable. Two cells collapsing means a
238
+ // caller can set an axis and see nothing change.
239
+ test("gives all four variant and selection pairs a distinct treatment", () => {
240
+ const seen = new Set<string>();
241
+ for (const variant of RADIO_VARIANTS) {
242
+ for (const isSelected of [false, true]) {
243
+ seen.add(radioVariants({ variant, isSelected }).indicator());
244
+ }
245
+ }
246
+ expect(seen.size).toBe(RADIO_VARIANTS.length * 2);
247
+ });
248
+
249
+ // Invalid is reported and selection is routine, so invalid outranks it — the
250
+ // precedence `Input` gives invalid over focus, and for the same reason.
251
+ test("stays destructive while invalid, whatever else is set", () => {
252
+ for (const cell of CELLS.filter((one) => one.isInvalid)) {
253
+ const indicator = radioVariants(cell).indicator();
254
+ expect(indicator).toContain("border-destructive");
255
+ expect(indicator).not.toContain("border-primary");
256
+ expect(indicator).not.toContain("border-input");
257
+ expect(indicator).not.toContain("border-border");
258
+ }
259
+ });
260
+
261
+ test("carries no text colour, in any cell", () => {
262
+ for (const cell of CELLS) {
263
+ expect(radioVariants(cell).indicator()).not.toMatch(/\btext-/);
264
+ }
265
+ });
266
+ });
267
+
268
+ describe("the dot slot", () => {
269
+ // Its colour is unconditional rather than gated on `isSelected`, so a dot
270
+ // being deselected keeps its fill while it animates out. Gate the class and
271
+ // the dot vanishes on the frame the state flips, instead of shrinking.
272
+ test("names a fill whether selected or not", () => {
273
+ for (const cell of CELLS) {
274
+ expect(radioVariants(cell).dot()).toMatch(/\bbg-[\w-]+/);
275
+ }
276
+ });
277
+
278
+ test("turns destructive while invalid", () => {
279
+ expect(radioVariants({ isInvalid: true }).dot()).toContain("bg-destructive");
280
+ expect(radioVariants({ isInvalid: false }).dot()).not.toContain("bg-destructive");
281
+ });
282
+
283
+ // This is the assertion that stands in for a `--spacing-radio-*` token scale:
284
+ // it makes the ring-to-dot coupling checked rather than merely hoped for.
285
+ test("fits inside its own ring at every size, border included", () => {
286
+ for (const size of RADIO_SIZES) {
287
+ const slots = radioVariants({ size });
288
+ const ringToken = slots.indicator().match(/\bsize-(icon-[\w-]+)\b/)?.[1];
289
+ const ringPx = spacingPx(ringToken as string);
290
+ const borderPx = 2;
291
+ const dotPx = sizeStep(slots.dot()) * SPACING_STEP_PX;
292
+ expect(dotPx).toBeGreaterThan(0);
293
+ expect(dotPx).toBeLessThan(ringPx - 2 * borderPx);
294
+ }
295
+ });
296
+
297
+ test("steps with the ring, so the pair reads the same at every size", () => {
298
+ const dots = RADIO_SIZES.map((size) => sizeStep(radioVariants({ size }).dot()));
299
+ expect(new Set(dots).size).toBe(RADIO_SIZES.length);
300
+ expect([...dots]).toEqual([...dots].sort((a, b) => a - b));
301
+ });
302
+
303
+ // Both belong to the animated style. A class fighting a `useAnimatedStyle`
304
+ // for the same property is a dot that never appears, with no error anywhere.
305
+ test("claims neither opacity nor scale, which the animation owns", () => {
306
+ for (const cell of CELLS) {
307
+ const dot = radioVariants(cell).dot();
308
+ expect(dot).not.toMatch(/\bopacity-/);
309
+ expect(dot).not.toMatch(/\bscale-/);
310
+ }
311
+ });
312
+
313
+ test("carries no text colour, in any cell", () => {
314
+ for (const cell of CELLS) {
315
+ expect(radioVariants(cell).dot()).not.toMatch(/\btext-/);
316
+ }
317
+ });
318
+ });
319
+
320
+ describe("the label slot", () => {
321
+ // The scale belongs to `Text.Label`, which the part renders. Restating it
322
+ // here would be a second definition that could drift from it — the rule
323
+ // `Field` is built on, and the reason `Input` ships no label part at all.
324
+ test("restates no type scale", () => {
325
+ for (const cell of CELLS) {
326
+ const label = cls(radioVariants(cell).label());
327
+ expect(label).not.toMatch(/\btext-(xs|sm|base|lg|xl|2xl|3xl)\b/);
328
+ expect(label).not.toMatch(/\bfont-(normal|medium|semibold|bold)\b/);
329
+ }
330
+ });
331
+
332
+ test("carries no colour either, not even while invalid", () => {
333
+ for (const cell of CELLS) {
334
+ expect(cls(radioVariants(cell).label())).not.toMatch(/\btext-[\w-]+/);
335
+ }
336
+ });
337
+
338
+ // `flex-1` sets `flex-basis: 0%`, and in a content-sized horizontal row Yoga
339
+ // resolves that to zero — collapsing the label to nothing.
340
+ test("shrinks rather than claiming a basis", () => {
341
+ for (const cell of CELLS) {
342
+ const label = cls(radioVariants(cell).label());
343
+ expect(label).toContain("shrink");
344
+ expect(label).not.toMatch(/\bflex-1\b/);
345
+ }
346
+ });
347
+ });
348
+
349
+ describe("resolveIndicatorPlacement", () => {
350
+ test("reports nothing to place when the caller wrote no indicator", () => {
351
+ expect(resolveIndicatorPlacement([])).toBe("none");
352
+ expect(resolveIndicatorPlacement([false, false])).toBe("none");
353
+ });
354
+
355
+ test("reports a leading indicator", () => {
356
+ expect(resolveIndicatorPlacement([true, false])).toBe("start");
357
+ expect(resolveIndicatorPlacement([true, false, false])).toBe("start");
358
+ });
359
+
360
+ // A settings row: the content takes the left and the ring the far right.
361
+ test("reports a trailing indicator when something comes before it", () => {
362
+ expect(resolveIndicatorPlacement([false, true])).toBe("end");
363
+ expect(resolveIndicatorPlacement([false, false, true])).toBe("end");
364
+ });
365
+
366
+ // There is nothing to spread a lone ring away from.
367
+ test("treats a lone indicator as leading", () => {
368
+ expect(resolveIndicatorPlacement([true])).toBe("start");
369
+ });
370
+
371
+ test("ignores an indicator that is neither first nor last", () => {
372
+ expect(resolveIndicatorPlacement([false, true, false])).toBe("start");
373
+ });
374
+ });
375
+
376
+ describe("a trailing indicator", () => {
377
+ // Without this the caller has to wedge a `flex-1` spacer between the content
378
+ // and the ring to get a settings row, which is a layout hack in every call site.
379
+ test("spreads the row, so the ring reaches the far edge", () => {
380
+ expect(radioVariants({ isIndicatorTrailing: true }).root()).toContain("justify-between");
381
+ });
382
+
383
+ test("leaves a leading indicator packed against its label", () => {
384
+ expect(radioVariants({ isIndicatorTrailing: false }).root()).not.toContain("justify-between");
385
+ expect(radioVariants().root()).not.toContain("justify-between");
386
+ });
387
+
388
+ // The row is still one control however the ring is placed.
389
+ test("changes nothing else about the row", () => {
390
+ for (const size of RADIO_SIZES) {
391
+ const trailing = radioVariants({ isIndicatorTrailing: true, size }).root();
392
+ const leading = radioVariants({ isIndicatorTrailing: false, size }).root();
393
+ expect(trailing.replace(" justify-between", "").split(" ").sort()).toEqual(leading.split(" ").sort());
394
+ }
395
+ });
396
+ });
397
+
398
+ describe("a disabled radio", () => {
399
+ // The ring and the label carry the fade; the root stays clear so the two
400
+ // never compound.
401
+ test("fades its ring and its label", () => {
402
+ expect(radioVariants({ isDisabled: true }).indicator()).toContain("opacity-50");
403
+ expect(cls(radioVariants({ isDisabled: true }).label())).toContain("opacity-50");
404
+ });
405
+
406
+ test("fades nothing when it is not disabled", () => {
407
+ expect(radioVariants({ isDisabled: false }).indicator()).not.toContain("opacity-50");
408
+ expect(cls(radioVariants({ isDisabled: false }).label())).not.toContain("opacity-50");
409
+ });
410
+
411
+ // The dot sits inside an already-faded ring, and its own opacity belongs to
412
+ // the selection animation — a class here would fight the animated style.
413
+ test("leaves the dot alone, whose opacity the animation owns", () => {
414
+ expect(radioVariants({ isDisabled: true }).dot()).not.toMatch(/\bopacity-/);
415
+ });
416
+ });
417
+
418
+ describe("the defaults", () => {
419
+ // Two places name them — `defaultVariants` and `resolveRadioState` — and a
420
+ // drift between the two is a radio that renders at one size and reports another.
421
+ test("the named constants are the ones tv falls back to", () => {
422
+ expect(radioVariants().root()).toBe(radioVariants({ size: RADIO_DEFAULT_SIZE }).root());
423
+ expect(radioVariants().indicator()).toBe(
424
+ radioVariants({ size: RADIO_DEFAULT_SIZE, variant: RADIO_DEFAULT_VARIANT }).indicator()
425
+ );
426
+ });
427
+ });
428
+
429
+ describe("RADIO_LABEL_TEXT_SIZE", () => {
430
+ test("names a size the Text component actually has", () => {
431
+ for (const size of RADIO_SIZES) {
432
+ expect(TEXT_SIZES).toContain(RADIO_LABEL_TEXT_SIZE[size]);
433
+ }
434
+ });
435
+
436
+ test("covers every radio size", () => {
437
+ for (const size of RADIO_SIZES) {
438
+ expect(RADIO_LABEL_TEXT_SIZE[size]).toBeTruthy();
439
+ }
440
+ });
441
+
442
+ test("grows with the radio", () => {
443
+ const steps = RADIO_SIZES.map((size) => TEXT_SIZES.indexOf(RADIO_LABEL_TEXT_SIZE[size]));
444
+ expect(steps.every((step) => step >= 0)).toBe(true);
445
+ expect([...steps]).toEqual([...steps].sort((a, b) => a - b));
446
+ });
447
+ });
448
+
449
+ describe("RADIO_DOT_SPRING", () => {
450
+ // A zero mass hangs the UI thread, and a critically damped spring never
451
+ // overshoots — which is the whole reason to use one rather than a timing.
452
+ test("describes a spring that actually springs", () => {
453
+ expect(RADIO_DOT_SPRING.mass).toBeGreaterThan(0);
454
+ expect(RADIO_DOT_SPRING.stiffness).toBeGreaterThan(0);
455
+ expect(RADIO_DOT_SPRING.damping).toBeGreaterThan(0);
456
+ const critical = 2 * Math.sqrt(RADIO_DOT_SPRING.stiffness * RADIO_DOT_SPRING.mass);
457
+ expect(RADIO_DOT_SPRING.damping).toBeLessThan(critical);
458
+ });
459
+ });
460
+
461
+ describe("resolveRadioState, ungrouped", () => {
462
+ test("takes its own axes, and the defaults when it names none", () => {
463
+ expect(resolveRadioState({})).toEqual({
464
+ size: RADIO_DEFAULT_SIZE,
465
+ variant: RADIO_DEFAULT_VARIANT,
466
+ isSelected: false,
467
+ isDisabled: false,
468
+ isInvalid: false,
469
+ isGrouped: false,
470
+ });
471
+ expect(resolveRadioState({ own: { size: "lg", variant: "secondary" } })).toMatchObject({
472
+ size: "lg",
473
+ variant: "secondary",
474
+ });
475
+ });
476
+
477
+ test("reads selection from its own prop", () => {
478
+ expect(resolveRadioState({ own: { isSelected: true } }).isSelected).toBe(true);
479
+ expect(resolveRadioState({ own: { isSelected: false } }).isSelected).toBe(false);
480
+ });
481
+
482
+ // Without a group there is nothing for a value to identify it to.
483
+ test("ignores a value", () => {
484
+ expect(resolveRadioState({ own: { value: "a" } }).isSelected).toBe(false);
485
+ });
486
+
487
+ test("reports itself ungrouped", () => {
488
+ expect(resolveRadioState({ own: { isSelected: true } }).isGrouped).toBe(false);
489
+ });
490
+ });
491
+
492
+ describe("resolveRadioState, grouped", () => {
493
+ const group = { selected: "a", size: "lg", variant: "secondary" } as const;
494
+
495
+ test("is selected only when the group names its value", () => {
496
+ expect(resolveRadioState({ group, own: { value: "a" } }).isSelected).toBe(true);
497
+ expect(resolveRadioState({ group, own: { value: "b" } }).isSelected).toBe(false);
498
+ });
499
+
500
+ // A radio in a group with no value can never be selected. The dev warning
501
+ // for that lives in radio.tsx, where the component can be named.
502
+ test("is never selected without a value, even against an empty group", () => {
503
+ expect(resolveRadioState({ group, own: {} }).isSelected).toBe(false);
504
+ expect(resolveRadioState({ group: { ...group, selected: null }, own: {} }).isSelected).toBe(false);
505
+ });
506
+
507
+ // One box, one set of axes. A group whose options were different sizes is
508
+ // not a design — the call `Input` makes inside an `Input.Group`.
509
+ test("takes the group's size and variant over its own", () => {
510
+ for (const size of RADIO_SIZES) {
511
+ for (const variant of RADIO_VARIANTS) {
512
+ const state = resolveRadioState({ group, own: { size, variant } });
513
+ expect(state.size).toBe(group.size);
514
+ expect(state.variant).toBe(group.variant);
515
+ }
516
+ }
517
+ });
518
+
519
+ test("ignores its own isSelected", () => {
520
+ expect(resolveRadioState({ group, own: { value: "b", isSelected: true } }).isSelected).toBe(false);
521
+ });
522
+
523
+ test("reports itself grouped, even when the group has no selection", () => {
524
+ expect(resolveRadioState({ group: { ...group, selected: null } }).isGrouped).toBe(true);
525
+ });
526
+ });
527
+
528
+ // Nearest wins: the group first, the radio's own prop next, an enclosing Field
529
+ // last. Swept as a table so the precedence is a property rather than an anecdote.
530
+ describe("resolveRadioState, the state ladder", () => {
531
+ const SOURCES = [undefined, true, false] as const;
532
+
533
+ for (const axis of ["isDisabled", "isInvalid"] as const) {
534
+ test(`resolves ${axis} nearest-first across all twenty-seven combinations`, () => {
535
+ for (const inGroup of SOURCES) {
536
+ for (const inOwn of SOURCES) {
537
+ for (const inField of SOURCES) {
538
+ const state = resolveRadioState({
539
+ group: { selected: null, size: "md", variant: "primary", [axis]: inGroup },
540
+ own: { [axis]: inOwn },
541
+ field: { [axis]: inField },
542
+ });
543
+ expect(state[axis]).toBe(inGroup ?? inOwn ?? inField ?? false);
544
+ }
545
+ }
546
+ }
547
+ });
548
+ }
549
+
550
+ test("a radio opts out of an invalid Field", () => {
551
+ expect(resolveRadioState({ own: { isInvalid: false }, field: { isInvalid: true } }).isInvalid).toBe(false);
552
+ expect(resolveRadioState({ field: { isInvalid: true } }).isInvalid).toBe(true);
553
+ });
554
+
555
+ // The one place this differs from Input.Group, and the reason the group
556
+ // publishes its state axes raw: a group holds many radios, so disabling one
557
+ // option out of five has to be possible.
558
+ test("a group that names nothing lets one option disable itself", () => {
559
+ const group = { selected: null, size: "md", variant: "primary" } as const;
560
+ expect(resolveRadioState({ group, own: { isDisabled: true } }).isDisabled).toBe(true);
561
+ });
562
+
563
+ test("a group that disables everything cannot be escaped", () => {
564
+ const group = { selected: null, size: "md", variant: "primary", isDisabled: true } as const;
565
+ expect(resolveRadioState({ group, own: { isDisabled: false } }).isDisabled).toBe(true);
566
+ });
567
+ });
568
+
569
+ describe("shouldEmitSelection", () => {
570
+ test("reports a real change", () => {
571
+ expect(shouldEmitSelection(null, "a")).toBe(true);
572
+ expect(shouldEmitSelection("a", "b")).toBe(true);
573
+ });
574
+
575
+ // Re-picking the selected option is not a change, and a radio group has no
576
+ // deselect gesture. HTML's own radio does not fire `change` here either.
577
+ test("stays quiet when the option is already selected", () => {
578
+ expect(shouldEmitSelection("a", "a")).toBe(false);
579
+ });
580
+ });