@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,24 @@
1
+ import type { ReactElement } from "react";
2
+ import type { TextProps } from "react-native";
3
+ import { Text } from "../text";
4
+ import { useButtonPart } from "./button.context";
5
+ import { buttonVariants } from "./button.variants";
6
+
7
+ export type ButtonLabelProps = TextProps & { className?: string };
8
+
9
+ /**
10
+ * The button's text.
11
+ *
12
+ * Carries its own colour and type scale, read from the button's context: a
13
+ * React Native `View` does not cascade colour to a `Text` descendant, so a
14
+ * colour set on the root would be lost.
15
+ *
16
+ * The class is passed rather than inherited even though the button publishes the
17
+ * same one through `TextClassProvider` — the two agree, and stating it here
18
+ * keeps the label correct if it is ever rendered somewhere the provider is not.
19
+ */
20
+ export function ButtonLabel({ className, ...props }: ButtonLabelProps): ReactElement {
21
+ const { variant, size } = useButtonPart("Button.Label");
22
+ return <Text className={buttonVariants({ size, variant }).label({ className })} {...props} />;
23
+ }
24
+ ButtonLabel.displayName = "DelacourUI.Button.Label";
@@ -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 leading 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 ButtonStartContent({ className, ...props }: ButtonSlotProps): ReactElement {
13
+ return <View className={buttonVariants().startContent({ className })} {...props} />;
14
+ }
15
+ ButtonStartContent.displayName = "DelacourUI.Button.StartContent";
@@ -0,0 +1,187 @@
1
+ import { createContext, type ReactElement, type ReactNode, use } from "react";
2
+ import type { PressableFeedback } from "../pressable/pressable.variants";
3
+ import type { ButtonGroupOrientation, ButtonGroupPosition, ButtonSize, ButtonVariant } from "./button.variants";
4
+
5
+ export type ButtonContextValue = {
6
+ /** Size of the button. */
7
+ size: ButtonSize;
8
+ /** Visual variant of the button. */
9
+ variant: ButtonVariant;
10
+ /** Whether the button is disabled. */
11
+ isDisabled: boolean;
12
+ /** Whether the button has work in flight. */
13
+ isLoading: boolean;
14
+ };
15
+
16
+ const ButtonContext = createContext<ButtonContextValue | null>(null);
17
+
18
+ /**
19
+ * Supplies the enclosing button's variant, size and state to its subtree.
20
+ *
21
+ * Lives in its own module, importing nothing but `button.variants`, so a
22
+ * component the button itself renders — `Spinner` — can read it without
23
+ * importing `../button`. That import would close a cycle through
24
+ * `button.tsx`, and Metro serves a partially initialised module for a cycle,
25
+ * leaving the context `undefined` at import time.
26
+ */
27
+ export function ButtonProvider({ value, children }: { value: ButtonContextValue; children: ReactNode }): ReactElement {
28
+ return <ButtonContext value={value}>{children}</ButtonContext>;
29
+ }
30
+ ButtonProvider.displayName = "DelacourUI.Button.Provider";
31
+
32
+ /** The enclosing button's context, or null outside a `<Button>`. */
33
+ export function useButtonContext(): ButtonContextValue | null {
34
+ return use(ButtonContext);
35
+ }
36
+
37
+ /**
38
+ * Reads the enclosing button's variant, size and state.
39
+ *
40
+ * Lets a custom child style itself to match without the button having to pass
41
+ * props down through every slot. Throws outside a `<Button>` — use
42
+ * {@link useButtonContext} where the enclosing button is optional.
43
+ */
44
+ export function useButton(): ButtonContextValue {
45
+ const context = useButtonContext();
46
+ if (!context) {
47
+ throw new Error("useButton must be called inside a <Button>.");
48
+ }
49
+ return context;
50
+ }
51
+
52
+ /**
53
+ * The enclosing button's context, for a compound part that cannot work without
54
+ * one.
55
+ *
56
+ * Internal: deliberately not re-exported from `index.ts`. A caller outside the
57
+ * library wants {@link useButton}, whose error message names the hook rather
58
+ * than a part.
59
+ */
60
+ export function useButtonPart(component: string): ButtonContextValue {
61
+ const context = useButtonContext();
62
+ if (!context) {
63
+ throw new Error(`${component} must be rendered inside a <Button>.`);
64
+ }
65
+ return context;
66
+ }
67
+
68
+ export type ButtonGroupContextValue = {
69
+ /** Which way the group runs. Members square the pair of corners crossing it. */
70
+ orientation: ButtonGroupOrientation;
71
+ /** The group owns this outright — members of different heights do not join. */
72
+ size: ButtonSize;
73
+ /** A default a member may override. */
74
+ variant?: ButtonVariant;
75
+ /** A default a member may override, so one option can disable itself. */
76
+ isDisabled?: boolean;
77
+ /** Press treatment for members. Unset falls back to `fade` — see `resolveButtonFeedback`. */
78
+ feedback?: PressableFeedback;
79
+ };
80
+
81
+ export type ButtonGroupItemContextValue = ButtonGroupContextValue & {
82
+ /** Where this member sits among the group's members, separators skipped. */
83
+ position: ButtonGroupPosition;
84
+ /** Overlap the member before it, so the shared edge is drawn once. */
85
+ isSeamed: boolean;
86
+ };
87
+
88
+ const ButtonGroupContext = createContext<ButtonGroupContextValue | null>(null);
89
+
90
+ const ButtonGroupItemContext = createContext<ButtonGroupItemContextValue | null>(null);
91
+
92
+ /**
93
+ * Supplies the enclosing group's axes to everything inside it.
94
+ *
95
+ * A second context beside {@link ButtonGroupItemProvider} rather than a field on
96
+ * it, because `Button.Group.Separator` needs the orientation and is deliberately
97
+ * *not* a member — so it has no item context to read one from.
98
+ *
99
+ * The optional axes are published raw. `undefined` means "the group said
100
+ * nothing", which is what lets a member's `??` ladder see past a group that
101
+ * named none, and what lets one member disable itself inside a group that did
102
+ * not.
103
+ */
104
+ export function ButtonGroupProvider({
105
+ value,
106
+ children,
107
+ }: {
108
+ value: ButtonGroupContextValue;
109
+ children: ReactNode;
110
+ }): ReactElement {
111
+ return <ButtonGroupContext value={value}>{children}</ButtonGroupContext>;
112
+ }
113
+ ButtonGroupProvider.displayName = "DelacourUI.Button.Group.Provider";
114
+
115
+ /** The enclosing group's axes, or null outside a `<Button.Group>`. */
116
+ export function useButtonGroupContext(): ButtonGroupContextValue | null {
117
+ return use(ButtonGroupContext);
118
+ }
119
+
120
+ /**
121
+ * Reads the enclosing group's axes.
122
+ *
123
+ * Throws outside a `<Button.Group>` — use {@link useButtonGroupContext} where
124
+ * the enclosing group is optional, as a `Button` itself does.
125
+ */
126
+ export function useButtonGroup(): ButtonGroupContextValue {
127
+ const context = useButtonGroupContext();
128
+ if (!context) {
129
+ throw new Error("useButtonGroup must be called inside a <Button.Group>.");
130
+ }
131
+ return context;
132
+ }
133
+
134
+ /**
135
+ * The enclosing group's axes, for a compound part that cannot work without one.
136
+ *
137
+ * Internal: deliberately not re-exported from `index.ts`. A caller outside the
138
+ * library wants {@link useButtonGroup}, whose error message names the hook
139
+ * rather than a part.
140
+ */
141
+ export function useButtonGroupPart(component: string): ButtonGroupContextValue {
142
+ const context = useButtonGroupContext();
143
+ if (!context) {
144
+ throw new Error(`${component} must be rendered inside a <Button.Group>.`);
145
+ }
146
+ return context;
147
+ }
148
+
149
+ /**
150
+ * Supplies one member its place in the group.
151
+ *
152
+ * The group wraps each member in one of these rather than cloning props onto it.
153
+ * A provider is not a host component, so no view is created and Yoga sees
154
+ * exactly the children the group's own `View` already had — while a cloned prop
155
+ * would reach only a direct child, and never one a caller wrapped in a `View` or
156
+ * produced from a helper.
157
+ */
158
+ export function ButtonGroupItemProvider({
159
+ value,
160
+ children,
161
+ }: {
162
+ value: ButtonGroupItemContextValue;
163
+ children: ReactNode;
164
+ }): ReactElement {
165
+ return <ButtonGroupItemContext value={value}>{children}</ButtonGroupItemContext>;
166
+ }
167
+ ButtonGroupItemProvider.displayName = "DelacourUI.Button.Group.ItemProvider";
168
+
169
+ /** This member's place in the group, or null for a control standing on its own. */
170
+ export function useButtonGroupItemContext(): ButtonGroupItemContextValue | null {
171
+ return use(ButtonGroupItemContext);
172
+ }
173
+
174
+ /**
175
+ * Reads this member's place in the group.
176
+ *
177
+ * For a custom control that has to join a group the way a `Button` does. Throws
178
+ * outside one — use {@link useButtonGroupItemContext} where the group is
179
+ * optional, as the button itself does.
180
+ */
181
+ export function useButtonGroupItem(): ButtonGroupItemContextValue {
182
+ const context = useButtonGroupItemContext();
183
+ if (!context) {
184
+ throw new Error("useButtonGroupItem must be called inside a <Button.Group>.");
185
+ }
186
+ return context;
187
+ }
@@ -0,0 +1,273 @@
1
+ import { Children, isValidElement, type ReactElement, type ReactNode, useMemo } from "react";
2
+ import { Icon, IconDefaultsProvider } from "../icon";
3
+ import { Pressable, type PressableProps } from "../pressable";
4
+ import { Spinner } from "../spinner";
5
+ import { TextClassProvider } from "../text/text.context";
6
+ import { type ButtonContextValue, ButtonProvider, useButtonGroupItemContext } from "./button.context";
7
+ import {
8
+ BUTTON_FOREGROUND_TOKEN,
9
+ type ButtonLayout,
10
+ type ButtonSize,
11
+ type ButtonSpinnerPlacement,
12
+ type ButtonVariant,
13
+ buttonVariants,
14
+ resolveButtonFeedback,
15
+ resolveButtonLayout,
16
+ resolveGroupedButtonSize,
17
+ resolveSpinnerSwapIndex,
18
+ } from "./button.variants";
19
+ import { ButtonEndContent } from "./button-end-content";
20
+ import { ButtonGroup } from "./button-group";
21
+ import { ButtonLabel } from "./button-label";
22
+ import { ButtonStartContent } from "./button-start-content";
23
+
24
+ export type ButtonProps = Omit<PressableProps, "busy" | "children" | "disabled" | "pressedOpacity" | "pressedScale"> & {
25
+ variant?: ButtonVariant;
26
+ /** Height, label step, icon step, corner and padding on one axis. `icon-*` is a square. */
27
+ size?: ButtonSize;
28
+ isDisabled?: boolean;
29
+ /** Work is in flight: a spinner is composed in and presses are blocked. */
30
+ isLoading?: boolean;
31
+ /** Where the spinner sits. `only` replaces the content, keeping the footprint. */
32
+ spinnerPlacement?: ButtonSpinnerPlacement;
33
+ /** Fade the button while loading, the way `isDisabled` does. Off by default. */
34
+ isDimmedWhileLoading?: boolean;
35
+ children?: ReactNode;
36
+ };
37
+
38
+ function ButtonRoot({
39
+ variant,
40
+ size,
41
+ isDisabled,
42
+ isLoading = false,
43
+ isDimmedWhileLoading = false,
44
+ spinnerPlacement = "start",
45
+ feedback,
46
+ accessibilityLabel,
47
+ className,
48
+ children,
49
+ ...props
50
+ }: ButtonProps): ReactElement {
51
+ // Deliberately not defaulted in the destructure above: a default there would
52
+ // swallow the group before it was ever consulted. The group owns a member's
53
+ // *step* — controls of different heights do not join — but not its shape, so
54
+ // a square member keeps its width inside a run. `variant` and `isDisabled`
55
+ // are defaults it may override, so one member can disable itself inside a
56
+ // group that did not.
57
+ const item = useButtonGroupItemContext();
58
+ const resolvedSize = resolveGroupedButtonSize(size, item?.size);
59
+ const resolvedVariant = variant ?? item?.variant ?? "primary";
60
+ const resolvedIsDisabled = isDisabled ?? item?.isDisabled ?? false;
61
+
62
+ const context = useMemo<ButtonContextValue>(
63
+ () => ({ variant: resolvedVariant, size: resolvedSize, isDisabled: resolvedIsDisabled, isLoading }),
64
+ [resolvedVariant, resolvedSize, resolvedIsDisabled, isLoading]
65
+ );
66
+
67
+ const layout = useMemo(() => resolveButtonLayout({ isLoading, spinnerPlacement }), [isLoading, spinnerPlacement]);
68
+
69
+ const content = useMemo(() => composeContent(children, layout), [children, layout]);
70
+
71
+ const slots = buttonVariants({
72
+ groupPosition: item?.position ?? "none",
73
+ isDimmedWhileLoading,
74
+ isDisabled: resolvedIsDisabled,
75
+ isLoading,
76
+ isSeamed: item?.isSeamed ?? false,
77
+ orientation: item?.orientation ?? "horizontal",
78
+ size: resolvedSize,
79
+ variant: resolvedVariant,
80
+ });
81
+
82
+ // Icons and spinners composed into the button adopt these unless told otherwise.
83
+ const iconClassName = slots.icon();
84
+ const iconDefaults = useMemo(
85
+ () => ({ className: iconClassName, color: BUTTON_FOREGROUND_TOKEN[resolvedVariant] }),
86
+ [iconClassName, resolvedVariant]
87
+ );
88
+
89
+ const label = accessibilityLabel ?? (layout.isSpinnerOnly ? textOf(children) : undefined);
90
+
91
+ return (
92
+ <ButtonProvider value={context}>
93
+ <Pressable
94
+ accessibilityLabel={label}
95
+ accessibilityRole="button"
96
+ busy={isLoading}
97
+ className={slots.root({ className })}
98
+ disabled={resolvedIsDisabled}
99
+ feedback={resolveButtonFeedback(feedback, item?.feedback, item !== null)}
100
+ {...props}
101
+ >
102
+ <IconDefaultsProvider value={iconDefaults}>
103
+ <TextClassProvider value={slots.label()}>{content}</TextClassProvider>
104
+ </IconDefaultsProvider>
105
+ </Pressable>
106
+ </ButtonProvider>
107
+ );
108
+ }
109
+
110
+ /**
111
+ * Composes the spinner into the button's children.
112
+ *
113
+ * A loading button **replaces** its icon rather than showing both. Adding a
114
+ * spinner alongside would push the label sideways the moment work started and
115
+ * pull it back when it finished; swapping costs no layout at all, because the
116
+ * spinner and the icon are drawn at the same `size-icon-*` token — the button
117
+ * publishes one class and both read it.
118
+ *
119
+ * Only the icon at the named edge is swapped — the first child at `start`, the
120
+ * last at `end`. An icon on the other side stays where it is and the spinner is
121
+ * inserted at the edge instead, spaced off the label by the root's own `gap`:
122
+ * `spinnerPlacement` is where the caller wants the spinner, and a button
123
+ * holding one leading icon must not answer `end` by spinning at the start.
124
+ *
125
+ * `only` drops the children: the spinner stands in for the whole content, and
126
+ * the button keeps whatever footprint its `size` gave it.
127
+ *
128
+ * The spinner is emitted bare — it reads its size and colour from the button's
129
+ * context, so nothing is passed here.
130
+ */
131
+ function composeContent(children: ReactNode, layout: ButtonLayout): ReactNode {
132
+ if (layout.isSpinnerOnly) return <Spinner />;
133
+ if (layout.spinnerSide === null) return wrapTextChildren(children);
134
+
135
+ const items = Children.toArray(wrapTextChildren(children));
136
+ const swapAt = resolveSpinnerSwapIndex(items.map(isIcon), layout.spinnerSide);
137
+
138
+ if (swapAt !== null) {
139
+ return items.map((child, index) => (index === swapAt ? <Spinner key="spinner" /> : child));
140
+ }
141
+
142
+ return layout.spinnerSide === "start" ? (
143
+ <>
144
+ <Spinner />
145
+ {items}
146
+ </>
147
+ ) : (
148
+ <>
149
+ {items}
150
+ <Spinner />
151
+ </>
152
+ );
153
+ }
154
+
155
+ /**
156
+ * Whether a child is an `Icon` the spinner can stand in for.
157
+ *
158
+ * Only a bare `Icon` qualifies. A `Button.StartContent` wraps arbitrary content
159
+ * whose height the button does not know, so replacing one could resize the
160
+ * button — the thing this swap exists to avoid.
161
+ */
162
+ function isIcon(child: ReactNode): boolean {
163
+ return isValidElement(child) && child.type === Icon;
164
+ }
165
+
166
+ /**
167
+ * The button's own text, for use as an accessibility label.
168
+ *
169
+ * `spinnerPlacement="only"` takes the label out of the tree, so the name a
170
+ * screen reader was reading has to be carried onto the root instead. Reaches
171
+ * one level into a `Button.Label`; anything more deeply nested needs an
172
+ * explicit `accessibilityLabel`.
173
+ */
174
+ function textOf(children: ReactNode): string | undefined {
175
+ const parts: string[] = [];
176
+
177
+ for (const child of Children.toArray(children)) {
178
+ if (typeof child === "string" || typeof child === "number") {
179
+ parts.push(String(child));
180
+ continue;
181
+ }
182
+ if (!isValidElement(child) || child.type !== ButtonLabel) continue;
183
+ const nested = (child.props as { children?: ReactNode }).children;
184
+ if (typeof nested === "string" || typeof nested === "number") parts.push(String(nested));
185
+ }
186
+
187
+ return parts.length > 0 ? parts.join("") : undefined;
188
+ }
189
+
190
+ /**
191
+ * Wraps bare text children in a `Button.Label`.
192
+ *
193
+ * React Native cannot render a string outside a `<Text>`, so `<Button>Save</Button>`
194
+ * would otherwise crash. Consecutive strings and numbers are collected into a
195
+ * single label rather than one each — `Row {index}` is one piece of text, and
196
+ * wrapping the parts separately would space them apart by the button's gap.
197
+ */
198
+ function wrapTextChildren(children: ReactNode): ReactNode {
199
+ const items = Children.toArray(children);
200
+ const output: ReactNode[] = [];
201
+ let run: (string | number)[] = [];
202
+
203
+ const flushRun = () => {
204
+ if (run.length === 0) return;
205
+ output.push(<ButtonLabel key={`label-${output.length}`}>{run.join("")}</ButtonLabel>);
206
+ run = [];
207
+ };
208
+
209
+ for (const child of items) {
210
+ if (typeof child === "string" || typeof child === "number") {
211
+ run.push(child);
212
+ continue;
213
+ }
214
+ flushRun();
215
+ output.push(child);
216
+ }
217
+ flushRun();
218
+
219
+ return output;
220
+ }
221
+
222
+ /**
223
+ * A pressable action, composed from parts rather than configured by flags.
224
+ *
225
+ * `variant`, `size` and the button's state reach the sub-components through
226
+ * context, so `Button.Label` picks its own text colour. That indirection is not
227
+ * incidental: a React Native `View` does not cascade colour to a `Text`
228
+ * descendant the way a DOM element would, so a colour set on the root is lost.
229
+ *
230
+ * Plain string or number children are wrapped in a `Button.Label`
231
+ * automatically; pass the compound parts when a layout needs more control.
232
+ *
233
+ * Icons are composed in, not passed as props. Anything in the subtree inherits
234
+ * the button's icon size and its variant's colour, so an `Icon` needs nothing
235
+ * but the glyph — and a `Spinner` needs nothing at all.
236
+ *
237
+ * Text works the same way: the button publishes its label treatment through a
238
+ * `TextClassProvider`, so a bare `<Text>` composed in comes out at the label's
239
+ * colour and type scale without being told to.
240
+ *
241
+ * `isLoading` blocks presses and announces the button as busy, but does not dim
242
+ * it: a spinner already says the press landed. `isDimmedWhileLoading` opts into
243
+ * the faded treatment.
244
+ *
245
+ * A button is a `Pressable`, so `feedback`, `haptic` and the rest are inherited
246
+ * rather than restated here. Only the default differs: `scale`, the spring the
247
+ * rest of the library's controls press with.
248
+ *
249
+ * @example
250
+ * <Button haptic="selection" onPress={next}>
251
+ * <Button.Label>Continue</Button.Label>
252
+ * <Icon icon={IconArrowRight} />
253
+ * </Button>
254
+ *
255
+ * @example
256
+ * <Button isLoading={isSaving} onPress={save}>Save</Button>
257
+ *
258
+ * @example
259
+ * <Button accessibilityLabel="Favourite" size="icon-md" variant="ghost">
260
+ * <Icon icon={IconHeart} />
261
+ * </Button>
262
+ */
263
+ export const Button = Object.assign(ButtonRoot, {
264
+ /** The button's text. Picks its colour and type scale from the button's variant and size. */
265
+ Label: ButtonLabel,
266
+ /** A centred wrapper for leading content that is not an `Icon`. */
267
+ StartContent: ButtonStartContent,
268
+ /** A centred wrapper for trailing content that is not an `Icon`. */
269
+ EndContent: ButtonEndContent,
270
+ /** Several controls joined into one run, sharing a corner and a seam. */
271
+ Group: ButtonGroup,
272
+ displayName: "DelacourUI.Button",
273
+ });
@@ -0,0 +1,10 @@
1
+ import type { ViewProps } from "react-native";
2
+
3
+ /**
4
+ * The shape of a button's leading and trailing slots.
5
+ *
6
+ * Shared by `Button.StartContent` and `Button.EndContent`, which are identical
7
+ * apart from where the root places them, so it lives in a leaf rather than in
8
+ * one of the two files arbitrarily.
9
+ */
10
+ export type ButtonSlotProps = ViewProps & { className?: string };