@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,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
+ });
@@ -0,0 +1,273 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { ICON_SIZE_TOKENS } from "../../styles/tokens";
3
+ import {
4
+ isSpinnerSize,
5
+ resolveSpinnerColor,
6
+ resolveSpinnerRootClass,
7
+ SPINNER_ARC_HEAD_OPACITY,
8
+ SPINNER_ARC_JOINT_OPACITY,
9
+ SPINNER_ARC_STOP_COUNT,
10
+ SPINNER_ARC_STROKE_WIDTH,
11
+ SPINNER_ARC_TAIL_OPACITY,
12
+ SPINNER_COLOR_TOKEN,
13
+ SPINNER_COLORS,
14
+ SPINNER_FALLBACK_SIZE_CLASS,
15
+ SPINNER_GLYPH_SIZE_CLASS,
16
+ SPINNER_SIZES,
17
+ spinnerArcStops,
18
+ spinnerVariants,
19
+ } from "./spinner.variants";
20
+
21
+ /**
22
+ * Position of a class string's `size-icon-*` token on the shared icon scale.
23
+ *
24
+ * Compares by step rather than by points, so a test says what it means and
25
+ * survives a token being retuned in `tokens.css`. `tokens.test.ts` is what
26
+ * keeps that array ordered.
27
+ */
28
+ function iconStep(cls: string): number {
29
+ const token = cls.match(/\bsize-(icon-[\w-]+)\b/)?.[1];
30
+ return ICON_SIZE_TOKENS.indexOf(token as (typeof ICON_SIZE_TOKENS)[number]);
31
+ }
32
+
33
+ describe("isSpinnerSize", () => {
34
+ test("separates a named size from an edge length", () => {
35
+ for (const size of SPINNER_SIZES) {
36
+ expect(isSpinnerSize(size)).toBe(true);
37
+ }
38
+ expect(isSpinnerSize(40)).toBe(false);
39
+ expect(isSpinnerSize(undefined)).toBe(false);
40
+ });
41
+ });
42
+
43
+ describe("resolveSpinnerRootClass", () => {
44
+ test("centres its content whatever it is given", () => {
45
+ for (const size of [undefined, "sm", 40] as const) {
46
+ expect(resolveSpinnerRootClass({ size })).toContain("items-center");
47
+ }
48
+ });
49
+
50
+ test("falls back only when there is nothing to inherit", () => {
51
+ expect(resolveSpinnerRootClass({})).toContain(SPINNER_FALLBACK_SIZE_CLASS);
52
+ });
53
+
54
+ test("an inherited class beats the fallback", () => {
55
+ expect(resolveSpinnerRootClass({ inherited: "size-icon-xs" })).toContain("size-icon-xs");
56
+ });
57
+
58
+ test("a named size beats an inherited class", () => {
59
+ expect(resolveSpinnerRootClass({ inherited: "size-icon-xs", size: "lg" })).toContain("size-icon-lg");
60
+ });
61
+
62
+ test("a className beats a named size", () => {
63
+ expect(resolveSpinnerRootClass({ className: "size-7", size: "sm" })).toContain("size-7");
64
+ });
65
+
66
+ test("gives every named size a distinct token, increasing with it", () => {
67
+ const steps = SPINNER_SIZES.map((size) => iconStep(resolveSpinnerRootClass({ size })));
68
+ expect(steps).not.toContain(-1);
69
+ expect(new Set(steps).size).toBe(SPINNER_SIZES.length);
70
+ expect([...steps]).toEqual([...steps].sort((a, b) => a - b));
71
+ });
72
+
73
+ // A number is not a class. `size-[40px]` built at runtime is never scanned by
74
+ // Tailwind, so it would compile to nothing — the root takes a numeric size
75
+ // through `style` instead, and the chain steps out of the way entirely rather
76
+ // than leaving a losing class behind to argue with it.
77
+ test("a numeric size emits no size class at all", () => {
78
+ expect(resolveSpinnerRootClass({ size: 40 })).not.toMatch(/\bsize-/);
79
+ expect(resolveSpinnerRootClass({ inherited: "size-icon-sm", size: 40 })).not.toMatch(/\bsize-/);
80
+ });
81
+
82
+ test("carries no colour", () => {
83
+ for (const size of SPINNER_SIZES) {
84
+ expect(resolveSpinnerRootClass({ size })).not.toMatch(/\b(text|bg|border)-/);
85
+ }
86
+ });
87
+ });
88
+
89
+ describe("SPINNER_GLYPH_SIZE_CLASS", () => {
90
+ // A composed glyph fills the spinner rather than taking a fixed class, so it
91
+ // still matches at a numeric size the class scale cannot express.
92
+ test("fills the spinner rather than pinning a step", () => {
93
+ expect(SPINNER_GLYPH_SIZE_CLASS).toBe("size-full");
94
+ });
95
+ });
96
+
97
+ describe("resolveSpinnerColor", () => {
98
+ test("maps each named colour to its token", () => {
99
+ for (const color of SPINNER_COLORS) {
100
+ expect(resolveSpinnerColor(color)).toBe(SPINNER_COLOR_TOKEN[color]);
101
+ }
102
+ });
103
+
104
+ test("gives every named colour a distinct token", () => {
105
+ const tokens = SPINNER_COLORS.map((color) => SPINNER_COLOR_TOKEN[color]);
106
+ expect(new Set(tokens).size).toBe(SPINNER_COLORS.length);
107
+ });
108
+
109
+ test("passes a literal colour through untouched", () => {
110
+ expect(resolveSpinnerColor("#EC4899")).toBe("#EC4899");
111
+ expect(resolveSpinnerColor("rgb(236, 72, 153)")).toBe("rgb(236, 72, 153)");
112
+ });
113
+
114
+ test("passes an unnamed theme token through untouched", () => {
115
+ expect(resolveSpinnerColor("emerald-500")).toBe("emerald-500");
116
+ expect(resolveSpinnerColor("primary-foreground")).toBe("primary-foreground");
117
+ });
118
+
119
+ test("takes the inherited token when none is given", () => {
120
+ expect(resolveSpinnerColor(undefined, "primary-foreground")).toBe("primary-foreground");
121
+ });
122
+
123
+ test("maps an inherited value that happens to be a named colour", () => {
124
+ expect(resolveSpinnerColor(undefined, "destructive")).toBe(SPINNER_COLOR_TOKEN.destructive);
125
+ });
126
+
127
+ test("falls back only when there is nothing to inherit", () => {
128
+ expect(resolveSpinnerColor(undefined)).toBe(SPINNER_COLOR_TOKEN.default);
129
+ });
130
+
131
+ test("an explicit colour beats the inherited one", () => {
132
+ expect(resolveSpinnerColor("#EC4899", "primary-foreground")).toBe("#EC4899");
133
+ expect(resolveSpinnerColor("destructive", "primary-foreground")).toBe(SPINNER_COLOR_TOKEN.destructive);
134
+ });
135
+ });
136
+
137
+ describe("spinnerVariants root slot", () => {
138
+ test("centres its content and carries no colour", () => {
139
+ const cls = spinnerVariants().root();
140
+ expect(cls).toContain("items-center");
141
+ expect(cls).toContain("justify-center");
142
+ expect(cls).not.toMatch(/\b(text|bg|border)-/);
143
+ });
144
+
145
+ test("takes no size until it is given one", () => {
146
+ expect(spinnerVariants().root()).not.toMatch(/\bsize-/);
147
+ });
148
+
149
+ test("gives every named size a distinct token, increasing with it", () => {
150
+ const steps = SPINNER_SIZES.map((size) => iconStep(spinnerVariants({ size }).root()));
151
+ expect(steps).not.toContain(-1);
152
+ expect(new Set(steps).size).toBe(SPINNER_SIZES.length);
153
+ expect([...steps]).toEqual([...steps].sort((a, b) => a - b));
154
+ });
155
+
156
+ test("merges an incoming className last", () => {
157
+ expect(spinnerVariants().root({ className: "opacity-80" })).toContain("opacity-80");
158
+ });
159
+ });
160
+
161
+ describe("spinnerVariants content slot", () => {
162
+ // Load-bearing, not tidiness. The arc is an <Svg> with no width or height,
163
+ // which react-native-svg resolves to '100%'. If this layer were content-sized
164
+ // that percentage would resolve against an indefinite parent and the whole
165
+ // glyph would collapse to zero.
166
+ test("fills the root, so the arc has a definite box to be 100% of", () => {
167
+ expect(spinnerVariants().content()).toContain("size-full");
168
+ });
169
+
170
+ test("does not take a size of its own", () => {
171
+ for (const size of SPINNER_SIZES) {
172
+ expect(spinnerVariants({ size }).content()).toBe(spinnerVariants().content());
173
+ }
174
+ });
175
+
176
+ test("carries no colour", () => {
177
+ expect(spinnerVariants().content()).not.toMatch(/\b(text|bg|border)-/);
178
+ });
179
+ });
180
+
181
+ /** The arc's geometry, restated here so a test compares against it rather than against itself. */
182
+ const ARC_CENTRE = 12;
183
+ const ARC_RADIUS = 10;
184
+
185
+ /**
186
+ * Where a gradient running from `y = 2` to `y = 22` lands on the arc, a given
187
+ * fraction of a half-turn clockwise from the top.
188
+ *
189
+ * This is the arc's real position — `y = 12 - 10·cos θ` — not a restatement of
190
+ * the resolver's formula, so the linearity test below has something independent
191
+ * to check against.
192
+ */
193
+ function arcOffsetAtHalfTurn(turn: number): number {
194
+ const y = ARC_CENTRE - ARC_RADIUS * Math.cos(Math.PI * turn);
195
+ return (y - (ARC_CENTRE - ARC_RADIUS)) / (ARC_RADIUS * 2);
196
+ }
197
+
198
+ describe("spinnerArcStops", () => {
199
+ test("spans the whole gradient axis, from one endpoint to the other", () => {
200
+ const stops = spinnerArcStops(1, 0.55);
201
+ expect(stops).toHaveLength(SPINNER_ARC_STOP_COUNT + 1);
202
+ expect(stops.at(0)).toEqual({ offset: 0, opacity: 1 });
203
+ expect(stops.at(-1)).toEqual({ offset: 1, opacity: 0.55 });
204
+ });
205
+
206
+ test("places every stop where the arc actually is at that angle", () => {
207
+ const stops = spinnerArcStops(SPINNER_ARC_HEAD_OPACITY, SPINNER_ARC_JOINT_OPACITY);
208
+ for (const [index, stop] of stops.entries()) {
209
+ expect(stop.offset).toBeCloseTo(arcOffsetAtHalfTurn(index / SPINNER_ARC_STOP_COUNT), 10);
210
+ }
211
+ });
212
+
213
+ // The whole point of the resolver. A two-stop gradient dims linearly in `y`,
214
+ // and the arc's `y` goes as -cos θ, so the fade stalls at the sides and races
215
+ // through the top and bottom — a white chunk beside a flat grey quadrant.
216
+ test("dims by a constant step per equal turn of the arc", () => {
217
+ const stops = spinnerArcStops(1, 0.55);
218
+ const deltas = stops.slice(1).map((stop, index) => stop.opacity - (stops[index]?.opacity ?? 0));
219
+ for (const delta of deltas) {
220
+ expect(delta).toBeCloseTo(-0.45 / SPINNER_ARC_STOP_COUNT, 10);
221
+ }
222
+ });
223
+
224
+ test("advances monotonically along both axes", () => {
225
+ const stops = spinnerArcStops(SPINNER_ARC_TAIL_OPACITY, SPINNER_ARC_JOINT_OPACITY);
226
+ for (const [index, stop] of stops.slice(1).entries()) {
227
+ const previous = stops[index];
228
+ expect(previous).toBeDefined();
229
+ expect(stop.offset).toBeGreaterThan(previous?.offset ?? 0);
230
+ expect(stop.opacity).toBeGreaterThan(previous?.opacity ?? 0);
231
+ }
232
+ });
233
+
234
+ // The two half-rings are painted by separate gradients on one shared axis, so
235
+ // a stop ladder that differed between them would kink the fade at the joint.
236
+ test("gives both half-rings one offset ladder", () => {
237
+ const lead = spinnerArcStops(SPINNER_ARC_HEAD_OPACITY, SPINNER_ARC_JOINT_OPACITY);
238
+ const tail = spinnerArcStops(SPINNER_ARC_TAIL_OPACITY, SPINNER_ARC_JOINT_OPACITY);
239
+ expect(tail.map((stop) => stop.offset)).toEqual(lead.map((stop) => stop.offset));
240
+ });
241
+
242
+ // The two ramps are drawn separately but read as one. Anything other than the
243
+ // midpoint gives them different slopes, and the ring creases at the joint —
244
+ // a soft bright wedge on one side of it, even though the alpha is continuous.
245
+ test("carries one straight ramp across both half-rings", () => {
246
+ const lead = spinnerArcStops(SPINNER_ARC_HEAD_OPACITY, SPINNER_ARC_JOINT_OPACITY);
247
+ const tail = spinnerArcStops(SPINNER_ARC_TAIL_OPACITY, SPINNER_ARC_JOINT_OPACITY);
248
+ const leadStep = (SPINNER_ARC_JOINT_OPACITY - SPINNER_ARC_HEAD_OPACITY) / SPINNER_ARC_STOP_COUNT;
249
+ const tailStep = (SPINNER_ARC_TAIL_OPACITY - SPINNER_ARC_JOINT_OPACITY) / SPINNER_ARC_STOP_COUNT;
250
+
251
+ expect(leadStep).toBeCloseTo(tailStep, 10);
252
+ expect(SPINNER_ARC_JOINT_OPACITY).toBeCloseTo((SPINNER_ARC_HEAD_OPACITY + SPINNER_ARC_TAIL_OPACITY) / 2, 10);
253
+ expect(lead.at(-1)?.opacity).toBe(tail.at(-1)?.opacity);
254
+ });
255
+
256
+ // Both halves terminate at the bottom of the ring. Meeting on one value is
257
+ // what lets their butt caps abut without a seam — and what makes the round
258
+ // caps they used to carry compound into a visible dot.
259
+ test("brings the two half-rings to the same opacity where they meet", () => {
260
+ const lead = spinnerArcStops(SPINNER_ARC_HEAD_OPACITY, SPINNER_ARC_JOINT_OPACITY);
261
+ const tail = spinnerArcStops(SPINNER_ARC_TAIL_OPACITY, SPINNER_ARC_JOINT_OPACITY);
262
+ expect(lead.at(-1)?.opacity).toBe(SPINNER_ARC_JOINT_OPACITY);
263
+ expect(tail.at(-1)?.opacity).toBe(SPINNER_ARC_JOINT_OPACITY);
264
+ });
265
+ });
266
+
267
+ describe("SPINNER_ARC_STROKE_WIDTH", () => {
268
+ // The head cap is a circle of half this radius drawn at the top of the ring,
269
+ // so an over-wide stroke would clip against the viewBox rather than warn.
270
+ test("leaves the ring inside the 24-unit viewBox", () => {
271
+ expect(ARC_RADIUS + SPINNER_ARC_STROKE_WIDTH / 2).toBeLessThanOrEqual(ARC_CENTRE);
272
+ });
273
+ });