@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,187 @@
1
+ import { cn } from "../../lib/cn";
2
+ import { tv } from "../../lib/tv";
3
+ import { ICON_SIZES, type IconSize } from "../icon/icon.variants";
4
+
5
+ /**
6
+ * The spinner's named sizes *are* the icon's.
7
+ *
8
+ * A spinner is a glyph that stands in for an icon — inside a button it replaces
9
+ * one outright — so the two have to mean the same thing by `size="md"`. Sharing
10
+ * the scale makes that structural instead of two lists that have to be kept in
11
+ * step. A leaf import, so no cycle: see AGENTS.md rule 3.
12
+ */
13
+ export const SPINNER_SIZES = ICON_SIZES;
14
+ export const SPINNER_COLORS = ["default", "success", "warning", "destructive"] as const;
15
+
16
+ export type SpinnerSize = IconSize;
17
+ export type SpinnerColor = (typeof SPINNER_COLORS)[number];
18
+
19
+ /** Theme token behind each named colour. */
20
+ export const SPINNER_COLOR_TOKEN: Record<SpinnerColor, string> = {
21
+ default: "foreground",
22
+ success: "success",
23
+ warning: "warning",
24
+ destructive: "destructive",
25
+ };
26
+
27
+ /** One full turn, in milliseconds, at speed 1. */
28
+ export const SPINNER_DURATION_MS = 900;
29
+
30
+ /**
31
+ * Stroke width of the default arc, in viewBox units.
32
+ *
33
+ * The head cap is a circle of half this radius, so the two cannot be tuned
34
+ * apart — see `spinner-arc.tsx` for why the head is drawn separately at all.
35
+ */
36
+ export const SPINNER_ARC_STROKE_WIDTH = 2.5;
37
+
38
+ /** Alpha at the comet's head, and at the far end of its tail. */
39
+ export const SPINNER_ARC_HEAD_OPACITY = 1;
40
+ export const SPINNER_ARC_TAIL_OPACITY = 0;
41
+
42
+ /**
43
+ * Alpha where the two half-rings meet, opposite the head.
44
+ *
45
+ * Derived, never picked. Each half is a straight ramp, so the joint has to be
46
+ * the midpoint of the two ends or the ramps meet at different slopes and crease
47
+ * the ring — a soft bright wedge on one side of the joint, visible even though
48
+ * the alpha itself is continuous there. Held at 0.55 for a while, which is what
49
+ * that crease was.
50
+ */
51
+ export const SPINNER_ARC_JOINT_OPACITY = (SPINNER_ARC_HEAD_OPACITY + SPINNER_ARC_TAIL_OPACITY) / 2;
52
+
53
+ /** Gradient steps per half-ring. Eight holds the residual skew under 0.01 alpha. */
54
+ export const SPINNER_ARC_STOP_COUNT = 8;
55
+
56
+ /** One `<Stop>` of a half-ring's gradient. */
57
+ export type SpinnerArcStop = { offset: number; opacity: number };
58
+
59
+ /**
60
+ * Gradient stops that dim a half-ring at a constant rate per degree of turn.
61
+ *
62
+ * A linear gradient interpolates along its axis, and the axis here is `y` — but
63
+ * a point at angle θ clockwise from the top sits at `y = 12 - 10·cos θ`, so two
64
+ * stops alone make the fade stall near 3 and 9 o'clock and race through 12 and
65
+ * 6. The ring then reads as a bright chunk beside a flat grey quadrant rather
66
+ * than as an even comet.
67
+ *
68
+ * Each stop is therefore placed at the offset the arc actually occupies at that
69
+ * angle while its opacity steps evenly, which inverts the skew. Both half-rings
70
+ * share the resulting offset ladder; only the endpoints differ.
71
+ *
72
+ * Pure, so the whole ladder is reachable from `bun test`. See AGENTS.md.
73
+ */
74
+ export function spinnerArcStops(from: number, to: number): SpinnerArcStop[] {
75
+ return Array.from({ length: SPINNER_ARC_STOP_COUNT + 1 }, (_unused, index) => {
76
+ const turn = index / SPINNER_ARC_STOP_COUNT;
77
+
78
+ return {
79
+ offset: (1 - Math.cos(Math.PI * turn)) / 2,
80
+ opacity: from + (to - from) * turn,
81
+ };
82
+ });
83
+ }
84
+
85
+ /** Edge length used with no `size` prop and nothing to inherit from — 24pt. */
86
+ export const SPINNER_FALLBACK_SIZE_CLASS = "size-icon-xl";
87
+
88
+ /**
89
+ * Sizing a glyph composed into a spinner inherits.
90
+ *
91
+ * It fills rather than pinning a step, so it still matches at a numeric `size`
92
+ * — which the class scale cannot express, since Tailwind's scanner is static and
93
+ * a runtime `size-[40px]` is never compiled.
94
+ */
95
+ export const SPINNER_GLYPH_SIZE_CLASS = "size-full";
96
+
97
+ /** Named colour used with no `color` prop and nothing to inherit from. */
98
+ export const SPINNER_FALLBACK_COLOR: SpinnerColor = "default";
99
+
100
+ /**
101
+ * Styling for every part of a spinner.
102
+ *
103
+ * Neither slot carries a colour: the glyph is an SVG stroke, which takes a
104
+ * resolved value rather than a class. See {@link SPINNER_COLOR_TOKEN}.
105
+ *
106
+ * Free of React Native imports so it stays unit-testable — `bun test` cannot
107
+ * parse React Native's Flow-typed source. See AGENTS.md.
108
+ */
109
+ export const spinnerVariants = tv({
110
+ slots: {
111
+ // The root is the only sized box in a spinner. Everything under it fills.
112
+ root: "items-center justify-center",
113
+ // `size-full` is load-bearing rather than tidiness. The arc is an `<Svg>`
114
+ // with no width or height, which react-native-svg resolves to `'100%'`.
115
+ // If this layer were content-sized instead, that percentage would resolve
116
+ // against an indefinite parent and the glyph would collapse to zero.
117
+ content: "size-full items-center justify-center",
118
+ },
119
+ variants: {
120
+ size: {
121
+ xs: { root: "size-icon-xs" },
122
+ sm: { root: "size-icon-sm" },
123
+ md: { root: "size-icon-md" },
124
+ lg: { root: "size-icon-lg" },
125
+ xl: { root: "size-icon-xl" },
126
+ "2xl": { root: "size-icon-2xl" },
127
+ },
128
+ },
129
+ });
130
+
131
+ /** Whether a size is one of the named steps rather than an edge length in points. */
132
+ export function isSpinnerSize(size: SpinnerSize | number | undefined): size is SpinnerSize {
133
+ return typeof size === "string";
134
+ }
135
+
136
+ /**
137
+ * The class chain that sizes a spinner's root, weakest source first: the
138
+ * fallback, the enclosing component's icon class, this spinner's named size,
139
+ * then the caller's own className.
140
+ *
141
+ * A numeric size drops the chain's sizing entirely rather than leaving a losing
142
+ * class behind: the root takes the number through `style`, and relying on an
143
+ * inline style to out-rank a className-derived one is a dependency worth not
144
+ * having. Tailwind cannot help either way — its scanner is static, so a runtime
145
+ * `size-[40px]` is never compiled and would draw nothing.
146
+ *
147
+ * Precedence matches {@link resolveSpinnerColor}, and the rule `Icon` follows,
148
+ * so a spinner composed into a button matches it without being told to.
149
+ *
150
+ * Pure, so the whole ladder is reachable from `bun test`. See AGENTS.md.
151
+ */
152
+ export function resolveSpinnerRootClass({
153
+ size,
154
+ inherited,
155
+ className,
156
+ }: {
157
+ size?: SpinnerSize | number;
158
+ inherited?: string;
159
+ className?: string;
160
+ }): string {
161
+ const isNumeric = typeof size === "number";
162
+
163
+ return cn(
164
+ isNumeric ? undefined : SPINNER_FALLBACK_SIZE_CLASS,
165
+ isNumeric ? undefined : inherited,
166
+ spinnerVariants({ size: isSpinnerSize(size) ? size : undefined }).root({ className })
167
+ );
168
+ }
169
+
170
+ function isSpinnerColor(color: string): color is SpinnerColor {
171
+ return (SPINNER_COLORS as readonly string[]).includes(color);
172
+ }
173
+
174
+ /**
175
+ * Colour string to hand `useThemeColor`.
176
+ *
177
+ * A named colour maps through {@link SPINNER_COLOR_TOKEN}; anything else — a
178
+ * theme token such as `muted-foreground`, or a literal `#EC4899` — passes
179
+ * through untouched for the hook to resolve or pass on.
180
+ *
181
+ * Precedence matches {@link resolveSpinnerRootClass}: explicit prop, then the
182
+ * inherited token, then the fallback.
183
+ */
184
+ export function resolveSpinnerColor(color: string | undefined, inherited?: string): string {
185
+ const value = color ?? inherited ?? SPINNER_FALLBACK_COLOR;
186
+ return isSpinnerColor(value) ? SPINNER_COLOR_TOKEN[value] : value;
187
+ }
@@ -0,0 +1,213 @@
1
+ # Switch
2
+
3
+ A binary preference, flipped by a tap or by dragging its thumb. Compound root
4
+ plus `Switch.Thumb`, `Switch.StartContent` and `Switch.EndContent`. It inherits
5
+ almost every structural decision from [Slider](../slider/AGENTS.md), which is the
6
+ section to read first — what follows is only where a switch differs.
7
+
8
+ `import { Switch } from "@delacour/react-native-ui/switch";`
9
+
10
+ ## Files
11
+
12
+ | File | What it holds |
13
+ | --- | --- |
14
+ | `index.ts` | → `@delacour/react-native-ui/switch` |
15
+ | `switch.tsx` | Root — owns the pan, the state and the track |
16
+ | `switch-thumb.tsx` | `Switch.Thumb`, the knob and its one animated style |
17
+ | `switch-content.tsx` | The shared body behind both content layers — internal |
18
+ | `switch-start-content.tsx` | `Switch.StartContent` |
19
+ | `switch-end-content.tsx` | `Switch.EndContent` |
20
+ | `switch.context.tsx` | `SwitchContext`, `useSwitch()`, `useSwitchContext()`, `useSwitchPart()` |
21
+ | `switch.types.ts` | Prop types shared by two or more parts |
22
+ | `switch.variants.ts` | Pure `tv()` slots + the release worklet, no RN imports |
23
+ | `switch.variants.test.ts` | |
24
+
25
+ ## Design
26
+
27
+ - **Colours**: `default`, `primary`, `success`, `warning`, `destructive`, `info` —
28
+ Badge's, Checkbox's and Slider's set. **Sizes**: `sm`, `md`, `lg`. There is no
29
+ `variant` axis: a switch has one shape, and a second way to paint it would be
30
+ a second thing to keep in step with the checkbox beside it.
31
+
32
+ ## Gesture
33
+
34
+ - **The root is not a `Pressable`**, for `Slider`'s three reasons: its
35
+ `Gesture.Tap()` would fire `onPress` on every toggle, its root `Animated.View`
36
+ already owns `transform` — which is exactly what a thumb's position is — and a
37
+ thumb inside its own pressable would nest a descendant recogniser in the
38
+ root's. `HapticFeedback` and `playHaptic` are imported from `pressable.tsx`,
39
+ which is what that export is for.
40
+ - **One `Gesture.Pan()` serves the tap and the drag, and there is no `Tap` to
41
+ race it.** A release whose finger barely moved *is* the tap, which
42
+ `resolveSwitchRelease` decides along with everything else: tap slop first, then
43
+ a flick's velocity, then the position. Two recognisers would have to negotiate
44
+ which one owned a press that turned into a drag, and the negotiation is the
45
+ bug — this way there is nothing to arbitrate.
46
+ - **`distance` is the larger of the two axes, not the along-track
47
+ translation.** A vertical swipe that began on the switch moves nothing
48
+ horizontally, so reading only that axis would call every attempt to scroll past
49
+ the control a tap and toggle it. Reading both means such a swipe is movement
50
+ with no travel, which settles back to the state it started in — the switch is
51
+ left alone.
52
+ - **The value is settled in `onFinalize`, and only there.** Unlike a slider there
53
+ is nothing to write on `onBegin`: a switch has no position to move a handle to,
54
+ only a state to end up in. `onFinalize` is still the callback because it is the
55
+ one that fires on every path, the never-activated one included — which is
56
+ exactly the path a stationary tap takes.
57
+ - **`minDistance(0)` and `shouldCancelWhenOutside(false)`**, for the reasons
58
+ [Slider](../slider/AGENTS.md) sets out. The cost is the same one: a drag that
59
+ starts on the switch is the switch's, so you cannot scroll a list by putting
60
+ your finger on one.
61
+
62
+ ## Geometry
63
+
64
+ - **The knob is a rounded rectangle lying on its side, not a disc**, and it is
65
+ the reason the thumb stopped reading `--spacing-icon-*`. That scale is where a
66
+ *glyph* belongs; a knob is the body of the control rather than a mark drawn on
67
+ it — the move `Slider`'s handle already made, and why `Checkbox` still reads
68
+ that scale for a square that really is a glyph in a box. No
69
+ `--spacing-switch-*` was minted either way; the thumb is plain spacing steps.
70
+ - **Three relationships hold at every size, and the tests pin each rather than
71
+ the points.** The knob's width is the track's height; its height is that less
72
+ twice `SWITCH_THUMB_INSET`; and because both are `rounded-full`, the two
73
+ capsules come out **concentric** — each radius is half its own height, so the
74
+ difference is exactly that inset. It is the subtraction `Checkbox`'s fill makes
75
+ against its border, arrived at by construction rather than by a number.
76
+ - **One inset, all four sides, and only two of them are written down.**
77
+ Horizontally it is a class the travel maths subtracts twice; vertically it is
78
+ never written at all, because the track is `justify-center` and an absolutely
79
+ positioned child with no vertical inset is centred by its parent — `Slider`'s
80
+ rule, where the track centres the thumb and the thumb carries no offset of its
81
+ own. On that axis the constant is a relationship a test pins, not a value
82
+ anything reads.
83
+ - **A content layer is as wide as the travel, not as wide as the knob.** It
84
+ occupies exactly the space the knob vacates at its own end, which is what the
85
+ travel *is*. Size it like the knob and the far layer reaches under a knob drawn
86
+ on top of it, and its text is clipped — visible only at the size where the text
87
+ is longest, which is the last place anyone looks.
88
+ - **The knob draws no border and no shadow.** A border is a second line where
89
+ there is already a boundary, and against a saturated track it reads as a dark
90
+ ring rather than as definition; `Slider`'s handle dropped its own for the same
91
+ reason. Contrast at rest comes from the track instead, which is why
92
+ `SWITCH_TRACK_REST_TOKEN` is `input` — the chrome a field's own box wears, a
93
+ step darker than the page in light and a step lighter in dark, so a knob
94
+ painted the page's own colour reads against it at either end of the theme.
95
+ - **The track and its touch padding ride in the same size cell**, summing to 44pt
96
+ at every size, and the test asserts the sum rather than the parts — `Slider`'s
97
+ rule, and the trap it names: split them across two variants and a shorter track
98
+ silently shrinks the target.
99
+
100
+ ## Colour
101
+
102
+ - **Every colour on the control is interpolated, so the `tv()` describes almost
103
+ none of it.** The track, the thumb and both content layers fade between two
104
+ token *values* off the one `progress`, and a colour being interpolated cannot
105
+ be a class — `Checkbox`'s border, four times over. The slot set keeps
106
+ `bg-secondary` and `bg-background` as the resting appearance those styles start
107
+ from and names no colour anywhere else; the maps are the single source. There
108
+ is deliberately **no `color` axis in the `tv()`** — a `bg-*` per colour there
109
+ would be a second source for one surface, which is how a class and a style end
110
+ up disagreeing for a frame on every toggle.
111
+ - **The thumb takes the `-foreground` of the track it is travelling on**, so a
112
+ pale knob is never left unreadable on `warning`. `default` is the exception the
113
+ theme forces: there is no `--foreground-foreground`, and `background` is
114
+ what content drawn on the page's ink actually is. A test pins the whole map
115
+ against the track's.
116
+ - **`Switch.StartContent` and `.EndContent` crossfade themselves.** Start sits at
117
+ the leading edge, which the knob vacates as the switch turns **on**, so it fades
118
+ in with `progress`; end is the mirror. That is the whole reason both can be
119
+ written once with no `isSelected &&` at the call site — the knob reads as
120
+ uncovering the other end rather than sliding over content that was always
121
+ there. Each layer is exactly the thumb's footprint at its own end, so a glyph is
122
+ centred on the space the knob will vacate rather than beside it, and each
123
+ publishes an `IconDefaultsProvider` and a `TextClassProvider` for the surface it
124
+ sits on: the coloured track for start, the resting one for end.
125
+ - **A glyph's colour is a token and a `Text`'s is a class**, so
126
+ `SWITCH_CONTENT_TEXT_CLASS` exists beside `SWITCH_THUMB_TOKEN` rather than being
127
+ derived from it — Tailwind's scanner is static, so a runtime `text-${token}` is
128
+ never compiled and would silently draw nothing. A test pins every entry against
129
+ the token it must agree with.
130
+
131
+ ## Layering and motion
132
+
133
+ - **The thumb is drawn last however the children were written.** Every part is
134
+ absolutely positioned and React Native paints later siblings on top, so a
135
+ `Switch.Thumb` written first — which is the order the anatomy reads best in —
136
+ would slide *under* the content layers. The root reorders rather than leaving a
137
+ gotcha in the API, and composes one in when the children hold none, so
138
+ `<Switch />` is already a complete control.
139
+ - **The track scales on press, and the thumb does not** — the reverse of
140
+ `Slider`, which grows its handle. The track clips, so a scaled knob would be
141
+ cut off by its own capsule: a bite taken out of the knob rather than an
142
+ acknowledgement of the press. The outermost node is the one thing nothing can
143
+ crop, so that is where the feedback lives, on `Pressable`'s own `PRESS_SPRING`
144
+ so a switch and a button answer a touch identically.
145
+ - **The thumb's own spring is critically damped**, where every other spring in
146
+ this package overshoots a little. A wider knob travels a shorter distance
147
+ inside a track that clips it, so an overshoot has nowhere to go — it would
148
+ visibly squash against the end of its own capsule on every toggle. A test pins
149
+ the damping ratio at or above one, and below the point where it crawls.
150
+ - **The colours interpolate off `progress`, not off a timing of their own.** The
151
+ track therefore colours *with the finger* through a drag rather than snapping
152
+ when it is let go, and there is no second clock for the position to drift
153
+ from — the reason all four animated properties read one shared value.
154
+ - **The colour and the press scale are two entries in one `useAnimatedStyle`,
155
+ never two calls.** Two animated styles on one view fight for the same props and
156
+ the later one silently wins, so the press would land on a track that never
157
+ scaled — `Radio.Indicator`'s rule, arrived at from the other side.
158
+ - **The haptic fires at the commit, never at the grab.** A slider ticks on grab
159
+ because the grab already moves the value; a switch dragged half way and released
160
+ back has changed nothing, and one that buzzed for it would be reporting a state
161
+ change that did not happen. Default `haptic="selection"`, matching `Checkbox`.
162
+ - **`settledDrags` and the `isDragging` ref are `Slider`'s, verbatim in shape.**
163
+ A controlled parent that rejects a dragged value leaves the state unchanged, so
164
+ without a token that moves on every release the sync effect has nothing to
165
+ re-run on and the thumb stays where the finger let go. The playground's
166
+ `/switch` has a switch that rejects every change, so the spring-back is on
167
+ screen rather than merely asserted.
168
+ - **Reduce motion is left at Reanimated's default `System`**, with `Radio` and
169
+ `Checkbox` and against `Spinner`. The state is carried by the thumb's
170
+ *position*, so snapping straight to it is the right degradation.
171
+
172
+ ## Accessibility and API
173
+
174
+ - **The accessibility surface is written out, because there is no `Pressable` to
175
+ inherit it from.** `accessibilityRole="switch"` and a checked state announce it;
176
+ `onAccessibilityTap` and an `activate` entry in `accessibilityActions` are what
177
+ actually flip it, on iOS and Android respectively. Without them the switch would
178
+ announce its state and offer no way to change it — the same gap
179
+ `Slider.Thumb`'s `adjustable` actions close.
180
+ - **The root is marked `accessible`, and that one word is load-bearing.** Without
181
+ it the view is not an accessibility element on iOS at all, so the role and the
182
+ checked state written beside it never reach VoiceOver. It also merges the track
183
+ and its layers into the single element a control should be.
184
+ - **There is no `Switch.Label` and no `Switch.Group`.** The track is a fixed pill
185
+ and a label cannot sit inside it, so the name is a `Field.Label` or a
186
+ `ListGroup.ItemTitle` a row away — and unlike `Slider`, the switch **does**
187
+ register `field.registerPress`, because a row-wide press means exactly what a
188
+ tap on the pill means. A switch is a binary preference, not one of a set, so
189
+ there is nothing for a group to own; the axis ladder is two rungs, the switch's
190
+ own props then an enclosing `Field`.
191
+ - **A part is recognised by its `displayName`, never by reference alone.** The
192
+ root composes a `Switch.Thumb` in when the children hold none, which means it
193
+ has to *ask* whether a child is one — and `child.type === SwitchThumb` is not a
194
+ safe way to ask. This package ships raw `.tsx` for the consuming app to
195
+ compile, so React Compiler rewrites the binding on the way through, and Metro
196
+ can serve two instances of one module through a workspace symlink; either
197
+ leaves an element whose `type` is a different object standing for the same
198
+ component. The failure is silent and nearly invisible: detection returns false,
199
+ a second knob is composed on top of the caller's, and because the two are the
200
+ same size and colour the only symptom is that **anything inside the caller's
201
+ knob disappears**. `displayName` survives all of it — rule 12 requires one and
202
+ `display-name.test.ts` enforces that they are present and unique — so that is
203
+ what `isSwitchThumbElement` asks for, with reference equality kept only as the
204
+ fast path.
205
+
206
+ **[`Radio`](../radio/AGENTS.md) and `ListGroup` still ask the unsafe question**
207
+ (`radio.tsx`'s indicator detection, and the divider and icon-swap walks
208
+ elsewhere). Nobody has reported it because their duplicates overlap invisibly
209
+ rather than hiding content — a second ring exactly behind the first. Worth
210
+ fixing the next time one of them is touched.
211
+ - **RTL is not handled.** The thumb travels on `translateX`, which does not flip,
212
+ and nothing else in this package handles it yet. Stated here rather than
213
+ half-solved.
@@ -0,0 +1,42 @@
1
+ export { Switch, type SwitchProps } from "./switch";
2
+ export { type SwitchContextValue, SwitchProvider, useSwitch, useSwitchContext } from "./switch.context";
3
+ export type { SwitchContentProps } from "./switch.types";
4
+ export {
5
+ hasThumbChild,
6
+ resolveSwitchAxes,
7
+ resolveSwitchContentTreatment,
8
+ resolveSwitchRelease,
9
+ resolveSwitchThumbTokens,
10
+ resolveSwitchTrackTokens,
11
+ SWITCH_COLORS,
12
+ SWITCH_CONTENT_ICON_STEP,
13
+ SWITCH_CONTENT_REST_TEXT_CLASS,
14
+ SWITCH_CONTENT_REST_TOKEN,
15
+ SWITCH_CONTENT_TEXT_CLASS,
16
+ SWITCH_DEFAULT_COLOR,
17
+ SWITCH_DEFAULT_SIZE,
18
+ SWITCH_FLING_VELOCITY,
19
+ SWITCH_INVALID_CONTENT_TEXT_CLASS,
20
+ SWITCH_INVALID_THUMB_TOKEN,
21
+ SWITCH_INVALID_TRACK_TOKEN,
22
+ SWITCH_PRESS_ANIMATION,
23
+ SWITCH_PRESS_SPRING,
24
+ SWITCH_SIZES,
25
+ SWITCH_TAP_SLOP,
26
+ SWITCH_THUMB_INSET,
27
+ SWITCH_THUMB_REST_TOKEN,
28
+ SWITCH_THUMB_SPRING,
29
+ SWITCH_THUMB_TOKEN,
30
+ SWITCH_TRACK_REST_TOKEN,
31
+ SWITCH_TRACK_TOKEN,
32
+ type SwitchAxes,
33
+ type SwitchColor,
34
+ type SwitchContentPlacement,
35
+ type SwitchFieldAxes,
36
+ type SwitchOwnAxes,
37
+ type SwitchSize,
38
+ type SwitchVariantProps,
39
+ switchTravel,
40
+ switchVariants,
41
+ } from "./switch.variants";
42
+ export type { SwitchThumbProps } from "./switch-thumb";
@@ -0,0 +1,111 @@
1
+ import { Children, type ReactElement, type ReactNode, useMemo } from "react";
2
+ import Animated, { useAnimatedStyle } from "react-native-reanimated";
3
+ import { useThemeColor } from "../../hooks/use-theme-color";
4
+ import { IconDefaultsProvider } from "../icon";
5
+ import { Text } from "../text";
6
+ import { TextClassProvider } from "../text/text.context";
7
+ import { useSwitchPart } from "./switch.context";
8
+ import type { SwitchContentProps } from "./switch.types";
9
+ import { resolveSwitchContentTreatment, type SwitchContentPlacement, switchVariants } from "./switch.variants";
10
+
11
+ /**
12
+ * The shared implementation behind `Switch.StartContent` and `.EndContent`.
13
+ *
14
+ * The two are the same box at opposite ends of the track — which end, and which
15
+ * way their opacity runs, are the only differences — so they share this leaf
16
+ * rather than duplicating {@link wrapTextChildren} and an animated style into
17
+ * two part files. `Input.Group`'s two decorators make the same trade.
18
+ *
19
+ * **The crossfade is the component's, not the caller's.** `StartContent` sits at
20
+ * the leading edge, which the thumb vacates as the switch turns **on**, so it
21
+ * fades in with `progress`; `EndContent` sits at the trailing edge and fades out
22
+ * with it. That is what lets both be written once with no conditionals — the
23
+ * thumb reads as uncovering the other end rather than as sliding over content
24
+ * that was always there.
25
+ *
26
+ * The layer is exactly the thumb's footprint at its own end, so a glyph is
27
+ * centred on the space the knob will vacate rather than beside it.
28
+ *
29
+ * Not a part itself: it takes the caller-facing name so the error thrown outside
30
+ * a switch names `Switch.StartContent` rather than something private.
31
+ */
32
+ export function SwitchContent({
33
+ part,
34
+ placement,
35
+ className,
36
+ children,
37
+ ...props
38
+ }: SwitchContentProps & { part: string; placement: SwitchContentPlacement }): ReactElement {
39
+ const { color, size, isDisabled, isInvalid, progress } = useSwitchPart(part);
40
+ const slots = switchVariants({ isDisabled, size });
41
+
42
+ const treatment = resolveSwitchContentTreatment({ color, isInvalid, placement });
43
+ const glyphColor = useThemeColor(treatment.color);
44
+ const glyphClassName = slots.glyph();
45
+ const iconDefaults = useMemo(
46
+ () => ({ className: glyphClassName, color: glyphColor ?? "" }),
47
+ [glyphClassName, glyphColor]
48
+ );
49
+
50
+ const isStart = placement === "start";
51
+ const contentStyle = useAnimatedStyle(() => ({
52
+ opacity: isStart ? progress.value : 1 - progress.value,
53
+ }));
54
+
55
+ const textClassName = slots.contentText({ className: treatment.textClass });
56
+
57
+ return (
58
+ <Animated.View
59
+ className={isStart ? slots.startContent({ className }) : slots.endContent({ className })}
60
+ // Never the element a screen reader lands on. The switch announces itself
61
+ // as one control with a checked state, and a decorative glyph either side
62
+ // of the knob has nothing to add to that.
63
+ accessible={false}
64
+ importantForAccessibility="no-hide-descendants"
65
+ pointerEvents="none"
66
+ style={contentStyle}
67
+ {...props}
68
+ >
69
+ <IconDefaultsProvider value={iconDefaults}>
70
+ <TextClassProvider value={textClassName}>{wrapTextChildren(children)}</TextClassProvider>
71
+ </IconDefaultsProvider>
72
+ </Animated.View>
73
+ );
74
+ }
75
+ SwitchContent.displayName = "DelacourUI.Switch.Content";
76
+
77
+ /**
78
+ * Wraps bare text children in a `Text`.
79
+ *
80
+ * A content layer renders a `View`, and React Native cannot render a string
81
+ * outside a `<Text>` — so `<Switch.EndContent>OFF</Switch.EndContent>`, which is
82
+ * the shortest thing anyone will write, would otherwise crash. Consecutive
83
+ * strings and numbers collapse into one `Text` rather than one each, the same
84
+ * rule and the same reason as `Input`'s decorators.
85
+ *
86
+ * The wrapped text needs no className: the layer has already published its
87
+ * treatment through `TextClassProvider`.
88
+ */
89
+ function wrapTextChildren(children: ReactNode): ReactNode {
90
+ const items = Children.toArray(children);
91
+ const output: ReactNode[] = [];
92
+ let run: (string | number)[] = [];
93
+
94
+ const flushRun = () => {
95
+ if (run.length === 0) return;
96
+ output.push(<Text key={`content-${output.length}`}>{run.join("")}</Text>);
97
+ run = [];
98
+ };
99
+
100
+ for (const child of items) {
101
+ if (typeof child === "string" || typeof child === "number") {
102
+ run.push(child);
103
+ continue;
104
+ }
105
+ flushRun();
106
+ output.push(child);
107
+ }
108
+ flushRun();
109
+
110
+ return output;
111
+ }
@@ -0,0 +1,18 @@
1
+ import type { ReactElement } from "react";
2
+ import type { SwitchContentProps } from "./switch.types";
3
+ import { SwitchContent } from "./switch-content";
4
+
5
+ /**
6
+ * Content at the trailing edge of the track, behind the thumb.
7
+ *
8
+ * Revealed as the switch turns **off** — this is the end the knob vacates — so
9
+ * it is where the "off" mark goes: a cross, a moon, the word OFF. The fade is
10
+ * the component's, so write it once and unconditionally.
11
+ *
12
+ * It sits on the resting track rather than the coloured one, so its glyph takes
13
+ * a muted foreground rather than the switch's colour.
14
+ */
15
+ export function SwitchEndContent(props: SwitchContentProps): ReactElement {
16
+ return <SwitchContent part="Switch.EndContent" placement="end" {...props} />;
17
+ }
18
+ SwitchEndContent.displayName = "DelacourUI.Switch.EndContent";
@@ -0,0 +1,20 @@
1
+ import type { ReactElement } from "react";
2
+ import type { SwitchContentProps } from "./switch.types";
3
+ import { SwitchContent } from "./switch-content";
4
+
5
+ /**
6
+ * Content at the leading edge of the track, behind the thumb.
7
+ *
8
+ * Revealed as the switch turns **on** — this is the end the knob vacates — so it
9
+ * is where the "on" mark goes: a tick, a sun, the word ON. The fade is the
10
+ * component's, so write it once and unconditionally; there is no `isSelected &&`
11
+ * to remember.
12
+ *
13
+ * An `Icon` needs nothing said at the call site: it inherits the switch's glyph
14
+ * step and the foreground of the colour the track fades to. A bare string is
15
+ * wrapped in a `Text` that inherits the same treatment.
16
+ */
17
+ export function SwitchStartContent(props: SwitchContentProps): ReactElement {
18
+ return <SwitchContent part="Switch.StartContent" placement="start" {...props} />;
19
+ }
20
+ SwitchStartContent.displayName = "DelacourUI.Switch.StartContent";