@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,30 @@
1
+ # Separator
2
+
3
+ A one-pixel rule, hidden from assistive technology — a line between every row
4
+ carries nothing a screen reader can use, and announcing them buries the rows.
5
+
6
+ `import { Separator } from "@delacour/react-native-ui/separator";`
7
+
8
+ ## Files
9
+
10
+ | File | What it holds |
11
+ | --- | --- |
12
+ | `index.ts` | → `@delacour/react-native-ui/separator` |
13
+ | `separator.tsx` | The `tv()` and the component, in one file |
14
+
15
+ ## Design
16
+
17
+ - **`separatorVariants` lives in `separator.tsx`**, above the component, not in a
18
+ `*.variants.ts` sibling — the pattern C carve-out. That file imports React
19
+ Native, so the `tv()` is not reachable from `bun test`; the exclusive-axis and
20
+ `self-stretch` rules below are stated in its doc comment instead of asserted.
21
+ - **Orientations**: `horizontal` (default), `vertical`.
22
+ - **The long axis is `self-stretch`, never `w-full` / `h-full`.** Yoga resolves
23
+ a percentage length against the parent's content box and then adds the
24
+ margins on top, so an inset `w-full` line starts 16pt in and runs 16pt past
25
+ the far edge — a gap down one side and none down the other. Stretching
26
+ subtracts the margins instead, which is what an inset divider needs. Do not
27
+ "fix" this back to a percentage width.
28
+ - **A filled box, not a border**, so a caller insets it with a plain `mx-*`
29
+ without fighting a border's own box model. This is how
30
+ [`ListGroup`](../list-group/AGENTS.md) positions the dividers it inserts.
@@ -0,0 +1,8 @@
1
+ export {
2
+ SEPARATOR_ORIENTATIONS,
3
+ Separator,
4
+ type SeparatorOrientation,
5
+ type SeparatorProps,
6
+ type SeparatorVariantProps,
7
+ separatorVariants,
8
+ } from "./separator";
@@ -0,0 +1,85 @@
1
+ import type { ReactElement } from "react";
2
+ import { View, type ViewProps } from "react-native";
3
+ import type { VariantProps } from "tailwind-variants";
4
+ import { tv } from "../../lib/tv";
5
+
6
+ export const SEPARATOR_ORIENTATIONS = ["horizontal", "vertical"] as const;
7
+
8
+ export type SeparatorOrientation = (typeof SEPARATOR_ORIENTATIONS)[number];
9
+
10
+ /**
11
+ * Styling for a separator.
12
+ *
13
+ * The line is a filled box rather than a border, so a caller can inset it with
14
+ * a plain `mx-*` / `my-*` without fighting a border's own box model — which is
15
+ * how `ListGroup` positions the dividers it inserts between its rows.
16
+ *
17
+ * The long axis is `self-stretch`, never `w-full` / `h-full`. Yoga resolves a
18
+ * percentage length against the parent's content box and then adds the margins
19
+ * on top, so an inset `w-full` line starts 16pt in and runs 16pt past the far
20
+ * edge — a gap down one side and none down the other. Stretching subtracts the
21
+ * margins instead, which is what an inset divider actually needs.
22
+ *
23
+ * The two orientations are exclusive: `horizontal` draws `h-px` and no `w-px`,
24
+ * `vertical` the reverse, so the hairline is only ever on one axis.
25
+ *
26
+ * Declared here rather than in a `*.variants.ts` sibling because a separator is
27
+ * a single styled element with no compound parts and no pure resolvers — there
28
+ * is no slot set for another file to share. The cost is that `bun test` cannot
29
+ * reach it: this module imports React Native, whose Flow-typed source Bun's
30
+ * transpiler cannot parse. See AGENTS.md.
31
+ */
32
+ export const separatorVariants = tv({
33
+ base: "self-stretch bg-border",
34
+ variants: {
35
+ orientation: {
36
+ horizontal: "h-px",
37
+ vertical: "w-px",
38
+ },
39
+ },
40
+ defaultVariants: {
41
+ orientation: "horizontal",
42
+ },
43
+ });
44
+
45
+ export type SeparatorVariantProps = VariantProps<typeof separatorVariants>;
46
+
47
+ export type SeparatorProps = ViewProps & {
48
+ /** Axis the line runs along. A vertical separator needs a parent with a height. */
49
+ orientation?: SeparatorOrientation;
50
+ className?: string;
51
+ };
52
+
53
+ /**
54
+ * A one-pixel rule dividing content.
55
+ *
56
+ * Hidden from assistive technology: the line carries no information a screen
57
+ * reader can use, and announcing one between every row of a list would bury the
58
+ * rows themselves.
59
+ *
60
+ * `ListGroup` inserts these between its rows automatically, so this is written
61
+ * out by hand only for a divider elsewhere — or to place one inside a
62
+ * `ListGroup` deliberately, which suppresses the automatic one at that point.
63
+ *
64
+ * @example
65
+ * <Separator className="my-4" />
66
+ *
67
+ * @example
68
+ * <View className="flex-row items-center gap-3">
69
+ * <Text>Left</Text>
70
+ * <Separator className="h-4" orientation="vertical" />
71
+ * <Text>Right</Text>
72
+ * </View>
73
+ */
74
+ export function Separator({ orientation = "horizontal", className, ...props }: SeparatorProps): ReactElement {
75
+ return (
76
+ <View
77
+ accessibilityElementsHidden
78
+ accessible={false}
79
+ className={separatorVariants({ className, orientation })}
80
+ importantForAccessibility="no-hide-descendants"
81
+ {...props}
82
+ />
83
+ );
84
+ }
85
+ Separator.displayName = "DelacourUI.Separator";
@@ -0,0 +1,274 @@
1
+ # Slider
2
+
3
+ A value picked by dragging along a track — one value, or a range. Compound root
4
+ plus `Slider.Output`, `Slider.Track`, `Slider.Fill` and `Slider.Thumb`. The
5
+ package's first drag-driven control, and its first `Gesture.Pan()`.
6
+
7
+ `import { Slider } from "@delacour/react-native-ui/slider";`
8
+
9
+ ## Files
10
+
11
+ | File | What it holds |
12
+ | --- | --- |
13
+ | `index.ts` | → `@delacour/react-native-ui/slider` |
14
+ | `slider.tsx` | Root + the `Object.assign` compound surface |
15
+ | `slider-output.tsx` | `Slider.Output`, the `Text.Label` readout |
16
+ | `slider-track.tsx` | `Slider.Track` — owns the pan, the measurement, the haptic |
17
+ | `slider-fill.tsx` | `Slider.Fill` |
18
+ | `slider-thumb.tsx` | `Slider.Thumb`, and the whole accessibility surface |
19
+ | `slider.context.tsx` | `SliderContext`, `useSlider()`, `useSliderContext()`, `useSliderPart()` |
20
+ | `slider.types.ts` | Prop types shared by two or more parts |
21
+ | `slider.variants.ts` | Pure `tv()` slots + the geometry worklets, no RN imports |
22
+ | `slider.variants.test.ts` | |
23
+
24
+ ## Design
25
+
26
+ - **Colours**: `default`, `primary`, `success`, `warning`, `destructive`, `info` —
27
+ Badge's and Checkbox's set. **Sizes**: `sm`, `md`, `lg`, driving the groove's
28
+ thickness, the thumb's diameter and the readout's type step.
29
+ **Orientations**: `horizontal`, `vertical`.
30
+ - **The anatomy is written out, never assembled from props.** A range's thumb
31
+ count is *data*, so `Slider.Track` takes a function and is handed the settled
32
+ state to map over. [Radio](../radio/AGENTS.md) composes its own indicator in
33
+ because a radio has exactly one; a slider does not know how many it has until
34
+ it is told.
35
+
36
+ ## Gesture
37
+
38
+ - **The root is not a `Pressable`, and neither is the thumb.** Three reasons and
39
+ all of them structural. [Pressable](../pressable/AGENTS.md) mounts a
40
+ `Gesture.Tap()` whose `onEnd` fires `onPress`, so every tap-to-position would
41
+ also fire a press. Its root `Animated.View` already owns `opacity` and
42
+ `transform` through a `useAnimatedStyle` of its own, and the thumb's position
43
+ *is* a `transform` — two animated styles on one node fight for the same prop,
44
+ the rule `Radio.Indicator` states and the reason `Radio` takes no `asChild`.
45
+ And a thumb wrapped in its own `Pressable` would nest a descendant `Tap`
46
+ inside the track's ancestor `Pan`, leaving two recognisers to negotiate for
47
+ one drag. What is inherited is the *vocabulary*: `HapticFeedback` and
48
+ `playHaptic` come from `pressable.tsx`, which is exported for exactly this —
49
+ one haptic switch in the library, never a second.
50
+ - **`Slider.Thumb` holds no gesture at all.** One pan on the track drives every
51
+ thumb, because a press 40pt along an empty groove should still lift the thumb it
52
+ is about to move, and a per-thumb gesture cannot know that. The grabbed scale is
53
+ therefore driven by an `activeIndex` shared value the track writes, not by a
54
+ press state the thumb owns.
55
+ - **The value is written in `onBegin`, not only in `onUpdate`.** This is the one
56
+ that will bite a rewrite. A pan activates on the first *movement*, so a
57
+ stationary tap never reaches `onStart` or `onUpdate`: a slider that computed
58
+ only there would tick, lift its thumb, and then not move it. `onFinalize` is
59
+ likewise where the drag is reported finished, because it is the only callback
60
+ that fires on every path, the never-activated one included.
61
+ - **`minDistance(0)` is what wins the touch from a scroll view**, and it is not
62
+ tuning. `Screen.ScrollArea` renders React Native's own `ScrollView`
63
+ (`Animated.ScrollView`), not Gesture Handler's, so there is no sibling handler
64
+ to negotiate with — the two race, and a pan that activates on the first move
65
+ beats a scroll view's ten-point slop on both platforms. Which is also why there
66
+ is **no `activeOffsetX`**: waiting for the axis to declare itself hands the
67
+ scroll the first move and puts a dead zone at the start of every drag.
68
+ **`blocksExternalGesture` is not the escape hatch it looks like** — it resolves
69
+ a ref to a handler tag, a plain `ScrollView` has none, and Gesture Handler drops
70
+ the call without an error. If Android ever hands a drag to the scroll anyway,
71
+ the documented fix is a nested `GestureHandlerRootView` around the slider, not
72
+ an offset filter.
73
+ - **`shouldCancelWhenOutside(false)`**, where `Pressable`'s tap sets it `true`.
74
+ Dragging a thumb to the far end routinely leaves the track's bounds, and the
75
+ value has to keep tracking rather than the gesture giving up half way.
76
+ - **On iOS, a slider inside a scroll view feels late until
77
+ `delaysContentTouches={false}`.** UIScrollView's default holds touch delivery to
78
+ its descendants for about 150ms while it decides whether the touch is a scroll.
79
+ Nothing inside this component can reach that — it is a prop on the scrollable,
80
+ so it belongs at the call site.
81
+ - **The groove is not the touch target on its own.** A `sm` track is sixteen
82
+ points, so the drag is claimed on a transparent `touchArea` whose padding brings
83
+ it up to 44. The thickness and that padding live in the **same compound cell**,
84
+ because they are one number: they sum to 44 at every size, and a test asserts
85
+ the sum rather than the parts so the ladder can be retuned without the test
86
+ becoming a transcript of it. Split them across two variants and a retune of the
87
+ thickness silently shrinks the target.
88
+ - **That padding is on the cross axis only**, and this is the load-bearing half.
89
+ The two boxes share an origin along the axis the value is measured on, so the
90
+ pan reads its offset straight off the touch with no gutter to correct for. Pad
91
+ the main axis and every value is wrong by the padding, silently, and visibly
92
+ only at the ends.
93
+
94
+ ## Colour
95
+
96
+ - **The colour paints the fill, the capsule and the knob — never the groove.**
97
+ An empty groove is the same chrome at every colour, the way an unticked
98
+ checkbox is `border-input bg-card` however it is coloured, and a test asserts
99
+ that. Invalid outranks the colour on all three, the precedence
100
+ [Checkbox](../checkbox/AGENTS.md) sets on its border.
101
+ - **The capsule takes the fill's own colour, and the knob takes that colour's
102
+ `-foreground`.** The first is what makes the handle read as the leading end of
103
+ the fill rather than as something sitting on top of it — same colour, no seam.
104
+ The second is rule 11 doing its job: a single pale knob would be unreadable on
105
+ `warning`, whose foreground is near-black, so the knob follows the surface it
106
+ sits on. A test pins the pair rather than trusting two maps to stay in step, and
107
+ checks every token it names exists in both variants of `theme.css`.
108
+ - **`default` and `primary` name different tokens this theme tunes to the same
109
+ value.** `foreground` is the page's ink and `primary` is the brand's action
110
+ colour; both are `#262626` today, which is the situation
111
+ [Badge](../badge/AGENTS.md) already documents for its neutral end. Collapsing
112
+ them into one token would be the drift, not the duplication — an app that
113
+ re-themes `primary` to blue wants `color="primary"` blue and `color="default"`
114
+ still ink. A test pins that the four *semantic* colours stay distinct from
115
+ each other and from both neutrals, and that every token named is declared in
116
+ **both** variants of `theme.css`.
117
+
118
+ ## The handle
119
+
120
+ - **The handle is two nodes: a capsule and a knob.** The capsule carries the
121
+ colour, the size and the position; the knob is the pale bar inside it, held off
122
+ the capsule's edge by its padding, and the only thing that moves when a finger
123
+ lands. One node could not be both the surface and the thing inset within it.
124
+ - **Neither takes a shadow**, and the capsule takes no border either. Nothing else
125
+ in this package draws a shadow, and React Native's shadow props diverge between
126
+ platforms in a way a flat fill does not; a test sweeps the whole matrix for the
127
+ absence. The capsule is a solid block of the fill's colour, so its edge is
128
+ already the boundary between the fill and the groove — a border would be a
129
+ second line drawn over one that is already there.
130
+ - **The capsule is flush across the track and longer along it.** The flush half is
131
+ geometry rather than decoration: it is what lets `fillExtent` land exactly on
132
+ both extremes — one capsule's length of fill at the minimum, so the handle covers
133
+ it completely and a slider at rest shows a plain groove; the track's full length
134
+ at the maximum, with no sliver past the handle; and one capsule's length again
135
+ for a collapsed range, so the fill does not blink out from under two handles
136
+ dragged together. Inset the capsule inside the track by any padding and every one
137
+ of those is off by the inset, at every size. The long axis is two steps up from
138
+ the short one, which is what makes a handle you can tell apart from the groove.
139
+ - **Both are plain spacing steps, not tokens.** They are numbers read in one
140
+ component, which is the trade `Radio` already makes for the dot inside its ring.
141
+ `Checkbox` reads `--spacing-icon-*` for its square and should keep doing so: a
142
+ glyph in a box is a mark on the icon scale, where a slider's handle is the body
143
+ of the control itself. This is why the handle stopped reading that scale.
144
+ - **The capsule's size lives in the same six compound cells as the track's
145
+ thickness**, not in a `size`-only variant, because its two axes differ and only
146
+ the orientation knows which is which.
147
+
148
+ ## Geometry
149
+
150
+ - **The track still centres the thumb, and now has nothing to centre.** An
151
+ absolutely-positioned child with no cross-axis inset is placed at the static
152
+ position the parent's `items-center` decides. That did the work while the thumb
153
+ overhung a hairline groove; it is a no-op now the two are the same size. It
154
+ stays because it is load-bearing again the moment those sizes are allowed to
155
+ differ, and because it is why the track is `flex-row` when horizontal:
156
+ `items-center` centres on the *cross* axis, and a column track would centre the
157
+ wrong one.
158
+ - **Every length is measured, never tabulated.** `trackSize` and `thumbSize` come
159
+ from their own `onLayout` — `size-5` cannot be read from JavaScript, and a
160
+ table of numbers here would be `tokens.css` restated in TypeScript, the drift
161
+ `tokens.test.ts` exists to catch everywhere else. A measured `0` therefore means
162
+ **not measured yet**, never "a track with no length": every geometry helper
163
+ guards `travel <= 0` and the thumb renders at `opacity: 0` until the track has
164
+ reported, because a thumb drawn before then sits at a garbage offset for a frame
165
+ and reads as a flicker on every mount.
166
+ - **The fill runs to the thumb's far edge**, which is the `+ thumbSize` in
167
+ `fillExtent` and the reason both extremes come out exact. It used to stop at the
168
+ thumb's *centre*, which was correct but invisible while a large disc overhung a
169
+ hairline groove; with the thumb now the same size as the track, a half-thumb of
170
+ bar would sit unfilled at the maximum in plain view. A lone thumb fills from the
171
+ start of the track, because that is what a single value means; a range fills
172
+ *between* its thumbs, because the ends are what the caller excluded. The extent
173
+ is floored at one thumb so a collapsed range never disappears.
174
+ - **The pixel arithmetic is a function, not four lines in `slider-fill.tsx`.**
175
+ `fillExtent` earns its place because what it encodes is not self-evident — it is
176
+ the whole justification for the thumb's size — and inline in a `.tsx` no test
177
+ could reach it. `travel` and the touch's `position` are the opposite case and
178
+ stay written out: `trackSize - thumbSize` and `along - thumbSize / 2` appear in
179
+ three files and are self-evident, and routing them through a cross-module
180
+ worklet would add a call in three per-frame paths to hide arithmetic nobody
181
+ would get wrong.
182
+ - **The vertical axis turns around in exactly two places**: `valueFromOffset`'s
183
+ inversion and the sign of the thumb's translate. Not in a `flex-col-reverse` —
184
+ the fill and the thumb are absolutely positioned, so a `flexDirection` never
185
+ reaches them, and a reversed column flips every `justify-*` inside the track as
186
+ well. A test asserts the two orientations are mirror images, reading a vertical
187
+ track from the far end and a horizontal one from the near end and demanding the
188
+ same value. A vertical slider needs a **definite height** from its parent.
189
+
190
+ ## Value and state
191
+
192
+ - **Both ends of the range are always stops**, even when the step does not divide
193
+ it. 0–100 by 7 reaches 0, 7, 14 … 98 and then 100, because a slider whose
194
+ maximum cannot be reached by dragging all the way to the end is a slider that
195
+ lies about its own range. A tie goes to the regular stop, so the extra one only
196
+ ever appears at the very end of the drag.
197
+ - **Snapping happens on the UI thread, and that is what bounds the re-renders.**
198
+ `positions` holds *snapped* values, so the mirror back to React fires on a
199
+ step crossing rather than on a frame — a full-width drag at the default step
200
+ is a few dozen commits, not a hundred and twenty a second. `step={0}` is
201
+ continuous and does re-render per frame; that is the trade for
202
+ `formatOptions`, since `Intl` is not available to a worklet and a readout
203
+ derived on the UI thread could not format a currency. If it ever profiles
204
+ badly the escape hatch is an `Animated.Text` fed by a `useDerivedValue`, which
205
+ [Text](../text/AGENTS.md) already renders.
206
+ - **`positions` is one `SharedValue<number[]>`, reassigned and never mutated.**
207
+ One shared value per thumb is not available — a thumb count is data and hooks
208
+ cannot be called in a loop — so the array is the shape. `positions.value[0] = x`
209
+ updates nothing and fails **silently**: an array element has no setter behind
210
+ it. Always build a new array and assign it.
211
+ - **A drag stops the root syncing the shared value from React state.** The mirror
212
+ hop back would otherwise round-trip through a render and land on the thumb a
213
+ frame late, dragging it backwards on every commit — a jitter only a fast drag
214
+ reveals. The guard is a ref, because nothing renders differently for it. Its
215
+ counterpart is `settledDrags`, a counter bumped on release whose only job is to
216
+ give the sync effect something to re-run on: a **controlled parent that rejects
217
+ a dragged value** leaves `current` unchanged, and without the token the thumb
218
+ would stay where the finger let go instead of snapping back.
219
+ - **The shape is the caller's, and it is locked on first render.** A slider given
220
+ a number reports a number; one given an array reports an array. Switching warns
221
+ in development and follows the caller, the lock-and-warn `useControllableState`
222
+ already runs for controlled versus uncontrolled — a slider that silently started
223
+ reporting an array to a caller holding a number is a bug with no error attached.
224
+
225
+ ## Haptics
226
+
227
+ - **The haptic is rate-limited by distance, not by a clock.** "Tick when the
228
+ snapped value changed" is not a limit on its own: 0–100 in whole steps across a
229
+ 300pt track is a step every three points, and a flick crosses a hundred of them
230
+ in a fifth of a second — a buzz, and a hundred synchronous calls into the haptic
231
+ engine to produce it. `shouldTickHaptic` gates on `SLIDER_HAPTIC_MIN_TRAVEL`,
232
+ which keeps the rule pure so `bun test` reaches it and makes it degrade the right
233
+ way: a coarse scale ticks on every stop, a fine one thins to a cadence a hand can
234
+ feel. Either **end** of the range always ticks — it is the one moment a slider
235
+ has something to say the screen does not already show. The grab itself always
236
+ confirms, the way a press does. A continuous slider never ticks, because there
237
+ is no stop to land on.
238
+
239
+ ## Accessibility
240
+
241
+ - **`accessibilityRole="adjustable"` on the thumb, and this is the package's first
242
+ `accessibilityValue`.** It is not polish: the thumb holds no gesture, so without
243
+ `accessibilityActions` and `onAccessibilityAction` there is no assistive path to
244
+ the value **at all** — a VoiceOver or TalkBack swipe would have nothing to call.
245
+ The increment steps by `step`, or by a tenth of the range when the slider is
246
+ continuous. `updateValue` is the one way into the value that has no gesture
247
+ behind it, and it runs the same snap and the same clamp the pan does.
248
+
249
+ ## Worklets
250
+
251
+ - **A worklet crosses back to JS with `scheduleOnRN`**, never `runOnJS` — see
252
+ [Pressable](../pressable/AGENTS.md). `onFinalize` queues `setDragging(false)`
253
+ *before* `commitEnd`, and the order is load-bearing: the root has to have
254
+ stopped treating this as a live drag before it is asked to re-sync.
255
+ - **Every exported worklet in `slider.variants.ts` is flat.** None of them calls
256
+ another. A module-scope worklet is rewritten into a factory call that runs at
257
+ import time in source order, so a worklet calling a sibling works only while
258
+ the sibling happens to be declared first — and a tidy-up that reorders the
259
+ file crashes the UI thread with `undefined is not a function`. That is the
260
+ real shape of the `clampUnit` incident the [Screen](../screen/AGENTS.md)
261
+ section records: not "cross-module is unsafe" — `screen.variants.ts`'s own
262
+ resolvers are imported into `useAnimatedStyle` and work — but "a module-scope
263
+ worklet must not depend on one declared below it". The pan's own shared helper
264
+ lives *inside* the `useMemo` beside its callers, where ordinary closure
265
+ capture applies.
266
+
267
+ ## Axes
268
+
269
+ - **There is no `Slider.Group`**, so the axis ladder is two rungs rather than
270
+ three: the slider's own props, then an enclosing [Field](../field/AGENTS.md).
271
+ A `Field` reaches the two *state* axes only, and a test pins that it cannot
272
+ acquire a paint axis by accident. The slider does **not** register
273
+ `field.registerPress` the way a `Checkbox` does — a row-wide press has no
274
+ meaning for a control whose value is a position.
@@ -0,0 +1,50 @@
1
+ export { Slider, type SliderProps } from "./slider";
2
+ export {
3
+ type SliderContextValue,
4
+ SliderProvider,
5
+ useSlider,
6
+ useSliderContext,
7
+ } from "./slider.context";
8
+ export type { SliderRenderChildren, SliderRenderProps } from "./slider.types";
9
+ export {
10
+ clampThumb,
11
+ fillBounds,
12
+ fillExtent,
13
+ formatSliderValue,
14
+ fromValueArray,
15
+ nearestThumbIndex,
16
+ progressOf,
17
+ resolveSliderAxes,
18
+ resolveThumbAccessibilityLabel,
19
+ SLIDER_COLORS,
20
+ SLIDER_DEFAULT_COLOR,
21
+ SLIDER_DEFAULT_ORIENTATION,
22
+ SLIDER_DEFAULT_SIZE,
23
+ SLIDER_HAPTIC_MIN_TRAVEL,
24
+ SLIDER_MAX_VALUE,
25
+ SLIDER_MIN_VALUE,
26
+ SLIDER_ORIENTATIONS,
27
+ SLIDER_OUTPUT_TEXT_SIZE,
28
+ SLIDER_RANGE_SEPARATOR,
29
+ SLIDER_SIZES,
30
+ SLIDER_STEP,
31
+ SLIDER_THUMB_ANIMATION,
32
+ SLIDER_THUMB_NAMES,
33
+ SLIDER_THUMB_SPRING,
34
+ type SliderAxes,
35
+ type SliderColor,
36
+ type SliderFieldAxes,
37
+ type SliderOrientation,
38
+ type SliderOwnAxes,
39
+ type SliderSize,
40
+ type SliderVariantProps,
41
+ shouldTickHaptic,
42
+ sliderVariants,
43
+ snapToStep,
44
+ toValueArray,
45
+ valueFromOffset,
46
+ } from "./slider.variants";
47
+ export type { SliderFillProps } from "./slider-fill";
48
+ export type { SliderOutputProps } from "./slider-output";
49
+ export type { SliderThumbProps } from "./slider-thumb";
50
+ export type { SliderTrackProps } from "./slider-track";
@@ -0,0 +1,68 @@
1
+ import type { ReactElement } from "react";
2
+ import type { ViewProps } from "react-native";
3
+ import Animated, { useAnimatedStyle } from "react-native-reanimated";
4
+ import { useSliderPart } from "./slider.context";
5
+ import { fillBounds, fillExtent, sliderVariants } from "./slider.variants";
6
+
7
+ export type SliderFillProps = Omit<ViewProps, "children" | "style"> & {
8
+ className?: string;
9
+ };
10
+
11
+ /**
12
+ * The painted part of the groove.
13
+ *
14
+ * A lone thumb fills from the start of the track, because that is what a single
15
+ * value means — how far along. A range fills *between* its own thumbs, because
16
+ * the ends are what the caller excluded. {@link fillBounds} is that decision and
17
+ * is pure, so `bun test` sweeps it.
18
+ *
19
+ * **The far end lands on the thumb's far edge**, which is what makes both extremes
20
+ * exact: one thumb's width of fill at the minimum, so the handle covers it
21
+ * completely and a slider at rest shows a plain track, and the track's full length
22
+ * at the maximum, with no sliver of empty groove past the handle. That works only
23
+ * because the thumb's diameter is the track's thickness — {@link fillExtent} is
24
+ * where the arithmetic lives, and where `bun test` can hold it down.
25
+ *
26
+ * The extent is an animated style rather than a class because it is a measured
27
+ * length in points, and a class cannot name one. The *colour* is a class, which
28
+ * is what keeps the whole colour matrix inside `bun test`.
29
+ *
30
+ * Nothing here reads React state: the fill follows the shared value the pan
31
+ * writes, so it tracks the finger at the display's refresh rate rather than at
32
+ * React's.
33
+ */
34
+ export function SliderFill({ className, ...props }: SliderFillProps): ReactElement {
35
+ const { positions, trackSize, thumbSize, minValue, maxValue, orientation, color, size, isInvalid, isDisabled } =
36
+ useSliderPart("Slider.Fill");
37
+ const isVertical = orientation === "vertical";
38
+
39
+ const fillStyle = useAnimatedStyle(() => {
40
+ const thumb = thumbSize.value;
41
+ const values = positions.value;
42
+ const bounds = fillBounds(values, minValue, maxValue);
43
+ // Both helpers hold themselves at zero until the layouts have landed. A
44
+ // measured 0 means "not measured yet", never "a track with no length", and
45
+ // dividing into it would put NaN on the UI thread — which no later frame
46
+ // recovers from.
47
+ const { offset, extent } = fillExtent({
48
+ end: bounds.end,
49
+ isRange: values.length > 1,
50
+ start: bounds.start,
51
+ thumbSize: thumb,
52
+ travel: trackSize.value - thumb,
53
+ });
54
+
55
+ // The same keys every frame, so Reanimated is never asked to swap a style's
56
+ // shape mid-animation.
57
+ return isVertical ? { bottom: offset, height: extent } : { left: offset, width: extent };
58
+ });
59
+
60
+ return (
61
+ <Animated.View
62
+ className={sliderVariants({ color, isDisabled, isInvalid, orientation, size }).fill({ className })}
63
+ style={fillStyle}
64
+ {...props}
65
+ />
66
+ );
67
+ }
68
+ SliderFill.displayName = "DelacourUI.Slider.Fill";
@@ -0,0 +1,55 @@
1
+ import type { ReactElement } from "react";
2
+ import { Text, type TextPresetProps } from "../text";
3
+ import { useSliderPart } from "./slider.context";
4
+ import type { SliderRenderChildren } from "./slider.types";
5
+ import { formatSliderValue, SLIDER_OUTPUT_TEXT_SIZE, sliderVariants } from "./slider.variants";
6
+
7
+ export type SliderOutputProps = Omit<TextPresetProps, "children"> & {
8
+ /** Custom content, or a function called with the slider's settled state. */
9
+ children?: SliderRenderChildren;
10
+ };
11
+
12
+ /**
13
+ * The current value, formatted.
14
+ *
15
+ * Renders `Text.Label` and names a size, never a scale of its own. The weight and
16
+ * the colour belong to the preset — restating them here would be a second
17
+ * definition of `Text.Label` that could drift from it, the rule `Field` is built
18
+ * on. Naming the size is what lets the readout still track the slider's own axis,
19
+ * since `Text`'s size axis is built to beat its preset.
20
+ *
21
+ * A range is joined with an en dash. `formatOptions` goes straight to
22
+ * `Intl.NumberFormat`, so a currency or a percentage needs nothing but the
23
+ * options object the platform already understands.
24
+ *
25
+ * **It reads React state, not the shared value the thumb reads**, and that is the
26
+ * one place in this component where a value crosses back to the JS thread during
27
+ * a drag. `Intl` is not available to a worklet, so a formatted readout cannot be
28
+ * derived on the UI thread without giving up `formatOptions` entirely. The cost
29
+ * is bounded instead: the pan snaps on the UI thread and only a *changed* snapped
30
+ * value is mirrored across, so a full-width drag at the default step is a few
31
+ * dozen commits. A continuous slider — `step={0}` — has no stops to change on and
32
+ * does re-render per frame; if that ever profiles badly, the escape hatch is an
33
+ * `Animated.Text` fed by a `useDerivedValue`, which `Text` already renders and
34
+ * which trades `formatOptions` away.
35
+ */
36
+ export function SliderOutput({ children, className, size, ...props }: SliderOutputProps): ReactElement {
37
+ const context = useSliderPart("Slider.Output");
38
+ const { color, size: sliderSize, isDisabled, isInvalid, orientation, values, formatOptions } = context;
39
+
40
+ const content =
41
+ typeof children === "function"
42
+ ? children(context.renderProps)
43
+ : (children ?? formatSliderValue(values, formatOptions));
44
+
45
+ return (
46
+ <Text.Label
47
+ className={sliderVariants({ color, isDisabled, isInvalid, orientation, size: sliderSize }).output({ className })}
48
+ size={size ?? SLIDER_OUTPUT_TEXT_SIZE[sliderSize]}
49
+ {...props}
50
+ >
51
+ {content}
52
+ </Text.Label>
53
+ );
54
+ }
55
+ SliderOutput.displayName = "DelacourUI.Slider.Output";