@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,661 @@
1
+ import type { VariantProps } from "tailwind-variants";
2
+ import { tv } from "../../lib/tv";
3
+ import type { TextSize } from "../text/text.variants";
4
+
5
+ /** What a filled track means. Badge's and Checkbox's set, reusing tokens the theme already has. */
6
+ export const SLIDER_COLORS = ["default", "primary", "success", "warning", "destructive", "info"] as const;
7
+
8
+ export const SLIDER_SIZES = ["sm", "md", "lg"] as const;
9
+
10
+ /** Which way the track runs. A `vertical` slider counts up from the bottom. */
11
+ export const SLIDER_ORIENTATIONS = ["horizontal", "vertical"] as const;
12
+
13
+ export type SliderColor = (typeof SLIDER_COLORS)[number];
14
+ export type SliderSize = (typeof SLIDER_SIZES)[number];
15
+ export type SliderOrientation = (typeof SLIDER_ORIENTATIONS)[number];
16
+
17
+ /**
18
+ * The axes a slider falls back to when nothing names one.
19
+ *
20
+ * Named once because two places read them — `defaultVariants` below and
21
+ * {@link resolveSliderAxes}, which runs before `tv` is ever called. A test pins
22
+ * the pair, since a drift between them is a slider that renders at one size and
23
+ * reports another.
24
+ */
25
+ export const SLIDER_DEFAULT_COLOR: SliderColor = "default";
26
+ export const SLIDER_DEFAULT_SIZE: SliderSize = "md";
27
+ export const SLIDER_DEFAULT_ORIENTATION: SliderOrientation = "horizontal";
28
+
29
+ /** The range a slider covers when the caller names none. */
30
+ export const SLIDER_MIN_VALUE = 0;
31
+ export const SLIDER_MAX_VALUE = 100;
32
+
33
+ /** The increment a slider snaps to when the caller names none. `0` is continuous. */
34
+ export const SLIDER_STEP = 1;
35
+
36
+ /**
37
+ * The spring the grabbed thumb grows on.
38
+ *
39
+ * Deliberately `Pressable`'s own `PRESS_SPRING`: a thumb responding to a touch is
40
+ * the same event as a button responding to one, and two springs a frame apart
41
+ * would read as two things happening.
42
+ */
43
+ export const SLIDER_THUMB_SPRING = { damping: 18, mass: 0.4, stiffness: 320 } as const;
44
+
45
+ /**
46
+ * How far the grabbed handle's knob travels on the scale axis.
47
+ *
48
+ * It **shrinks**, the way every other pressable in this library does, and it is
49
+ * the *knob* that moves rather than the capsule around it. Scaling the capsule
50
+ * would push it past the track it sits flush inside — a bulge on both edges every
51
+ * time a finger lands. The knob is a descendant with padding to spare, so the
52
+ * squeeze happens entirely within the handle's own footprint and the capsule
53
+ * stays exactly where the value says it is.
54
+ */
55
+ export const SLIDER_THUMB_ANIMATION = { restScale: 1, grabbedScale: 0.9 } as const;
56
+
57
+ /**
58
+ * The `Text` size step each slider size hands its output.
59
+ *
60
+ * `Slider.Output` renders `Text.Label` and names this step rather than restating a
61
+ * type scale in the `output` slot. `Text`'s own size axis is built to beat its
62
+ * preset, so the weight and the colour stay in exactly one place — the preset —
63
+ * while the readout still tracks the slider's size. A test asserts every value
64
+ * here is a size `Text` actually has.
65
+ */
66
+ export const SLIDER_OUTPUT_TEXT_SIZE: Record<SliderSize, TextSize> = { sm: "sm", md: "md", lg: "lg" };
67
+
68
+ /**
69
+ * How far a drag must travel between two haptic ticks, in points.
70
+ *
71
+ * A pan updates at the display's refresh rate, so "tick whenever the snapped
72
+ * value changed" is not a rate limit on its own: 0–100 in whole steps across a
73
+ * 300pt track is a step every three points, and a flick crosses a hundred of them
74
+ * in a fifth of a second. That is not a hundred ticks, it is a buzz — and a
75
+ * hundred synchronous calls into the haptic engine to produce it.
76
+ *
77
+ * Gating on distance rather than on a clock keeps the rule pure, so `bun test`
78
+ * reaches it, and makes it degrade the right way: a coarse step scale ticks on
79
+ * every stop, and a fine one thins out to a cadence a hand can actually feel.
80
+ */
81
+ export const SLIDER_HAPTIC_MIN_TRAVEL = 6;
82
+
83
+ /**
84
+ * What sits between the two ends of a range in `Slider.Output`.
85
+ *
86
+ * An en dash, not a hyphen. A hyphen beside a negative number reads as arithmetic
87
+ * — `-20 - -5` — and a range is exactly the case where that happens.
88
+ */
89
+ export const SLIDER_RANGE_SEPARATOR = " – ";
90
+
91
+ /**
92
+ * Styling for every part of a slider.
93
+ *
94
+ * One slotted `tv()` rather than a call per part, because `slider-track.tsx`,
95
+ * `slider-fill.tsx` and `slider-thumb.tsx` cannot import the root without closing
96
+ * a cycle (AGENTS.md rule 3) yet all three read the same `color`, `size` and
97
+ * `orientation`.
98
+ *
99
+ * **The colour paints the fill, the capsule and the knob — never the groove.** An
100
+ * empty track is the same chrome at every colour, the way an unticked checkbox is
101
+ * `border-input bg-card` however it is coloured, and a test asserts that. The
102
+ * capsule takes the fill's *own* colour so the two meet with no seam and the
103
+ * handle reads as the leading end of the fill rather than as something sitting on
104
+ * top of it; the knob is that colour's `-foreground`, which is what the token
105
+ * means (rule 11) and what keeps it legible on all six. A single pale knob would
106
+ * be unreadable on `warning`, and a test pins the pair rather than trusting two
107
+ * maps to stay in step.
108
+ *
109
+ * **`default` and `primary` name different tokens that this theme tunes to the
110
+ * same value.** `foreground` is the page's ink and `primary` is the brand's
111
+ * action colour; they are `#262626` in both roles today, which is the situation
112
+ * `Badge` already documents for its neutral end. Modelling them as one token
113
+ * would be the drift, not the duplication: an app that re-themes `primary` to
114
+ * blue wants `color="primary"` blue and `color="default"` still ink.
115
+ *
116
+ * **The handle is two nodes: a capsule and a knob.** The capsule carries the
117
+ * colour, the size and the position; the knob is the pale bar inside it and the
118
+ * only thing that moves when a finger lands. Two nodes rather than one because a
119
+ * single view cannot be both the surface and the thing inset within it, and
120
+ * because two animated styles on one node fight for the same prop — see
121
+ * {@link SLIDER_THUMB_ANIMATION}.
122
+ *
123
+ * **Neither takes a shadow.** Nothing else in this package draws one, and React
124
+ * Native's shadow props diverge between platforms in a way a flat fill does not.
125
+ * A test pins that absence across the whole matrix. The capsule needs no border
126
+ * either: it is a solid block of the fill's colour, so its edge is already the
127
+ * boundary between the fill and the groove.
128
+ *
129
+ * **The capsule is flush across the track and longer along it**, and the flush
130
+ * half is load-bearing rather than decorative. It is what lets {@link fillExtent}
131
+ * land exactly on both extremes — one capsule's length of fill at the minimum, the
132
+ * track's full length at the maximum — with no inset to leave stray colour at one
133
+ * end and empty groove at the other. So the cross axis takes the track's own step
134
+ * and a test asserts the two classes name the same one, while the long axis is two
135
+ * steps up from it: a handle you can tell apart from the groove it runs in.
136
+ *
137
+ * Both are plain spacing steps rather than tokens: these are numbers read in one
138
+ * component, the trade `Radio` already makes for the dot inside its ring.
139
+ * `Checkbox` reads `--spacing-icon-*` for its square and should keep doing so — a
140
+ * glyph in a box is a mark on the icon scale, where a slider's handle is the body
141
+ * of the control itself.
142
+ *
143
+ * Because the two axes differ and only the orientation knows which is which, the
144
+ * capsule's size lives in the same six compound cells as the track's thickness
145
+ * rather than in a `size`-only variant.
146
+ *
147
+ * **The groove is not the touch target on its own.** A `sm` track is sixteen
148
+ * points, so the drag is claimed on a transparent `touchArea` whose padding brings
149
+ * it up to 44. The thickness and that padding live in the *same* compound cell
150
+ * because they are one number — they sum to 44 at every size, and a test asserts
151
+ * the sum rather than the parts. Split across two variants, a retune of the
152
+ * thickness silently shrinks the target.
153
+ *
154
+ * That padding is on the **cross axis only**. The two boxes therefore share an
155
+ * origin along the axis the value is measured on, which is what lets the pan read
156
+ * its offset straight off the touch without correcting for a gutter it cannot see.
157
+ * Pad the main axis and every value is wrong by the padding, silently, and
158
+ * visibly only at the ends.
159
+ *
160
+ * **The track still centres the thumb, and now has nothing to centre.** An
161
+ * absolutely-positioned child with no cross-axis inset is placed at the static
162
+ * position the parent's `items-center` decides — which did the work when the thumb
163
+ * overhung a hairline groove, and is a no-op now that the two are the same size.
164
+ * It stays because the moment those sizes are allowed to differ it is load-bearing
165
+ * again, and because it is why the track is `flex-row` when horizontal:
166
+ * `items-center` centres on the *cross* axis, and a column track would centre the
167
+ * wrong one.
168
+ *
169
+ * **The fade lands on the root**, which is a plain `View` here rather than a
170
+ * `Pressable`, so nothing animated shares the `opacity` property with it.
171
+ *
172
+ * No slot worn by a `View` carries `text-*` or `font-*`: `Slider.Output` renders
173
+ * a `Text` preset and names a step — see {@link SLIDER_OUTPUT_TEXT_SIZE}.
174
+ *
175
+ * Free of React Native imports so it stays unit-testable — `bun test` cannot parse
176
+ * React Native's Flow-typed source. See AGENTS.md.
177
+ */
178
+ export const sliderVariants = tv({
179
+ slots: {
180
+ /** The column holding the readout and the track. */
181
+ root: "",
182
+ /** Handed to a `Text.Label`. Layout only, and today there is none. */
183
+ output: "",
184
+ /** The transparent box the drag is claimed on. Padded on the cross axis only. */
185
+ touchArea: "items-center justify-center",
186
+ /** The groove. Positions the fill and the thumbs inside it. */
187
+ track: "relative items-center rounded-full bg-secondary",
188
+ /** The painted part of the groove. Its extent is an animated style, never a class. */
189
+ fill: "absolute rounded-full",
190
+ /** The handle's capsule. Painted in the fill's own colour, so the two join seamlessly. */
191
+ thumb: "absolute justify-center rounded-full p-0.5",
192
+ /** The pale bar inside the capsule, and the only part that moves under a finger. */
193
+ knob: "flex-1 rounded-full",
194
+ },
195
+ variants: {
196
+ orientation: {
197
+ horizontal: {
198
+ root: "w-full gap-2",
199
+ touchArea: "w-full flex-row",
200
+ track: "w-full flex-row",
201
+ fill: "bottom-0 top-0",
202
+ thumb: "left-0",
203
+ },
204
+ vertical: {
205
+ root: "h-full items-center gap-2",
206
+ touchArea: "h-full flex-col",
207
+ track: "h-full justify-end",
208
+ fill: "left-0 right-0",
209
+ thumb: "bottom-0",
210
+ },
211
+ },
212
+ // Empty because a capsule's two axes differ and only the orientation knows
213
+ // which is which — the six cells below carry both. Declared anyway so `size`
214
+ // stays a variant `tv` can type and default.
215
+ size: { sm: {}, md: {}, lg: {} },
216
+ // The empty branches are load-bearing typing, not placeholders. `tv` derives
217
+ // the prop type from the declared keys, so a map with only `true` types the
218
+ // prop as `true` rather than `boolean`.
219
+ color: { default: {}, primary: {}, success: {}, warning: {}, destructive: {}, info: {} },
220
+ isInvalid: { true: {}, false: {} },
221
+ isDisabled: { true: { root: "opacity-50" }, false: {} },
222
+ },
223
+ compoundVariants: [
224
+ // The groove's thickness is the one measurement that has to know the
225
+ // orientation, because it is the *cross* axis of whichever way the track
226
+ // runs. Six cells rather than a second size scale, so the two orientations
227
+ // cannot drift to different weights.
228
+ //
229
+ // The touch padding rides in the same cell, because the two are one number:
230
+ // they sum to 44pt at every size, and splitting them across two variants is
231
+ // how a retune of the thickness silently shrinks the target.
232
+ { orientation: "horizontal", size: "sm", class: { track: "h-4", touchArea: "py-3.5", thumb: "h-4 w-6" } },
233
+ { orientation: "horizontal", size: "md", class: { track: "h-5", touchArea: "py-3", thumb: "h-5 w-7" } },
234
+ { orientation: "horizontal", size: "lg", class: { track: "h-6", touchArea: "py-2.5", thumb: "h-6 w-8" } },
235
+ { orientation: "vertical", size: "sm", class: { track: "w-4", touchArea: "px-3.5", thumb: "w-4 h-6" } },
236
+ { orientation: "vertical", size: "md", class: { track: "w-5", touchArea: "px-3", thumb: "w-5 h-7" } },
237
+ { orientation: "vertical", size: "lg", class: { track: "w-6", touchArea: "px-2.5", thumb: "w-6 h-8" } },
238
+ // `color` is the only axis painting these three, so its six cells could be a
239
+ // plain variant. They are compounds so that `isInvalid` below, emitted after
240
+ // them, can beat every one — the ordering `Radio` leans on for its ring.
241
+ //
242
+ // The capsule takes the fill's own colour rather than a chrome of its own, so
243
+ // the two meet with no seam and the handle reads as the leading end of the
244
+ // fill rather than as something sitting on top of it. The knob is that
245
+ // colour's `-foreground`, which is what the token means (rule 11) and what
246
+ // keeps it legible on all six.
247
+ { color: "default", class: { fill: "bg-foreground", thumb: "bg-foreground", knob: "bg-background" } },
248
+ { color: "primary", class: { fill: "bg-primary", thumb: "bg-primary", knob: "bg-primary-foreground" } },
249
+ { color: "success", class: { fill: "bg-success", thumb: "bg-success", knob: "bg-success-foreground" } },
250
+ { color: "warning", class: { fill: "bg-warning", thumb: "bg-warning", knob: "bg-warning-foreground" } },
251
+ {
252
+ color: "destructive",
253
+ class: { fill: "bg-destructive", thumb: "bg-destructive", knob: "bg-destructive-foreground" },
254
+ },
255
+ { color: "info", class: { fill: "bg-info", thumb: "bg-info", knob: "bg-info-foreground" } },
256
+ // Invalid outranks the colour, the way it does on a checkbox's border. A
257
+ // slider that stayed green while its value was rejected would drop its only
258
+ // signal exactly while the value is being corrected.
259
+ { isInvalid: true, class: { fill: "bg-destructive", thumb: "bg-destructive", knob: "bg-destructive-foreground" } },
260
+ ],
261
+ defaultVariants: {
262
+ color: SLIDER_DEFAULT_COLOR,
263
+ size: SLIDER_DEFAULT_SIZE,
264
+ orientation: SLIDER_DEFAULT_ORIENTATION,
265
+ isInvalid: false,
266
+ isDisabled: false,
267
+ },
268
+ });
269
+
270
+ /**
271
+ * The caller's value, as the array every part reads.
272
+ *
273
+ * Always a copy: the array is handed to a shared value and written on the UI
274
+ * thread, and aliasing the caller's own would mutate state they still hold.
275
+ */
276
+ export function toValueArray(value: number | number[]): number[] {
277
+ "worklet";
278
+ return Array.isArray(value) ? [...value] : [value];
279
+ }
280
+
281
+ /**
282
+ * The array, back in the shape the caller passed in.
283
+ *
284
+ * A slider given a number reports a number; one given an array reports an array.
285
+ * `isRange` is locked on first render rather than sniffed per call — see
286
+ * `slider.tsx` — so the shape a caller receives never changes under them.
287
+ */
288
+ export function fromValueArray(values: readonly number[], isRange: boolean): number | number[] {
289
+ if (isRange) return [...values];
290
+ return values[0] ?? 0;
291
+ }
292
+
293
+ /**
294
+ * Where a value sits on the track, as 0–1.
295
+ *
296
+ * Clamped rather than extrapolated, and `0` for a degenerate range — a slider
297
+ * whose minimum equals its maximum has one position, and dividing by the
298
+ * difference would put `NaN` into a shared value and freeze the thumb for good.
299
+ */
300
+ export function progressOf(value: number, minValue: number, maxValue: number): number {
301
+ "worklet";
302
+ const span = maxValue - minValue;
303
+ if (span <= 0) return 0;
304
+ const ratio = (value - minValue) / span;
305
+ return ratio < 0 ? 0 : ratio > 1 ? 1 : ratio;
306
+ }
307
+
308
+ /**
309
+ * The nearest reachable value, measured in steps **from the minimum**.
310
+ *
311
+ * Not from zero: a slider from 5 to 100 in tens reaches 5, 15, 25 — the stops a
312
+ * caller can actually land on — rather than 10, 20, 30 with an unreachable 5 at
313
+ * the end.
314
+ *
315
+ * **Both ends are always stops, even when the step does not divide the range.**
316
+ * 0–100 by 7 reaches 0, 7, 14 … 98 and then 100, because a slider whose maximum
317
+ * cannot be reached by dragging all the way to the end is a slider that lies about
318
+ * its own range — the caller wrote `maxValue`, and the last two points of travel
319
+ * are not where they get told it was unreachable. A tie goes to the regular stop,
320
+ * so the extra one only ever appears at the very end of the drag.
321
+ *
322
+ * `step <= 0` means continuous and passes the clamped value through.
323
+ *
324
+ * The final rounding kills binary floating-point noise: ten steps of `0.1` land on
325
+ * `0.30000000000000004` without it, which a formatted readout shows in full.
326
+ */
327
+ export function snapToStep(value: number, step: number, minValue: number, maxValue: number): number {
328
+ "worklet";
329
+ const clamped = value < minValue ? minValue : value > maxValue ? maxValue : value;
330
+ if (step <= 0) return clamped;
331
+
332
+ const snapped = minValue + Math.round((clamped - minValue) / step) * step;
333
+ const bounded = snapped < minValue ? minValue : snapped > maxValue ? maxValue : snapped;
334
+ const rounded = Math.round(bounded * 1e10) / 1e10;
335
+
336
+ return Math.abs(clamped - maxValue) < Math.abs(clamped - rounded) ? maxValue : rounded;
337
+ }
338
+
339
+ /**
340
+ * One thumb's value, held inside the range and inside its own neighbours.
341
+ *
342
+ * The outer thumbs are bounded by the range; an inner one by the thumbs either
343
+ * side of it. This is the whole of what makes a range a range — without it a drag
344
+ * past a neighbour reorders the values and the fill inverts.
345
+ */
346
+ export function clampThumb(
347
+ value: number,
348
+ values: readonly number[],
349
+ index: number,
350
+ minValue: number,
351
+ maxValue: number
352
+ ): number {
353
+ "worklet";
354
+ const lower = index > 0 ? (values[index - 1] ?? minValue) : minValue;
355
+ const upper = index < values.length - 1 ? (values[index + 1] ?? maxValue) : maxValue;
356
+ const floor = lower < minValue ? minValue : lower;
357
+ const ceiling = upper > maxValue ? maxValue : upper;
358
+ return value < floor ? floor : value > ceiling ? ceiling : value;
359
+ }
360
+
361
+ /**
362
+ * The value a touch at `position` points at.
363
+ *
364
+ * `position` is the touch's offset along the track with the thumb's own half-width
365
+ * already taken off, and `travel` is what the thumb's leading edge can cover —
366
+ * `trackSize - thumbSize`. Working in the thumb's own frame is what keeps the
367
+ * handle inside the groove at both ends instead of overhanging them.
368
+ *
369
+ * **A vertical track is inverted here**, and in exactly one other place — the sign
370
+ * of the thumb's translate. A touch offset is measured from the top and a vertical
371
+ * slider counts up from the bottom, so one of the two has to turn around, and one
372
+ * place deciding it is what keeps them agreeing.
373
+ *
374
+ * `travel <= 0` returns the minimum rather than dividing: the track reports its
375
+ * size on layout, so every frame before the first one has nothing to divide by.
376
+ */
377
+ export function valueFromOffset({
378
+ position,
379
+ travel,
380
+ minValue,
381
+ maxValue,
382
+ isVertical,
383
+ }: {
384
+ position: number;
385
+ travel: number;
386
+ minValue: number;
387
+ maxValue: number;
388
+ isVertical: boolean;
389
+ }): number {
390
+ "worklet";
391
+ if (travel <= 0) return minValue;
392
+
393
+ const raw = position / travel;
394
+ const oriented = isVertical ? 1 - raw : raw;
395
+ const ratio = oriented < 0 ? 0 : oriented > 1 ? 1 : oriented;
396
+ return minValue + ratio * (maxValue - minValue);
397
+ }
398
+
399
+ /**
400
+ * Which thumb a touch grabs.
401
+ *
402
+ * A tie resolves to the lower index, and it has to resolve to *something* fixed:
403
+ * pressing the exact midpoint between two thumbs is a real gesture, and which one
404
+ * moves must not depend on iteration order changing under a refactor.
405
+ */
406
+ export function nearestThumbIndex(values: readonly number[], target: number): number {
407
+ "worklet";
408
+ let nearest = 0;
409
+ let shortest = Number.POSITIVE_INFINITY;
410
+
411
+ for (let index = 0; index < values.length; index++) {
412
+ const distance = Math.abs((values[index] ?? 0) - target);
413
+ if (distance < shortest) {
414
+ shortest = distance;
415
+ nearest = index;
416
+ }
417
+ }
418
+
419
+ return nearest;
420
+ }
421
+
422
+ /**
423
+ * The stretch of track the fill covers, as two 0–1 positions.
424
+ *
425
+ * A lone thumb fills from the minimum, because that is what a single value means:
426
+ * how far along. A range fills *between* its own thumbs, because the ends are what
427
+ * the caller excluded.
428
+ *
429
+ * The outermost thumbs are taken by value rather than by index, so a caller who
430
+ * hands over a descending array still gets a fill with a positive extent instead
431
+ * of one drawn backwards. The drag itself keeps them in order — see
432
+ * {@link clampThumb} — but the first render happens before any drag.
433
+ */
434
+ export function fillBounds(
435
+ values: readonly number[],
436
+ minValue: number,
437
+ maxValue: number
438
+ ): { start: number; end: number } {
439
+ "worklet";
440
+ const span = maxValue - minValue;
441
+ if (values.length === 0 || span <= 0) return { start: 0, end: 0 };
442
+
443
+ let lowest = values[0] ?? minValue;
444
+ let highest = lowest;
445
+ for (const value of values) {
446
+ if (value < lowest) lowest = value;
447
+ if (value > highest) highest = value;
448
+ }
449
+
450
+ // The progress maths is written out rather than calling `progressOf`. A
451
+ // module-scope worklet calling another one binds its closure once, at module
452
+ // init, in source order — so the pair works only while the callee happens to be
453
+ // declared first, and a tidy-up that reorders the file crashes the UI thread
454
+ // with `undefined is not a function`. Every exported worklet here stays flat,
455
+ // which is the conclusion `screen.variants.ts` reached the hard way.
456
+ const start = values.length > 1 ? Math.min(Math.max((lowest - minValue) / span, 0), 1) : 0;
457
+ const end = Math.min(Math.max((highest - minValue) / span, 0), 1);
458
+
459
+ return { start, end: Math.max(start, end) };
460
+ }
461
+
462
+ /**
463
+ * The fill's box in points: where it starts along the track, and how long it is.
464
+ *
465
+ * Takes {@link fillBounds}' two 0–1 positions into the track's own frame. It is a
466
+ * function rather than four lines inside `slider-fill.tsx` because the property it
467
+ * encodes is not self-evident and is the whole reason the thumb is drawn the size
468
+ * it is — a `+ thumbSize` at the far end, which lands exactly on both extremes:
469
+ *
470
+ * - at the **minimum** the extent is one thumb, so the handle covers the fill
471
+ * completely and a slider at rest shows a plain track;
472
+ * - at the **maximum** the extent is `travel + thumbSize`, the track's full
473
+ * length, with no sliver of empty groove past the handle;
474
+ * - a **collapsed range** is one thumb wide rather than zero, so the fill does not
475
+ * blink out from under two thumbs dragged together.
476
+ *
477
+ * All three hold only because the thumb's diameter equals the track's thickness.
478
+ * Inset the thumb inside the track and every one of them is off by the inset — a
479
+ * few points of stray colour at one end and of empty groove at the other, at every
480
+ * size. This is the arithmetic that pays for that proportion, so it is the
481
+ * arithmetic `bun test` has to be able to reach.
482
+ *
483
+ * It replaced a `+ thumbSize / 2`, which stopped the fill at the thumb's *centre*.
484
+ * That was correct but invisible while a large disc overhung a hairline groove; it
485
+ * would now leave the last half-thumb of the bar unfilled, in plain view.
486
+ *
487
+ * `travel <= 0` draws nothing: a measured `0` means *not measured yet*, and a bar
488
+ * sized off it would flash at a garbage length on the frame before layout lands.
489
+ */
490
+ export function fillExtent({
491
+ start,
492
+ end,
493
+ travel,
494
+ thumbSize,
495
+ isRange,
496
+ }: {
497
+ start: number;
498
+ end: number;
499
+ travel: number;
500
+ thumbSize: number;
501
+ isRange: boolean;
502
+ }): { offset: number; extent: number } {
503
+ "worklet";
504
+ if (travel <= 0) return { offset: 0, extent: 0 };
505
+
506
+ const from = isRange ? Math.min(Math.max(start, 0), 1) : 0;
507
+ const to = Math.min(Math.max(end, 0), 1);
508
+ const offset = from * travel;
509
+ const extent = Math.max(to * travel + thumbSize - offset, thumbSize);
510
+
511
+ return { offset, extent };
512
+ }
513
+
514
+ /**
515
+ * The values as one readable string.
516
+ *
517
+ * JS-thread only — `Intl` is not available to a worklet, which is the reason
518
+ * `Slider.Output` reads React state rather than the shared value the thumb reads.
519
+ */
520
+ export function formatSliderValue(values: readonly number[], formatOptions?: Intl.NumberFormatOptions): string {
521
+ if (values.length === 0) return "";
522
+ const format = new Intl.NumberFormat(undefined, formatOptions);
523
+ return values.map((value) => format.format(value)).join(SLIDER_RANGE_SEPARATOR);
524
+ }
525
+
526
+ /**
527
+ * Whether crossing into `snapped` should tick.
528
+ *
529
+ * Four rules, in order:
530
+ *
531
+ * - a **continuous** slider never ticks. There is no stop to land on, so a tick
532
+ * would be reporting the refresh rate rather than the value.
533
+ * - a value that did not change does not tick.
534
+ * - either **end of the range** always ticks, however fast the drag arrived. It
535
+ * is the one moment a slider has something to say that the screen does not
536
+ * already show — the thumb has stopped and the finger has not.
537
+ * - otherwise the drag must have travelled {@link SLIDER_HAPTIC_MIN_TRAVEL}
538
+ * points since the last tick.
539
+ *
540
+ * Pure, so the whole ladder is reachable from `bun test`, and flat, so it is safe
541
+ * to call from the pan's worklet. See AGENTS.md.
542
+ */
543
+ export function shouldTickHaptic({
544
+ step,
545
+ snapped,
546
+ lastSnapped,
547
+ position,
548
+ lastPosition,
549
+ minValue,
550
+ maxValue,
551
+ }: {
552
+ step: number;
553
+ snapped: number;
554
+ lastSnapped: number;
555
+ position: number;
556
+ lastPosition: number;
557
+ minValue: number;
558
+ maxValue: number;
559
+ }): boolean {
560
+ "worklet";
561
+ if (step <= 0) return false;
562
+ if (snapped === lastSnapped) return false;
563
+ if (snapped === minValue || snapped === maxValue) return true;
564
+ return Math.abs(position - lastPosition) >= SLIDER_HAPTIC_MIN_TRAVEL;
565
+ }
566
+
567
+ /** What a slider was given at its own call site. */
568
+ export type SliderOwnAxes = {
569
+ color?: SliderColor;
570
+ size?: SliderSize;
571
+ orientation?: SliderOrientation;
572
+ isDisabled?: boolean;
573
+ isInvalid?: boolean;
574
+ };
575
+
576
+ /** What an enclosing `Field` publishes, or null outside one. */
577
+ export type SliderFieldAxes = { isDisabled?: boolean; isInvalid?: boolean };
578
+
579
+ /** Every axis settled, ready to hand to {@link sliderVariants} and to context. */
580
+ export type SliderAxes = Required<SliderOwnAxes>;
581
+
582
+ /**
583
+ * Settles a slider's axes from the two places they can come from.
584
+ *
585
+ * There is no `Slider.Group`, so the ladder is two rungs rather than three: the
586
+ * slider's own props, then an enclosing `Field`. A slider inside `<Field isDisabled>`
587
+ * dims with nothing said at the call site, and `isDisabled={false}` opts that one
588
+ * out — `??` throughout and never `||`, so an explicit `false` is a value rather
589
+ * than an absence.
590
+ *
591
+ * A `Field` reaches the two *state* axes only. It carries no colour, size or
592
+ * orientation, and a test pins that it cannot acquire one by accident.
593
+ *
594
+ * Pure, so the whole matrix is reachable from `bun test`. See AGENTS.md.
595
+ */
596
+ export function resolveSliderAxes({ own, field }: { own?: SliderOwnAxes; field?: SliderFieldAxes | null }): SliderAxes {
597
+ return {
598
+ color: own?.color ?? SLIDER_DEFAULT_COLOR,
599
+ size: own?.size ?? SLIDER_DEFAULT_SIZE,
600
+ orientation: own?.orientation ?? SLIDER_DEFAULT_ORIENTATION,
601
+ isDisabled: own?.isDisabled ?? field?.isDisabled ?? false,
602
+ isInvalid: own?.isInvalid ?? field?.isInvalid ?? false,
603
+ };
604
+ }
605
+
606
+ /**
607
+ * What a range's thumbs are called when nothing names them.
608
+ *
609
+ * Written out once so the two ends and the counted form cannot drift apart, and
610
+ * so the playground and the docs can quote the same words.
611
+ */
612
+ export const SLIDER_THUMB_NAMES = { minimum: "Minimum", maximum: "Maximum", thumb: "Thumb" } as const;
613
+
614
+ /**
615
+ * The accessible name a `Slider.Thumb` announces.
616
+ *
617
+ * A thumb is a capsule with no text, so without this a screen reader reads a
618
+ * bare number and nothing about what it is a number *of*. The ladder is the one
619
+ * every prop in this package climbs: an explicit `accessibilityLabel` on the
620
+ * thumb wins, then the slider's name, then nothing — an empty override counts as
621
+ * no override, because a screen reader handed `""` reads nothing at all.
622
+ *
623
+ * A lone thumb *is* the control, so it takes the control's name unadorned. A
624
+ * range has to tell its two thumbs apart, so each is named for the end it holds
625
+ * — "Price range, minimum" — and that holds even with no name to hang it on,
626
+ * since two thumbs reading the same bare number are indistinguishable. Past two
627
+ * thumbs there are no ends to name, so they count: "Stops, 2 of 3".
628
+ *
629
+ * Pure, so the whole ladder is reachable from `bun test`. See AGENTS.md.
630
+ */
631
+ export function resolveThumbAccessibilityLabel({
632
+ label,
633
+ index,
634
+ count,
635
+ override,
636
+ }: {
637
+ /** The slider's own name — a `Field.Label`'s text, or nothing. */
638
+ label?: string | null;
639
+ /** Which thumb, in the order the values were given. */
640
+ index: number;
641
+ /** How many thumbs the slider holds. */
642
+ count: number;
643
+ /** An `accessibilityLabel` written on the thumb itself. */
644
+ override?: string;
645
+ }): string | undefined {
646
+ const explicit = override?.trim();
647
+ if (explicit) return explicit;
648
+
649
+ const name = label?.trim() || undefined;
650
+ if (count <= 1) return name;
651
+
652
+ if (count === 2) {
653
+ const end = index === 0 ? SLIDER_THUMB_NAMES.minimum : SLIDER_THUMB_NAMES.maximum;
654
+ return name ? `${name}, ${end.toLowerCase()}` : end;
655
+ }
656
+
657
+ const position = `${index + 1} of ${count}`;
658
+ return name ? `${name}, ${position}` : `${SLIDER_THUMB_NAMES.thumb} ${position}`;
659
+ }
660
+
661
+ export type SliderVariantProps = VariantProps<typeof sliderVariants>;