@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,480 @@
1
+ import type { VariantProps } from "tailwind-variants";
2
+ import { tv } from "../../lib/tv";
3
+ import type { PressableFeedback } from "../pressable/pressable.variants";
4
+
5
+ export const BUTTON_VARIANTS = [
6
+ "primary",
7
+ "secondary",
8
+ "tertiary",
9
+ "outline",
10
+ "ghost",
11
+ "destructive",
12
+ "destructive-soft",
13
+ ] as const;
14
+
15
+ /** Sizes that hold a label. Horizontal padding; the width comes from the content. */
16
+ export const BUTTON_LABEL_SIZES = ["sm", "md", "lg"] as const;
17
+
18
+ /** Sizes with a square footprint, for a button whose only content is an icon. */
19
+ export const BUTTON_ICON_SIZES = ["icon-sm", "icon-md", "icon-lg"] as const;
20
+
21
+ /**
22
+ * Every value `size` accepts.
23
+ *
24
+ * Derived rather than written out — a tuple key is not a class, so composing one
25
+ * costs nothing. The class strings below are a different matter and are spelled
26
+ * in full; see the note on the `size` axis.
27
+ */
28
+ export const BUTTON_SIZES = [...BUTTON_LABEL_SIZES, ...BUTTON_ICON_SIZES] as const;
29
+
30
+ export const BUTTON_SPINNER_PLACEMENTS = ["start", "end", "only"] as const;
31
+
32
+ export const BUTTON_GROUP_ORIENTATIONS = ["horizontal", "vertical"] as const;
33
+
34
+ export const BUTTON_GROUP_POSITIONS = ["first", "middle", "last", "only"] as const;
35
+
36
+ export type ButtonVariant = (typeof BUTTON_VARIANTS)[number];
37
+ export type ButtonLabelSize = (typeof BUTTON_LABEL_SIZES)[number];
38
+ export type ButtonIconSize = (typeof BUTTON_ICON_SIZES)[number];
39
+ export type ButtonSize = (typeof BUTTON_SIZES)[number];
40
+ export type ButtonSpinnerPlacement = (typeof BUTTON_SPINNER_PLACEMENTS)[number];
41
+ export type ButtonGroupOrientation = (typeof BUTTON_GROUP_ORIENTATIONS)[number];
42
+ export type ButtonGroupPosition = (typeof BUTTON_GROUP_POSITIONS)[number];
43
+
44
+ /** A member's place in its group, or `none` for a button standing on its own. */
45
+ export type ButtonGroupSlotPosition = ButtonGroupPosition | "none";
46
+
47
+ /**
48
+ * The axis a group's separator runs along — a rule crosses the run.
49
+ *
50
+ * Typed on the group's own union rather than on `SeparatorOrientation`, which
51
+ * lives in `separator.tsx` beside a React Native import and would make this file
52
+ * unparseable by `bun test`. The two vocabularies are the same two words, and a
53
+ * test asserts this map still names one of them.
54
+ */
55
+ export const BUTTON_GROUP_SEPARATOR_ORIENTATION: Record<ButtonGroupOrientation, ButtonGroupOrientation> = {
56
+ horizontal: "vertical",
57
+ vertical: "horizontal",
58
+ };
59
+
60
+ /** What a button presses with on its own, and what it presses with joined. */
61
+ export const BUTTON_FEEDBACK: PressableFeedback = "scale";
62
+ export const BUTTON_GROUP_FEEDBACK: PressableFeedback = "fade";
63
+
64
+ /** Theme token whose value gives icons and text their colour on each surface. */
65
+ export const BUTTON_FOREGROUND_TOKEN: Record<ButtonVariant, string> = {
66
+ primary: "primary-foreground",
67
+ secondary: "secondary-foreground",
68
+ tertiary: "tertiary-foreground",
69
+ outline: "foreground",
70
+ ghost: "foreground",
71
+ destructive: "destructive-foreground",
72
+ "destructive-soft": "destructive-soft-foreground",
73
+ };
74
+
75
+ /**
76
+ * Styling for every part of a button.
77
+ *
78
+ * One slotted `tv()` rather than a call per part, so `size` and `variant` are
79
+ * declared once.
80
+ *
81
+ * A size names tokens rather than raw utilities: `h-button-md` for the height,
82
+ * `text-button-md` for the label, `rounded-button-md` for the corner,
83
+ * `size-icon-md` for a composed glyph. The values live in `tokens.css`, so a
84
+ * button's icon and the spinner that replaces it resolve to the same edge
85
+ * length by construction rather than by two numbers happening to agree. `cn`
86
+ * has to know these token names — see `lib/cn.ts`. The root holds no `text-*`
87
+ * utility: a React Native `View` does not cascade colour to a `Text`
88
+ * descendant, so label colour lives on the `label` slot and icon colour is
89
+ * resolved from {@link BUTTON_FOREGROUND_TOKEN}.
90
+ *
91
+ * The corner belongs to the size axis and the group axis together, and to
92
+ * nothing else — not the base, not a variant — so exactly one corner
93
+ * *statement* ever reaches the root and a caller's `rounded-lg` has a single
94
+ * thing to beat. Each step is half its own height, which draws a capsule, and a
95
+ * circle on the square steps.
96
+ *
97
+ * A joined member **replaces** that corner with a per-side pair rather than
98
+ * layering a squaring class on top of it. Layering would in fact render
99
+ * correctly — Uniwind arbitrates a className string per style property in token
100
+ * order, and React Native puts a per-corner radius above the uniform one — but
101
+ * tailwind-merge deletes a side class the moment any all-corner class is emitted
102
+ * after it, and the `size` branch emitting one is exactly that case. Correctness
103
+ * would then rest on the key order of this object, one reorder away from a
104
+ * silent regression.
105
+ *
106
+ * A horizontal member squares on the inline axis (`rounded-s-*` / `rounded-e-*`,
107
+ * which flip under RTL); a vertical one squares top and bottom, where there is
108
+ * no logical form and no flip to want. **Never mix the two on one member** —
109
+ * React Native resolves a physical corner above a logical one, so a stray
110
+ * `rounded-t-*` would silently outrank a `rounded-s-*` beside it.
111
+ *
112
+ * A seamed member overlaps the one before it by a point, so two adjacent borders
113
+ * draw as one hairline instead of two. That is a negative margin rather than a
114
+ * dropped border on purpose: a margin moves the box and changes nothing about
115
+ * it, while `border-s-0` would make the member a point narrower than its
116
+ * neighbours and shift its centred content half a point — and would only be
117
+ * right for the variants that draw a visible border, which the group cannot know
118
+ * because a member may name its own.
119
+ *
120
+ * `overflow-hidden` on the root is load-bearing rather than tidiness — a pressed
121
+ * button fades to the edge of its own box, and the fade is clipped to the same
122
+ * corner the button draws.
123
+ *
124
+ * Free of React Native imports so it stays unit-testable — `bun test` cannot
125
+ * parse React Native's Flow-typed source. See AGENTS.md.
126
+ */
127
+ export const buttonVariants = tv({
128
+ slots: {
129
+ root: "flex-row items-center justify-center gap-2 overflow-hidden border border-transparent",
130
+ label: "text-center font-semibold",
131
+ startContent: "items-center justify-center",
132
+ endContent: "items-center justify-center",
133
+ /** Edge length an `Icon` or `Spinner` composed into the button inherits. */
134
+ icon: "",
135
+ /**
136
+ * A `Button.Group`'s box.
137
+ *
138
+ * Holds no `gap` and no `overflow-hidden`, and both absences are
139
+ * load-bearing. A gap is the seam this component exists to close. A clip
140
+ * would square off the very corners the position compounds just rounded,
141
+ * and would have to restate the group's own corner to avoid it — while the
142
+ * members already square themselves, which is the whole point.
143
+ *
144
+ * Paints no state of its own either: a disabled group publishes
145
+ * `isDisabled` and each member fades itself, so a group and its members
146
+ * cannot compound `opacity-50` down to a quarter.
147
+ */
148
+ group: "",
149
+ /** Wraps a group's rule. `shrink-0`, or a row of `flex-1` members squeezes it to nothing. */
150
+ groupSeparator: "shrink-0",
151
+ },
152
+ variants: {
153
+ variant: {
154
+ primary: { root: "bg-primary", label: "text-primary-foreground" },
155
+ secondary: { root: "bg-secondary", label: "text-secondary-foreground" },
156
+ tertiary: { root: "bg-tertiary", label: "text-tertiary-foreground" },
157
+ outline: { root: "border-border bg-transparent", label: "text-foreground" },
158
+ ghost: { root: "border-transparent bg-transparent", label: "text-foreground" },
159
+ destructive: { root: "bg-destructive", label: "text-destructive-foreground" },
160
+ "destructive-soft": { root: "bg-destructive-soft", label: "text-destructive-soft-foreground" },
161
+ },
162
+ // Six steps in two families, and a square is a step rather than a flag on
163
+ // one. `icon-md` is `md` with its horizontal padding traded for a width
164
+ // off the same token, so the two can never fight each other in the merge
165
+ // the way a boolean crossed with a size had to.
166
+ //
167
+ // Written out rather than built. Tailwind scans source text, so a class
168
+ // assembled at runtime — `h-button-${step}` — is never compiled and
169
+ // silently draws nothing; see `styles/tokens.ts`. The tuples above are
170
+ // keys, not classes, which is why those may be composed and these may not.
171
+ //
172
+ // A square carries no `gap`: it holds one child. That is also what keeps
173
+ // each row on one line, which is the whole readability of the table.
174
+ //
175
+ // The square steps are reachable only through `size`, never through
176
+ // loading. A definite width defeats the parent's `alignItems: stretch` —
177
+ // a stretch-aligned child with a definite cross size resolves to
178
+ // cross-*start* — so a full-width button that squared itself the moment
179
+ // work began would snap to a small box flush against the left edge. That
180
+ // is why `spinnerPlacement="only"` swaps the content and leaves the
181
+ // footprint exactly as the caller sized it.
182
+ size: {
183
+ sm: { root: "h-button-sm gap-1.5 px-3", label: "text-button-sm", icon: "size-icon-sm" },
184
+ md: { root: "h-button-md px-4", label: "text-button-md", icon: "size-icon-md" },
185
+ lg: { root: "h-button-lg px-5", label: "text-button-lg", icon: "size-icon-lg" },
186
+ "icon-sm": { root: "h-button-sm w-button-sm", label: "text-button-sm", icon: "size-icon-sm" },
187
+ "icon-md": { root: "h-button-md w-button-md", label: "text-button-md", icon: "size-icon-md" },
188
+ "icon-lg": { root: "h-button-lg w-button-lg", label: "text-button-lg", icon: "size-icon-lg" },
189
+ },
190
+ /** Which way a `Button.Group` runs. Only the `group` slot reads it on its own. */
191
+ orientation: {
192
+ horizontal: { group: "flex-row self-start" },
193
+ vertical: { group: "flex-col" },
194
+ },
195
+ // `middle` is the one position whose corner needs neither the size nor the
196
+ // orientation, so it lands here rather than in six compound cells that
197
+ // would all say the same word.
198
+ groupPosition: { none: {}, first: {}, middle: { root: "rounded-none" }, last: {}, only: {} },
199
+ // The empty `false` branches are load-bearing typing, not placeholders.
200
+ // `tv` derives the prop type from the declared keys, so a map with only
201
+ // `true` types the prop as `true` rather than `boolean` and rejects
202
+ // `buttonVariants({ isLoading })`. The compound variants below would match
203
+ // without them — `tv` compares against `defaultVariants` plus props and
204
+ // never reads this map.
205
+ isSeamed: { true: {}, false: {} },
206
+ isDisabled: { true: { root: "opacity-50" }, false: {} },
207
+ isLoading: { true: {}, false: {} },
208
+ isDimmedWhileLoading: { true: {}, false: {} },
209
+ },
210
+ compoundVariants: [
211
+ // Loading is not a disabled state. The button keeps full contrast — the
212
+ // spinner already says the press landed — unless the caller opts in.
213
+ { isLoading: true, isDimmedWhileLoading: true, class: { root: "opacity-50" } },
214
+ // A lone button and a lone member draw the same corner, so the two positions
215
+ // share a cell. Each step pairs its label size with its square one: a corner
216
+ // is half the button's height, and an `icon-*` step is the same height as the
217
+ // label step it is named for.
218
+ { groupPosition: ["none", "only"], size: ["sm", "icon-sm"], class: { root: "rounded-button-sm" } },
219
+ { groupPosition: ["none", "only"], size: ["md", "icon-md"], class: { root: "rounded-button-md" } },
220
+ { groupPosition: ["none", "only"], size: ["lg", "icon-lg"], class: { root: "rounded-button-lg" } },
221
+ // Each cell names all four corners, so none is left to a default and a
222
+ // uniform radius could not leak into one.
223
+ {
224
+ groupPosition: "first",
225
+ orientation: "horizontal",
226
+ size: ["sm", "icon-sm"],
227
+ class: { root: "rounded-s-button-sm rounded-e-none" },
228
+ },
229
+ {
230
+ groupPosition: "first",
231
+ orientation: "horizontal",
232
+ size: ["md", "icon-md"],
233
+ class: { root: "rounded-s-button-md rounded-e-none" },
234
+ },
235
+ {
236
+ groupPosition: "first",
237
+ orientation: "horizontal",
238
+ size: ["lg", "icon-lg"],
239
+ class: { root: "rounded-s-button-lg rounded-e-none" },
240
+ },
241
+ {
242
+ groupPosition: "last",
243
+ orientation: "horizontal",
244
+ size: ["sm", "icon-sm"],
245
+ class: { root: "rounded-e-button-sm rounded-s-none" },
246
+ },
247
+ {
248
+ groupPosition: "last",
249
+ orientation: "horizontal",
250
+ size: ["md", "icon-md"],
251
+ class: { root: "rounded-e-button-md rounded-s-none" },
252
+ },
253
+ {
254
+ groupPosition: "last",
255
+ orientation: "horizontal",
256
+ size: ["lg", "icon-lg"],
257
+ class: { root: "rounded-e-button-lg rounded-s-none" },
258
+ },
259
+ {
260
+ groupPosition: "first",
261
+ orientation: "vertical",
262
+ size: ["sm", "icon-sm"],
263
+ class: { root: "rounded-t-button-sm rounded-b-none" },
264
+ },
265
+ {
266
+ groupPosition: "first",
267
+ orientation: "vertical",
268
+ size: ["md", "icon-md"],
269
+ class: { root: "rounded-t-button-md rounded-b-none" },
270
+ },
271
+ {
272
+ groupPosition: "first",
273
+ orientation: "vertical",
274
+ size: ["lg", "icon-lg"],
275
+ class: { root: "rounded-t-button-lg rounded-b-none" },
276
+ },
277
+ {
278
+ groupPosition: "last",
279
+ orientation: "vertical",
280
+ size: ["sm", "icon-sm"],
281
+ class: { root: "rounded-b-button-sm rounded-t-none" },
282
+ },
283
+ {
284
+ groupPosition: "last",
285
+ orientation: "vertical",
286
+ size: ["md", "icon-md"],
287
+ class: { root: "rounded-b-button-md rounded-t-none" },
288
+ },
289
+ {
290
+ groupPosition: "last",
291
+ orientation: "vertical",
292
+ size: ["lg", "icon-lg"],
293
+ class: { root: "rounded-b-button-lg rounded-t-none" },
294
+ },
295
+ // Written out per orientation rather than built from the axis, because
296
+ // Tailwind's scanner is static: a class assembled at runtime is never
297
+ // compiled and Uniwind's store has nothing to look up. See AGENTS.md.
298
+ { isSeamed: true, orientation: "horizontal", class: { root: "-ms-px" } },
299
+ { isSeamed: true, orientation: "vertical", class: { root: "-mt-px" } },
300
+ ],
301
+ defaultVariants: {
302
+ variant: "primary",
303
+ size: "md",
304
+ orientation: "horizontal",
305
+ groupPosition: "none",
306
+ isSeamed: false,
307
+ isDisabled: false,
308
+ isLoading: false,
309
+ isDimmedWhileLoading: false,
310
+ },
311
+ });
312
+
313
+ /** What the spinner does to a button's children, once loading and placement are folded together. */
314
+ export type ButtonLayout = {
315
+ /** The spinner has replaced the children entirely. */
316
+ isSpinnerOnly: boolean;
317
+ /** Side the spinner is composed onto, or null when no spinner is shown. */
318
+ spinnerSide: "start" | "end" | null;
319
+ };
320
+
321
+ /**
322
+ * Folds `isLoading` and `spinnerPlacement` into the facts the content needs.
323
+ *
324
+ * It says nothing about the footprint, and that is the point: `only` swaps the
325
+ * content out and leaves the button exactly the size the caller asked for. A
326
+ * square is a `size`, so `only` cannot reach it — see the note on the `size`
327
+ * axis for the `alignItems: stretch` failure that rules out earning one here.
328
+ *
329
+ * Pure, so the whole matrix is reachable from `bun test`. See AGENTS.md.
330
+ */
331
+ export function resolveButtonLayout({
332
+ isLoading = false,
333
+ spinnerPlacement = "start",
334
+ }: {
335
+ isLoading?: boolean;
336
+ spinnerPlacement?: ButtonSpinnerPlacement;
337
+ }): ButtonLayout {
338
+ if (!isLoading) return { isSpinnerOnly: false, spinnerSide: null };
339
+ if (spinnerPlacement === "only") return { isSpinnerOnly: true, spinnerSide: null };
340
+ return { isSpinnerOnly: false, spinnerSide: spinnerPlacement };
341
+ }
342
+
343
+ /**
344
+ * Index of the child the spinner should replace, or null to insert one.
345
+ *
346
+ * A loading button swaps its icon for the spinner rather than showing both.
347
+ * Adding one would push the label sideways the moment work started and pull it
348
+ * back when it finished — and with the spinner and the icon drawn at the same
349
+ * `size-icon-*` token, replacing costs no layout at all.
350
+ *
351
+ * Only the child at the named edge is a candidate — the first at `start`, the
352
+ * last at `end`. An icon on the other side is left alone and the spinner is
353
+ * inserted at the edge instead: `spinnerPlacement` says where the caller wants
354
+ * the spinner, so taking the one icon a button holds regardless of which side
355
+ * it sits on would draw the spinner opposite the side that was asked for.
356
+ *
357
+ * Pure, so the whole matrix is reachable from `bun test`. See AGENTS.md.
358
+ */
359
+ export function resolveSpinnerSwapIndex(isIcon: readonly boolean[], side: "start" | "end"): number | null {
360
+ const index = side === "start" ? 0 : isIcon.length - 1;
361
+ return isIcon[index] === true ? index : null;
362
+ }
363
+
364
+ /**
365
+ * The labelled step a size is built on — `icon-md` is `md`'s height.
366
+ *
367
+ * A square size is its labelled step with the horizontal padding traded for a
368
+ * width off the same token, so the two share a height and therefore a corner.
369
+ * Anything that needs the step rather than the shape reads it through here
370
+ * rather than slicing the prefix at the call site: a control joining a
371
+ * `Button.Group` has to line up with the run without having a square form of its
372
+ * own, which is exactly what an `Input` does.
373
+ *
374
+ * Pure, so the whole matrix is reachable from `bun test`. See AGENTS.md.
375
+ */
376
+ export function resolveButtonSizeStep(size: ButtonSize): ButtonLabelSize {
377
+ return (size.startsWith("icon-") ? size.slice("icon-".length) : size) as ButtonLabelSize;
378
+ }
379
+
380
+ /**
381
+ * The size a member draws at, once its group has had its say.
382
+ *
383
+ * A group owns the *step* outright — members of different heights do not join —
384
+ * but not the *shape*. Those are one axis since a square footprint became a size
385
+ * rather than a flag, so resolving the whole prop to the group's value would
386
+ * make a square member impossible inside a run: the icon button at the end of a
387
+ * split button would silently grow a label's padding and lose its width.
388
+ *
389
+ * So the step comes from the group and the shape from the member, falling back
390
+ * to the group's own shape when the member names no size. `icon-md` inside a
391
+ * `sm` group is `icon-sm` — square, and the same height as everything beside it.
392
+ *
393
+ * Pure, so the whole matrix is reachable from `bun test`. See AGENTS.md.
394
+ */
395
+ export function resolveGroupedButtonSize(own: ButtonSize | undefined, group: ButtonSize | undefined): ButtonSize {
396
+ if (group === undefined) return own ?? "md";
397
+ const step = resolveButtonSizeStep(group);
398
+ const isSquare = own === undefined ? group.startsWith("icon-") : own.startsWith("icon-");
399
+ return (isSquare ? `icon-${step}` : step) as ButtonSize;
400
+ }
401
+
402
+ /**
403
+ * Where each child sits among the group's *members*.
404
+ *
405
+ * `null` is "not a member". A `Button.Group.Separator` between two buttons is a
406
+ * rule, not a segment, so the buttons either side of one are still the first and
407
+ * the last and keep their rounded outer corners. A single member is `only` and
408
+ * draws the corner it would have alone, which is what makes a group rendering
409
+ * one button conditionally look no different from the button on its own.
410
+ *
411
+ * Takes an array of "is this child a member" rather than the children
412
+ * themselves, so it stays free of React and reachable from `bun test` — the
413
+ * trade {@link resolveSpinnerSwapIndex} already makes. React Native has no
414
+ * sibling selector, so this is the only place a member's corner can be decided.
415
+ *
416
+ * Pure, so the whole matrix is reachable from `bun test`. See AGENTS.md.
417
+ */
418
+ export function resolveGroupPositions(isMember: readonly boolean[]): (ButtonGroupPosition | null)[] {
419
+ const count = isMember.reduce((total, member) => (member ? total + 1 : total), 0);
420
+ let seen = 0;
421
+
422
+ return isMember.map((member) => {
423
+ if (!member) return null;
424
+ const index = seen++;
425
+ if (count === 1) return "only";
426
+ if (index === 0) return "first";
427
+ if (index === count - 1) return "last";
428
+ return "middle";
429
+ });
430
+ }
431
+
432
+ /**
433
+ * Which members overlap the one before them, so a shared border draws once.
434
+ *
435
+ * Only where two members are *adjacent*. A member following a
436
+ * `Button.Group.Separator` must not overlap: the rule is one point wide, and a
437
+ * one-point overlap would slide straight over it and delete the divider the
438
+ * caller asked for.
439
+ *
440
+ * A separate walk from {@link resolveGroupPositions} because it asks a different
441
+ * question — "what is immediately before me" rather than "how many of us are
442
+ * there" — and folding both into one return type would hand two callers a record
443
+ * where each wants one field.
444
+ *
445
+ * `isMember[-1]` is `undefined`, so the first child falls out as `false` without
446
+ * a case of its own.
447
+ *
448
+ * Pure, so the whole matrix is reachable from `bun test`. See AGENTS.md.
449
+ */
450
+ export function resolveGroupSeams(isMember: readonly boolean[]): boolean[] {
451
+ return isMember.map((member, index) => member && isMember[index - 1] === true);
452
+ }
453
+
454
+ /**
455
+ * Settles how a press moves the button, from the three places it can come from.
456
+ *
457
+ * **Nearest wins** — the button's own prop, then its group's, then the built-in
458
+ * default: the ladder `Radio` and `Input.Group` already run. `??` rather than
459
+ * `||`, so an explicit `none` is honoured instead of read as an absence.
460
+ *
461
+ * A joined member falls back to `fade` rather than `scale`, and that is not a
462
+ * taste call. A scaling member pulls its own edges in by a point and a half
463
+ * while its neighbours hold still, so the seam the group exists to close tears
464
+ * open for the length of the press. `fade` moves no geometry.
465
+ *
466
+ * A caller still gets `scale` by asking for it, on one button or on a whole
467
+ * group. `pressedScale` beats both, because `resolvePressedState` lets an
468
+ * explicit value win on the axis it names — see `pressable.variants.ts`.
469
+ *
470
+ * Pure, so the whole matrix is reachable from `bun test`. See AGENTS.md.
471
+ */
472
+ export function resolveButtonFeedback(
473
+ own: PressableFeedback | undefined,
474
+ group: PressableFeedback | undefined,
475
+ isGrouped: boolean
476
+ ): PressableFeedback {
477
+ return own ?? group ?? (isGrouped ? BUTTON_GROUP_FEEDBACK : BUTTON_FEEDBACK);
478
+ }
479
+
480
+ export type ButtonVariantProps = VariantProps<typeof buttonVariants>;
@@ -0,0 +1,50 @@
1
+ export { Button, type ButtonProps } from "./button";
2
+ export {
3
+ type ButtonContextValue,
4
+ type ButtonGroupContextValue,
5
+ type ButtonGroupItemContextValue,
6
+ ButtonGroupItemProvider,
7
+ ButtonGroupProvider,
8
+ ButtonProvider,
9
+ useButton,
10
+ useButtonContext,
11
+ useButtonGroup,
12
+ useButtonGroupContext,
13
+ useButtonGroupItem,
14
+ useButtonGroupItemContext,
15
+ } from "./button.context";
16
+ export type { ButtonSlotProps } from "./button.types";
17
+ export {
18
+ BUTTON_FEEDBACK,
19
+ BUTTON_FOREGROUND_TOKEN,
20
+ BUTTON_GROUP_FEEDBACK,
21
+ BUTTON_GROUP_ORIENTATIONS,
22
+ BUTTON_GROUP_POSITIONS,
23
+ BUTTON_GROUP_SEPARATOR_ORIENTATION,
24
+ BUTTON_ICON_SIZES,
25
+ BUTTON_LABEL_SIZES,
26
+ BUTTON_SIZES,
27
+ BUTTON_SPINNER_PLACEMENTS,
28
+ BUTTON_VARIANTS,
29
+ type ButtonGroupOrientation,
30
+ type ButtonGroupPosition,
31
+ type ButtonGroupSlotPosition,
32
+ type ButtonIconSize,
33
+ type ButtonLabelSize,
34
+ type ButtonLayout,
35
+ type ButtonSize,
36
+ type ButtonSpinnerPlacement,
37
+ type ButtonVariant,
38
+ type ButtonVariantProps,
39
+ buttonVariants,
40
+ resolveButtonFeedback,
41
+ resolveButtonLayout,
42
+ resolveButtonSizeStep,
43
+ resolveGroupedButtonSize,
44
+ resolveGroupPositions,
45
+ resolveGroupSeams,
46
+ } from "./button.variants";
47
+ export type { ButtonGroupProps } from "./button-group";
48
+ export type { ButtonGroupSeparatorProps } from "./button-group-separator";
49
+ export type { ButtonGroupTextProps } from "./button-group-text";
50
+ export type { ButtonLabelProps } from "./button-label";