@delacour/react-native-ui 0.1.0-alpha.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (273) hide show
  1. package/README.md +113 -0
  2. package/package.json +125 -0
  3. package/src/components/accordion/AGENTS.md +192 -0
  4. package/src/components/accordion/accordion-content.tsx +125 -0
  5. package/src/components/accordion/accordion-description.tsx +17 -0
  6. package/src/components/accordion/accordion-indicator.tsx +104 -0
  7. package/src/components/accordion/accordion-item.tsx +78 -0
  8. package/src/components/accordion/accordion-title.tsx +18 -0
  9. package/src/components/accordion/accordion-trigger.tsx +148 -0
  10. package/src/components/accordion/accordion.context.tsx +153 -0
  11. package/src/components/accordion/accordion.tsx +253 -0
  12. package/src/components/accordion/accordion.types.ts +11 -0
  13. package/src/components/accordion/accordion.variants.test.ts +435 -0
  14. package/src/components/accordion/accordion.variants.ts +358 -0
  15. package/src/components/accordion/index.ts +45 -0
  16. package/src/components/badge/AGENTS.md +83 -0
  17. package/src/components/badge/badge-close-button.tsx +48 -0
  18. package/src/components/badge/badge-end-content.tsx +15 -0
  19. package/src/components/badge/badge-label.tsx +24 -0
  20. package/src/components/badge/badge-start-content.tsx +16 -0
  21. package/src/components/badge/badge.context.tsx +64 -0
  22. package/src/components/badge/badge.tsx +192 -0
  23. package/src/components/badge/badge.types.ts +10 -0
  24. package/src/components/badge/badge.variants.test.ts +269 -0
  25. package/src/components/badge/badge.variants.ts +238 -0
  26. package/src/components/badge/index.ts +17 -0
  27. package/src/components/bottom-sheet/AGENTS.md +232 -0
  28. package/src/components/bottom-sheet/bottom-sheet-background.tsx +35 -0
  29. package/src/components/bottom-sheet/bottom-sheet-close.tsx +60 -0
  30. package/src/components/bottom-sheet/bottom-sheet-container.tsx +245 -0
  31. package/src/components/bottom-sheet/bottom-sheet-content.tsx +66 -0
  32. package/src/components/bottom-sheet/bottom-sheet-description.tsx +29 -0
  33. package/src/components/bottom-sheet/bottom-sheet-footer.tsx +148 -0
  34. package/src/components/bottom-sheet/bottom-sheet-handle.tsx +47 -0
  35. package/src/components/bottom-sheet/bottom-sheet-overlay.tsx +180 -0
  36. package/src/components/bottom-sheet/bottom-sheet-portal.tsx +72 -0
  37. package/src/components/bottom-sheet/bottom-sheet-scroll-view.tsx +95 -0
  38. package/src/components/bottom-sheet/bottom-sheet-title.tsx +24 -0
  39. package/src/components/bottom-sheet/bottom-sheet-trigger.tsx +73 -0
  40. package/src/components/bottom-sheet/bottom-sheet.context.tsx +135 -0
  41. package/src/components/bottom-sheet/bottom-sheet.tsx +125 -0
  42. package/src/components/bottom-sheet/bottom-sheet.variants.test.ts +276 -0
  43. package/src/components/bottom-sheet/bottom-sheet.variants.ts +199 -0
  44. package/src/components/bottom-sheet/index.ts +40 -0
  45. package/src/components/bottom-sheet/use-bottom-sheet-input.ts +125 -0
  46. package/src/components/button/AGENTS.md +217 -0
  47. package/src/components/button/button-end-content.tsx +15 -0
  48. package/src/components/button/button-group-separator.tsx +37 -0
  49. package/src/components/button/button-group-text.tsx +101 -0
  50. package/src/components/button/button-group.tsx +158 -0
  51. package/src/components/button/button-label.tsx +24 -0
  52. package/src/components/button/button-start-content.tsx +15 -0
  53. package/src/components/button/button.context.tsx +187 -0
  54. package/src/components/button/button.tsx +273 -0
  55. package/src/components/button/button.types.ts +10 -0
  56. package/src/components/button/button.variants.test.ts +811 -0
  57. package/src/components/button/button.variants.ts +480 -0
  58. package/src/components/button/index.ts +50 -0
  59. package/src/components/chart/AGENTS.md +210 -0
  60. package/src/components/chart/chart-area.tsx +93 -0
  61. package/src/components/chart/chart-bar.tsx +103 -0
  62. package/src/components/chart/chart-bars.tsx +75 -0
  63. package/src/components/chart/chart-candlestick.tsx +60 -0
  64. package/src/components/chart/chart-grid.tsx +33 -0
  65. package/src/components/chart/chart-legend.tsx +41 -0
  66. package/src/components/chart/chart-line.tsx +50 -0
  67. package/src/components/chart/chart-scatter.tsx +35 -0
  68. package/src/components/chart/chart-tooltip-dot.tsx +70 -0
  69. package/src/components/chart/chart-tooltip-x.tsx +121 -0
  70. package/src/components/chart/chart-tooltip-y.tsx +55 -0
  71. package/src/components/chart/chart-tooltip.tsx +133 -0
  72. package/src/components/chart/chart-x-axis.tsx +27 -0
  73. package/src/components/chart/chart-y-axis.tsx +21 -0
  74. package/src/components/chart/chart.context.tsx +127 -0
  75. package/src/components/chart/chart.tsx +448 -0
  76. package/src/components/chart/chart.types.ts +108 -0
  77. package/src/components/chart/chart.variants.test.ts +893 -0
  78. package/src/components/chart/chart.variants.ts +726 -0
  79. package/src/components/chart/index.ts +103 -0
  80. package/src/components/chart/pie-chart-center.tsx +40 -0
  81. package/src/components/chart/pie-chart-label.tsx +56 -0
  82. package/src/components/chart/pie-chart-slice.tsx +45 -0
  83. package/src/components/chart/pie-chart-tooltip.tsx +85 -0
  84. package/src/components/chart/pie-chart.context.tsx +77 -0
  85. package/src/components/chart/pie-chart.tsx +247 -0
  86. package/src/components/chart/use-chart-font.ts +25 -0
  87. package/src/components/chart/use-chart-palette.ts +38 -0
  88. package/src/components/checkbox/AGENTS.md +185 -0
  89. package/src/components/checkbox/checkbox-box.tsx +152 -0
  90. package/src/components/checkbox/checkbox-group.tsx +107 -0
  91. package/src/components/checkbox/checkbox-label.tsx +34 -0
  92. package/src/components/checkbox/checkbox.context.tsx +136 -0
  93. package/src/components/checkbox/checkbox.tsx +258 -0
  94. package/src/components/checkbox/checkbox.types.ts +14 -0
  95. package/src/components/checkbox/checkbox.variants.test.ts +634 -0
  96. package/src/components/checkbox/checkbox.variants.ts +484 -0
  97. package/src/components/checkbox/index.ts +42 -0
  98. package/src/components/field/AGENTS.md +100 -0
  99. package/src/components/field/field-content.tsx +24 -0
  100. package/src/components/field/field-description.tsx +29 -0
  101. package/src/components/field/field-error.tsx +61 -0
  102. package/src/components/field/field-group.tsx +22 -0
  103. package/src/components/field/field-label.tsx +33 -0
  104. package/src/components/field/field-legend.tsx +34 -0
  105. package/src/components/field/field-separator.tsx +81 -0
  106. package/src/components/field/field-set.tsx +22 -0
  107. package/src/components/field/field.context.tsx +78 -0
  108. package/src/components/field/field.tsx +144 -0
  109. package/src/components/field/field.types.ts +15 -0
  110. package/src/components/field/field.variants.test.ts +216 -0
  111. package/src/components/field/field.variants.ts +136 -0
  112. package/src/components/field/index.ts +21 -0
  113. package/src/components/icon/AGENTS.md +104 -0
  114. package/src/components/icon/icon.context.tsx +29 -0
  115. package/src/components/icon/icon.tsx +92 -0
  116. package/src/components/icon/icon.variants.test.ts +111 -0
  117. package/src/components/icon/icon.variants.ts +79 -0
  118. package/src/components/icon/index.ts +11 -0
  119. package/src/components/input/AGENTS.md +126 -0
  120. package/src/components/input/index.ts +27 -0
  121. package/src/components/input/input-group-decorator.tsx +84 -0
  122. package/src/components/input/input-group-prefix.tsx +16 -0
  123. package/src/components/input/input-group-suffix.tsx +15 -0
  124. package/src/components/input/input-group.tsx +140 -0
  125. package/src/components/input/input.context.tsx +87 -0
  126. package/src/components/input/input.tsx +184 -0
  127. package/src/components/input/input.types.ts +10 -0
  128. package/src/components/input/input.variants.test.ts +369 -0
  129. package/src/components/input/input.variants.ts +329 -0
  130. package/src/components/list-group/AGENTS.md +67 -0
  131. package/src/components/list-group/index.ts +20 -0
  132. package/src/components/list-group/list-group-item-content.tsx +10 -0
  133. package/src/components/list-group/list-group-item-description.tsx +12 -0
  134. package/src/components/list-group/list-group-item-prefix.tsx +30 -0
  135. package/src/components/list-group/list-group-item-suffix.tsx +55 -0
  136. package/src/components/list-group/list-group-item-title.tsx +17 -0
  137. package/src/components/list-group/list-group-item.tsx +87 -0
  138. package/src/components/list-group/list-group.context.tsx +65 -0
  139. package/src/components/list-group/list-group.tsx +116 -0
  140. package/src/components/list-group/list-group.types.ts +14 -0
  141. package/src/components/list-group/list-group.variants.test.ts +223 -0
  142. package/src/components/list-group/list-group.variants.ts +107 -0
  143. package/src/components/pressable/AGENTS.md +64 -0
  144. package/src/components/pressable/index.ts +9 -0
  145. package/src/components/pressable/pressable.tsx +256 -0
  146. package/src/components/pressable/pressable.variants.test.ts +99 -0
  147. package/src/components/pressable/pressable.variants.ts +63 -0
  148. package/src/components/provider/AGENTS.md +90 -0
  149. package/src/components/provider/index.ts +1 -0
  150. package/src/components/provider/provider.tsx +105 -0
  151. package/src/components/radio/AGENTS.md +247 -0
  152. package/src/components/radio/index.ts +35 -0
  153. package/src/components/radio/radio-group.tsx +126 -0
  154. package/src/components/radio/radio-indicator.tsx +91 -0
  155. package/src/components/radio/radio-label.tsx +39 -0
  156. package/src/components/radio/radio.context.tsx +132 -0
  157. package/src/components/radio/radio.tsx +215 -0
  158. package/src/components/radio/radio.variants.test.ts +581 -0
  159. package/src/components/radio/radio.variants.ts +272 -0
  160. package/src/components/screen/AGENTS.md +287 -0
  161. package/src/components/screen/index.ts +85 -0
  162. package/src/components/screen/screen-chat-list.tsx +485 -0
  163. package/src/components/screen/screen-content.tsx +69 -0
  164. package/src/components/screen/screen-debug.ts +43 -0
  165. package/src/components/screen/screen-error.tsx +61 -0
  166. package/src/components/screen/screen-flat-list.tsx +88 -0
  167. package/src/components/screen/screen-footer-background.tsx +60 -0
  168. package/src/components/screen/screen-footer.tsx +170 -0
  169. package/src/components/screen/screen-header.tsx +36 -0
  170. package/src/components/screen/screen-legend-list.tsx +105 -0
  171. package/src/components/screen/screen-list-component.tsx +23 -0
  172. package/src/components/screen/screen-loading.tsx +50 -0
  173. package/src/components/screen/screen-navbar-back-button.tsx +79 -0
  174. package/src/components/screen/screen-navbar-background.tsx +45 -0
  175. package/src/components/screen/screen-navbar-subtitle.tsx +26 -0
  176. package/src/components/screen/screen-navbar-title.tsx +31 -0
  177. package/src/components/screen/screen-navbar.tsx +154 -0
  178. package/src/components/screen/screen-root.tsx +38 -0
  179. package/src/components/screen/screen-scroll-area.tsx +116 -0
  180. package/src/components/screen/screen-scroll-shadow.tsx +186 -0
  181. package/src/components/screen/screen-section-list.tsx +81 -0
  182. package/src/components/screen/screen-view.tsx +59 -0
  183. package/src/components/screen/screen.context.tsx +167 -0
  184. package/src/components/screen/screen.tsx +114 -0
  185. package/src/components/screen/screen.types.ts +66 -0
  186. package/src/components/screen/screen.variants.test.ts +602 -0
  187. package/src/components/screen/screen.variants.ts +475 -0
  188. package/src/components/screen/use-screen-scroll-insets.ts +218 -0
  189. package/src/components/separator/AGENTS.md +30 -0
  190. package/src/components/separator/index.ts +8 -0
  191. package/src/components/separator/separator.tsx +85 -0
  192. package/src/components/slider/AGENTS.md +274 -0
  193. package/src/components/slider/index.ts +48 -0
  194. package/src/components/slider/slider-fill.tsx +68 -0
  195. package/src/components/slider/slider-output.tsx +55 -0
  196. package/src/components/slider/slider-thumb.tsx +161 -0
  197. package/src/components/slider/slider-track.tsx +233 -0
  198. package/src/components/slider/slider.context.tsx +118 -0
  199. package/src/components/slider/slider.tsx +321 -0
  200. package/src/components/slider/slider.types.ts +26 -0
  201. package/src/components/slider/slider.variants.test.ts +804 -0
  202. package/src/components/slider/slider.variants.ts +607 -0
  203. package/src/components/spinner/AGENTS.md +73 -0
  204. package/src/components/spinner/index.ts +17 -0
  205. package/src/components/spinner/spinner-arc.tsx +90 -0
  206. package/src/components/spinner/spinner-content.tsx +63 -0
  207. package/src/components/spinner/spinner.context.tsx +52 -0
  208. package/src/components/spinner/spinner.tsx +128 -0
  209. package/src/components/spinner/spinner.variants.test.ts +273 -0
  210. package/src/components/spinner/spinner.variants.ts +187 -0
  211. package/src/components/switch/AGENTS.md +213 -0
  212. package/src/components/switch/index.ts +42 -0
  213. package/src/components/switch/switch-content.tsx +111 -0
  214. package/src/components/switch/switch-end-content.tsx +18 -0
  215. package/src/components/switch/switch-start-content.tsx +20 -0
  216. package/src/components/switch/switch-thumb.tsx +102 -0
  217. package/src/components/switch/switch.context.tsx +79 -0
  218. package/src/components/switch/switch.tsx +423 -0
  219. package/src/components/switch/switch.types.ts +14 -0
  220. package/src/components/switch/switch.variants.test.ts +570 -0
  221. package/src/components/switch/switch.variants.ts +511 -0
  222. package/src/components/tabs/AGENTS.md +286 -0
  223. package/src/components/tabs/index.ts +69 -0
  224. package/src/components/tabs/tabs-content.tsx +65 -0
  225. package/src/components/tabs/tabs-indicator.tsx +97 -0
  226. package/src/components/tabs/tabs-label.tsx +62 -0
  227. package/src/components/tabs/tabs-list.tsx +139 -0
  228. package/src/components/tabs/tabs-pager.tsx +59 -0
  229. package/src/components/tabs/tabs-scroll-view.tsx +171 -0
  230. package/src/components/tabs/tabs-separator.tsx +73 -0
  231. package/src/components/tabs/tabs-trigger.tsx +210 -0
  232. package/src/components/tabs/tabs.context.tsx +294 -0
  233. package/src/components/tabs/tabs.tsx +435 -0
  234. package/src/components/tabs/tabs.types.ts +13 -0
  235. package/src/components/tabs/tabs.variants.test.ts +1020 -0
  236. package/src/components/tabs/tabs.variants.ts +672 -0
  237. package/src/components/text/AGENTS.md +95 -0
  238. package/src/components/text/index.ts +25 -0
  239. package/src/components/text/text.context.tsx +60 -0
  240. package/src/components/text/text.tsx +251 -0
  241. package/src/components/text/text.variants.test.ts +422 -0
  242. package/src/components/text/text.variants.ts +282 -0
  243. package/src/display-name.test.ts +145 -0
  244. package/src/docs.test.ts +98 -0
  245. package/src/expo/navigation-theme.tsx +58 -0
  246. package/src/hooks/use-controllable-state.ts +45 -0
  247. package/src/hooks/use-keyboard-state-sync.tsx +147 -0
  248. package/src/hooks/use-navigation-theme.ts +78 -0
  249. package/src/hooks/use-theme-color.ts +44 -0
  250. package/src/icons/central.ts +1 -0
  251. package/src/lib/cn.test.ts +136 -0
  252. package/src/lib/cn.ts +29 -0
  253. package/src/lib/color.test.ts +80 -0
  254. package/src/lib/color.ts +79 -0
  255. package/src/lib/compose-refs.test.ts +64 -0
  256. package/src/lib/compose-refs.ts +37 -0
  257. package/src/lib/keyboard-animation.test.ts +33 -0
  258. package/src/lib/keyboard-animation.ts +28 -0
  259. package/src/lib/merge-props.test.ts +82 -0
  260. package/src/lib/merge-props.ts +47 -0
  261. package/src/lib/navigation-theme.test.ts +49 -0
  262. package/src/lib/navigation-theme.ts +53 -0
  263. package/src/lib/slot.tsx +45 -0
  264. package/src/lib/tv.ts +20 -0
  265. package/src/styles/base.css +2 -0
  266. package/src/styles/geometry.test.ts +117 -0
  267. package/src/styles/index.css +3 -0
  268. package/src/styles/theme-tokens.test.ts +252 -0
  269. package/src/styles/theme.css +447 -0
  270. package/src/styles/tokens.css +132 -0
  271. package/src/styles/tokens.test.ts +181 -0
  272. package/src/styles/tokens.ts +86 -0
  273. package/src/uniwind-env.d.ts +1 -0
