@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,423 @@
1
+ import {
2
+ Children,
3
+ isValidElement,
4
+ type ReactElement,
5
+ type ReactNode,
6
+ useCallback,
7
+ useEffect,
8
+ useMemo,
9
+ useRef,
10
+ useState,
11
+ } from "react";
12
+ import type { AccessibilityActionEvent, LayoutChangeEvent, ViewProps } from "react-native";
13
+ import { Gesture, GestureDetector } from "react-native-gesture-handler";
14
+ import Animated, {
15
+ cancelAnimation,
16
+ interpolateColor,
17
+ useAnimatedStyle,
18
+ useSharedValue,
19
+ withSpring,
20
+ } from "react-native-reanimated";
21
+ import { scheduleOnRN } from "react-native-worklets";
22
+ import { useControllableState } from "../../hooks/use-controllable-state";
23
+ import { useThemeColor } from "../../hooks/use-theme-color";
24
+ import { useFieldContext } from "../field/field.context";
25
+ import { type HapticFeedback, playHaptic } from "../pressable";
26
+ import { type SwitchContextValue, SwitchProvider } from "./switch.context";
27
+ import {
28
+ hasThumbChild,
29
+ resolveSwitchAxes,
30
+ resolveSwitchRelease,
31
+ resolveSwitchTrackTokens,
32
+ SWITCH_PRESS_ANIMATION,
33
+ SWITCH_PRESS_SPRING,
34
+ SWITCH_THUMB_INSET,
35
+ SWITCH_THUMB_SPRING,
36
+ type SwitchColor,
37
+ type SwitchSize,
38
+ switchTravel,
39
+ switchVariants,
40
+ } from "./switch.variants";
41
+ import { SwitchEndContent } from "./switch-end-content";
42
+ import { SwitchStartContent } from "./switch-start-content";
43
+ import { SwitchThumb } from "./switch-thumb";
44
+
45
+ export type SwitchProps = Omit<ViewProps, "children" | "style"> & {
46
+ /** Controlled state. Pass nothing and the switch holds its own. */
47
+ isSelected?: boolean;
48
+ /** Starting state while uncontrolled. */
49
+ defaultSelected?: boolean;
50
+ onSelectedChange?: (isSelected: boolean) => void;
51
+ /** What a switch that is on means. */
52
+ color?: SwitchColor;
53
+ size?: SwitchSize;
54
+ /** Blocks the gesture and fades the control. Inherited from an enclosing `Field`. */
55
+ isDisabled?: boolean;
56
+ /** Reports an invalid value. Inherited from an enclosing `Field`. */
57
+ isInvalid?: boolean;
58
+ /** Played when a press or a drag actually flips it. `false` silences it. */
59
+ haptic?: false | HapticFeedback;
60
+ className?: string;
61
+ children?: ReactNode;
62
+ };
63
+
64
+ function SwitchRoot({
65
+ isSelected,
66
+ defaultSelected = false,
67
+ onSelectedChange,
68
+ color,
69
+ size,
70
+ isDisabled,
71
+ isInvalid,
72
+ haptic = "selection",
73
+ className,
74
+ children,
75
+ ...props
76
+ }: SwitchProps): ReactElement {
77
+ const field = useFieldContext();
78
+ const axes = resolveSwitchAxes({ field, own: { color, isDisabled, isInvalid, size } });
79
+
80
+ const [selected, setSelected] = useControllableState({
81
+ defaultValue: defaultSelected,
82
+ onChange: onSelectedChange,
83
+ value: isSelected,
84
+ });
85
+
86
+ // Seeded from the current state rather than from zero, so a switch mounted on
87
+ // does not slide its thumb across on its first paint.
88
+ const progress = useSharedValue(selected ? 1 : 0);
89
+ const trackWidth = useSharedValue(0);
90
+ const thumbWidth = useSharedValue(0);
91
+ // Where the thumb was when the finger landed, so a drag is relative to the
92
+ // grab rather than to the leading edge — picking up a half-travelled thumb
93
+ // mid-spring does not snap it to the finger.
94
+ const grabbed = useSharedValue(0);
95
+ // Whether a finger is down, driving the track's press scale. A shared value
96
+ // rather than state: nothing renders differently for it, so a commit on every
97
+ // touch would be pure cost.
98
+ const isPressed = useSharedValue(0);
99
+
100
+ // A ref rather than state: the pan reads it twice a drag and nothing renders
101
+ // differently for it, so a re-render on touch-down would be pure cost.
102
+ const isDragging = useRef(false);
103
+ // Bumped on every release, purely to make the sync effect below run again.
104
+ const [settledDrags, setSettledDrags] = useState(0);
105
+
106
+ // `settledDrags` is not read in the body — being unread is the whole point of
107
+ // it. A controlled parent that *rejects* a dragged value leaves `selected`
108
+ // unchanged, so without a token that moves on every release there is nothing
109
+ // to re-run on, and the thumb stays where the finger let go rather than
110
+ // springing back to the state the parent actually holds.
111
+ // biome-ignore lint/correctness/useExhaustiveDependencies: the extra dependency is the re-run trigger, see above
112
+ useEffect(() => {
113
+ if (isDragging.current) return;
114
+ progress.value = withSpring(selected ? 1 : 0, SWITCH_THUMB_SPRING);
115
+
116
+ // Without this a switch unmounted mid-travel leaves its spring running.
117
+ return () => cancelAnimation(progress);
118
+ }, [progress, selected, settledDrags]);
119
+
120
+ const setDragging = useCallback((dragging: boolean) => {
121
+ isDragging.current = dragging;
122
+ }, []);
123
+
124
+ const selectedRef = useRef(selected);
125
+ selectedRef.current = selected;
126
+
127
+ const commit = useCallback(
128
+ (next: boolean) => {
129
+ setSettledDrags((count) => count + 1);
130
+ if (next !== selectedRef.current) setSelected(next);
131
+ },
132
+ [setSelected]
133
+ );
134
+
135
+ const handleLayout = useCallback(
136
+ (event: LayoutChangeEvent) => {
137
+ trackWidth.value = event.nativeEvent.layout.width;
138
+ },
139
+ [trackWidth]
140
+ );
141
+
142
+ const gesture = useMemo(
143
+ () =>
144
+ Gesture.Pan()
145
+ .enabled(!axes.isDisabled)
146
+ .minDistance(0)
147
+ .shouldCancelWhenOutside(false)
148
+ .onBegin(() => {
149
+ "worklet";
150
+ grabbed.value = progress.value;
151
+ isPressed.value = withSpring(1, SWITCH_PRESS_SPRING);
152
+ scheduleOnRN(setDragging, true);
153
+ })
154
+ .onUpdate((event) => {
155
+ "worklet";
156
+ const travel = switchTravel({
157
+ inset: SWITCH_THUMB_INSET,
158
+ thumbWidth: thumbWidth.value,
159
+ trackWidth: trackWidth.value,
160
+ });
161
+ if (travel <= 0) return;
162
+
163
+ progress.value = Math.min(1, Math.max(0, grabbed.value + event.translationX / travel));
164
+ })
165
+ .onFinalize((event) => {
166
+ "worklet";
167
+ // Whichever axis moved further. A vertical swipe that began on the
168
+ // switch has to count as movement, or every attempt to scroll past
169
+ // the control would read as a tap and toggle it.
170
+ const target = resolveSwitchRelease({
171
+ distance: Math.max(Math.abs(event.translationX), Math.abs(event.translationY)),
172
+ progress: progress.value,
173
+ velocity: event.velocityX,
174
+ wasSelected: selected,
175
+ });
176
+
177
+ progress.value = withSpring(target ? 1 : 0, SWITCH_THUMB_SPRING);
178
+ isPressed.value = withSpring(0, SWITCH_PRESS_SPRING);
179
+ // At the commit, never at the grab. A drag taken half way and
180
+ // released back has changed nothing, and a switch that buzzed for it
181
+ // would be reporting a state change that did not happen.
182
+ if (haptic !== false && target !== selected) playHaptic(haptic);
183
+
184
+ // Order matters: the root must have stopped treating this as a live
185
+ // drag before `commit` asks it to re-sync. `scheduleOnRN` keeps them
186
+ // in the order they were queued.
187
+ scheduleOnRN(setDragging, false);
188
+ scheduleOnRN(commit, target);
189
+ }),
190
+ [axes.isDisabled, commit, grabbed, haptic, isPressed, progress, selected, setDragging, thumbWidth, trackWidth]
191
+ );
192
+
193
+ const toggle = useCallback(() => {
194
+ commit(!selectedRef.current);
195
+ }, [commit]);
196
+
197
+ // Offered to an enclosing `Field` so tapping the label or the description
198
+ // beside the switch flips it. A switch in a form is a small pill next to a
199
+ // sentence, and the sentence is what people aim at.
200
+ //
201
+ // Registered through a ref-backed trampoline rather than `toggle` itself, the
202
+ // way `Checkbox` does it: re-registering whenever the state changed would
203
+ // re-render the whole field for nothing.
204
+ const toggleRef = useRef(toggle);
205
+ toggleRef.current = toggle;
206
+ const registerPress = field?.registerPress;
207
+ const stableToggle = useCallback(() => toggleRef.current(), []);
208
+
209
+ useEffect(() => {
210
+ if (!registerPress) return;
211
+ registerPress(stableToggle);
212
+ return () => registerPress(null);
213
+ }, [registerPress, stableToggle]);
214
+
215
+ const trackTokens = resolveSwitchTrackTokens({ color: axes.color, isInvalid: axes.isInvalid });
216
+ const restColor = useThemeColor(trackTokens.rest) ?? "transparent";
217
+ const activeColor = useThemeColor(trackTokens.active) ?? "transparent";
218
+
219
+ // One animated style on one node: the colour the switch travels through and
220
+ // the press scale are two entries here rather than two `useAnimatedStyle`
221
+ // calls, because two on one view fight for the same props and the later one
222
+ // silently wins — the rule `Radio.Indicator` states.
223
+ //
224
+ // The colour interpolates off `progress` rather than running a timing of its
225
+ // own against `selected`, so the track colours *with the finger* through a
226
+ // drag instead of snapping when it is let go. A separate timing would also be
227
+ // a second clock the position could drift from.
228
+ const trackStyle = useAnimatedStyle(() => ({
229
+ backgroundColor: interpolateColor(progress.value, [0, 1], [restColor, activeColor]),
230
+ transform: [
231
+ {
232
+ scale:
233
+ SWITCH_PRESS_ANIMATION.restScale -
234
+ isPressed.value * (SWITCH_PRESS_ANIMATION.restScale - SWITCH_PRESS_ANIMATION.pressedScale),
235
+ },
236
+ ],
237
+ }));
238
+
239
+ const context = useMemo<SwitchContextValue>(
240
+ () => ({
241
+ color: axes.color,
242
+ isDisabled: axes.isDisabled,
243
+ isInvalid: axes.isInvalid,
244
+ isSelected: selected,
245
+ progress,
246
+ size: axes.size,
247
+ thumbWidth,
248
+ trackWidth,
249
+ }),
250
+ [axes.color, axes.isDisabled, axes.isInvalid, axes.size, progress, selected, thumbWidth, trackWidth]
251
+ );
252
+
253
+ const content = useMemo(() => withThumb(children), [children]);
254
+ const slots = switchVariants({ isDisabled: axes.isDisabled, size: axes.size });
255
+
256
+ const handleAccessibilityAction = useCallback(
257
+ (event: AccessibilityActionEvent) => {
258
+ if (event.nativeEvent.actionName === "activate") toggle();
259
+ },
260
+ [toggle]
261
+ );
262
+
263
+ return (
264
+ <SwitchProvider value={context}>
265
+ <GestureDetector gesture={gesture}>
266
+ <Animated.View
267
+ accessibilityActions={ACCESSIBILITY_ACTIONS}
268
+ accessibilityRole="switch"
269
+ accessibilityState={{ checked: selected, disabled: axes.isDisabled }}
270
+ // Without this the view is not an accessibility element on iOS, and
271
+ // the role and the state above never reach VoiceOver. It also merges
272
+ // the parts into one element, which is what a control should be.
273
+ accessible
274
+ className={slots.touchArea({ className })}
275
+ onAccessibilityAction={handleAccessibilityAction}
276
+ onAccessibilityTap={toggle}
277
+ {...props}
278
+ >
279
+ <Animated.View className={slots.track()} onLayout={handleLayout} style={trackStyle}>
280
+ {content}
281
+ </Animated.View>
282
+ </Animated.View>
283
+ </GestureDetector>
284
+ </SwitchProvider>
285
+ );
286
+ }
287
+
288
+ /**
289
+ * The one action an assistive double-tap on a `switch` maps to.
290
+ *
291
+ * Module scope on purpose: a fresh array each render is a new prop every commit,
292
+ * and React Native sends the whole accessibility config across on any change.
293
+ *
294
+ * It exists at all because the root is not a `Pressable` — there is no touch
295
+ * responder for TalkBack's activation to land on, so without this the switch
296
+ * would announce its state and offer no way to change it. `onAccessibilityTap`
297
+ * is the iOS half of the same gap.
298
+ */
299
+ const ACCESSIBILITY_ACTIONS = [{ name: "activate" }] as const;
300
+
301
+ /**
302
+ * Composes a `Switch.Thumb` in, and puts it last however it was written.
303
+ *
304
+ * Composed in when the children hold none, so `<Switch />` on its own is already
305
+ * a complete control — `Radio`'s rule for its indicator, and the same reasoning:
306
+ * a switch has exactly one thumb, so a caller who wrote none wants the default
307
+ * rather than an empty capsule.
308
+ *
309
+ * **Moved to the end when they did write one.** Every part here is absolutely
310
+ * positioned, and React Native paints later siblings on top — so a thumb written
311
+ * first, which is the order the anatomy reads best in, would slide *under* the
312
+ * content layers. Reordering here rather than asking the caller to is the
313
+ * difference between an anatomy that documents itself and one that has a gotcha.
314
+ *
315
+ * {@link hasThumbChild} is the decision and is pure, so `bun test` reaches it.
316
+ *
317
+ * Lives here rather than with the parts: it is the root that arranges its own
318
+ * children, and importing it from a part would close a cycle. See AGENTS.md.
319
+ */
320
+ function withThumb(children: ReactNode): ReactNode {
321
+ const items = Children.toArray(children);
322
+ const isThumb = items.map(isSwitchThumbElement);
323
+
324
+ if (!hasThumbChild(isThumb)) return [...items, <SwitchThumb key="thumb" />];
325
+
326
+ return [...items.filter((_, index) => !isThumb[index]), ...items.filter((_, index) => isThumb[index])];
327
+ }
328
+
329
+ /**
330
+ * Whether a child is a `Switch.Thumb`.
331
+ *
332
+ * **Reference equality is asked first and is not trusted alone.** A consuming
333
+ * app compiles this package's source itself — the package ships raw `.tsx` — so
334
+ * the function this module holds and the one behind a caller's `<Switch.Thumb>`
335
+ * are only the same object while nothing has rewritten the binding in between.
336
+ * React Compiler does exactly that, and Metro can serve two instances of one
337
+ * module through a workspace symlink; either leaves an element whose `type` is a
338
+ * different object standing for the same component.
339
+ *
340
+ * The failure is silent and specific: detection returns false, a second thumb is
341
+ * composed in on top of the caller's, and because the two are the same size and
342
+ * colour the only visible symptom is that anything *inside* the caller's knob
343
+ * disappears under the default one. It cost an afternoon to find, which is why
344
+ * the check no longer rests on identity.
345
+ *
346
+ * `displayName` is what it rests on instead. Rule 12 requires every component in
347
+ * this package to carry one, `display-name.test.ts` enforces that they are
348
+ * present and unique, and it survives every rewrite above because it is a
349
+ * property assigned to the function rather than the function itself. Read off
350
+ * the element's own `type`, which is a function today and would be an object if
351
+ * this part were ever wrapped in `memo`.
352
+ */
353
+ function isSwitchThumbElement(child: ReactNode): boolean {
354
+ if (!isValidElement(child)) return false;
355
+ if (child.type === SwitchThumb) return true;
356
+
357
+ const type = child.type as { displayName?: string } | null;
358
+ return type?.displayName !== undefined && type.displayName === SwitchThumb.displayName;
359
+ }
360
+
361
+ /**
362
+ * A binary preference, flipped by a tap or by dragging its thumb.
363
+ *
364
+ * The whole pill is the control and one `Gesture.Pan()` drives it: a tap toggles,
365
+ * a drag takes the thumb with the finger, and a release settles by position or by
366
+ * a flick's velocity. There is no separate tap mode — a release that barely moved
367
+ * *is* the tap, which is why the two never race.
368
+ *
369
+ * A `Switch.Thumb` is composed in when the children hold none, so `<Switch />` is
370
+ * already complete. Anything else composed inside lands *behind* the thumb at one
371
+ * end of the track: `Switch.StartContent` is revealed as the switch turns on and
372
+ * `Switch.EndContent` as it turns off, each fading with the thumb's own travel, so
373
+ * both are written once with no conditionals. The thumb is drawn last however the
374
+ * children were ordered.
375
+ *
376
+ * **State works either way from one hook**: pass `isSelected` to control it, or
377
+ * nothing and let it hold its own.
378
+ *
379
+ * `isInvalid` and `isDisabled` cascade in from an enclosing `Field`, so
380
+ * `<Field isDisabled>` dims it with nothing said here — and an explicit prop still
381
+ * wins, in either direction. Inside a `Field` the switch also hands its toggle
382
+ * back up, so tapping a `Field.Label` or the description under it flips it.
383
+ *
384
+ * There is no `Switch.Label`: the track is a fixed pill and a label cannot sit
385
+ * inside it, so the name is a `Field.Label` or a `ListGroup.ItemTitle` a row away.
386
+ * A switch with no label anywhere near it needs an `accessibilityLabel`, the same
387
+ * rule an icon-sized `Button` follows.
388
+ *
389
+ * A haptic fires when a gesture actually flips it — never on the grab, since a
390
+ * drag taken half way and released back has changed nothing. `haptic={false}`
391
+ * silences it.
392
+ *
393
+ * @example
394
+ * <Switch isSelected={isOn} onSelectedChange={setOn} />
395
+ *
396
+ * @example
397
+ * <Field orientation="horizontal">
398
+ * <Field.Content>
399
+ * <Field.Label>Notifications</Field.Label>
400
+ * <Field.Description>Push alerts for new messages.</Field.Description>
401
+ * </Field.Content>
402
+ * <Switch color="success" isSelected={alerts} onSelectedChange={setAlerts} />
403
+ * </Field>
404
+ *
405
+ * @example
406
+ * <Switch defaultSelected size="lg">
407
+ * <Switch.StartContent>
408
+ * <Icon icon={IconCheckmark1Small} />
409
+ * </Switch.StartContent>
410
+ * <Switch.EndContent>
411
+ * <Icon icon={IconXmarkSmall} />
412
+ * </Switch.EndContent>
413
+ * </Switch>
414
+ */
415
+ export const Switch = Object.assign(SwitchRoot, {
416
+ /** The knob. Composed in automatically; write it out to restyle it or fill it. */
417
+ Thumb: SwitchThumb,
418
+ /** Behind the thumb at the leading edge — revealed as the switch turns on. */
419
+ StartContent: SwitchStartContent,
420
+ /** Behind the thumb at the trailing edge — revealed as the switch turns off. */
421
+ EndContent: SwitchEndContent,
422
+ displayName: "DelacourUI.Switch",
423
+ });
@@ -0,0 +1,14 @@
1
+ import type { ReactNode } from "react";
2
+ import type { ViewProps } from "react-native";
3
+
4
+ /**
5
+ * Props shared by `Switch.StartContent` and `Switch.EndContent`.
6
+ *
7
+ * The two are the same box at opposite ends of the track — only which end, and
8
+ * which way their opacity runs, differ — so the type lives here rather than
9
+ * twice. `style` is `Omit`ed because both carry an animated one of their own.
10
+ */
11
+ export type SwitchContentProps = Omit<ViewProps, "children" | "style"> & {
12
+ className?: string;
13
+ children?: ReactNode;
14
+ };