@delacour/react-native-ui 0.1.0-alpha.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (273) hide show
  1. package/README.md +113 -0
  2. package/package.json +125 -0
  3. package/src/components/accordion/AGENTS.md +192 -0
  4. package/src/components/accordion/accordion-content.tsx +125 -0
  5. package/src/components/accordion/accordion-description.tsx +17 -0
  6. package/src/components/accordion/accordion-indicator.tsx +104 -0
  7. package/src/components/accordion/accordion-item.tsx +78 -0
  8. package/src/components/accordion/accordion-title.tsx +18 -0
  9. package/src/components/accordion/accordion-trigger.tsx +148 -0
  10. package/src/components/accordion/accordion.context.tsx +153 -0
  11. package/src/components/accordion/accordion.tsx +253 -0
  12. package/src/components/accordion/accordion.types.ts +11 -0
  13. package/src/components/accordion/accordion.variants.test.ts +435 -0
  14. package/src/components/accordion/accordion.variants.ts +358 -0
  15. package/src/components/accordion/index.ts +45 -0
  16. package/src/components/badge/AGENTS.md +83 -0
  17. package/src/components/badge/badge-close-button.tsx +48 -0
  18. package/src/components/badge/badge-end-content.tsx +15 -0
  19. package/src/components/badge/badge-label.tsx +24 -0
  20. package/src/components/badge/badge-start-content.tsx +16 -0
  21. package/src/components/badge/badge.context.tsx +64 -0
  22. package/src/components/badge/badge.tsx +192 -0
  23. package/src/components/badge/badge.types.ts +10 -0
  24. package/src/components/badge/badge.variants.test.ts +269 -0
  25. package/src/components/badge/badge.variants.ts +238 -0
  26. package/src/components/badge/index.ts +17 -0
  27. package/src/components/bottom-sheet/AGENTS.md +232 -0
  28. package/src/components/bottom-sheet/bottom-sheet-background.tsx +35 -0
  29. package/src/components/bottom-sheet/bottom-sheet-close.tsx +60 -0
  30. package/src/components/bottom-sheet/bottom-sheet-container.tsx +245 -0
  31. package/src/components/bottom-sheet/bottom-sheet-content.tsx +66 -0
  32. package/src/components/bottom-sheet/bottom-sheet-description.tsx +29 -0
  33. package/src/components/bottom-sheet/bottom-sheet-footer.tsx +148 -0
  34. package/src/components/bottom-sheet/bottom-sheet-handle.tsx +47 -0
  35. package/src/components/bottom-sheet/bottom-sheet-overlay.tsx +180 -0
  36. package/src/components/bottom-sheet/bottom-sheet-portal.tsx +72 -0
  37. package/src/components/bottom-sheet/bottom-sheet-scroll-view.tsx +95 -0
  38. package/src/components/bottom-sheet/bottom-sheet-title.tsx +24 -0
  39. package/src/components/bottom-sheet/bottom-sheet-trigger.tsx +73 -0
  40. package/src/components/bottom-sheet/bottom-sheet.context.tsx +135 -0
  41. package/src/components/bottom-sheet/bottom-sheet.tsx +125 -0
  42. package/src/components/bottom-sheet/bottom-sheet.variants.test.ts +276 -0
  43. package/src/components/bottom-sheet/bottom-sheet.variants.ts +199 -0
  44. package/src/components/bottom-sheet/index.ts +40 -0
  45. package/src/components/bottom-sheet/use-bottom-sheet-input.ts +125 -0
  46. package/src/components/button/AGENTS.md +217 -0
  47. package/src/components/button/button-end-content.tsx +15 -0
  48. package/src/components/button/button-group-separator.tsx +37 -0
  49. package/src/components/button/button-group-text.tsx +101 -0
  50. package/src/components/button/button-group.tsx +158 -0
  51. package/src/components/button/button-label.tsx +24 -0
  52. package/src/components/button/button-start-content.tsx +15 -0
  53. package/src/components/button/button.context.tsx +187 -0
  54. package/src/components/button/button.tsx +273 -0
  55. package/src/components/button/button.types.ts +10 -0
  56. package/src/components/button/button.variants.test.ts +811 -0
  57. package/src/components/button/button.variants.ts +480 -0
  58. package/src/components/button/index.ts +50 -0
  59. package/src/components/chart/AGENTS.md +210 -0
  60. package/src/components/chart/chart-area.tsx +93 -0
  61. package/src/components/chart/chart-bar.tsx +103 -0
  62. package/src/components/chart/chart-bars.tsx +75 -0
  63. package/src/components/chart/chart-candlestick.tsx +60 -0
  64. package/src/components/chart/chart-grid.tsx +33 -0
  65. package/src/components/chart/chart-legend.tsx +41 -0
  66. package/src/components/chart/chart-line.tsx +50 -0
  67. package/src/components/chart/chart-scatter.tsx +35 -0
  68. package/src/components/chart/chart-tooltip-dot.tsx +70 -0
  69. package/src/components/chart/chart-tooltip-x.tsx +121 -0
  70. package/src/components/chart/chart-tooltip-y.tsx +55 -0
  71. package/src/components/chart/chart-tooltip.tsx +133 -0
  72. package/src/components/chart/chart-x-axis.tsx +27 -0
  73. package/src/components/chart/chart-y-axis.tsx +21 -0
  74. package/src/components/chart/chart.context.tsx +127 -0
  75. package/src/components/chart/chart.tsx +448 -0
  76. package/src/components/chart/chart.types.ts +108 -0
  77. package/src/components/chart/chart.variants.test.ts +893 -0
  78. package/src/components/chart/chart.variants.ts +726 -0
  79. package/src/components/chart/index.ts +103 -0
  80. package/src/components/chart/pie-chart-center.tsx +40 -0
  81. package/src/components/chart/pie-chart-label.tsx +56 -0
  82. package/src/components/chart/pie-chart-slice.tsx +45 -0
  83. package/src/components/chart/pie-chart-tooltip.tsx +85 -0
  84. package/src/components/chart/pie-chart.context.tsx +77 -0
  85. package/src/components/chart/pie-chart.tsx +247 -0
  86. package/src/components/chart/use-chart-font.ts +25 -0
  87. package/src/components/chart/use-chart-palette.ts +38 -0
  88. package/src/components/checkbox/AGENTS.md +185 -0
  89. package/src/components/checkbox/checkbox-box.tsx +152 -0
  90. package/src/components/checkbox/checkbox-group.tsx +107 -0
  91. package/src/components/checkbox/checkbox-label.tsx +34 -0
  92. package/src/components/checkbox/checkbox.context.tsx +136 -0
  93. package/src/components/checkbox/checkbox.tsx +258 -0
  94. package/src/components/checkbox/checkbox.types.ts +14 -0
  95. package/src/components/checkbox/checkbox.variants.test.ts +634 -0
  96. package/src/components/checkbox/checkbox.variants.ts +484 -0
  97. package/src/components/checkbox/index.ts +42 -0
  98. package/src/components/field/AGENTS.md +100 -0
  99. package/src/components/field/field-content.tsx +24 -0
  100. package/src/components/field/field-description.tsx +29 -0
  101. package/src/components/field/field-error.tsx +61 -0
  102. package/src/components/field/field-group.tsx +22 -0
  103. package/src/components/field/field-label.tsx +33 -0
  104. package/src/components/field/field-legend.tsx +34 -0
  105. package/src/components/field/field-separator.tsx +81 -0
  106. package/src/components/field/field-set.tsx +22 -0
  107. package/src/components/field/field.context.tsx +78 -0
  108. package/src/components/field/field.tsx +144 -0
  109. package/src/components/field/field.types.ts +15 -0
  110. package/src/components/field/field.variants.test.ts +216 -0
  111. package/src/components/field/field.variants.ts +136 -0
  112. package/src/components/field/index.ts +21 -0
  113. package/src/components/icon/AGENTS.md +104 -0
  114. package/src/components/icon/icon.context.tsx +29 -0
  115. package/src/components/icon/icon.tsx +92 -0
  116. package/src/components/icon/icon.variants.test.ts +111 -0
  117. package/src/components/icon/icon.variants.ts +79 -0
  118. package/src/components/icon/index.ts +11 -0
  119. package/src/components/input/AGENTS.md +126 -0
  120. package/src/components/input/index.ts +27 -0
  121. package/src/components/input/input-group-decorator.tsx +84 -0
  122. package/src/components/input/input-group-prefix.tsx +16 -0
  123. package/src/components/input/input-group-suffix.tsx +15 -0
  124. package/src/components/input/input-group.tsx +140 -0
  125. package/src/components/input/input.context.tsx +87 -0
  126. package/src/components/input/input.tsx +184 -0
  127. package/src/components/input/input.types.ts +10 -0
  128. package/src/components/input/input.variants.test.ts +369 -0
  129. package/src/components/input/input.variants.ts +329 -0
  130. package/src/components/list-group/AGENTS.md +67 -0
  131. package/src/components/list-group/index.ts +20 -0
  132. package/src/components/list-group/list-group-item-content.tsx +10 -0
  133. package/src/components/list-group/list-group-item-description.tsx +12 -0
  134. package/src/components/list-group/list-group-item-prefix.tsx +30 -0
  135. package/src/components/list-group/list-group-item-suffix.tsx +55 -0
  136. package/src/components/list-group/list-group-item-title.tsx +17 -0
  137. package/src/components/list-group/list-group-item.tsx +87 -0
  138. package/src/components/list-group/list-group.context.tsx +65 -0
  139. package/src/components/list-group/list-group.tsx +116 -0
  140. package/src/components/list-group/list-group.types.ts +14 -0
  141. package/src/components/list-group/list-group.variants.test.ts +223 -0
  142. package/src/components/list-group/list-group.variants.ts +107 -0
  143. package/src/components/pressable/AGENTS.md +64 -0
  144. package/src/components/pressable/index.ts +9 -0
  145. package/src/components/pressable/pressable.tsx +256 -0
  146. package/src/components/pressable/pressable.variants.test.ts +99 -0
  147. package/src/components/pressable/pressable.variants.ts +63 -0
  148. package/src/components/provider/AGENTS.md +90 -0
  149. package/src/components/provider/index.ts +1 -0
  150. package/src/components/provider/provider.tsx +105 -0
  151. package/src/components/radio/AGENTS.md +247 -0
  152. package/src/components/radio/index.ts +35 -0
  153. package/src/components/radio/radio-group.tsx +126 -0
  154. package/src/components/radio/radio-indicator.tsx +91 -0
  155. package/src/components/radio/radio-label.tsx +39 -0
  156. package/src/components/radio/radio.context.tsx +132 -0
  157. package/src/components/radio/radio.tsx +215 -0
  158. package/src/components/radio/radio.variants.test.ts +581 -0
  159. package/src/components/radio/radio.variants.ts +272 -0
  160. package/src/components/screen/AGENTS.md +287 -0
  161. package/src/components/screen/index.ts +85 -0
  162. package/src/components/screen/screen-chat-list.tsx +485 -0
  163. package/src/components/screen/screen-content.tsx +69 -0
  164. package/src/components/screen/screen-debug.ts +43 -0
  165. package/src/components/screen/screen-error.tsx +61 -0
  166. package/src/components/screen/screen-flat-list.tsx +88 -0
  167. package/src/components/screen/screen-footer-background.tsx +60 -0
  168. package/src/components/screen/screen-footer.tsx +170 -0
  169. package/src/components/screen/screen-header.tsx +36 -0
  170. package/src/components/screen/screen-legend-list.tsx +105 -0
  171. package/src/components/screen/screen-list-component.tsx +23 -0
  172. package/src/components/screen/screen-loading.tsx +50 -0
  173. package/src/components/screen/screen-navbar-back-button.tsx +79 -0
  174. package/src/components/screen/screen-navbar-background.tsx +45 -0
  175. package/src/components/screen/screen-navbar-subtitle.tsx +26 -0
  176. package/src/components/screen/screen-navbar-title.tsx +31 -0
  177. package/src/components/screen/screen-navbar.tsx +154 -0
  178. package/src/components/screen/screen-root.tsx +38 -0
  179. package/src/components/screen/screen-scroll-area.tsx +116 -0
  180. package/src/components/screen/screen-scroll-shadow.tsx +186 -0
  181. package/src/components/screen/screen-section-list.tsx +81 -0
  182. package/src/components/screen/screen-view.tsx +59 -0
  183. package/src/components/screen/screen.context.tsx +167 -0
  184. package/src/components/screen/screen.tsx +114 -0
  185. package/src/components/screen/screen.types.ts +66 -0
  186. package/src/components/screen/screen.variants.test.ts +602 -0
  187. package/src/components/screen/screen.variants.ts +475 -0
  188. package/src/components/screen/use-screen-scroll-insets.ts +218 -0
  189. package/src/components/separator/AGENTS.md +30 -0
  190. package/src/components/separator/index.ts +8 -0
  191. package/src/components/separator/separator.tsx +85 -0
  192. package/src/components/slider/AGENTS.md +274 -0
  193. package/src/components/slider/index.ts +48 -0
  194. package/src/components/slider/slider-fill.tsx +68 -0
  195. package/src/components/slider/slider-output.tsx +55 -0
  196. package/src/components/slider/slider-thumb.tsx +161 -0
  197. package/src/components/slider/slider-track.tsx +233 -0
  198. package/src/components/slider/slider.context.tsx +118 -0
  199. package/src/components/slider/slider.tsx +321 -0
  200. package/src/components/slider/slider.types.ts +26 -0
  201. package/src/components/slider/slider.variants.test.ts +804 -0
  202. package/src/components/slider/slider.variants.ts +607 -0
  203. package/src/components/spinner/AGENTS.md +73 -0
  204. package/src/components/spinner/index.ts +17 -0
  205. package/src/components/spinner/spinner-arc.tsx +90 -0
  206. package/src/components/spinner/spinner-content.tsx +63 -0
  207. package/src/components/spinner/spinner.context.tsx +52 -0
  208. package/src/components/spinner/spinner.tsx +128 -0
  209. package/src/components/spinner/spinner.variants.test.ts +273 -0
  210. package/src/components/spinner/spinner.variants.ts +187 -0
  211. package/src/components/switch/AGENTS.md +213 -0
  212. package/src/components/switch/index.ts +42 -0
  213. package/src/components/switch/switch-content.tsx +111 -0
  214. package/src/components/switch/switch-end-content.tsx +18 -0
  215. package/src/components/switch/switch-start-content.tsx +20 -0
  216. package/src/components/switch/switch-thumb.tsx +102 -0
  217. package/src/components/switch/switch.context.tsx +79 -0
  218. package/src/components/switch/switch.tsx +423 -0
  219. package/src/components/switch/switch.types.ts +14 -0
  220. package/src/components/switch/switch.variants.test.ts +570 -0
  221. package/src/components/switch/switch.variants.ts +511 -0
  222. package/src/components/tabs/AGENTS.md +286 -0
  223. package/src/components/tabs/index.ts +69 -0
  224. package/src/components/tabs/tabs-content.tsx +65 -0
  225. package/src/components/tabs/tabs-indicator.tsx +97 -0
  226. package/src/components/tabs/tabs-label.tsx +62 -0
  227. package/src/components/tabs/tabs-list.tsx +139 -0
  228. package/src/components/tabs/tabs-pager.tsx +59 -0
  229. package/src/components/tabs/tabs-scroll-view.tsx +171 -0
  230. package/src/components/tabs/tabs-separator.tsx +73 -0
  231. package/src/components/tabs/tabs-trigger.tsx +210 -0
  232. package/src/components/tabs/tabs.context.tsx +294 -0
  233. package/src/components/tabs/tabs.tsx +435 -0
  234. package/src/components/tabs/tabs.types.ts +13 -0
  235. package/src/components/tabs/tabs.variants.test.ts +1020 -0
  236. package/src/components/tabs/tabs.variants.ts +672 -0
  237. package/src/components/text/AGENTS.md +95 -0
  238. package/src/components/text/index.ts +25 -0
  239. package/src/components/text/text.context.tsx +60 -0
  240. package/src/components/text/text.tsx +251 -0
  241. package/src/components/text/text.variants.test.ts +422 -0
  242. package/src/components/text/text.variants.ts +282 -0
  243. package/src/display-name.test.ts +145 -0
  244. package/src/docs.test.ts +98 -0
  245. package/src/expo/navigation-theme.tsx +58 -0
  246. package/src/hooks/use-controllable-state.ts +45 -0
  247. package/src/hooks/use-keyboard-state-sync.tsx +147 -0
  248. package/src/hooks/use-navigation-theme.ts +78 -0
  249. package/src/hooks/use-theme-color.ts +44 -0
  250. package/src/icons/central.ts +1 -0
  251. package/src/lib/cn.test.ts +136 -0
  252. package/src/lib/cn.ts +29 -0
  253. package/src/lib/color.test.ts +80 -0
  254. package/src/lib/color.ts +79 -0
  255. package/src/lib/compose-refs.test.ts +64 -0
  256. package/src/lib/compose-refs.ts +37 -0
  257. package/src/lib/keyboard-animation.test.ts +33 -0
  258. package/src/lib/keyboard-animation.ts +28 -0
  259. package/src/lib/merge-props.test.ts +82 -0
  260. package/src/lib/merge-props.ts +47 -0
  261. package/src/lib/navigation-theme.test.ts +49 -0
  262. package/src/lib/navigation-theme.ts +53 -0
  263. package/src/lib/slot.tsx +45 -0
  264. package/src/lib/tv.ts +20 -0
  265. package/src/styles/base.css +2 -0
  266. package/src/styles/geometry.test.ts +117 -0
  267. package/src/styles/index.css +3 -0
  268. package/src/styles/theme-tokens.test.ts +252 -0
  269. package/src/styles/theme.css +447 -0
  270. package/src/styles/tokens.css +132 -0
  271. package/src/styles/tokens.test.ts +181 -0
  272. package/src/styles/tokens.ts +86 -0
  273. package/src/uniwind-env.d.ts +1 -0