@@ -0,0 +1,161 @@
1
+ import { type ComponentProps, type ReactElement, useCallback } from "react";
2
+ import type { AccessibilityActionEvent, LayoutChangeEvent, ViewProps } from "react-native";
3
+ import Animated, { useAnimatedStyle, useDerivedValue, withSpring } from "react-native-reanimated";
4
+ import { useSliderPart } from "./slider.context";
5
+ import {
6
+ formatSliderValue,
7
+ progressOf,
8
+ SLIDER_THUMB_ANIMATION,
9
+ SLIDER_THUMB_SPRING,
10
+ sliderVariants,
11
+ } from "./slider.variants";
12
+
13
+ /** What one press of an assistive increment moves the value by, when `step` is continuous. */
14
+ const CONTINUOUS_ACCESSIBILITY_STEPS = 10;
15
+
16
+ export type SliderThumbProps = Omit<ViewProps, "children" | "style"> & {
17
+ /** Which value this thumb drives. `0` unless the slider holds a range. */
18
+ index?: number;
19
+ className?: string;
20
+ /**
21
+ * Passed to the knob inside the capsule.
22
+ *
23
+ * A prop rather than a `classNames` map, which is the shape `Radio.Indicator`
24
+ * already uses for its dot: one escape hatch, named after the thing it reaches,
25
+ * and it carries every `View` prop rather than only a class.
26
+ */
27
+ knobProps?: Omit<ComponentProps<typeof Animated.View>, "children" | "className" | "style"> & { className?: string };
28
+ };
29
+
30
+ /**
31
+ * The grab handle.
32
+ *
33
+ * **It holds no gesture of its own**, which is the design rather than an
34
+ * omission. One `Gesture.Pan()` on the track drives every thumb: a press 40pt
35
+ * along an empty groove should still lift the thumb it is about to move, and a
36
+ * per-thumb gesture could not know that. It would also nest a descendant handler
37
+ * inside the track's, leaving two recognisers to negotiate for one drag.
38
+ *
39
+ * **One animated style, one node.** The position and the grabbed scale are two
40
+ * entries in a single `transform`, because two `useAnimatedStyle` calls on one
41
+ * view fight for the same prop and the later one silently wins — the rule
42
+ * `Radio.Indicator` states, and the reason `Pressable` cannot be the thing that
43
+ * moves here.
44
+ *
45
+ * The scale is a `useDerivedValue` rather than a branch inside the position
46
+ * style: it depends only on which thumb is being dragged, so deriving it
47
+ * separately means the spring is evaluated when the grab changes rather than on
48
+ * every frame of the drag.
49
+ *
50
+ * **It is invisible until the track has been measured.** Both layouts arrive a
51
+ * commit or two after mount, and a thumb drawn before then sits at a garbage
52
+ * offset for a frame — which reads as a flicker on every mount.
53
+ *
54
+ * **The accessibility surface lives here**, and it is the whole of the control
55
+ * for anyone not using a finger. `adjustable` plus a value is what VoiceOver and
56
+ * TalkBack read; the increment and decrement actions are what their swipe
57
+ * gestures call. Without them a slider with no gesture on its thumb would have no
58
+ * assistive path to its value at all.
59
+ */
60
+ export function SliderThumb({ index = 0, className, knobProps, ...props }: SliderThumbProps): ReactElement {
61
+ const {
62
+ positions,
63
+ trackSize,
64
+ thumbSize,
65
+ activeIndex,
66
+ values,
67
+ minValue,
68
+ maxValue,
69
+ step,
70
+ formatOptions,
71
+ orientation,
72
+ color,
73
+ size,
74
+ isInvalid,
75
+ isDisabled,
76
+ updateValue,
77
+ } = useSliderPart("Slider.Thumb");
78
+ const isVertical = orientation === "vertical";
79
+ const slots = sliderVariants({ color, isDisabled, isInvalid, orientation, size });
80
+
81
+ const handleLayout = useCallback(
82
+ (event: LayoutChangeEvent) => {
83
+ // Every thumb writes the same number — they are one size — so the last
84
+ // one to lay out wins with the value all of them hold.
85
+ thumbSize.value = isVertical ? event.nativeEvent.layout.height : event.nativeEvent.layout.width;
86
+ },
87
+ [isVertical, thumbSize]
88
+ );
89
+
90
+ // The capsule carries the position and the knob carries the squeeze, on two
91
+ // different nodes. Two animated styles on *one* node fight for the same prop
92
+ // and the later one silently wins — the rule `Radio.Indicator` states — and
93
+ // scaling the capsule would push it past the track it sits flush inside.
94
+ const thumbStyle = useAnimatedStyle(() => {
95
+ const measured = trackSize.value;
96
+ const travel = measured - thumbSize.value;
97
+ const progress = progressOf(positions.value[index] ?? minValue, minValue, maxValue);
98
+ const offset = travel <= 0 ? 0 : progress * travel;
99
+
100
+ return {
101
+ opacity: measured > 0 ? 1 : 0,
102
+ // A vertical slider counts up from the bottom, so the handle is anchored
103
+ // there and travels the other way. This sign and `valueFromOffset`'s
104
+ // inversion are the only two places the axis turns around.
105
+ transform: isVertical ? [{ translateY: -offset }] : [{ translateX: offset }],
106
+ };
107
+ });
108
+
109
+ // Derived rather than branched inside the style above: it depends only on which
110
+ // thumb is being dragged, so the spring is evaluated when the grab changes
111
+ // rather than on every frame of the drag.
112
+ const scale = useDerivedValue(() =>
113
+ withSpring(
114
+ activeIndex.value === index ? SLIDER_THUMB_ANIMATION.grabbedScale : SLIDER_THUMB_ANIMATION.restScale,
115
+ SLIDER_THUMB_SPRING
116
+ )
117
+ );
118
+ const knobStyle = useAnimatedStyle(() => ({ transform: [{ scale: scale.value }] }));
119
+
120
+ const value = values[index] ?? minValue;
121
+ const nudge = step > 0 ? step : (maxValue - minValue) / CONTINUOUS_ACCESSIBILITY_STEPS;
122
+
123
+ const handleAccessibilityAction = useCallback(
124
+ (event: AccessibilityActionEvent) => {
125
+ const direction = event.nativeEvent.actionName === "increment" ? 1 : -1;
126
+ updateValue(index, value + direction * nudge);
127
+ },
128
+ [index, nudge, updateValue, value]
129
+ );
130
+
131
+ return (
132
+ <Animated.View
133
+ accessibilityActions={ACCESSIBILITY_ACTIONS}
134
+ accessibilityRole="adjustable"
135
+ accessibilityState={{ disabled: isDisabled }}
136
+ accessibilityValue={{
137
+ max: maxValue,
138
+ min: minValue,
139
+ now: value,
140
+ text: formatSliderValue([value], formatOptions),
141
+ }}
142
+ accessible
143
+ className={slots.thumb({ className })}
144
+ onAccessibilityAction={handleAccessibilityAction}
145
+ onLayout={handleLayout}
146
+ style={thumbStyle}
147
+ {...props}
148
+ >
149
+ <Animated.View {...knobProps} className={slots.knob({ className: knobProps?.className })} style={knobStyle} />
150
+ </Animated.View>
151
+ );
152
+ }
153
+ SliderThumb.displayName = "DelacourUI.Slider.Thumb";
154
+
155
+ /**
156
+ * The two actions an assistive swipe on an `adjustable` maps to.
157
+ *
158
+ * Module scope on purpose: a fresh array each render is a new prop every commit,
159
+ * and React Native sends the whole accessibility config across on any change.
160
+ */
161
+ const ACCESSIBILITY_ACTIONS = [{ name: "increment" }, { name: "decrement" }] as const;
@@ -0,0 +1,233 @@
1
+ import { type ReactElement, useCallback, useMemo } from "react";
2
+ import type { LayoutChangeEvent, ViewProps } from "react-native";
3
+ import { Gesture, GestureDetector } from "react-native-gesture-handler";
4
+ import Animated, { useAnimatedReaction, useSharedValue } from "react-native-reanimated";
5
+ import { scheduleOnRN } from "react-native-worklets";
6
+ import { playHaptic } from "../pressable";
7
+ import { useSliderPart } from "./slider.context";
8
+ import type { SliderRenderChildren } from "./slider.types";
9
+ import {
10
+ clampThumb,
11
+ nearestThumbIndex,
12
+ shouldTickHaptic,
13
+ sliderVariants,
14
+ snapToStep,
15
+ valueFromOffset,
16
+ } from "./slider.variants";
17
+
18
+ export type SliderTrackProps = Omit<ViewProps, "children" | "style"> & {
19
+ /**
20
+ * The fill and the thumbs. A function is called with the slider's settled
21
+ * state, which is how a range maps over its own values.
22
+ */
23
+ children?: SliderRenderChildren;
24
+ className?: string;
25
+ };
26
+
27
+ /**
28
+ * The groove the thumbs run along, and the surface the drag is claimed on.
29
+ *
30
+ * This is where the whole gesture lives. One `Gesture.Pan()` drives every thumb:
31
+ * touching down grabs the nearest one and moves it to the finger, and from there
32
+ * the drag tracks it. One rule rather than a tap mode and a drag mode — the
33
+ * alternative, dragging the handle only, is what iOS's own slider does and it is
34
+ * a poor trade on a wide track, where the thumb is usually nowhere near where you
35
+ * are pointing.
36
+ *
37
+ * **The value is written in `onBegin`, not only in `onUpdate`.** A pan activates
38
+ * on the first *movement*, so a stationary tap never reaches `onStart` or
39
+ * `onUpdate` at all — a slider that only computed there would tick, lift its
40
+ * thumb and then not move it. `onFinalize` is likewise where the drag is reported
41
+ * as finished, because it is the one callback that fires on every path, including
42
+ * the one where the pan never activated.
43
+ *
44
+ * **`minDistance(0)` is what wins the touch from a scroll view.**
45
+ * `Screen.ScrollArea` renders React Native's own `ScrollView`, not Gesture
46
+ * Handler's, so there is no handler to negotiate with — the two race, and a pan
47
+ * that activates on the first move beats a scroll view's ten-point slop on both
48
+ * platforms. It is also why there is no `activeOffsetX` here: waiting for the
49
+ * axis to declare itself would hand the scroll the first move and put a dead zone
50
+ * at the start of every drag. `blocksExternalGesture` is not the answer either —
51
+ * it resolves a ref to a handler tag, a plain `ScrollView` has none, and the call
52
+ * is dropped without an error.
53
+ *
54
+ * **`shouldCancelWhenOutside(false)`** where `Pressable`'s tap sets it `true`:
55
+ * dragging a thumb to the far end routinely leaves the track's bounds, and the
56
+ * value must keep tracking rather than the gesture giving up.
57
+ *
58
+ * The mirror back to React is deliberately thin. `positions` is written on the UI
59
+ * thread at the display's refresh rate; only a *changed* set of snapped values is
60
+ * scheduled across, so a whole-track drag at the default step is a few dozen
61
+ * commits rather than one per frame.
62
+ */
63
+ export function SliderTrack({ children, className, ...props }: SliderTrackProps): ReactElement {
64
+ const {
65
+ positions,
66
+ trackSize,
67
+ thumbSize,
68
+ activeIndex,
69
+ minValue,
70
+ maxValue,
71
+ step,
72
+ haptic,
73
+ orientation,
74
+ color,
75
+ size,
76
+ isInvalid,
77
+ isDisabled,
78
+ commitValues,
79
+ commitEnd,
80
+ setDragging,
81
+ renderProps,
82
+ } = useSliderPart("Slider.Track");
83
+ const isVertical = orientation === "vertical";
84
+
85
+ // What the last haptic reported, and where the finger was when it did. Both
86
+ // feed `shouldTickHaptic`, which is what keeps a flick across a fine step scale
87
+ // from firing a hundred times.
88
+ const lastTick = useSharedValue(Number.NaN);
89
+ const lastTickPosition = useSharedValue(0);
90
+
91
+ const handleLayout = useCallback(
92
+ (event: LayoutChangeEvent) => {
93
+ trackSize.value = isVertical ? event.nativeEvent.layout.height : event.nativeEvent.layout.width;
94
+ },
95
+ [isVertical, trackSize]
96
+ );
97
+
98
+ const gesture = useMemo(() => {
99
+ // Declared here rather than at module scope, and called only from the two
100
+ // callbacks below. A worklet defined beside its callers in one scope is
101
+ // captured by ordinary closure; a module-scope one binds at import time, in
102
+ // source order, which is how a helper ends up `undefined` on the UI thread.
103
+ const applyTouch = (along: number, forIndex: number, isGrab: boolean) => {
104
+ "worklet";
105
+ const thumb = thumbSize.value;
106
+ const travel = trackSize.value - thumb;
107
+ // The touch is read in the thumb's own frame — its centre, not its left
108
+ // edge — so the handle stays inside the groove at both ends.
109
+ const position = along - thumb / 2;
110
+ const raw = valueFromOffset({ position, travel, minValue, maxValue, isVertical });
111
+ const snapped = snapToStep(raw, step, minValue, maxValue);
112
+ const current = positions.value;
113
+ const next = clampThumb(snapped, current, forIndex, minValue, maxValue);
114
+
115
+ if (next !== current[forIndex]) {
116
+ const updated = [...current];
117
+ updated[forIndex] = next;
118
+ positions.value = updated;
119
+ }
120
+
121
+ // The grab itself always confirms, the way a press does. After that the
122
+ // ticks are the step crossings, rate-limited by distance travelled.
123
+ if (isGrab) {
124
+ lastTick.value = next;
125
+ lastTickPosition.value = position;
126
+ if (haptic !== false) playHaptic(haptic);
127
+ return;
128
+ }
129
+
130
+ if (
131
+ haptic !== false &&
132
+ shouldTickHaptic({
133
+ lastPosition: lastTickPosition.value,
134
+ lastSnapped: lastTick.value,
135
+ maxValue,
136
+ minValue,
137
+ position,
138
+ snapped: next,
139
+ step,
140
+ })
141
+ ) {
142
+ playHaptic(haptic);
143
+ lastTick.value = next;
144
+ lastTickPosition.value = position;
145
+ }
146
+ };
147
+
148
+ return Gesture.Pan()
149
+ .enabled(!isDisabled)
150
+ .minDistance(0)
151
+ .shouldCancelWhenOutside(false)
152
+ .onBegin((event) => {
153
+ "worklet";
154
+ const along = isVertical ? event.y : event.x;
155
+ const thumb = thumbSize.value;
156
+ const raw = valueFromOffset({
157
+ isVertical,
158
+ maxValue,
159
+ minValue,
160
+ position: along - thumb / 2,
161
+ travel: trackSize.value - thumb,
162
+ });
163
+ const index = nearestThumbIndex(positions.value, raw);
164
+ activeIndex.value = index;
165
+ scheduleOnRN(setDragging, true);
166
+ applyTouch(along, index, true);
167
+ })
168
+ .onUpdate((event) => {
169
+ "worklet";
170
+ const index = activeIndex.value;
171
+ if (index < 0) return;
172
+ applyTouch(isVertical ? event.y : event.x, index, false);
173
+ })
174
+ .onFinalize(() => {
175
+ "worklet";
176
+ activeIndex.value = -1;
177
+ // Order matters: the root must have stopped treating this as a live
178
+ // drag before `commitEnd` asks it to re-sync. `scheduleOnRN` keeps
179
+ // them in the order they were queued.
180
+ scheduleOnRN(setDragging, false);
181
+ scheduleOnRN(commitEnd, [...positions.value]);
182
+ });
183
+ }, [
184
+ activeIndex,
185
+ commitEnd,
186
+ haptic,
187
+ isDisabled,
188
+ isVertical,
189
+ lastTick,
190
+ lastTickPosition,
191
+ maxValue,
192
+ minValue,
193
+ positions,
194
+ setDragging,
195
+ step,
196
+ thumbSize,
197
+ trackSize,
198
+ ]);
199
+
200
+ // The one hop from the UI thread back to React, and it is gated element-wise:
201
+ // `useAnimatedReaction` re-runs on every write, and `positions` is a fresh
202
+ // array each time, so identity says nothing about whether the value moved.
203
+ useAnimatedReaction(
204
+ () => positions.value,
205
+ (current, previous) => {
206
+ if (previous !== null && current.length === previous.length) {
207
+ let changed = false;
208
+ for (let index = 0; index < current.length; index++) {
209
+ if (current[index] !== previous[index]) {
210
+ changed = true;
211
+ break;
212
+ }
213
+ }
214
+ if (!changed) return;
215
+ }
216
+ scheduleOnRN(commitValues, [...current]);
217
+ },
218
+ [commitValues]
219
+ );
220
+
221
+ const slots = sliderVariants({ color, isDisabled, isInvalid, orientation, size });
222
+
223
+ return (
224
+ <GestureDetector gesture={gesture}>
225
+ <Animated.View className={slots.touchArea()} {...props}>
226
+ <Animated.View className={slots.track({ className })} onLayout={handleLayout}>
227
+ {typeof children === "function" ? children(renderProps) : children}
228
+ </Animated.View>
229
+ </Animated.View>
230
+ </GestureDetector>
231
+ );
232
+ }
233
+ SliderTrack.displayName = "DelacourUI.Slider.Track";
@@ -0,0 +1,118 @@
1
+ import { createContext, type ReactElement, type ReactNode, use } from "react";
2
+ import type { SharedValue } from "react-native-reanimated";
3
+ import type { HapticFeedback } from "../pressable";
4
+ import type { SliderRenderProps } from "./slider.types";
5
+ import type { SliderColor, SliderOrientation, SliderSize } from "./slider.variants";
6
+
7
+ export type SliderContextValue = {
8
+ /**
9
+ * Every thumb's value, snapped, on the **JS thread**.
10
+ *
11
+ * What `Slider.Output` and the accessibility props read. The live position a
12
+ * finger is dragging lives in {@link SliderContextValue.positions} instead —
13
+ * these two agree at rest and lag by a mirror hop during a drag, which is the
14
+ * whole reason the drag never waits on React.
15
+ */
16
+ values: number[];
17
+ minValue: number;
18
+ maxValue: number;
19
+ /** The increment the values snap to. `0` is continuous. */
20
+ step: number;
21
+ orientation: SliderOrientation;
22
+ color: SliderColor;
23
+ size: SliderSize;
24
+ isDisabled: boolean;
25
+ isInvalid: boolean;
26
+ /** Passed to `Intl.NumberFormat` by `Slider.Output`. */
27
+ formatOptions?: Intl.NumberFormatOptions;
28
+ /** Played as the value crosses a step. `false` silences it. */
29
+ haptic: false | HapticFeedback;
30
+ /**
31
+ * Every thumb's live value, on the **UI thread**.
32
+ *
33
+ * One shared value holding the whole array rather than one per thumb, because
34
+ * a thumb count is data and hooks cannot be called in a loop. Reassign it —
35
+ * `positions.value = next` — and never write an element in place: an array
36
+ * element has no setter behind it, so `positions.value[0] = x` updates nothing
37
+ * and fails silently.
38
+ */
39
+ positions: SharedValue<number[]>;
40
+ /** The track's own length, from its `onLayout`. `0` until it has been measured. */
41
+ trackSize: SharedValue<number>;
42
+ /** A thumb's diameter, from its `onLayout`. `0` until it has been measured. */
43
+ thumbSize: SharedValue<number>;
44
+ /** Which thumb is being dragged, or `-1` when none is. */
45
+ activeIndex: SharedValue<number>;
46
+ /**
47
+ * Writes one thumb's value from the **JS thread**, snapping and clamping it
48
+ * first.
49
+ *
50
+ * The one path into the value that does not go through the pan — an
51
+ * accessibility increment has no gesture behind it.
52
+ */
53
+ updateValue: (index: number, next: number) => void;
54
+ /**
55
+ * Tells the root a drag has started or finished.
56
+ *
57
+ * The root stops syncing the shared value from React state while this is true.
58
+ * Without it the mirror hop back from the UI thread would round-trip through a
59
+ * render and land on the thumb a frame late, dragging it backwards on every
60
+ * commit — a jitter that only shows up on a fast drag.
61
+ */
62
+ setDragging: (dragging: boolean) => void;
63
+ /** Reports a drag's new values. Called from the pan's mirror, never in render. */
64
+ commitValues: (next: number[]) => void;
65
+ /** Reports the values a drag settled on, once, on release. */
66
+ commitEnd: (next: number[]) => void;
67
+ /** The settled state a render function is handed. */
68
+ renderProps: SliderRenderProps;
69
+ };
70
+
71
+ const SliderContext = createContext<SliderContextValue | null>(null);
72
+
73
+ /**
74
+ * Supplies one slider's settled state to its own parts.
75
+ *
76
+ * Lives in its own module, importing nothing but React and types, so a part can
77
+ * read it without importing `./slider`. That import would close a cycle, and Metro
78
+ * serves a partially initialised module for a cycle — leaving the context
79
+ * `undefined` at import time and red-boxing the app on a cold start.
80
+ */
81
+ export function SliderProvider({ value, children }: { value: SliderContextValue; children: ReactNode }): ReactElement {
82
+ return <SliderContext value={value}>{children}</SliderContext>;
83
+ }
84
+ SliderProvider.displayName = "DelacourUI.Slider.Provider";
85
+
86
+ /** The enclosing slider's state, or null outside a `<Slider>`. */
87
+ export function useSliderContext(): SliderContextValue | null {
88
+ return use(SliderContext);
89
+ }
90
+
91
+ /**
92
+ * Reads the enclosing slider's settled state.
93
+ *
94
+ * For a custom readout or a custom thumb that has to match the slider it sits in.
95
+ * Throws outside one — use {@link useSliderContext} where the slider is optional.
96
+ */
97
+ export function useSlider(): SliderContextValue {
98
+ const context = useSliderContext();
99
+ if (!context) {
100
+ throw new Error("useSlider must be called inside a <Slider>.");
101
+ }
102
+ return context;
103
+ }
104
+
105
+ /**
106
+ * The enclosing slider's state, for a compound part that cannot work without one.
107
+ *
108
+ * Internal: deliberately not re-exported from `index.ts`. A caller outside the
109
+ * library wants {@link useSlider}, whose error message names the hook rather than
110
+ * a part.
111
+ */
112
+ export function useSliderPart(component: string): SliderContextValue {
113
+ const context = useSliderContext();
114
+ if (!context) {
115
+ throw new Error(`${component} must be rendered inside a <Slider>.`);
116
+ }
117
+ return context;
118
+ }