@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,245 @@
1
+ # Radio
2
+
3
+ One option in a set where exactly one can be chosen — alone, or inside a group
4
+ that owns the selection. Compound root plus `Radio.Label`, `Radio.Indicator` and
5
+ `Radio.Group`.
6
+
7
+ `import { Radio } from "@delacour/react-native-ui/radio";`
8
+
9
+ ## Files
10
+
11
+ | File | What it holds |
12
+ | --- | --- |
13
+ | `index.ts` | → `@delacour/react-native-ui/radio` |
14
+ | `radio.tsx` | Root + the `Object.assign` compound surface, and `withIndicator` |
15
+ | `radio-group.tsx` | `Radio.Group`, which owns the selection |
16
+ | `radio-indicator.tsx` | `Radio.Indicator`, the ring and its animated dot |
17
+ | `radio-label.tsx` | `Radio.Label`, the `Text.Label` inside the tap target |
18
+ | `radio.context.tsx` | `RadioContext` and `RadioGroupContext`, with their hooks |
19
+ | `radio.variants.ts` | Pure `tv()` slots + three resolvers, no RN imports |
20
+ | `radio.variants.test.ts` | |
21
+
22
+ There is no `radio.types.ts`, unlike most compound folders here.
23
+ `{name}.types.ts` holds only prop types shared by two or more modules and a type
24
+ with exactly one consumer stays in that consumer's file — `RadioProps`,
25
+ `RadioGroupProps`, `RadioIndicatorProps` and `RadioLabelProps` each have exactly
26
+ one, so each lives beside the component it types. The state types the ladder
27
+ needs — `RadioGroupState`, `RadioOwnState`, `RadioFieldState`, `RadioState` —
28
+ sit in `radio.variants.ts` beside the pure resolver that reads them. See
29
+ [Compound component layout](../../../AGENTS.md#compound-component-layout).
30
+
31
+ ## Design
32
+
33
+ - **Variants**: `primary`, `secondary` — `secondary` fills the ring, `primary`
34
+ leaves it hollow. **Sizes**: `sm`, `md`, `lg`. **Orientations**, on the group
35
+ only: `vertical` stacks the radios, `horizontal` lays them out in a wrapping
36
+ row.
37
+ - **The whole row is the control.** The ring, the label beside it and the space
38
+ between them are one tap target, so a press anywhere on the row selects. A
39
+ `Radio.Indicator` is composed in automatically and plain string children are
40
+ wrapped in a `Radio.Label`, so the shortest thing that works is
41
+ `<Radio value="pro">Pro</Radio>`.
42
+ - **A radio works with or without a group.** Inside a `Radio.Group` its `value`
43
+ identifies it and the group owns the selection, the size and the variant.
44
+ Outside one it is driven by its own `isSelected` and `onSelected`.
45
+ - **`onSelected` is only ever called with `true`.** A radio does not deselect
46
+ itself, which is [Switch](../switch/AGENTS.md) and
47
+ [Checkbox](../checkbox/AGENTS.md) semantics rather than radio ones. The
48
+ group's own `onSelected` takes the newly selected value and is never called
49
+ for a re-press of the current one: re-picking the option already selected is
50
+ not a change and a radio group has no deselect gesture, so without
51
+ `shouldEmitSelection` a tap on the current selection would re-notify the caller
52
+ with a value it already holds. HTML's own radio does not fire `change` there
53
+ either. The decision is pure, so `bun test` reaches it.
54
+ - **The axis ladder is `group ?? own ?? field ?? default`, and it deliberately
55
+ puts the group *first*** — the inverse of `Checkbox`'s
56
+ `own ?? group ?? field ?? default`. A radio group puts itself first because
57
+ escaping a disabled form group is a bug. `size` and `variant` the group owns
58
+ outright and publishes resolved, so a grouped radio's own copies are ignored: a
59
+ group whose options were different sizes is not a design. The two *state* axes
60
+ are published raw, which is the one place this differs from `Input.Group`, and
61
+ deliberately: a group holds many radios, so disabling one option out of five
62
+ has to be possible. A group that names the axis still wins outright, so
63
+ `isDisabled={false}` opts a radio out of a disabled `Field` and not out of a
64
+ disabled group. `resolveRadioState` is pure, so the whole matrix is reachable
65
+ from `bun test` — [Input](../input/AGENTS.md) runs this same ladder inline in
66
+ its own render, where no unit test can see it.
67
+ - **`Radio` takes no `asChild`.** `Pressable`'s root `Animated.View` already owns
68
+ `opacity` and `transform` through a `useAnimatedStyle` of its own, and two
69
+ animated styles on one node fight for the same props — the same rule that keeps
70
+ the dot's animated style off the ring, and that
71
+ [Slider](../slider/AGENTS.md)'s root and thumb are built around. `asChild`,
72
+ `busy`, `children` and `disabled` are the four props `Omit`ed from the
73
+ `Pressable` surface the root otherwise passes through; `isDisabled` is the
74
+ public spelling of the last, because the settled value is what reaches
75
+ `Pressable`.
76
+ - **The animated style lives on the dot, never on the ring.** The dot is a
77
+ descendant two levels down from that `Animated.View`, so the two never contend.
78
+ A test asserts the `dot` slot claims neither `opacity-*` nor `scale-*` in any
79
+ cell: a class fighting a `useAnimatedStyle` for the same property is a dot that
80
+ never appears, with no error anywhere.
81
+ - **The disabled fade lands on the ring and the label, never on the root.** It
82
+ was placed there because [`Pressable`](../pressable/AGENTS.md)'s animated
83
+ style used to overwrite an `opacity-50` on the root; `Pressable` now
84
+ multiplies a className's opacity into its press, so the root *could* fade,
85
+ but the fade stays on the parts: an `opacity-50` on the root as well would
86
+ compound with them and land at a quarter. The group must not fade either, for
87
+ the same reason. Tests pin both halves. `Switch`, `Tabs` and `Accordion` all
88
+ cite back here.
89
+ - **The ring is drawn from `View`s rather than a Central Icon.** Rule 5 governs
90
+ icons and [Spinner](../spinner/AGENTS.md)'s arc is the precedent for
91
+ primitives; here the set has no ring-with-a-centred-dot glyph at all. Two
92
+ things follow from drawing it: the dot can scale from the ring's centre
93
+ entirely on the UI thread, and the ring's four themed colours stay classes in
94
+ `radio.variants.ts` where `bun test` reaches them. An `Icon` would take its
95
+ colour as a resolved value, splitting one decision across two mechanisms.
96
+ - **The dot springs on `RADIO_DOT_SPRING`, deliberately near `Pressable`'s
97
+ `PRESS_SPRING` but a touch looser**, so it settles just after the row it sits
98
+ in has rebounded. It is read on the JS thread inside an effect rather than
99
+ captured by a worklet, so it stays a plain object. `progress` is seeded from the
100
+ current state rather than from zero, so a group that mounts with a selection
101
+ does not animate every dot in on its first paint. Both branches animate: the
102
+ outgoing dot shrinks rather than vanishing, which is what makes a group read as
103
+ one selection moving between rows — and the `dot` slot's fill is unconditional
104
+ rather than gated on `isSelected`, or the dot would vanish on the frame the
105
+ state flips instead of shrinking. A test asserts the spring actually springs:
106
+ non-zero mass and stiffness, and damping below critical.
107
+ - **It is a spring rather than a timing, and that is the whole of the choice.**
108
+ A dot that eases in reads as a fade — a mark arriving on the surface. A dot
109
+ that springs reads as a selection landing, which is what a press on a radio row
110
+ actually is.
111
+ - **Reduce motion is left at Reanimated's default `System`, deliberately the
112
+ opposite call to `Spinner`'s `ReduceMotion.Never`** — and with `Checkbox`,
113
+ `Switch` and `Accordion`. The spinner needs `Never` because a zero-length
114
+ animation inside `withRepeat(-1)` would spin forever. Here the state is carried
115
+ by the dot's *presence*, not by its motion, so `System` snapping straight to the
116
+ target is exactly the right degradation.
117
+ - **`Radio.Indicator` takes children, and a render function is one of them.** A
118
+ function is handed the settled `isSelected`, `isInvalid`, `size` and `variant`;
119
+ either form replaces the dot outright — the animated `Animated.View` is not
120
+ rendered at all — while the ring, its size and its colours stay the radio's.
121
+ `dotProps` is the other door: props for the dot's own `Animated.View`, for a
122
+ caller restyling it in place rather than replacing it, with the animated style
123
+ applied before the caller's own so `style` composes rather than clobbers.
124
+ - **Where the indicator sits decides the row's layout, and the root works it
125
+ out.** `none` means the caller wrote no indicator, so one is composed in at the
126
+ front. `end` means one placed last with something before it — a settings row,
127
+ `[label and description] [ring]` — and the row spreads to push the ring to the
128
+ far edge. A lone indicator with nothing beside it is `start`: there is nothing
129
+ to spread it away from. That is what makes the trailing ring work without a
130
+ `flex-1` spacer wedged between the two, which is the shape a caller would
131
+ otherwise have to reach for. `isIndicatorTrailing` is a variant rather than a
132
+ caller's `justify-between` because the root already knows where the indicator
133
+ landed, and a test asserts a trailing row differs from a leading one by exactly
134
+ that one class. `resolveIndicatorPlacement` takes an array of "is this child an
135
+ indicator" rather than the children themselves, so it stays free of React and
136
+ reachable from `bun test` — the trade `resolveSpinnerSwapIndex` already makes.
137
+ - **Bare text is wrapped in a `Radio.Label`, consecutive strings collapsing into
138
+ one.** React Native cannot render a string outside a `<Text>`, so
139
+ `<Radio>Yes</Radio>` would otherwise crash. They collapse rather than wrapping
140
+ one each because `Plan {n}` is a single piece of text, and wrapping the parts
141
+ separately would space them apart by the row's own gap — the same rule, and the
142
+ same reason, as [Button](../button/AGENTS.md). `withIndicator` lives in
143
+ `radio.tsx` rather than with the parts: it is the root that wraps its own
144
+ children, and importing it from a part would close a cycle. Rule 3.
145
+ - **The indicator is detected by reference, and that is a known wart.**
146
+ `withIndicator` asks `child.type === RadioIndicator`, where `Switch` asks its
147
+ child's `displayName` through `isSwitchThumbElement` and keeps reference
148
+ equality only as a fast path. Reference is not a safe question here: this
149
+ package ships raw `.tsx` for the consuming app to compile, so React Compiler
150
+ rewrites the binding on the way through, and Metro can serve two instances of
151
+ one module through a workspace symlink — either leaves an element whose `type`
152
+ is a different object standing for the same component. Detection then returns
153
+ false and a second ring is composed in at the front of the row. Nobody has
154
+ reported it because the duplicate overlaps invisibly rather than hiding
155
+ content — a second ring exactly behind the first — where the same bug in
156
+ `Switch` swallowed anything inside the caller's knob. `displayName` survives
157
+ all of it, rule 12 requires one, and `display-name.test.ts` already enforces
158
+ that they are present and unique. Worth fixing the next time this file is
159
+ touched.
160
+ - **`Radio.Label` *is* `Text.Label`.** It renders the preset and names a size
161
+ step from `RADIO_LABEL_TEXT_SIZE`, never a scale, a weight or a colour of its
162
+ own — restating them would be a second definition of `Text.Label` that could
163
+ drift from it, the rule [Field](../field/AGENTS.md) is built on. Naming the
164
+ size is what lets the label still track the radio's own axis, since
165
+ [Text](../text/AGENTS.md)'s size axis is built to beat its preset. The `label`
166
+ slot carries layout and nothing else, and a test asserts it holds no `text-*`
167
+ or `font-*` in any cell, plus that every step it names is a size `Text`
168
+ actually has.
169
+ - **The label does not redden while invalid.** The ring already carries that, and
170
+ a `Field.Error` under the group says what is actually wrong; five labels turning
171
+ red would read as five wrong answers. Disabled needs nothing from the part
172
+ either — the `isDisabled` variant fades the ring and the label together, as one
173
+ control.
174
+ - **The label is `shrink`, never `flex-1`.** `flex-1` sets `flex-basis: 0%`, and
175
+ in a content-sized `horizontal` row Yoga resolves that to zero and collapses
176
+ the text to nothing. `shrink` lets a long label wrap without claiming a basis.
177
+ - **The root takes no `self-start` and no `w-full`, and both absences are
178
+ load-bearing.** A radio row *is* its own tap target, so unlike a `Badge` it
179
+ wants the stretch it gets inside the group's `flex-col` — `self-start` would
180
+ shrink the target to the width of the word "Yes". But unlike a `ListGroup.Item`
181
+ it cannot take `w-full` either, because a `horizontal` group would then give
182
+ every radio the full width of the group and blow the row apart. Vertical
183
+ stretch already supplies the width; horizontal wants content width.
184
+ - **The row's height is a floor, never fixed.** `Text` respects OS font scaling,
185
+ so `h-*` would clip a label at a large accessibility step; `min-h-*` exists for
186
+ the hit target, which scaling may exceed but must never undercut.
187
+ - **The ring indexes the shared `--spacing-icon-*` scale rather than minting one
188
+ of its own.** A radio's ring is a small round mark in a row beside a label, the
189
+ same kind of thing as a row's chevron, and it should stay level with an `Icon`
190
+ at the same step by construction. The dot takes plain spacing steps — numbers
191
+ read in one component — and its fit inside the ring is pinned by a test rather
192
+ than by a token, the way `Field` pins its gap ladder: the test reads
193
+ `tokens.css` and asserts the dot clears the ring's inner diameter, border
194
+ included, at every size. See [Sizing](../../../AGENTS.md#sizing).
195
+ - **`border-2` sits in the base rather than on a variant.** A border declared
196
+ only where it is coloured would make the ring four points smaller the moment a
197
+ caller switched variant. A test asserts every cell reserves it and every cell
198
+ names a border colour.
199
+ - **All four `variant × isSelected` cells live in `compoundVariants`**, because
200
+ neither axis paints the ring alone — the reason a badge's twenty-four do.
201
+ Invalid outranks selected on both the ring and the dot, and it is a compound
202
+ rather than a plain variant purely for emission order: `tv` emits the variants
203
+ first and the compounds after, so a plain `isInvalid` branch would lose to the
204
+ four cells above. `Input` leans on the same mechanism for focused-and-invalid.
205
+ - **The group renders no legend, description or error.** `Field` already owns all
206
+ three, and a second definition of a label is a type scale that can drift — the
207
+ trade `Input` made. That does leave the group without an accessible name, since
208
+ React Native has no `aria-labelledby` to tie it to the `Field.Label` above it,
209
+ so pass `accessibilityLabel`. The group is a `View` carrying
210
+ `accessibilityRole="radiogroup"`, where `Checkbox.Group` carries no role at
211
+ all.
212
+ - **`selected` takes `value ?? null`.** `null` means "controlled, nothing
213
+ selected". Omitting the prop entirely is what makes the group uncontrolled, so a
214
+ `useState<string>()` seeded with `undefined` would silently hand the group its
215
+ own state and then switch it to controlled on the first press. This is the
216
+ first exercise `useControllableState` has had in the package, and the change
217
+ handler is memoised: without that, the setter rebuilds every render, which
218
+ rebuilds the context value, which re-renders every radio in the group on every
219
+ render of whatever holds it.
220
+ - **`isInvalid` and `isDisabled` are not defaulted in the group's destructure.**
221
+ A `false` there would swallow the `Field` before it was ever consulted. See
222
+ `input-group.tsx`.
223
+ - **A grouped radio with no `value` warns in development rather than throwing.**
224
+ It can never be selected, and group membership is invisible in the child's
225
+ props at compile time, so it cannot be a type error either. The warning lives
226
+ in `radio.tsx`, where the component name is available to name in the message.
227
+ - **A radio whose row holds no text needs an `accessibilityLabel`**, the same
228
+ rule an icon-sized `Button` follows.
229
+ - **There is no `Radio.Description`.** A `Text.Caption` composed inside the row
230
+ stays within the one tap target and inside the accessible name, which is what a
231
+ description part would have to do anyway. The root publishes the label's
232
+ treatment through `TextClassProvider` because one treatment covers the whole
233
+ subtree — a radio row has a label and no description part — which is the
234
+ condition for publishing into the cascade.
235
+ - **Two `Pressable` defaults differ and only two**: `feedback="fade"` and
236
+ `hitSlop={8}`, both ordinary props a caller can override. `haptic` is left at
237
+ `Pressable`'s own `false`, unlike `Checkbox`'s `haptic="selection"`.
238
+ - **The parts read only the settled state.** `RadioProvider` publishes it, so
239
+ `Radio.Indicator` never has to know whether it is inside a group, never reads a
240
+ `Field`, and never imports the root. `useRadioGroupContext` is nullable because
241
+ a radio has to work perfectly well on its own, the same way `useFieldContext`
242
+ is for every control that can stand outside a `Field`; `useRadio` and
243
+ `useRadioGroup` throw, and `useRadioPart` is internal and deliberately not
244
+ re-exported from `index.ts` — a caller outside the library wants `useRadio`,
245
+ whose error message names the hook rather than a part.
@@ -0,0 +1,35 @@
1
+ export { Radio, type RadioProps } from "./radio";
2
+ export {
3
+ type RadioContextValue,
4
+ type RadioGroupContextValue,
5
+ RadioGroupProvider,
6
+ RadioProvider,
7
+ useRadio,
8
+ useRadioContext,
9
+ useRadioGroup,
10
+ useRadioGroupContext,
11
+ } from "./radio.context";
12
+ export {
13
+ RADIO_DEFAULT_SIZE,
14
+ RADIO_DEFAULT_VARIANT,
15
+ RADIO_DOT_SPRING,
16
+ RADIO_LABEL_TEXT_SIZE,
17
+ RADIO_ORIENTATIONS,
18
+ RADIO_SIZES,
19
+ RADIO_VARIANTS,
20
+ type RadioFieldState,
21
+ type RadioGroupState,
22
+ type RadioOrientation,
23
+ type RadioOwnState,
24
+ type RadioSize,
25
+ type RadioState,
26
+ type RadioVariant,
27
+ type RadioVariantProps,
28
+ radioVariants,
29
+ resolveIndicatorPlacement,
30
+ resolveRadioState,
31
+ shouldEmitSelection,
32
+ } from "./radio.variants";
33
+ export type { RadioGroupProps } from "./radio-group";
34
+ export type { RadioIndicatorProps } from "./radio-indicator";
35
+ export type { RadioLabelProps } from "./radio-label";
@@ -0,0 +1,126 @@
1
+ import { type ReactElement, useCallback, useMemo } from "react";
2
+ import { View, type ViewProps } from "react-native";
3
+ import { useControllableState } from "../../hooks/use-controllable-state";
4
+ import { useFieldContext } from "../field/field.context";
5
+ import { type RadioGroupContextValue, RadioGroupProvider } from "./radio.context";
6
+ import {
7
+ type RadioOrientation,
8
+ type RadioSize,
9
+ type RadioVariant,
10
+ radioVariants,
11
+ shouldEmitSelection,
12
+ } from "./radio.variants";
13
+
14
+ export type RadioGroupProps = ViewProps & {
15
+ /**
16
+ * The selected radio's value.
17
+ *
18
+ * `null` means "controlled, nothing selected". Omitting the prop entirely is
19
+ * what makes the group uncontrolled, so a `useState<string>()` seeded with
20
+ * `undefined` would silently hand the group its own state and then switch it
21
+ * to controlled on the first press. Pass `value ?? null`.
22
+ */
23
+ selected?: string | null;
24
+ /** Initial selection while uncontrolled. */
25
+ defaultSelected?: string | null;
26
+ /** Called with the newly selected value. Never called for a re-press of the current one. */
27
+ onSelected?: (value: string) => void;
28
+ /** Size of every radio in the group. */
29
+ size?: RadioSize;
30
+ /** How every ring in the group is painted. */
31
+ variant?: RadioVariant;
32
+ /** `vertical` stacks the radios; `horizontal` lays them out in a wrapping row. */
33
+ orientation?: RadioOrientation;
34
+ /** Marks every radio invalid. Inherited from an enclosing `Field` when not given. */
35
+ isInvalid?: boolean;
36
+ /** Disables every radio. Inherited from an enclosing `Field` when not given. */
37
+ isDisabled?: boolean;
38
+ className?: string;
39
+ };
40
+
41
+ /**
42
+ * Groups radios and owns which one is selected.
43
+ *
44
+ * Selection is `selected` plus `onSelected`, or `defaultSelected` alone to let
45
+ * the group hold its own. `size` and `variant` belong to the group outright — a
46
+ * group whose options were different sizes is not a design — while `isDisabled`
47
+ * and `isInvalid` are published raw, so one option can still disable itself.
48
+ *
49
+ * It renders no legend, description or error. `Field` already owns all three, and
50
+ * a second definition of a label is a type scale that can drift — the trade
51
+ * `Input` made. That does leave the group without an accessible name, since React
52
+ * Native has no `aria-labelledby` to tie it to the `Field.Label` above it, so
53
+ * pass `accessibilityLabel`.
54
+ *
55
+ * @example
56
+ * <Radio.Group onSelected={setPlan} selected={plan ?? null} accessibilityLabel="Plan">
57
+ * <Radio value="free"><Radio.Label>Free</Radio.Label></Radio>
58
+ * <Radio value="pro"><Radio.Label>Pro</Radio.Label></Radio>
59
+ * </Radio.Group>
60
+ */
61
+ export function RadioGroup({
62
+ selected: selectedProp,
63
+ defaultSelected,
64
+ onSelected,
65
+ size = "md",
66
+ variant = "primary",
67
+ orientation = "vertical",
68
+ isInvalid,
69
+ isDisabled,
70
+ className,
71
+ ...props
72
+ }: RadioGroupProps): ReactElement {
73
+ const field = useFieldContext();
74
+
75
+ // Memoised, or `useControllableState`'s setter rebuilds every render, which
76
+ // rebuilds the context value, which re-renders every radio in the group on
77
+ // every render of whatever holds it.
78
+ const handleChange = useCallback(
79
+ (next: string | null) => {
80
+ if (next !== null) onSelected?.(next);
81
+ },
82
+ [onSelected]
83
+ );
84
+
85
+ const [selected, setSelected] = useControllableState<string | null>({
86
+ value: selectedProp,
87
+ defaultValue: defaultSelected ?? null,
88
+ onChange: handleChange,
89
+ });
90
+
91
+ const select = useCallback(
92
+ (next: string) => {
93
+ if (shouldEmitSelection(selected, next)) setSelected(next);
94
+ },
95
+ [selected, setSelected]
96
+ );
97
+
98
+ // Deliberately not defaulted in the destructure — `false` there would swallow
99
+ // the field before it was ever consulted. See `input-group.tsx`.
100
+ const resolvedIsInvalid = isInvalid ?? field?.isInvalid;
101
+ const resolvedIsDisabled = isDisabled ?? field?.isDisabled;
102
+
103
+ const context = useMemo<RadioGroupContextValue>(
104
+ () => ({
105
+ isDisabled: resolvedIsDisabled,
106
+ isInvalid: resolvedIsInvalid,
107
+ orientation,
108
+ select,
109
+ selected,
110
+ size,
111
+ variant,
112
+ }),
113
+ [resolvedIsDisabled, resolvedIsInvalid, orientation, select, selected, size, variant]
114
+ );
115
+
116
+ return (
117
+ <RadioGroupProvider value={context}>
118
+ <View
119
+ accessibilityRole="radiogroup"
120
+ className={radioVariants({ orientation, size }).group({ className })}
121
+ {...props}
122
+ />
123
+ </RadioGroupProvider>
124
+ );
125
+ }
126
+ RadioGroup.displayName = "DelacourUI.Radio.Group";
@@ -0,0 +1,91 @@
1
+ import { type ComponentProps, type ReactElement, type ReactNode, useEffect } from "react";
2
+ import { View, type ViewProps } from "react-native";
3
+ import Animated, { useAnimatedStyle, useSharedValue, withSpring } from "react-native-reanimated";
4
+ import { useRadioPart } from "./radio.context";
5
+ import { RADIO_DOT_SPRING, radioVariants } from "./radio.variants";
6
+
7
+ export type RadioIndicatorChildrenProps = {
8
+ isSelected: boolean;
9
+ isInvalid: boolean;
10
+ size: "lg" | "md" | "sm";
11
+ variant: "primary" | "secondary";
12
+ };
13
+ export type RadioIndicatorProps = Omit<ViewProps, "children"> & {
14
+ className?: string;
15
+ /** Replaces the dot. The ring, its size and its colours are still the radio's. */
16
+ children?: ReactNode | ((props: RadioIndicatorChildrenProps) => ReactNode);
17
+ /** Props for the dot's own `Animated.View`, for a caller restyling it in place. */
18
+ dotProps?: Omit<ComponentProps<typeof Animated.View>, "children" | "className"> & { className?: string };
19
+ };
20
+
21
+ /**
22
+ * The circle: a ring, and the dot that scales into it when the radio is selected.
23
+ *
24
+ * Composed in automatically as the radio's first child, so write it out by hand
25
+ * only to move it — a trailing indicator is `<Radio.Indicator />` placed last —
26
+ * or to replace the dot with children of your own.
27
+ *
28
+ * **Drawn from `View`s rather than a Central Icon.** Rule 5 governs icons, and
29
+ * `Spinner`'s arc is the precedent for primitives; here the set has no
30
+ * ring-with-a-centred-dot glyph at all. Two things follow from drawing it: the
31
+ * dot can scale from the ring's centre entirely on the UI thread, and the ring's
32
+ * four themed colours stay classes in `radio.variants.ts` where `bun test`
33
+ * reaches them. An `Icon` would take its colour as a resolved value, splitting
34
+ * one decision across two mechanisms.
35
+ *
36
+ * **The animated style lives on the dot, never on the ring.** `Pressable`'s root
37
+ * `Animated.View` already owns `opacity` and `transform` through a
38
+ * `useAnimatedStyle` of its own, and two animated styles on one node fight for
39
+ * the same props. The dot is a descendant two levels down, so the two never
40
+ * contend — which is also why `Radio` takes no `asChild`.
41
+ *
42
+ * **Reduce motion is left at Reanimated's default `System`, deliberately the
43
+ * opposite call to `Spinner`'s `ReduceMotion.Never`.** The spinner needs `Never`
44
+ * because a zero-length animation inside `withRepeat(-1)` would spin forever.
45
+ * Here the state is carried by the dot's *presence*, not by its motion, so
46
+ * `System` snapping straight to the target is exactly the right degradation.
47
+ */
48
+ export function RadioIndicator({ className, children, dotProps, ...props }: RadioIndicatorProps): ReactElement {
49
+ const { size, variant, isSelected, isInvalid } = useRadioPart("Radio.Indicator");
50
+ // Seeded from the current state rather than from zero, so a group that mounts
51
+ // with a selection does not animate every dot in on its first paint.
52
+ const progress = useSharedValue(isSelected ? 1 : 0);
53
+
54
+ useEffect(() => {
55
+ progress.value = withSpring(isSelected ? 1 : 0, RADIO_DOT_SPRING);
56
+ }, [isSelected, progress]);
57
+
58
+ // Both branches animate: the outgoing dot shrinks rather than vanishing, which
59
+ // is what makes a group read as one selection moving between rows.
60
+ const dotStyle = useAnimatedStyle(() => ({
61
+ opacity: progress.value,
62
+ transform: [{ scale: progress.value }],
63
+ }));
64
+
65
+ const slots = radioVariants({ isInvalid, isSelected, size, variant });
66
+
67
+ const renderChildren = () => {
68
+ if (typeof children === "function") {
69
+ return children({ isSelected, isInvalid, size, variant });
70
+ }
71
+
72
+ if (children) {
73
+ return children;
74
+ }
75
+
76
+ return null;
77
+ };
78
+
79
+ return (
80
+ <View className={slots.indicator({ className })} {...props}>
81
+ {renderChildren() ?? (
82
+ <Animated.View
83
+ {...dotProps}
84
+ className={slots.dot({ className: dotProps?.className })}
85
+ style={[dotStyle, dotProps?.style]}
86
+ />
87
+ )}
88
+ </View>
89
+ );
90
+ }
91
+ RadioIndicator.displayName = "DelacourUI.Radio.Indicator";
@@ -0,0 +1,39 @@
1
+ import type { ReactElement } from "react";
2
+ import { Text, type TextPresetProps } from "../text";
3
+ import { useRadioPart } from "./radio.context";
4
+ import { RADIO_LABEL_TEXT_SIZE, radioVariants } from "./radio.variants";
5
+
6
+ export type RadioLabelProps = TextPresetProps;
7
+
8
+ /**
9
+ * The radio's text, inside the radio's own tap target.
10
+ *
11
+ * Not a duplicate of `Field.Label`, and the distinction is the point: a
12
+ * `Field.Label` names the whole control from outside it, while this sits *within*
13
+ * one radio's press area, so tapping the word selects that option.
14
+ *
15
+ * Renders `Text.Label` and names a size, never a scale of its own. The weight and
16
+ * the colour belong to the preset — restating them here would be a second
17
+ * definition of `Text.Label` that could drift from it, the rule `Field` is built
18
+ * on. Naming the size is what lets the label still track the radio's own axis,
19
+ * since `Text`'s size axis is built to beat its preset.
20
+ *
21
+ * **It does not redden while invalid.** The ring already carries that, and a
22
+ * `Field.Error` under the group says what is actually wrong; five labels turning
23
+ * red would read as five wrong answers. Disabled needs nothing here either: the
24
+ * `label` slot carries its own `opacity-50`, and the `indicator` slot carries a
25
+ * matching one, so the ring and the label fade together as one control. Neither
26
+ * fade may move to the `root` — see {@link radioVariants}.
27
+ */
28
+ export function RadioLabel({ className, size, ...props }: RadioLabelProps): ReactElement {
29
+ const { size: radioSize, variant, isSelected, isInvalid } = useRadioPart("Radio.Label");
30
+
31
+ return (
32
+ <Text.Label
33
+ className={radioVariants({ isInvalid, isSelected, size: radioSize, variant }).label({ className })}
34
+ size={size ?? RADIO_LABEL_TEXT_SIZE[radioSize]}
35
+ {...props}
36
+ />
37
+ );
38
+ }
39
+ RadioLabel.displayName = "DelacourUI.Radio.Label";
@@ -0,0 +1,132 @@
1
+ import { createContext, type ReactElement, type ReactNode, use } from "react";
2
+ import type { RadioOrientation, RadioSize, RadioVariant } from "./radio.variants";
3
+
4
+ export type RadioGroupContextValue = {
5
+ /** The selected radio's value. `null` is "nothing selected", never `undefined`. */
6
+ selected: string | null;
7
+ /** What a radio calls on press. Stays quiet when the value is already selected. */
8
+ select: (value: string) => void;
9
+ /** Size of every radio in the group. The group owns this axis outright. */
10
+ size: RadioSize;
11
+ /** How every ring in the group is painted. The group owns this axis outright. */
12
+ variant: RadioVariant;
13
+ /** Which way the group lays its radios out. */
14
+ orientation: RadioOrientation;
15
+ /**
16
+ * Raw rather than resolved, and deliberately so.
17
+ *
18
+ * `Input.Group` publishes these settled because it draws a box whose state it
19
+ * has to paint. A radio group paints nothing, and it holds many radios — so
20
+ * leaving `undefined` to mean "the group said nothing" is what lets a single
21
+ * option disable itself. A group that does name the axis still wins outright.
22
+ */
23
+ isDisabled?: boolean;
24
+ isInvalid?: boolean;
25
+ };
26
+
27
+ export type RadioContextValue = {
28
+ /** Size of the radio, already settled from group, own props and field. */
29
+ size: RadioSize;
30
+ /** How the ring is painted, already settled. */
31
+ variant: RadioVariant;
32
+ /** Whether this radio is the selection. */
33
+ isSelected: boolean;
34
+ /** Whether this radio is unavailable. */
35
+ isDisabled: boolean;
36
+ /** Whether this radio reports an invalid value. */
37
+ isInvalid: boolean;
38
+ };
39
+
40
+ const RadioGroupContext = createContext<RadioGroupContextValue | null>(null);
41
+ const RadioContext = createContext<RadioContextValue | null>(null);
42
+
43
+ /**
44
+ * Supplies the enclosing group's selection and axes to the radios inside it.
45
+ *
46
+ * Lives in its own module, importing nothing but React and types, so a part can
47
+ * read it without importing `./radio` or `./radio-group`. That import would close
48
+ * a cycle, and Metro serves a partially initialised module for a cycle — leaving
49
+ * the context `undefined` at import time and red-boxing the app on a cold start.
50
+ */
51
+ export function RadioGroupProvider({
52
+ value,
53
+ children,
54
+ }: {
55
+ value: RadioGroupContextValue;
56
+ children: ReactNode;
57
+ }): ReactElement {
58
+ return <RadioGroupContext value={value}>{children}</RadioGroupContext>;
59
+ }
60
+ RadioGroupProvider.displayName = "DelacourUI.Radio.Group.Provider";
61
+
62
+ /**
63
+ * Supplies one radio's settled state to its own parts.
64
+ *
65
+ * What the parts read is always the *resolved* state, so `Radio.Indicator` never
66
+ * has to know whether it is inside a group, never reads a `Field`, and never
67
+ * imports the root.
68
+ */
69
+ export function RadioProvider({ value, children }: { value: RadioContextValue; children: ReactNode }): ReactElement {
70
+ return <RadioContext value={value}>{children}</RadioContext>;
71
+ }
72
+ RadioProvider.displayName = "DelacourUI.Radio.Provider";
73
+
74
+ /**
75
+ * The enclosing group's context, or null outside a `<Radio.Group>`.
76
+ *
77
+ * This is the export the radio root reads: it is nullable because a radio has to
78
+ * work perfectly well on its own, the same way `useFieldContext` is nullable for
79
+ * every control that can stand outside a `Field`.
80
+ */
81
+ export function useRadioGroupContext(): RadioGroupContextValue | null {
82
+ return use(RadioGroupContext);
83
+ }
84
+
85
+ /**
86
+ * Reads the enclosing group's selection and axes.
87
+ *
88
+ * For a custom control that has to match the group it sits in. Throws outside
89
+ * one — use {@link useRadioGroupContext} where the group is optional.
90
+ */
91
+ export function useRadioGroup(): RadioGroupContextValue {
92
+ const context = useRadioGroupContext();
93
+ if (!context) {
94
+ throw new Error("useRadioGroup must be called inside a <Radio.Group>.");
95
+ }
96
+ return context;
97
+ }
98
+
99
+ /** The enclosing radio's settled state, or null outside a `<Radio>`. */
100
+ export function useRadioContext(): RadioContextValue | null {
101
+ return use(RadioContext);
102
+ }
103
+
104
+ /**
105
+ * Reads the enclosing radio's settled state.
106
+ *
107
+ * Lets a custom child style itself to match without the radio having to pass
108
+ * props down through every slot. Throws outside a `<Radio>` — use
109
+ * {@link useRadioContext} where the enclosing radio is optional.
110
+ */
111
+ export function useRadio(): RadioContextValue {
112
+ const context = useRadioContext();
113
+ if (!context) {
114
+ throw new Error("useRadio must be called inside a <Radio>.");
115
+ }
116
+ return context;
117
+ }
118
+
119
+ /**
120
+ * The enclosing radio's state, for a compound part that cannot work without one.
121
+ *
122
+ * Internal: deliberately not re-exported from `index.ts`. A caller outside the
123
+ * library wants {@link useRadio}, whose error message names the hook rather than
124
+ * a part.
125
+ */
126
+ export function useRadioPart(component: string): RadioContextValue {
127
+ const context = useRadioContext();
128
+ if (!context) {
129
+ throw new Error(`${component} must be rendered inside a <Radio>.`);
130
+ }
131
+ return context;
132
+ }