@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,73 @@
1
+ # Spinner
2
+
3
+ An animated loading indicator. Compound root plus `Spinner.Content`, the part
4
+ that rotates.
5
+
6
+ `import { Spinner } from "@delacour/react-native-ui/spinner";`
7
+
8
+ ## Files
9
+
10
+ | File | What it holds |
11
+ | --- | --- |
12
+ | `index.ts` | → `@delacour/react-native-ui/spinner` |
13
+ | `spinner.tsx` | Root + the `Object.assign` compound surface |
14
+ | `spinner-content.tsx` | `Spinner.Content`, the rotating layer |
15
+ | `spinner-arc.tsx` | The default arc glyph |
16
+ | `spinner.context.tsx` | `SpinnerContext`, `useSpinner()`, `useSpinnerContext()` |
17
+ | `spinner.variants.ts` | Pure `tv()` slots + resolvers, no RN imports |
18
+ | `spinner.variants.test.ts` | |
19
+
20
+ ## Design
21
+
22
+ - **Sizes**: the icon scale, shared outright — `SPINNER_SIZES` *is*
23
+ `ICON_SIZES`, `xs`…`2xl` (14/16/18/20/24/32pt) — or an explicit number. A
24
+ spinner stands in for an icon, so the two must agree on what `md` means. **The root is the only sized box** — `Spinner.Content` is
25
+ `size-full` and the arc carries no width or height at all, because
26
+ react-native-svg resolves both to `'100%'` when neither is set. That
27
+ `size-full` is load-bearing: content-size the middle layer and the percentage
28
+ resolves against an indefinite parent, collapsing the glyph to zero.
29
+ **Colours**: `default`, `success`, `warning`, `destructive`, plus any token the
30
+ theme emits (`primary-foreground`, `muted-foreground`) or a literal
31
+ (`#EC4899`). A Tailwind palette name like `emerald-500` only resolves if some
32
+ utility class already pulled that variable into the build — otherwise the
33
+ token is unresolved and nothing is drawn. Prefer the semantic tokens.
34
+ - **Size and colour are inherited, not passed** — through exactly one path, the
35
+ nearest `IconDefaultsProvider`, falling back to `md` on `foreground`. A
36
+ [`Button`](../button/AGENTS.md) already wraps the spinner it composes in with
37
+ its own icon class and variant foreground, so the spinner does **not** read
38
+ the button's context: a second path would recompute the same two values and
39
+ could drift from them. An explicit `size` or `color` always wins — the
40
+ precedence [`Icon`](../icon/AGENTS.md) follows.
41
+ - **Any child is the glyph**, wrapped in a `Spinner.Content` automatically so it
42
+ still rotates — a custom icon or asset needs nothing but to be passed in.
43
+ `Spinner.Content` is the rotating layer itself, so write it out by hand only
44
+ when that layer needs styling.
45
+ - **`speed` belongs to the root**, not to `Spinner.Content`. It rides the
46
+ spinner's context alongside the resolved size and colour, so every part of the
47
+ spinner turns at one rate and a caller never reaches past the root to set it:
48
+ `<Spinner speed={0.7}>` — 1 is one full turn per 900ms.
49
+ - **The default glyph is drawn from SVG primitives**, not a Central Icon — the
50
+ set has no loader glyph. Rule 5 governs *icons*; primitives are fine.
51
+ - **The arc's caps are `butt`, and its round head is a separate `Circle`.** The
52
+ two half-rings share the endpoint at the bottom of the ring, where both sit at
53
+ `SPINNER_ARC_JOINT_OPACITY`. Round caps there stack, and two semi-transparent
54
+ discs composite to roughly 0.8 alpha — a bright dot straddling the joint,
55
+ opposite the head, at every size. Butt caps abut instead, and the one end that
56
+ wants rounding gets a disc of its own, drawn last so it sits on top; the tail
57
+ terminates fully transparent at that same point, so its flat end is invisible.
58
+ Do not put `strokeLinecap="round"` back.
59
+ - **The gradient stops are angle-compensated, and run in user space.** A linear
60
+ gradient fades along its axis and that axis is `y`, but a point at angle θ
61
+ clockwise from the top sits at `y = 12 - 10·cos θ` — so two stops alone make
62
+ the fade stall near 3 and 9 o'clock and race through 12 and 6, and the ring
63
+ reads as a bright chunk beside a flat grey quadrant rather than as an even
64
+ comet. `spinnerArcStops` places each stop at the offset the arc actually
65
+ occupies at that angle while stepping the opacity evenly, which inverts the
66
+ skew; it is pure, so `bun test` pins the whole ladder.
67
+ `gradientUnits="userSpaceOnUse"` then keeps the endpoints exact, where an
68
+ object bounding box would leave open whether a given renderer includes the
69
+ stroke — which shifts both ends off 0 and 1 and clips the head and the tail.
70
+ - **The rotation sets `ReduceMotion.Never` deliberately.** Under the default
71
+ `System` policy `withTiming` completes instantly while the OS reduce-motion
72
+ setting is on, so `withRepeat(-1)` would spin a zero-length animation forever.
73
+ A status indicator is not decorative motion.
@@ -0,0 +1,17 @@
1
+ export { Spinner, type SpinnerProps } from "./spinner";
2
+ export { useSpinner } from "./spinner.context";
3
+ export {
4
+ resolveSpinnerColor,
5
+ resolveSpinnerRootClass,
6
+ SPINNER_COLOR_TOKEN,
7
+ SPINNER_COLORS,
8
+ SPINNER_DURATION_MS,
9
+ SPINNER_FALLBACK_COLOR,
10
+ SPINNER_FALLBACK_SIZE_CLASS,
11
+ SPINNER_GLYPH_SIZE_CLASS,
12
+ SPINNER_SIZES,
13
+ type SpinnerColor,
14
+ type SpinnerSize,
15
+ spinnerVariants,
16
+ } from "./spinner.variants";
17
+ export type { SpinnerContentProps } from "./spinner-content";
@@ -0,0 +1,90 @@
1
+ import { type ReactElement, useId } from "react";
2
+ import Svg, { Circle, Defs, LinearGradient, Path, Stop } from "react-native-svg";
3
+ import { useSpinnerContext } from "./spinner.context";
4
+ import {
5
+ SPINNER_ARC_HEAD_OPACITY,
6
+ SPINNER_ARC_JOINT_OPACITY,
7
+ SPINNER_ARC_STROKE_WIDTH,
8
+ SPINNER_ARC_TAIL_OPACITY,
9
+ spinnerArcStops,
10
+ } from "./spinner.variants";
11
+
12
+ // The ring's geometry, in viewBox units. Everything below is derived from these
13
+ // three numbers — the paths, the gradient axis, the head cap and the viewBox —
14
+ // so the head cannot end up drawn somewhere the stroke no longer passes.
15
+ const CENTRE = 12;
16
+ const RADIUS = 10;
17
+ const TOP = CENTRE - RADIUS;
18
+ const BOTTOM = CENTRE + RADIUS;
19
+
20
+ const VIEW_BOX = `0 0 ${CENTRE * 2} ${CENTRE * 2}`;
21
+ const LEAD_PATH = `M ${CENTRE} ${TOP} A ${RADIUS} ${RADIUS} 0 0 1 ${CENTRE} ${BOTTOM}`;
22
+ const TAIL_PATH = `M ${CENTRE} ${BOTTOM} A ${RADIUS} ${RADIUS} 0 0 1 ${CENTRE} ${TOP}`;
23
+
24
+ const LEAD_STOPS = spinnerArcStops(SPINNER_ARC_HEAD_OPACITY, SPINNER_ARC_JOINT_OPACITY);
25
+ const TAIL_STOPS = spinnerArcStops(SPINNER_ARC_TAIL_OPACITY, SPINNER_ARC_JOINT_OPACITY);
26
+
27
+ /**
28
+ * The default glyph: a ring whose stroke fades from opaque to transparent
29
+ * around the circle.
30
+ *
31
+ * Drawn as two half-rings because a linear gradient only fades along one axis —
32
+ * one alone cannot carry the stroke all the way round. Central Icons has no
33
+ * loader glyph, so this is drawn from SVG primitives rather than imported.
34
+ *
35
+ * It carries no width or height on purpose. react-native-svg resolves both to
36
+ * `'100%'` when neither is set, so the arc fills whatever box the spinner root
37
+ * sized — one place decides the size instead of threading a number down. The
38
+ * viewBox stays fixed at 24, so the stroke stays proportional at every size
39
+ * with no geometry to recompute.
40
+ *
41
+ * **The caps are `butt`, and the round head is a separate `Circle`.** The two
42
+ * half-rings share the endpoint at the bottom of the ring, where both sit at
43
+ * `SPINNER_ARC_JOINT_OPACITY`. Round caps there stack, and two semi-transparent
44
+ * discs composite to roughly 0.8 alpha — a bright dot straddling the joint,
45
+ * opposite the head. Butt caps abut instead, and the one endpoint that actually
46
+ * wants a rounded end gets a disc of its own, drawn last so it sits on top. The
47
+ * tail terminates fully transparent at that same point, so its flat end is
48
+ * invisible and needs nothing. Do not put `strokeLinecap="round"` back.
49
+ *
50
+ * **The gradients run in user space.** The viewBox is fixed and the geometry is
51
+ * hard-coded, so `y = TOP … BOTTOM` is exact — where an object bounding box
52
+ * would leave open whether a given renderer includes the stroke, which shifts
53
+ * both endpoints off 0 and 1 and clips the head and the tail.
54
+ *
55
+ * Reads the spinner context optionally rather than through `useSpinner`: the
56
+ * arc is also the fallback glyph and must not throw outside a `<Spinner>`.
57
+ * Outside one there is no colour to resolve, and every paint here falls back to
58
+ * SVG's own black.
59
+ */
60
+ export function SpinnerArc(): ReactElement {
61
+ const spinner = useSpinnerContext();
62
+ const resolvedColor = spinner?.color;
63
+
64
+ // An SVG gradient is referenced by id, and ids are global to the document.
65
+ // React 19 emits `«r1»`-style ids, whose delimiters are invalid in `url(#…)`.
66
+ const id = useId().replace(/[^a-zA-Z0-9]/g, "");
67
+ const leadId = `spinner-lead-${id}`;
68
+ const tailId = `spinner-tail-${id}`;
69
+
70
+ return (
71
+ <Svg fill="none" viewBox={VIEW_BOX}>
72
+ <Defs>
73
+ <LinearGradient gradientUnits="userSpaceOnUse" id={leadId} x1={0} x2={0} y1={TOP} y2={BOTTOM}>
74
+ {LEAD_STOPS.map((stop) => (
75
+ <Stop key={stop.offset} offset={stop.offset} stopColor={resolvedColor} stopOpacity={stop.opacity} />
76
+ ))}
77
+ </LinearGradient>
78
+ <LinearGradient gradientUnits="userSpaceOnUse" id={tailId} x1={0} x2={0} y1={TOP} y2={BOTTOM}>
79
+ {TAIL_STOPS.map((stop) => (
80
+ <Stop key={stop.offset} offset={stop.offset} stopColor={resolvedColor} stopOpacity={stop.opacity} />
81
+ ))}
82
+ </LinearGradient>
83
+ </Defs>
84
+ <Path d={LEAD_PATH} stroke={`url(#${leadId})`} strokeLinecap="butt" strokeWidth={SPINNER_ARC_STROKE_WIDTH} />
85
+ <Path d={TAIL_PATH} stroke={`url(#${tailId})`} strokeLinecap="butt" strokeWidth={SPINNER_ARC_STROKE_WIDTH} />
86
+ <Circle cx={CENTRE} cy={TOP} fill={resolvedColor} r={SPINNER_ARC_STROKE_WIDTH / 2} />
87
+ </Svg>
88
+ );
89
+ }
90
+ SpinnerArc.displayName = "DelacourUI.Spinner.Arc";
@@ -0,0 +1,63 @@
1
+ import { type ComponentProps, type ReactElement, type ReactNode, useEffect } from "react";
2
+ import Animated, {
3
+ cancelAnimation,
4
+ Easing,
5
+ ReduceMotion,
6
+ useAnimatedStyle,
7
+ useSharedValue,
8
+ withRepeat,
9
+ withTiming,
10
+ } from "react-native-reanimated";
11
+ import { useSpinner } from "./spinner.context";
12
+ import { SPINNER_DURATION_MS, spinnerVariants } from "./spinner.variants";
13
+ import { SpinnerArc } from "./spinner-arc";
14
+
15
+ export type SpinnerContentProps = Omit<ComponentProps<typeof Animated.View>, "children"> & {
16
+ className?: string;
17
+ children?: ReactNode;
18
+ };
19
+
20
+ /**
21
+ * The rotating layer of a spinner.
22
+ *
23
+ * This is the part that turns, so a custom glyph or asset has to sit inside one
24
+ * to spin. The spinner wraps a bare child in it automatically, which leaves
25
+ * writing it out by hand for when the rotating layer itself needs styling.
26
+ *
27
+ * Colour and speed come from the spinner's context, and it fills the root
28
+ * rather than taking a size — there is nothing to pass down to it.
29
+ */
30
+ export function SpinnerContent({ className, style, children, ...props }: SpinnerContentProps): ReactElement {
31
+ const { speed } = useSpinner();
32
+ const angle = useSharedValue(0);
33
+
34
+ useEffect(() => {
35
+ angle.value = withRepeat(
36
+ withTiming(360, {
37
+ duration: SPINNER_DURATION_MS / speed,
38
+ easing: Easing.linear,
39
+ // Deliberate. Under the default `System` policy `withTiming` completes
40
+ // instantly while the OS reduce-motion setting is on, so `withRepeat(-1)`
41
+ // would spin a zero-length animation forever. A status indicator is not
42
+ // decorative motion — it is the only thing saying work is in flight.
43
+ reduceMotion: ReduceMotion.Never,
44
+ }),
45
+ -1,
46
+ false
47
+ );
48
+
49
+ // Without this the repeat outlives the unmount when a button stops loading.
50
+ return () => cancelAnimation(angle);
51
+ }, [angle, speed]);
52
+
53
+ const animatedStyle = useAnimatedStyle(() => ({
54
+ transform: [{ rotate: `${angle.value}deg` }],
55
+ }));
56
+
57
+ return (
58
+ <Animated.View className={spinnerVariants().content({ className })} style={[animatedStyle, style]} {...props}>
59
+ {children ?? <SpinnerArc />}
60
+ </Animated.View>
61
+ );
62
+ }
63
+ SpinnerContent.displayName = "DelacourUI.Spinner.Content";
@@ -0,0 +1,52 @@
1
+ import { createContext, type ReactElement, type ReactNode, use } from "react";
2
+
3
+ export type SpinnerContextValue = {
4
+ /** Resolved colour value, ready for an SVG stroke. */
5
+ color: string | undefined;
6
+ /** Rotation speed multiplier. 1 is one full turn per 900ms. */
7
+ speed: number;
8
+ /** Whether the spinner is showing. */
9
+ isLoading: boolean;
10
+ };
11
+
12
+ const SpinnerContext = createContext<SpinnerContextValue | null>(null);
13
+
14
+ /**
15
+ * Supplies the enclosing spinner's resolved colour and speed to its subtree.
16
+ *
17
+ * Lives in its own module, importing nothing at all, so the parts the spinner
18
+ * renders — `Spinner.Content` and the default arc glyph — can read it without
19
+ * importing `./spinner`. That import would close a cycle through `spinner.tsx`,
20
+ * and Metro serves a partially initialised module for a cycle, leaving the
21
+ * context `undefined` at import time. See AGENTS.md rule 3.
22
+ */
23
+ export function SpinnerProvider({
24
+ value,
25
+ children,
26
+ }: {
27
+ value: SpinnerContextValue;
28
+ children: ReactNode;
29
+ }): ReactElement {
30
+ return <SpinnerContext value={value}>{children}</SpinnerContext>;
31
+ }
32
+ SpinnerProvider.displayName = "DelacourUI.Spinner.Provider";
33
+
34
+ /** The enclosing spinner's context, or null outside a `<Spinner>`. */
35
+ export function useSpinnerContext(): SpinnerContextValue | null {
36
+ return use(SpinnerContext);
37
+ }
38
+
39
+ /**
40
+ * Reads the enclosing spinner's resolved colour and speed.
41
+ *
42
+ * Lets a custom glyph match the spinner without either being passed down to
43
+ * it. Size is not here: the root sizes itself and everything under it fills. Throws outside a `<Spinner>` — use {@link useSpinnerContext}
44
+ * where the enclosing spinner is optional.
45
+ */
46
+ export function useSpinner(): SpinnerContextValue {
47
+ const context = useSpinnerContext();
48
+ if (!context) {
49
+ throw new Error("useSpinner must be called inside a <Spinner>.");
50
+ }
51
+ return context;
52
+ }
@@ -0,0 +1,128 @@
1
+ import { Children, type ComponentProps, isValidElement, type ReactElement, type ReactNode, useMemo } from "react";
2
+ import Animated, { Easing, FadeIn, FadeOut } from "react-native-reanimated";
3
+ import { useThemeColor } from "../../hooks/use-theme-color";
4
+ import { IconDefaultsProvider, useIconDefaults } from "../icon";
5
+ import { type SpinnerContextValue, SpinnerProvider } from "./spinner.context";
6
+ import {
7
+ resolveSpinnerColor,
8
+ resolveSpinnerRootClass,
9
+ SPINNER_GLYPH_SIZE_CLASS,
10
+ type SpinnerColor,
11
+ type SpinnerSize,
12
+ } from "./spinner.variants";
13
+ import { SpinnerContent } from "./spinner-content";
14
+
15
+ const ENTERING = FadeIn.duration(200).easing(Easing.out(Easing.ease));
16
+ const EXITING = FadeOut.duration(100);
17
+
18
+ export type SpinnerProps = Omit<ComponentProps<typeof Animated.View>, "children" | "style"> & {
19
+ /** A named size, or an edge length in points. Defaults to the enclosing component's icon size. */
20
+ size?: SpinnerSize | number;
21
+ /**
22
+ * A named colour, any theme token the active theme defines
23
+ * (`primary-foreground`, `muted-foreground`) or a literal (`#EC4899`).
24
+ * Defaults to the enclosing component's icon colour.
25
+ */
26
+ color?: SpinnerColor | (string & {});
27
+ /** Whether the spinner is shown. Fades out when flipped off. */
28
+ isLoading?: boolean;
29
+ /** Rotation speed multiplier. 1 is one full turn per 900ms. */
30
+ speed?: number;
31
+ className?: string;
32
+ children?: ReactNode;
33
+ };
34
+
35
+ function SpinnerRoot({
36
+ size,
37
+ color,
38
+ isLoading = true,
39
+ speed = 1,
40
+ className,
41
+ children,
42
+ ...props
43
+ }: SpinnerProps): ReactElement | null {
44
+ // One inheritance path, not two. A Button wraps its whole subtree — the
45
+ // spinner it composes in included — in an `IconDefaultsProvider` carrying
46
+ // that button's icon class and its variant's foreground, so reading the
47
+ // button's own context here would recompute the same two values.
48
+ const iconDefaults = useIconDefaults();
49
+
50
+ // A number is not a class: Tailwind's scanner is static, so a runtime
51
+ // `size-[40px]` is never compiled. The numeric escape hatch rides `style`.
52
+ const numericSize = typeof size === "number" ? size : undefined;
53
+
54
+ const rootClassName = resolveSpinnerRootClass({ className, inherited: iconDefaults?.className, size });
55
+ const colorToken = resolveSpinnerColor(color, iconDefaults?.color);
56
+ const resolvedColor = useThemeColor(colorToken);
57
+
58
+ const context = useMemo<SpinnerContextValue>(
59
+ () => ({ color: resolvedColor, isLoading, speed }),
60
+ [resolvedColor, isLoading, speed]
61
+ );
62
+
63
+ // A glyph composed into the spinner adopts these unless told otherwise. It
64
+ // fills rather than taking a step, so it matches at a numeric size too.
65
+ const iconContext = useMemo(() => ({ className: SPINNER_GLYPH_SIZE_CLASS, color: colorToken }), [colorToken]);
66
+
67
+ const content = useMemo(() => wrapSpinnerChildren(children), [children]);
68
+
69
+ if (!isLoading) return null;
70
+
71
+ return (
72
+ <SpinnerProvider value={context}>
73
+ <Animated.View
74
+ accessibilityRole="progressbar"
75
+ accessibilityState={{ busy: true }}
76
+ className={rootClassName}
77
+ entering={ENTERING}
78
+ exiting={EXITING}
79
+ style={numericSize === undefined ? undefined : { height: numericSize, width: numericSize }}
80
+ {...props}
81
+ >
82
+ <IconDefaultsProvider value={iconContext}>{content}</IconDefaultsProvider>
83
+ </Animated.View>
84
+ </SpinnerProvider>
85
+ );
86
+ }
87
+
88
+ /**
89
+ * Wraps a bare glyph in a `Spinner.Content` so it rotates.
90
+ *
91
+ * Without this, `<Spinner><Icon … /></Spinner>` would render a motionless icon:
92
+ * the rotation lives on `Spinner.Content`, not on the root. A caller who passes
93
+ * `Spinner.Content` themselves is left alone, since they own the rotation.
94
+ */
95
+ function wrapSpinnerChildren(children: ReactNode): ReactNode {
96
+ if (children === undefined || children === null) return <SpinnerContent />;
97
+
98
+ const items = Children.toArray(children);
99
+ const hasContent = items.some((child) => isValidElement(child) && child.type === SpinnerContent);
100
+
101
+ return hasContent ? children : <SpinnerContent>{children}</SpinnerContent>;
102
+ }
103
+
104
+ /**
105
+ * An animated loading indicator, composed from parts rather than configured by
106
+ * flags.
107
+ *
108
+ * Size and colour are inherited, not passed. Inside a `Button` the spinner
109
+ * reads that button's context and comes out at its icon size in its variant's
110
+ * foreground; elsewhere it falls back to the nearest `IconDefaultsProvider`,
111
+ * then to `md` on `foreground`. An explicit `size` or `color` always wins.
112
+ *
113
+ * Any child becomes the glyph and is wrapped in a `Spinner.Content` so it still
114
+ * rotates, so a custom icon or asset needs nothing but to be passed in.
115
+ *
116
+ * @example
117
+ * <Spinner size="lg" color="success" />
118
+ *
119
+ * @example
120
+ * <Spinner speed={0.7}>
121
+ * <Icon icon={IconArrowsRepeat} />
122
+ * </Spinner>
123
+ */
124
+ export const Spinner = Object.assign(SpinnerRoot, {
125
+ /** The rotating layer. Wraps a custom glyph or asset so it spins; applied automatically. */
126
+ Content: SpinnerContent,
127
+ displayName: "DelacourUI.Spinner",
128
+ });