@@ -0,0 +1,125 @@
1
+ import { useBottomSheetInternal } from "@gorhom/bottom-sheet";
2
+ import { type RefCallback, useCallback, useRef } from "react";
3
+ import { type BlurEvent, type FocusEvent, findNodeHandle, TextInput } from "react-native";
4
+
5
+ /**
6
+ * `findNodeHandle` for a value React Native's own types do not admit.
7
+ *
8
+ * `TextInput.State.currentlyFocusedInput()` returns a Fabric host instance, and
9
+ * the shipped signature still describes the pre-Fabric parameter — the runtime
10
+ * accepts it, which is what gorhom's own `BottomSheetTextInput` relies on. One
11
+ * cast, in one place, rather than at each call.
12
+ */
13
+ function nodeHandleOf(instance: unknown): number | null {
14
+ return findNodeHandle(instance as Parameters<typeof findNodeHandle>[0]);
15
+ }
16
+
17
+ export type BottomSheetInputHandlers = {
18
+ onFocus: (event: FocusEvent) => void;
19
+ onBlur: (event: BlurEvent) => void;
20
+ ref: RefCallback<TextInput | null>;
21
+ };
22
+
23
+ /**
24
+ * Makes this package's `Input` behave like a text field the sheet owns.
25
+ *
26
+ * Spread the result onto any `TextInput` inside a `BottomSheet`:
27
+ *
28
+ * ```tsx
29
+ * <Input {...useBottomSheetInput()} placeholder="Name" />
30
+ * ```
31
+ *
32
+ * A sheet only knows to grow for the keyboard when it knows the focused field is
33
+ * one of its own. gorhom ships `BottomSheetTextInput` to register that, but
34
+ * reaching for it here would mean this library's `Input` — its variants, its
35
+ * `Input.Group`, its invalid and disabled cascade — could not be used inside a
36
+ * sheet at all. So the registration is exposed as handlers instead, and `Input`
37
+ * keeps knowing nothing about `@gorhom/bottom-sheet`.
38
+ *
39
+ * Three props, and all three are load-bearing:
40
+ *
41
+ * - `ref` registers the field's native node with the sheet. Without it, moving
42
+ * focus from one field in the sheet to the next reads as the keyboard closing
43
+ * and reopening, and the sheet resizes twice on the way.
44
+ * - `onFocus` tells the sheet which field to keep clear of the keyboard.
45
+ * - `onBlur` gives that up again, unless focus went to another field in the same
46
+ * sheet.
47
+ *
48
+ * Called outside a sheet it returns inert handlers rather than throwing, so a
49
+ * form component shared between a screen and a sheet needs no branch.
50
+ *
51
+ * It covers the keyboard half only. A *drag* across a field can still be claimed
52
+ * by the sheet's content pan, because gorhom's input is built on Gesture
53
+ * Handler's `TextInput` and this one is not; if that ever bites, the fix is
54
+ * `enableContentPanningGesture={false}` on the `BottomSheet.Container`. The
55
+ * *tap* half — a scrim that used to swallow the press meant for the field — is
56
+ * solved in `BottomSheet.Overlay`, not here.
57
+ *
58
+ * @example
59
+ * <Field>
60
+ * <Field.Label>Name</Field.Label>
61
+ * <Input {...useBottomSheetInput()} onChangeText={setName} value={name} />
62
+ * </Field>
63
+ */
64
+ export function useBottomSheetInput(): BottomSheetInputHandlers {
65
+ const internal = useBottomSheetInternal(true);
66
+ // The node this field last registered, so the ref can withdraw exactly it.
67
+ // A JS ref rather than a shared value: nothing renders differently for it.
68
+ const registeredNodeRef = useRef<number | null>(null);
69
+
70
+ const ref = useCallback<RefCallback<TextInput | null>>(
71
+ (instance) => {
72
+ const nodes = internal?.textInputNodesRef.current;
73
+ const previous = registeredNodeRef.current;
74
+
75
+ if (previous !== null) {
76
+ nodes?.delete(previous);
77
+ registeredNodeRef.current = null;
78
+ if (internal !== null && internal.animatedKeyboardState.get().target === previous) {
79
+ internal.animatedKeyboardState.set((state) => ({ ...state, target: undefined }));
80
+ }
81
+ }
82
+
83
+ if (instance === null || nodes === undefined) return;
84
+
85
+ const node = nodeHandleOf(instance);
86
+ if (node === null) return;
87
+
88
+ nodes.add(node);
89
+ registeredNodeRef.current = node;
90
+ },
91
+ [internal]
92
+ );
93
+
94
+ const onFocus = useCallback(
95
+ (event: FocusEvent) => {
96
+ if (internal === null) return;
97
+ const target = event.nativeEvent.target;
98
+ internal.animatedKeyboardState.set((state) => ({ ...state, target }));
99
+ },
100
+ [internal]
101
+ );
102
+
103
+ const onBlur = useCallback(
104
+ (event: BlurEvent) => {
105
+ if (internal === null) return;
106
+
107
+ const target = event.nativeEvent.target;
108
+ const isThisFieldsTarget = internal.animatedKeyboardState.get().target === target;
109
+ if (!isThisFieldsTarget) return;
110
+
111
+ // React Native's own focus registry, not the sheet's: focus may already
112
+ // have moved to the next field in the same sheet, and giving the target
113
+ // up there would close and reopen the sheet's keyboard allowance between
114
+ // two taps that never dismissed the keyboard.
115
+ const focused = TextInput.State.currentlyFocusedInput();
116
+ const focusedNode = focused === null ? null : nodeHandleOf(focused);
117
+ if (focusedNode !== null && internal.textInputNodesRef.current.has(focusedNode)) return;
118
+
119
+ internal.animatedKeyboardState.set((state) => ({ ...state, target: undefined }));
120
+ },
121
+ [internal]
122
+ );
123
+
124
+ return { onBlur, onFocus, ref };
125
+ }
@@ -0,0 +1,217 @@
1
+ # Button
2
+
3
+ A pressable action, composed from parts rather than configured by flags —
4
+ compound root plus `Button.Label`, `Button.StartContent` and `Button.EndContent`,
5
+ and `Button.Group` for joining several into one run.
6
+ The reference implementation for the patterns in the package
7
+ [AGENTS.md](../../../AGENTS.md).
8
+
9
+ `import { Button } from "@delacour/react-native-ui/button";`
10
+
11
+ ## Files
12
+
13
+ | File | What it holds |
14
+ | --- | --- |
15
+ | `index.ts` | → `@delacour/react-native-ui/button` |
16
+ | `button.tsx` | Root + the `Object.assign` compound surface |
17
+ | `button-label.tsx` | `Button.Label` |
18
+ | `button-start-content.tsx` | `Button.StartContent` |
19
+ | `button-end-content.tsx` | `Button.EndContent` |
20
+ | `button-group.tsx` | `Button.Group` + its `Object.assign` compound surface |
21
+ | `button-group-separator.tsx` | `Button.Group.Separator` |
22
+ | `button-group-text.tsx` | `Button.Group.Text` |
23
+ | `button.context.tsx` | `ButtonContext` and the two group contexts, with their hooks |
24
+ | `button.types.ts` | Prop types shared by two or more parts |
25
+ | `button.variants.ts` | Pure `tv()` slots, no RN imports |
26
+ | `button.variants.test.ts` | |
27
+
28
+ ## Design
29
+
30
+ - **Variants**: `primary`, `secondary`, `tertiary`, `outline`, `ghost`,
31
+ `destructive`, `destructive-soft`. **Sizes**: `sm`, `md`, `lg`, and the square
32
+ `icon-sm`, `icon-md`, `icon-lg`.
33
+ - **Icons are composed, never passed as props.** Put an `Icon` in the children,
34
+ before or after the label. The button wraps its subtree in an
35
+ `IconDefaultsProvider` carrying `buttonVariants({ size }).icon()` and
36
+ `BUTTON_FOREGROUND_TOKEN[variant]`, so a bare `<Icon icon={IconPlus} />`
37
+ comes out the right size and colour with nothing said at the call site. An
38
+ explicit `size` or `color` on the icon still wins. `Button.StartContent` /
39
+ `Button.EndContent` remain for wrapping non-icon content.
40
+ - **The corner is a token on the size axis crossed with the group axis, and
41
+ nothing else sets one.** A button standing alone names
42
+ `rounded-button-{step}` — half its own height, so it is a capsule, and a
43
+ circle at a square size. Keeping it out of the base and off every variant
44
+ means exactly one corner *statement* reaches the root, so a caller's
45
+ `className="rounded-lg"` has a single thing to beat and tailwind-merge is
46
+ never picking between two. The shape is a default rather than a law: three
47
+ numbers in `tokens.css` square the whole kit off without touching a
48
+ component. Values above half the height are clamped by the renderer, which is
49
+ why `tokens.test.ts` holds them under it.
50
+ - **A square footprint is a size, not a flag.** `icon-sm` / `icon-md` /
51
+ `icon-lg` are `sm` / `md` / `lg` with the horizontal padding traded for a width
52
+ off the same token. Padding and width are then mutually exclusive by
53
+ construction rather than by rule, which is what let an `isIconOnly` axis and
54
+ six compound variants reconciling it against `size` be deleted outright. It is
55
+ also shadcn's spelling (rule 11), so `size="icon-md"` pastes across. Always
56
+ pair one with an `accessibilityLabel`; there is no text for a screen reader to
57
+ fall back on.
58
+ - **String children** are wrapped in a `Button.Label` automatically. React
59
+ Native crashes on bare text outside a `<Text>`, so never render a raw string
60
+ in a component that accepts free-form children without doing the same. Note
61
+ that *consecutive* strings collapse into one label — `Row {i}` is a single
62
+ piece of text, and wrapping each part separately would space them apart by
63
+ the button's own `gap`.
64
+ - **A button is a [`Pressable`](../pressable/AGENTS.md).** `ButtonProps` extends
65
+ `PressableProps`, so `feedback`, `haptic` and the rest are inherited rather
66
+ than restated; only the default differs, `scale`. It carried a narrowed
67
+ `ButtonFeedback` union for a while — that is gone, because a second definition
68
+ of a prop the button does not change is a definition that can drift. **Do not
69
+ add ripple, ink, glow or highlight overlays** — no wash layers on pressables
70
+ in this library. That rule is about wash layers, not the opacity axis, which
71
+ `fade` and `scale-fade` are welcome to use.
72
+ - **`isLoading` replaces the icon on its own side, it does not join it.** The
73
+ spinner takes the place of the composed `Icon` at the edge `spinnerPlacement`
74
+ names — the first child at `start`, the last at `end` — so the label does not
75
+ shift when work begins and shift back when it ends. The swap costs no layout
76
+ because both glyphs are drawn at the button's own `size-icon-*` token: the
77
+ root publishes one class and the icon and the spinner both read it. **An icon
78
+ on the other side is not taken.** A button with one leading icon and
79
+ `spinnerPlacement="end"` keeps the icon and gains a spinner at the end; taking
80
+ the only icon whichever side it sat on used to draw the spinner opposite the
81
+ side that was asked for. With no icon at that edge the spinner is inserted, as
82
+ it always was. Only a bare `Icon` is swapped; a `Button.StartContent` wraps
83
+ content of unknown height, and replacing one could resize the button.
84
+ `resolveSpinnerSwapIndex` is the pure decision and is unit-tested.
85
+ - **`isLoading`** composes a `Spinner` in and blocks presses. Placement is
86
+ `spinnerPlacement`: `start` (default), `end`, or `only` — which drops the
87
+ children and centres the spinner in the footprint the button already has,
88
+ carrying the label onto `accessibilityLabel` so a screen reader still has a
89
+ name to read. `only` never changes the footprint — a square is a `size`, and
90
+ loading cannot reach one; use `size="icon-md"` when a square is what you want.
91
+ - **Loading is not disabled.** `isLoading` blocks the press and announces the
92
+ button as *busy*, but keeps full contrast: the spinner already says the press
93
+ landed, and dimming reads as "this control is unavailable". `isDimmedWhileLoading`
94
+ opts into the faded treatment where a caller wants it.
95
+ - **A stretched button does not change width while loading**, because `only`
96
+ keeps its footprint. In a *content-width* container — a `flex-row` — it still
97
+ shrinks to the spinner, and that snap is un-animated on purpose: Pressable's
98
+ `Animated.View` already runs a `useAnimatedStyle` on `opacity` and `transform`,
99
+ and a native layout transition on the same view fights it for prop ownership.
100
+ A caller who needs a stable width in a row pins it (`w-full`, `min-w-*`).
101
+ - **A definite width defeats `alignItems: stretch`.** A stretch-aligned child
102
+ with a definite cross size resolves to cross-*start*, not centre — so any
103
+ control that conditionally takes a fixed width inside a gap column jumps to
104
+ the left edge unless it also sets `self-center`. This is what made an earlier
105
+ `only` implementation collapse a full-width button into a small box flush
106
+ left. Prefer not taking the width at all.
107
+
108
+ ## Button.Group
109
+
110
+ Several controls joined into one run: `<Button.Group>` around the buttons, with
111
+ `Button.Group.Separator` for a rule between two of them and `Button.Group.Text`
112
+ for a chunk that says something rather than doing something. An
113
+ [`Input`](../input/AGENTS.md) joins the same way.
114
+
115
+ - **Position is computed in JavaScript, because React Native has no sibling
116
+ selector.** shadcn/ui squares a web button group's inner corners with
117
+ `[&>[data-slot]~[data-slot]]:rounded-l-none` and drops the seam border with
118
+ `border-l-0`. Neither selector exists here, and no parent `View` can reach a
119
+ child's style. So the root walks its children, works out each member's place
120
+ with `resolveGroupPositions`, and publishes it through a per-child context the
121
+ member reads. A provider is not a host component, so nothing is added to the
122
+ layout — Yoga sees exactly the children the group's `View` already had.
123
+ - **A member wrapped in a context, never cloned with props.** `cloneElement`
124
+ reaches only a *direct* child, so a member a caller wrapped in a `View` or
125
+ produced from a helper would silently keep its round corners. Context reaches
126
+ any depth, which is also what lets a control this package has never heard of
127
+ join a run by reading `useButtonGroupItem()`.
128
+ - **A joined member replaces its corner; it never layers a squaring class over
129
+ it.** This is the subtle one. Layering *renders* correctly — Uniwind
130
+ arbitrates a className string per style property in token order, and React
131
+ Native's corner cascade puts a per-corner radius above the uniform one — but
132
+ tailwind-merge annihilates a side class the moment any all-corner class is
133
+ emitted after it, and `tv` emits variants in declaration order. A surviving
134
+ `rounded-button-md` on the size axis would therefore delete the squaring pair
135
+ as soon as someone reordered two keys in `buttonVariants`, with nothing to see
136
+ in the diff. `cn.test.ts` pins the asymmetry in both directions.
137
+ - **Horizontal squares on the logical axis; vertical squares on the physical
138
+ one. Never mix them.** `rounded-s-*` / `rounded-e-*` compile to
139
+ `border-start-start-radius` and friends, survive lightningcss, and flip under
140
+ RTL — which is what a horizontal run wants. Tailwind's `rounded-t-*` /
141
+ `rounded-b-*` are physical and need no flip. React Native resolves a physical
142
+ corner *above* a logical one, so a stray `rounded-t-*` on a horizontal member
143
+ would silently outrank the `rounded-s-*` beside it and round the wrong edge. A
144
+ test asserts neither orientation emits the other's form.
145
+ - **The seam is a negative margin, not a dropped border.** Two adjacent
146
+ `outline` members would otherwise draw a two-point seam. `-ms-px` / `-mt-px`
147
+ pulls each member back over its neighbour so the two borders land as one
148
+ hairline. `border-s-0` was the alternative and is wrong twice: the base
149
+ reserves `border border-transparent` precisely so switching variant never
150
+ resizes the box, and dropping a point would make one member narrower than the
151
+ rest and shift its centred content half a point — and it would only be right
152
+ for the variants that draw a visible border, which the group cannot know
153
+ because a member may name its own. A margin moves the box and changes nothing
154
+ about it, so it is emitted for every seamed member regardless of paint.
155
+ - **A separator takes no position and suppresses the seam after it.** It is a
156
+ rule, not a segment, so the buttons either side of one are still the run's
157
+ first and last and keep their rounded outer corners. And the member following
158
+ it does not overlap: a one-point rule under a one-point overlap is an
159
+ invisible rule. `resolveGroupPositions` and `resolveGroupSeams` are two walks
160
+ rather than one because they ask different questions — "how many of us are
161
+ there" and "what is immediately before me" — and both are pure, so the whole
162
+ matrix is reachable from `bun test`.
163
+ - **Membership is a deny-list.** Anything that is not a separator is a member.
164
+ Not `child.type === Button`, for two reasons: it lets a custom control join,
165
+ and it means `button-group.tsx` never imports its own root for an identity
166
+ check — which would close a cycle (package AGENTS.md rule 3).
167
+ - **A joined member fades rather than scaling.** `feedback` defaults to `scale`
168
+ on a lone button, which pulls a member's edges in by a point and a half while
169
+ its neighbours hold still — the seam the group exists to close tears open for
170
+ the length of the press. `resolveButtonFeedback` runs the usual nearest-wins
171
+ ladder (`own ?? group ?? default`) and only changes the last rung, so a caller
172
+ still gets `scale` by asking for it on one button or on the whole group.
173
+ `??` rather than `||`, so an explicit `none` is honoured instead of read as an
174
+ absence. `pressedScale` still beats both — that is `resolvePressedState`'s
175
+ documented contract and a second rule contradicting it would be worse than the
176
+ tear.
177
+ - **The group owns the axes because it owns the shape.** `size` all but
178
+ outright, and `variant`, `isDisabled` and `feedback` as defaults a member may
179
+ override. Those three are published **raw**, so `undefined` means "the group
180
+ said nothing" and one member can disable itself inside a group that did not.
181
+ `Button`'s `variant` and `size` therefore come out of its destructure
182
+ *undefaulted*; a default there would swallow the group before it was ever
183
+ consulted.
184
+ - **A group owns a member's step, but not its shape.** Those are one axis now
185
+ that a square footprint is a size rather than a flag, so resolving `size` to
186
+ the group's value outright would make a square member impossible inside a run:
187
+ the icon button ending a split button would silently grow a label's padding
188
+ and lose its width. `resolveGroupedButtonSize` therefore takes the step from
189
+ the group — controls of different heights do not join — and the shape from the
190
+ member, falling back to the group's shape when the member names no size. An
191
+ `icon-md` member of an `sm` group comes out `icon-sm`: square, and the same
192
+ height as everything beside it. A control with no square form of its own reads
193
+ `resolveButtonSizeStep` instead, which is exactly what a joined `Input` does.
194
+ - **The group paints nothing.** No background, no border, no disabled fade. A
195
+ disabled group publishes `isDisabled` and each member fades itself; a group
196
+ fading as well would compound the two down to a quarter opacity. It carries no
197
+ `gap` either — a gap is the seam this component exists to close — and no
198
+ `overflow-hidden`, which would square off the very corners the position
199
+ compounds just rounded.
200
+ - **No `accessibilityRole` on the group.** Announcing a control with no action
201
+ in front of every member helps nobody; the members are already buttons.
202
+ - **Horizontal groups are content-width** (`self-start`), because that is what a
203
+ segmented control is. For a run that fills its parent, put `className="w-full"`
204
+ on the group and `className="flex-1"` on each member — **and make sure the
205
+ parent has a definite width**. Yoga resolves a percentage against the parent's
206
+ content box, so `w-full` inside a container that is itself content-sized
207
+ resolves to nothing and falls back to the content width: the group collapses
208
+ onto its buttons and a `flex-1` field between them is squeezed to a few points.
209
+ It looks like the group ignored `w-full`. It did not; there was no width to
210
+ take a percentage of.
211
+ - **`Button.Group.Text` draws the button's own chrome**, not chrome of its own,
212
+ which is what keeps its height, padding and corner identical to the buttons
213
+ beside it — those come off one axis, and restating any of them here is how a
214
+ row stops lining up three tokens later. It falls back to `secondary` rather
215
+ than `primary` when nothing names a variant: a chunk that cannot be pressed
216
+ should not wear the group's action paint. A group that *does* name a variant
217
+ is followed, so an outline run reads as one piece.
@@ -0,0 +1,15 @@
1
+ import type { ReactElement } from "react";
2
+ import { View } from "react-native";
3
+ import type { ButtonSlotProps } from "./button.types";
4
+ import { buttonVariants } from "./button.variants";
5
+
6
+ /**
7
+ * A centred wrapper for trailing content that is not an `Icon`.
8
+ *
9
+ * An `Icon` needs no wrapper — it inherits the button's icon size and its
10
+ * variant's colour from the root's `IconDefaultsProvider`.
11
+ */
12
+ export function ButtonEndContent({ className, ...props }: ButtonSlotProps): ReactElement {
13
+ return <View className={buttonVariants().endContent({ className })} {...props} />;
14
+ }
15
+ ButtonEndContent.displayName = "DelacourUI.Button.EndContent";
@@ -0,0 +1,37 @@
1
+ import type { ReactElement } from "react";
2
+ import { Separator } from "../separator";
3
+ import { useButtonGroupPart } from "./button.context";
4
+ import { BUTTON_GROUP_SEPARATOR_ORIENTATION, buttonVariants } from "./button.variants";
5
+
6
+ export type ButtonGroupSeparatorProps = {
7
+ className?: string;
8
+ };
9
+
10
+ /**
11
+ * A rule between two members of a group.
12
+ *
13
+ * Runs across the group rather than along it — a line drawn along the run would
14
+ * sit under the members instead of between them — so it reads the group's
15
+ * orientation and inverts it. That inversion is a map in `button.variants.ts`
16
+ * rather than a `SeparatorOrientation` here, because that type lives beside a
17
+ * React Native import and the variants file has to stay parseable by `bun test`.
18
+ *
19
+ * A separator is deliberately **not** a member: it consumes no position, so the
20
+ * buttons either side of one are still the group's first and last and keep their
21
+ * rounded outer corners. It also suppresses the seam of the member after it — a
22
+ * one-point rule under a one-point overlap would be an invisible rule.
23
+ *
24
+ * The line takes its length from this wrapper, since a `Separator` is
25
+ * `self-stretch` on its long axis rather than a percentage of its parent.
26
+ */
27
+ export function ButtonGroupSeparator({ className }: ButtonGroupSeparatorProps): ReactElement {
28
+ const { orientation } = useButtonGroupPart("Button.Group.Separator");
29
+
30
+ return (
31
+ <Separator
32
+ className={buttonVariants({ orientation }).groupSeparator({ className })}
33
+ orientation={BUTTON_GROUP_SEPARATOR_ORIENTATION[orientation]}
34
+ />
35
+ );
36
+ }
37
+ ButtonGroupSeparator.displayName = "DelacourUI.Button.Group.Separator";
@@ -0,0 +1,101 @@
1
+ import { Children, type ReactElement, type ReactNode } from "react";
2
+ import { View, type ViewProps } from "react-native";
3
+ import { IconDefaultsProvider } from "../icon";
4
+ import { Text } from "../text";
5
+ import { TextClassProvider } from "../text/text.context";
6
+ import { useButtonGroupItemContext } from "./button.context";
7
+ import {
8
+ BUTTON_FOREGROUND_TOKEN,
9
+ type ButtonVariant,
10
+ buttonVariants,
11
+ resolveGroupedButtonSize,
12
+ } from "./button.variants";
13
+
14
+ export type ButtonGroupTextProps = ViewProps & {
15
+ /** Paint for the chunk. Falls back to the group's, then to `secondary`. */
16
+ variant?: ButtonVariant;
17
+ className?: string;
18
+ children?: ReactNode;
19
+ };
20
+
21
+ /**
22
+ * A chunk of a group that says something rather than doing something.
23
+ *
24
+ * A member like any other — it reads the same item context a `Button` does, so
25
+ * it squares the corners crossing its seams and overlaps its neighbour without
26
+ * knowing anything about where it sits.
27
+ *
28
+ * It draws the button's own chrome rather than chrome of its own, which is what
29
+ * keeps its height, padding and corner identical to the buttons beside it: those
30
+ * five values come off one axis in `buttonVariants`, and restating any of them
31
+ * here is how a row stops lining up three tokens later.
32
+ *
33
+ * `secondary` rather than `primary` when nothing names a variant, because a
34
+ * chunk that cannot be pressed should not wear the group's action paint. A group
35
+ * that *does* name one is followed, so an outline group reads as one piece.
36
+ *
37
+ * Bare text children are wrapped in a `Text`, since React Native cannot render a
38
+ * string outside one. Composed content inherits the same icon size and label
39
+ * treatment a button publishes, so an `Icon` beside the text needs nothing.
40
+ */
41
+ export function ButtonGroupText({ variant, className, children, ...props }: ButtonGroupTextProps): ReactElement {
42
+ const item = useButtonGroupItemContext();
43
+ const resolvedVariant = variant ?? item?.variant ?? "secondary";
44
+ const slots = buttonVariants({
45
+ groupPosition: item?.position ?? "none",
46
+ isSeamed: item?.isSeamed ?? false,
47
+ orientation: item?.orientation ?? "horizontal",
48
+ size: resolveGroupedButtonSize(undefined, item?.size),
49
+ variant: resolvedVariant,
50
+ });
51
+
52
+ const label = slots.label();
53
+
54
+ return (
55
+ <View className={slots.root({ className })} {...props}>
56
+ <IconDefaultsProvider value={{ className: slots.icon(), color: BUTTON_FOREGROUND_TOKEN[resolvedVariant] }}>
57
+ <TextClassProvider value={label}>{wrapTextChildren(children, label)}</TextClassProvider>
58
+ </IconDefaultsProvider>
59
+ </View>
60
+ );
61
+ }
62
+ ButtonGroupText.displayName = "DelacourUI.Button.Group.Text";
63
+
64
+ /**
65
+ * Wraps bare text children in a `Text`.
66
+ *
67
+ * React Native cannot render a string outside a `<Text>`, so
68
+ * `<Button.Group.Text>3 of 7</Button.Group.Text>` would otherwise crash.
69
+ * Consecutive strings and numbers are collected into a single one rather than
70
+ * one each — `Page {n}` is one piece of text, and wrapping the parts separately
71
+ * would space them apart by the root's gap.
72
+ *
73
+ * Lives beside its caller rather than in the group's root, which inserts
74
+ * nothing: a helper follows the component that uses it.
75
+ */
76
+ function wrapTextChildren(children: ReactNode, className: string): ReactNode {
77
+ const output: ReactNode[] = [];
78
+ let run: (string | number)[] = [];
79
+
80
+ const flushRun = () => {
81
+ if (run.length === 0) return;
82
+ output.push(
83
+ <Text className={className} key={`label-${output.length}`}>
84
+ {run.join("")}
85
+ </Text>
86
+ );
87
+ run = [];
88
+ };
89
+
90
+ for (const child of Children.toArray(children)) {
91
+ if (typeof child === "string" || typeof child === "number") {
92
+ run.push(child);
93
+ continue;
94
+ }
95
+ flushRun();
96
+ output.push(child);
97
+ }
98
+ flushRun();
99
+
100
+ return output;
101
+ }
@@ -0,0 +1,158 @@
1
+ import { Children, isValidElement, type ReactElement, type ReactNode, useMemo } from "react";
2
+ import { View, type ViewProps } from "react-native";
3
+ import type { PressableFeedback } from "../pressable/pressable.variants";
4
+ import { Separator } from "../separator";
5
+ import {
6
+ type ButtonGroupContextValue,
7
+ type ButtonGroupItemContextValue,
8
+ ButtonGroupItemProvider,
9
+ ButtonGroupProvider,
10
+ } from "./button.context";
11
+ import {
12
+ type ButtonGroupOrientation,
13
+ type ButtonSize,
14
+ type ButtonVariant,
15
+ buttonVariants,
16
+ resolveGroupPositions,
17
+ resolveGroupSeams,
18
+ } from "./button.variants";
19
+ import { ButtonGroupSeparator } from "./button-group-separator";
20
+ import { ButtonGroupText } from "./button-group-text";
21
+
22
+ export type ButtonGroupProps = ViewProps & {
23
+ /** Which way the group runs. Members square the corners crossing the run. */
24
+ orientation?: ButtonGroupOrientation;
25
+ /** Shared by every member — controls of different heights do not join. */
26
+ size?: ButtonSize;
27
+ /** A default every member takes unless it names its own. */
28
+ variant?: ButtonVariant;
29
+ /** Disables every member that does not say otherwise. */
30
+ isDisabled?: boolean;
31
+ /** Press treatment for every member. Unset, a joined member fades rather than scaling. */
32
+ feedback?: PressableFeedback;
33
+ className?: string;
34
+ children?: ReactNode;
35
+ };
36
+
37
+ function ButtonGroupRoot({
38
+ orientation = "horizontal",
39
+ size = "md",
40
+ variant,
41
+ isDisabled,
42
+ feedback,
43
+ className,
44
+ children,
45
+ ...props
46
+ }: ButtonGroupProps): ReactElement {
47
+ const group = useMemo<ButtonGroupContextValue>(
48
+ () => ({ feedback, isDisabled, orientation, size, variant }),
49
+ [feedback, isDisabled, orientation, size, variant]
50
+ );
51
+
52
+ const items = useMemo(() => Children.toArray(children), [children]);
53
+
54
+ // One memo for the whole array rather than a hook per child, which a `.map()`
55
+ // cannot have. A member's value is a new object only when the group's axes or
56
+ // the members' order actually change.
57
+ const values = useMemo(() => {
58
+ const isMember = items.map(isGroupMember);
59
+ const positions = resolveGroupPositions(isMember);
60
+ const seams = resolveGroupSeams(isMember);
61
+
62
+ return positions.map<ButtonGroupItemContextValue | null>((position, index) =>
63
+ position === null ? null : { ...group, isSeamed: seams[index] === true, position }
64
+ );
65
+ }, [items, group]);
66
+
67
+ return (
68
+ <ButtonGroupProvider value={group}>
69
+ <View className={buttonVariants({ orientation }).group({ className })} {...props}>
70
+ {items.map((child, index) => {
71
+ const value = values[index];
72
+ if (!value) return child;
73
+ return (
74
+ <ButtonGroupItemProvider key={keyOf(child, index)} value={value}>
75
+ {child}
76
+ </ButtonGroupItemProvider>
77
+ );
78
+ })}
79
+ </View>
80
+ </ButtonGroupProvider>
81
+ );
82
+ }
83
+
84
+ /**
85
+ * Whether a child takes a position in the group.
86
+ *
87
+ * A deny-list rather than a check for `Button`, for two reasons. It lets any
88
+ * control join a group and style itself from `useButtonGroupItem()` — the whole
89
+ * point of the library's `useX()` hooks — and it means this file never imports
90
+ * its own root for an identity check, which would close a cycle (AGENTS.md rule
91
+ * 3).
92
+ *
93
+ * A hand-placed `Separator` is excluded alongside the group's own, the same
94
+ * tolerance `ListGroup` extends to one.
95
+ */
96
+ function isGroupMember(child: ReactNode): boolean {
97
+ return isValidElement(child) && child.type !== ButtonGroupSeparator && child.type !== Separator;
98
+ }
99
+
100
+ /**
101
+ * A stable key for a wrapped child.
102
+ *
103
+ * `Children.toArray` has already assigned every child a key, so the wrapper
104
+ * reuses it rather than minting an index-based one — a group whose members are
105
+ * reordered or conditionally rendered would otherwise remount them.
106
+ */
107
+ function keyOf(child: ReactNode, index: number): string {
108
+ return isValidElement(child) && child.key !== null ? child.key : `member-${index}`;
109
+ }
110
+
111
+ /**
112
+ * Several controls joined into one run, sharing a corner and a seam.
113
+ *
114
+ * The group owns the axes because it owns the shape: `size` outright, since
115
+ * controls of different heights do not join, and `variant`, `isDisabled` and
116
+ * `feedback` as defaults a member may still override. Those three are published
117
+ * raw, so `undefined` means "the group said nothing" and one member can disable
118
+ * itself inside a group that did not.
119
+ *
120
+ * Each member is wrapped in a context provider carrying its place in the run.
121
+ * That indirection is forced: React Native has no sibling selector, so the CSS
122
+ * shadcn/ui uses on the web — `[&>[data-slot]~[data-slot]]:rounded-l-none` —
123
+ * has no counterpart here and position has to be computed in JavaScript. A
124
+ * provider is not a host component, so nothing is added to the layout.
125
+ *
126
+ * A member squares the corners crossing each seam and overlaps its neighbour by
127
+ * a point, so two adjacent borders draw as one hairline. A group of one is
128
+ * indistinguishable from the control on its own.
129
+ *
130
+ * The group paints nothing itself — no background, no border, no disabled fade.
131
+ * A fade here would compound with the members' own down to a quarter opacity.
132
+ *
133
+ * Horizontal groups are content-width. For a run that fills its parent, put
134
+ * `className="w-full"` on the group and `className="flex-1"` on each member.
135
+ *
136
+ * @example
137
+ * <Button.Group variant="outline">
138
+ * <Button onPress={archive}>Archive</Button>
139
+ * <Button onPress={report}>Report</Button>
140
+ * <Button onPress={snooze}>Snooze</Button>
141
+ * </Button.Group>
142
+ *
143
+ * @example
144
+ * <Button.Group>
145
+ * <Button onPress={save}>Save</Button>
146
+ * <Button.Group.Separator />
147
+ * <Button accessibilityLabel="More" size="icon-md" onPress={openMenu}>
148
+ * <Icon icon={IconChevronDown} />
149
+ * </Button>
150
+ * </Button.Group>
151
+ */
152
+ export const ButtonGroup = Object.assign(ButtonGroupRoot, {
153
+ /** A rule between two members. Takes no position, so the ends stay rounded. */
154
+ Separator: ButtonGroupSeparator,
155
+ /** A chunk that says something rather than doing something, joined like any member. */
156
+ Text: ButtonGroupText,
157
+ displayName: "DelacourUI.Button.Group",
158
+ });