@astryxdesign/core 0.7.6 → 0.7.7-canary.0eb44a2

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 (272) hide show
  1. package/dist/AppShell/AppShell.d.ts.map +1 -1
  2. package/dist/AppShell/AppShell.js +23 -3
  3. package/dist/Avatar/Avatar.d.ts.map +1 -1
  4. package/dist/Avatar/Avatar.js +1 -1
  5. package/dist/BottomSheet/BottomSheetPanel.d.ts.map +1 -1
  6. package/dist/BottomSheet/BottomSheetPanel.js +16 -9
  7. package/dist/Breadcrumbs/BreadcrumbItem.d.ts.map +1 -1
  8. package/dist/Breadcrumbs/BreadcrumbItem.js +2 -1
  9. package/dist/Button/Button.d.ts.map +1 -1
  10. package/dist/Button/Button.js +3 -2
  11. package/dist/Chat/ChatComposerInput.d.ts +9 -0
  12. package/dist/Chat/ChatComposerInput.d.ts.map +1 -1
  13. package/dist/Chat/ChatComposerInput.js +2 -0
  14. package/dist/Chat/ChatMessage.js +2 -2
  15. package/dist/Chat/chatMessage.vars.stylex.d.ts +8 -8
  16. package/dist/Chat/chatMessage.vars.stylex.js +8 -8
  17. package/dist/CommandPalette/CommandPaletteInput.d.ts.map +1 -1
  18. package/dist/CommandPalette/CommandPaletteInput.js +8 -1
  19. package/dist/DateInput/MonthScroller.d.ts.map +1 -1
  20. package/dist/DateInput/MonthScroller.js +72 -2
  21. package/dist/DateInput/Wheel.d.ts +17 -1
  22. package/dist/DateInput/Wheel.d.ts.map +1 -1
  23. package/dist/DateInput/Wheel.js +179 -19
  24. package/dist/DateRangeInput/DateRangeInput.d.ts.map +1 -1
  25. package/dist/DateRangeInput/DateRangeInput.js +63 -49
  26. package/dist/Dialog/Dialog.d.ts.map +1 -1
  27. package/dist/Dialog/Dialog.js +37 -1
  28. package/dist/Drawer/Drawer.d.ts.map +1 -1
  29. package/dist/Drawer/Drawer.js +6 -0
  30. package/dist/Drawer/useDrawerDialogPresence.d.ts.map +1 -1
  31. package/dist/Drawer/useDrawerDialogPresence.js +4 -0
  32. package/dist/DropdownMenu/DropdownMenu.d.ts.map +1 -1
  33. package/dist/DropdownMenu/DropdownMenu.js +20 -4
  34. package/dist/DropdownMenu/useMenuOverflow.d.ts +13 -3
  35. package/dist/DropdownMenu/useMenuOverflow.d.ts.map +1 -1
  36. package/dist/DropdownMenu/useMenuOverflow.js +31 -8
  37. package/dist/Field/PanelSearchInput.d.ts.map +1 -1
  38. package/dist/Field/PanelSearchInput.js +8 -1
  39. package/dist/Field/textEntryTraits.d.ts +6 -6
  40. package/dist/Field/textEntryTraits.js +6 -6
  41. package/dist/Layer/layerClickBoundary.d.ts +7 -1
  42. package/dist/Layer/layerClickBoundary.d.ts.map +1 -1
  43. package/dist/Layer/layerClickBoundary.js +2 -2
  44. package/dist/Layer/usePressPin.d.ts +95 -0
  45. package/dist/Layer/usePressPin.d.ts.map +1 -0
  46. package/dist/Layer/usePressPin.js +365 -0
  47. package/dist/Layer/useTouchTrigger.d.ts +23 -1
  48. package/dist/Layer/useTouchTrigger.d.ts.map +1 -1
  49. package/dist/Layer/useTouchTrigger.js +45 -7
  50. package/dist/Lightbox/Lightbox.d.ts.map +1 -1
  51. package/dist/Lightbox/Lightbox.js +2 -1
  52. package/dist/Link/Link.d.ts.map +1 -1
  53. package/dist/Link/Link.js +1 -0
  54. package/dist/Popover/usePopover.d.ts.map +1 -1
  55. package/dist/Popover/usePopover.js +56 -7
  56. package/dist/PowerSearch/PowerSearch.d.ts.map +1 -1
  57. package/dist/PowerSearch/PowerSearch.js +38 -30
  58. package/dist/ProgressBar/ProgressBar.d.ts +1 -1
  59. package/dist/ProgressBar/ProgressBar.d.ts.map +1 -1
  60. package/dist/ProgressBar/ProgressBar.js +52 -14
  61. package/dist/ProgressBar/ProgressBarTouch.d.ts +29 -0
  62. package/dist/ProgressBar/ProgressBarTouch.d.ts.map +1 -0
  63. package/dist/ProgressBar/ProgressBarTouch.js +91 -0
  64. package/dist/SegmentedControl/SegmentedControlItem.d.ts.map +1 -1
  65. package/dist/SegmentedControl/SegmentedControlItem.js +2 -1
  66. package/dist/Selector/Selector.d.ts.map +1 -1
  67. package/dist/Selector/Selector.js +25 -13
  68. package/dist/Slider/Slider.d.ts.map +1 -1
  69. package/dist/Slider/Slider.js +404 -131
  70. package/dist/Slider/sliderTouchController.d.ts +189 -0
  71. package/dist/Slider/sliderTouchController.d.ts.map +1 -0
  72. package/dist/Slider/sliderTouchController.js +1017 -0
  73. package/dist/Slider/useSliderTouch.d.ts +22 -0
  74. package/dist/Slider/useSliderTouch.d.ts.map +1 -0
  75. package/dist/Slider/useSliderTouch.js +56 -0
  76. package/dist/Switch/Switch.d.ts +1 -1
  77. package/dist/Switch/Switch.d.ts.map +1 -1
  78. package/dist/Switch/Switch.js +200 -46
  79. package/dist/Switch/switchFill.d.ts +31 -0
  80. package/dist/Switch/switchFill.d.ts.map +1 -0
  81. package/dist/Switch/switchFill.js +88 -0
  82. package/dist/Switch/switchTouch.d.ts +81 -0
  83. package/dist/Switch/switchTouch.d.ts.map +1 -0
  84. package/dist/Switch/switchTouch.js +133 -0
  85. package/dist/Switch/useSwitchTouch.d.ts +285 -0
  86. package/dist/Switch/useSwitchTouch.d.ts.map +1 -0
  87. package/dist/Switch/useSwitchTouch.js +998 -0
  88. package/dist/TabList/Tab.d.ts.map +1 -1
  89. package/dist/TabList/Tab.js +4 -3
  90. package/dist/Tokenizer/Tokenizer.d.ts.map +1 -1
  91. package/dist/Tokenizer/Tokenizer.js +12 -5
  92. package/dist/Tokenizer/TokenizerItemRemovableContext.d.ts +3 -0
  93. package/dist/Tokenizer/TokenizerItemRemovableContext.d.ts.map +1 -0
  94. package/dist/Tokenizer/TokenizerItemRemovableContext.js +17 -0
  95. package/dist/Tooltip/Tooltip.d.ts +30 -6
  96. package/dist/Tooltip/Tooltip.d.ts.map +1 -1
  97. package/dist/Tooltip/Tooltip.js +5 -0
  98. package/dist/Tooltip/index.d.ts +1 -1
  99. package/dist/Tooltip/index.d.ts.map +1 -1
  100. package/dist/Tooltip/useTooltip.d.ts +31 -5
  101. package/dist/Tooltip/useTooltip.d.ts.map +1 -1
  102. package/dist/Tooltip/useTooltip.js +59 -18
  103. package/dist/Typeahead/BaseTypeahead.d.ts.map +1 -1
  104. package/dist/Typeahead/BaseTypeahead.js +19 -9
  105. package/dist/Typeahead/Typeahead.d.ts.map +1 -1
  106. package/dist/Typeahead/Typeahead.js +26 -15
  107. package/dist/astryx.css +91 -8
  108. package/dist/gestures/GestureArena.d.ts +21 -2
  109. package/dist/gestures/GestureArena.d.ts.map +1 -1
  110. package/dist/gestures/GestureArena.js +23 -3
  111. package/dist/gestures/GestureHost.d.ts +123 -3
  112. package/dist/gestures/GestureHost.d.ts.map +1 -1
  113. package/dist/gestures/GestureHost.js +797 -15
  114. package/dist/gestures/GestureRecognizer.d.ts +15 -1
  115. package/dist/gestures/GestureRecognizer.d.ts.map +1 -1
  116. package/dist/gestures/GestureRecognizer.js +12 -0
  117. package/dist/gestures/PanGestureRecognizer.d.ts +8 -0
  118. package/dist/gestures/PanGestureRecognizer.d.ts.map +1 -1
  119. package/dist/gestures/PinchGestureRecognizer.d.ts +5 -1
  120. package/dist/gestures/PinchGestureRecognizer.d.ts.map +1 -1
  121. package/dist/gestures/PinchGestureRecognizer.js +10 -0
  122. package/dist/gestures/PressTracker.d.ts +6 -0
  123. package/dist/gestures/PressTracker.d.ts.map +1 -1
  124. package/dist/gestures/PressTracker.js +3 -0
  125. package/dist/gestures/ScreenEdgePanGestureRecognizer.d.ts +3 -3
  126. package/dist/gestures/ScreenEdgePanGestureRecognizer.d.ts.map +1 -1
  127. package/dist/gestures/ScreenEdgePanGestureRecognizer.js +7 -5
  128. package/dist/gestures/nativeScroll.d.ts +16 -2
  129. package/dist/gestures/nativeScroll.d.ts.map +1 -1
  130. package/dist/gestures/nativeScroll.js +39 -8
  131. package/dist/gestures/recognizerInternals.d.ts +4 -2
  132. package/dist/gestures/recognizerInternals.d.ts.map +1 -1
  133. package/dist/gestures/types.d.ts +17 -0
  134. package/dist/gestures/types.d.ts.map +1 -1
  135. package/dist/gestures/types.js +7 -0
  136. package/dist/gestures/usePressTracker.d.ts.map +1 -1
  137. package/dist/gestures/usePressTracker.js +1 -0
  138. package/dist/gestures/wheelSegmenter.d.ts +56 -0
  139. package/dist/gestures/wheelSegmenter.d.ts.map +1 -0
  140. package/dist/gestures/wheelSegmenter.js +122 -0
  141. package/dist/hooks/useAttachment.d.ts +16 -8
  142. package/dist/hooks/useAttachment.d.ts.map +1 -1
  143. package/dist/hooks/useAttachment.js +35 -15
  144. package/dist/hooks/useFocusTrap.d.ts +15 -0
  145. package/dist/hooks/useFocusTrap.d.ts.map +1 -1
  146. package/dist/hooks/useFocusTrap.js +13 -2
  147. package/dist/hooks/usePager.d.ts +261 -0
  148. package/dist/hooks/usePager.d.ts.map +1 -0
  149. package/dist/hooks/usePager.js +1169 -0
  150. package/dist/motion/animator.d.ts.map +1 -1
  151. package/dist/motion/animator.js +140 -19
  152. package/dist/motion/paging.d.ts +69 -0
  153. package/dist/motion/paging.d.ts.map +1 -0
  154. package/dist/motion/paging.js +148 -0
  155. package/dist/motion/uikit/index.d.ts +8 -4
  156. package/dist/motion/uikit/index.d.ts.map +1 -1
  157. package/dist/motion/uikit/index.js +8 -4
  158. package/dist/motion/uikit/paging.d.ts +171 -0
  159. package/dist/motion/uikit/paging.d.ts.map +1 -0
  160. package/dist/motion/uikit/paging.js +192 -0
  161. package/dist/motion/uikit/progressBar.d.ts +79 -0
  162. package/dist/motion/uikit/progressBar.d.ts.map +1 -0
  163. package/dist/motion/uikit/progressBar.js +102 -0
  164. package/dist/motion/uikit/scroll.d.ts +0 -19
  165. package/dist/motion/uikit/scroll.d.ts.map +1 -1
  166. package/dist/motion/uikit/scroll.js +2 -21
  167. package/dist/motion/uikit/slider.d.ts +97 -0
  168. package/dist/motion/uikit/slider.d.ts.map +1 -0
  169. package/dist/motion/uikit/slider.js +111 -0
  170. package/dist/motion/uikit/springs.d.ts +59 -30
  171. package/dist/motion/uikit/springs.d.ts.map +1 -1
  172. package/dist/motion/uikit/springs.js +64 -34
  173. package/dist/motion/uikit/switch.d.ts +166 -0
  174. package/dist/motion/uikit/switch.d.ts.map +1 -0
  175. package/dist/motion/uikit/switch.js +186 -0
  176. package/dist/privateAttributes/pressHoldsDrag.stylex.d.ts +18 -0
  177. package/dist/privateAttributes/pressHoldsDrag.stylex.d.ts.map +1 -0
  178. package/dist/privateAttributes/pressHoldsDrag.stylex.js +28 -0
  179. package/dist/utils/pressFeedback.d.ts.map +1 -1
  180. package/dist/utils/pressFeedback.js +5 -0
  181. package/dist/utils/selectionLock.d.ts +43 -0
  182. package/dist/utils/selectionLock.d.ts.map +1 -1
  183. package/dist/utils/selectionLock.js +394 -6
  184. package/package.json +5 -5
  185. package/src/AppShell/AppShell.doc.mjs +10 -0
  186. package/src/AppShell/AppShell.tsx +48 -2
  187. package/src/Avatar/Avatar.tsx +3 -1
  188. package/src/BottomSheet/BottomSheetPanel.tsx +16 -9
  189. package/src/Breadcrumbs/BreadcrumbItem.tsx +4 -1
  190. package/src/Button/Button.tsx +3 -2
  191. package/src/Chat/Chat.doc.mjs +1 -0
  192. package/src/Chat/ChatComposerInput.doc.mjs +11 -0
  193. package/src/Chat/ChatComposerInput.tsx +11 -0
  194. package/src/Chat/ChatMessage.tsx +2 -2
  195. package/src/Chat/chatMessage.vars.stylex.ts +8 -8
  196. package/src/CommandPalette/CommandPaletteInput.tsx +6 -0
  197. package/src/DateInput/MonthScroller.tsx +63 -5
  198. package/src/DateInput/Wheel.tsx +174 -14
  199. package/src/DateRangeInput/DateRangeInput.doc.mjs +3 -2
  200. package/src/DateRangeInput/DateRangeInput.tsx +117 -71
  201. package/src/Dialog/Dialog.tsx +37 -1
  202. package/src/Drawer/Drawer.tsx +6 -0
  203. package/src/Drawer/useDrawerDialogPresence.ts +4 -0
  204. package/src/DropdownMenu/DropdownMenu.tsx +28 -6
  205. package/src/DropdownMenu/useMenuOverflow.ts +44 -8
  206. package/src/Field/PanelSearchInput.tsx +6 -0
  207. package/src/Field/textEntryTraits.ts +6 -6
  208. package/src/Layer/layerClickBoundary.ts +2 -2
  209. package/src/Layer/usePressPin.ts +516 -0
  210. package/src/Layer/useTouchTrigger.ts +56 -6
  211. package/src/Lightbox/Lightbox.tsx +2 -1
  212. package/src/Link/Link.tsx +3 -0
  213. package/src/Popover/usePopover.tsx +71 -9
  214. package/src/PowerSearch/PowerSearch.tsx +38 -31
  215. package/src/ProgressBar/ProgressBar.doc.mjs +7 -5
  216. package/src/ProgressBar/ProgressBar.tsx +147 -16
  217. package/src/ProgressBar/ProgressBarTouch.ts +109 -0
  218. package/src/SegmentedControl/SegmentedControlItem.tsx +2 -1
  219. package/src/Selector/Selector.tsx +29 -2
  220. package/src/Slider/Slider.doc.mjs +11 -7
  221. package/src/Slider/Slider.tsx +539 -132
  222. package/src/Slider/sliderTouchController.ts +1209 -0
  223. package/src/Slider/useSliderTouch.ts +78 -0
  224. package/src/Switch/Switch.doc.mjs +3 -3
  225. package/src/Switch/Switch.tsx +412 -86
  226. package/src/Switch/switchFill.ts +106 -0
  227. package/src/Switch/switchTouch.ts +194 -0
  228. package/src/Switch/useSwitchTouch.ts +1121 -0
  229. package/src/TabList/Tab.tsx +5 -3
  230. package/src/Tokenizer/Tokenizer.tsx +15 -5
  231. package/src/Tokenizer/TokenizerItemRemovableContext.ts +22 -0
  232. package/src/Tooltip/Tooltip.doc.mjs +21 -6
  233. package/src/Tooltip/Tooltip.tsx +42 -5
  234. package/src/Tooltip/index.ts +1 -0
  235. package/src/Tooltip/useTooltip.doc.mjs +4 -2
  236. package/src/Tooltip/useTooltip.tsx +84 -21
  237. package/src/Typeahead/BaseTypeahead.tsx +16 -8
  238. package/src/Typeahead/Typeahead.tsx +29 -15
  239. package/src/gestures/GestureArena.ts +45 -4
  240. package/src/gestures/GestureHost.ts +958 -15
  241. package/src/gestures/GestureRecognizer.ts +28 -0
  242. package/src/gestures/PanGestureRecognizer.ts +8 -0
  243. package/src/gestures/PinchGestureRecognizer.ts +16 -1
  244. package/src/gestures/PressTracker.ts +10 -0
  245. package/src/gestures/README.md +82 -3
  246. package/src/gestures/ScreenEdgePanGestureRecognizer.ts +7 -5
  247. package/src/gestures/nativeScroll.ts +49 -9
  248. package/src/gestures/recognizerInternals.ts +9 -2
  249. package/src/gestures/types.ts +18 -0
  250. package/src/gestures/usePressTracker.ts +2 -0
  251. package/src/gestures/wheelSegmenter.ts +143 -0
  252. package/src/hooks/useAttachment.ts +56 -14
  253. package/src/hooks/useFocusTrap.ts +26 -2
  254. package/src/hooks/usePager.ts +1446 -0
  255. package/src/motion/MEASUREMENTS.md +36 -27
  256. package/src/motion/README.md +7 -3
  257. package/src/motion/animator.ts +193 -25
  258. package/src/motion/paging.ts +161 -0
  259. package/src/motion/uikit/index.ts +7 -4
  260. package/src/motion/uikit/paging.ts +193 -0
  261. package/src/motion/uikit/progressBar.ts +102 -0
  262. package/src/motion/uikit/scroll.ts +2 -18
  263. package/src/motion/uikit/slider.ts +105 -0
  264. package/src/motion/uikit/springs.ts +53 -32
  265. package/src/motion/uikit/switch.ts +174 -0
  266. package/src/privateAttributes/pressHoldsDrag.stylex.ts +28 -0
  267. package/src/utils/pressFeedback.ts +5 -0
  268. package/src/utils/selectionLock.ts +501 -6
  269. package/dist/motion/selectPage.d.ts +0 -26
  270. package/dist/motion/selectPage.d.ts.map +0 -1
  271. package/dist/motion/selectPage.js +0 -55
  272. package/src/motion/selectPage.ts +0 -55
@@ -0,0 +1,1121 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ 'use client';
4
+
5
+ /**
6
+ * @file useSwitchTouch.ts
7
+ * @input Switch's parts (gesture surface, input, thumb and track), its value,
8
+ * whether it is interactive, the track's other state (switchFill.ts) and
9
+ * the thumb geometry (switchTouch.ts)
10
+ * @output Exports useSwitchTouch: Switch's gestures for a mouse, a pen and a
11
+ * finger, and its touch presentation under a coarse primary pointer
12
+ * @position DOM half of Switch's touch presentation, on the gesture system
13
+ * (../gestures) and the compositor animator (../motion/animator)
14
+ *
15
+ * SYNC: When modified, update switchTouch.ts, Switch.tsx
16
+ * and __tests__/Switch.interaction.browser.spec.ts.
17
+ */
18
+
19
+ import {useMemo, useState, type RefObject} from 'react';
20
+ import {getGestureHost} from '../gestures/GestureHost';
21
+ import {createPressTracker, type ControlEvent} from '../gestures/PressTracker';
22
+ import type {GestureTouch} from '../gestures/types';
23
+ import {isRtlElement} from '../hooks/isRtlElement';
24
+ import {useIsomorphicLayoutEffect} from '../hooks/useIsomorphicLayoutEffect';
25
+ import {
26
+ createAnimator,
27
+ type Animator,
28
+ type MotionCurve,
29
+ } from '../motion/animator';
30
+ import {solveSpring, toSpring, type SpringDescription} from '../motion/spring';
31
+ import {UIKIT_GESTURES} from '../motion/uikit/gestures';
32
+ import {registerSwitchTable, switchControl} from '../motion/uikit/switch';
33
+ import {useTouchPresentation} from '../utils/touchPresentation';
34
+ import {armClickGuard, withoutClickGuard} from '../utils/clickGuard';
35
+ import {
36
+ BROWSER_CLICK_WAIT_MS,
37
+ commitOnRelease,
38
+ dragTarget,
39
+ settleTime,
40
+ startFlip,
41
+ stepFlip,
42
+ switchTouchGeometry,
43
+ type SwitchFlip,
44
+ type SwitchTouchGeometry,
45
+ } from './switchTouch';
46
+ import {
47
+ readFillOf,
48
+ sameTrackState,
49
+ trackStateOf,
50
+ type TrackState,
51
+ } from './switchFill';
52
+
53
+ export interface SwitchTouchParts {
54
+ /** Receives the touches: the input and the track. */
55
+ readonly surface: RefObject<HTMLElement | null>;
56
+ readonly input: RefObject<HTMLInputElement | null>;
57
+ /** Moves: the themed thumb itself. */
58
+ readonly thumb: RefObject<HTMLElement | null>;
59
+ /**
60
+ * The track: its `::after` is the fade layer over its own fill, showing
61
+ * the fill it is leaving or heading to (switchFill.ts).
62
+ */
63
+ readonly track: RefObject<HTMLElement | null>;
64
+ }
65
+
66
+ export interface SwitchTouchOptions {
67
+ readonly isOn: boolean;
68
+ /** False while disabled or busy: no touch is tracked. */
69
+ readonly isInteractive: boolean;
70
+ /**
71
+ * Disabled: the touch presentation is off and attaches nothing. A
72
+ * busy switch stays attached, so a change that starts a busy state still
73
+ * plays its release motion.
74
+ */
75
+ readonly isDisabled: boolean;
76
+ /** The track's box, px (width × height): the thumb geometry's scale. */
77
+ readonly trackSize: readonly [number, number];
78
+ /**
79
+ * The props the track renders with in its other state (its theme target
80
+ * and fill), for the fade layer's colour.
81
+ */
82
+ readonly otherTrackProps: object;
83
+ }
84
+
85
+ /** The fade layer's colour, set on the track for its ::after (Switch.tsx). */
86
+ const FADE_FILL = '--_switch-fade-fill';
87
+ /** The track's own fill while a change is held (inline, over its CSS). */
88
+ const BASE_FILL = 'background-color';
89
+ const TRAVEL = switchControl.travel;
90
+ const TRACKING = switchControl.tracking;
91
+ /**
92
+ * Horizontal travel at which the press begins if it has not (a pan
93
+ * begins here), px.
94
+ */
95
+ const PAN_HYSTERESIS = UIKIT_GESTURES.pan.hysteresis.value;
96
+
97
+ function now(): number {
98
+ return typeof performance === 'undefined' ? Date.now() : performance.now();
99
+ }
100
+
101
+ /**
102
+ * The lift spring until `holdSeconds`, then the settle spring from where the
103
+ * lift got to. It is one curve, so the settle starts on the compositor's
104
+ * clock.
105
+ */
106
+ function liftThenSettle({
107
+ from,
108
+ velocity,
109
+ lifted,
110
+ rest,
111
+ holdSeconds,
112
+ threshold,
113
+ lift,
114
+ settle,
115
+ }: {
116
+ from: number;
117
+ velocity: number;
118
+ lifted: number;
119
+ rest: number;
120
+ holdSeconds: number;
121
+ threshold: number;
122
+ /** The lift and settle springs as the theme sets them. */
123
+ lift: SpringDescription;
124
+ settle: SpringDescription;
125
+ }): MotionCurve {
126
+ const hold = Math.max(0, holdSeconds);
127
+ const first = solveSpring(toSpring(lift), {from, to: lifted, velocity});
128
+ const second = solveSpring(toSpring(settle), {
129
+ from: first.value(hold),
130
+ to: rest,
131
+ velocity: first.velocity(hold),
132
+ });
133
+ return {
134
+ duration: hold + second.settleTime(threshold),
135
+ at: t => (t < hold ? first.value(t) : second.value(t - hold)),
136
+ velocity: t => (t < hold ? first.velocity(t) : second.velocity(t - hold)),
137
+ };
138
+ }
139
+
140
+ /**
141
+ * The switch's presentation: the touch presentation under a coarse primary
142
+ * pointer, or its own under a fine one.
143
+ */
144
+ type SwitchPresentation = 'touch' | 'pointer';
145
+
146
+ interface Elements {
147
+ surface: HTMLElement;
148
+ input: HTMLInputElement;
149
+ thumb: HTMLElement;
150
+ track: HTMLElement;
151
+ }
152
+
153
+ interface Drag {
154
+ /** The thumb's offset when the finger landed. */
155
+ readonly start: number;
156
+ flip: SwitchFlip;
157
+ }
158
+
159
+ /**
160
+ * The touch presentation of one switch: animators, recognizers and the
161
+ * gesture state. No React state: a gesture never renders. UISwitch on iOS
162
+ * 26.2 is the reference; every number is in motion/uikit/switch.ts.
163
+ *
164
+ * - The thumb's resting place is CSS (its inline-start inset). So the steady
165
+ * state needs no script and mirrors under RTL by itself. The animator only
166
+ * moves the thumb by a delta from that place (`translate`). When the value
167
+ * moves the CSS place, the delta is rebased in the same layout effect.
168
+ * Nothing moves on screen, and the velocity is kept.
169
+ * - Touches go to a press tracker, which never blocks a scroll. No
170
+ * recognizer claims the touch. `touch-action: pan-y` (Switch.tsx) keeps
171
+ * horizontal moves for the switch: a drag follows and flips it, and the
172
+ * release commits. A vertical drag is the page's scroll, and the switch
173
+ * yields.
174
+ * - A tap, a hold or a short drag activates through the browser's own click.
175
+ * The browser sends no click for a finger's hold, so the switch clicks its
176
+ * input once the click window passes. A mouse or a pen gets a click when
177
+ * it is released on the input. A drag that flipped the switch gets no
178
+ * click, so the switch clicks its input, and the shared click guard
179
+ * swallows the browser's late click. A finger's press slid off and lifted
180
+ * outside toggles too, as UISwitch's does. Forms, `onChange` and the
181
+ * keyboard stay the native checkbox's.
182
+ * - The drag never renders: each move retargets the thumb's tracking spring
183
+ * and steps the flip rule on the finger's travel.
184
+ * - The thumb lifts while a finger presses, into half of the gap around it,
185
+ * and its shadow fades in. At the release it settles back, no sooner than
186
+ * the minimum lift time. The lift and the settle are one curve, built at
187
+ * the release, so the settle starts on time on the compositor.
188
+ * - The track keeps its own fill in both states. A fade layer over it,
189
+ * painted with the track's other fill, fades the colour on the compositor.
190
+ * When the value changes, the layer swaps to the other fill and to the
191
+ * complement of its opacity. So nothing changes on screen. A change the
192
+ * fade already heads to swaps nothing: the track holds its previous fill
193
+ * inline until the fade lands. WebKit plays a curve built in a held frame
194
+ * only once the long task ends, so a swap there stepped the colour back.
195
+ * The layer is composited from the start (`will-change`, Switch.tsx), or
196
+ * iOS Safari runs its fade on the main thread.
197
+ * - Under reduced motion nothing travels or springs: a change plays the
198
+ * animator's Reduce Motion cross-fade. A dragging finger still moves the
199
+ * thumb: that is direct manipulation.
200
+ * - Under a fine primary pointer the switch keeps its own look. The same
201
+ * tracking, flip rule and release decide the value, with no animator, and
202
+ * the thumb moves when the value does. Only its press listens at rest. Its
203
+ * click listener comes with a press.
204
+ */
205
+ export class SwitchTouchController {
206
+ private elements: Elements | null = null;
207
+ private geometry: SwitchTouchGeometry;
208
+ private isOn: boolean;
209
+ private isInteractive = true;
210
+ /** The side the visuals show or head to. */
211
+ private isVisuallyOn: boolean;
212
+ /** +1 left-to-right, −1 right-to-left. */
213
+ private direction = 1;
214
+ private thumb: Animator | null = null;
215
+ /** The fade layer: its opacity is how far the visuals are from the value. */
216
+ private fade: Animator | null = null;
217
+ /** The lifted shadow: the thumb's `::after`, faded in with the lift. */
218
+ private shadow: Animator | null = null;
219
+ /** When the thumb lifted (document timeline, ms); null while at rest. */
220
+ private liftTime: number | null = null;
221
+ /** When the running settle leaves the lift (settleTime), ms. */
222
+ private settleAt: number | null = null;
223
+ private otherTrack: TrackState | null = null;
224
+ /**
225
+ * A change landed mid-fade, and the fade already heads to it. The track's
226
+ * own fill stays the previous value's (inline) until the fade lands. So the
227
+ * change's frame builds no curve and repaints nothing. `baseHold` counts
228
+ * holds, so a settled one ignores its late landing.
229
+ */
230
+ private baseHeld = false;
231
+ private baseHold = 0;
232
+ private detachers: (() => void)[] = [];
233
+ /** When the press began (document timeline, ms); null with no press. */
234
+ private pressTime: number | null = null;
235
+ /** The thumb's offset when the current finger landed. */
236
+ private downOffset = 0;
237
+ private drag: Drag | null = null;
238
+ /** The finger's last travel along x, for a lift that reports none. */
239
+ private lastTravel = 0;
240
+ /**
241
+ * The press and the pan both report every touch event; the first report
242
+ * takes the step and this skips the second (cleared after the event).
243
+ */
244
+ private isStepping = false;
245
+ /** The current touch already ended in a release. */
246
+ private released = false;
247
+ /**
248
+ * Reduced motion: a release changed the value. The thumb's travel waits for
249
+ * the value to commit, so its one cross-fade lands in the new frame.
250
+ */
251
+ private pendingTravel = false;
252
+ /** Passive: a finger is down on the switch. */
253
+ private isTracking = false;
254
+ /** Which presentation the switch has: the touch one, or its own. */
255
+ private presentation: SwitchPresentation = 'touch';
256
+ /** The press-time listeners under a fine pointer, while armed. */
257
+ private pressListeners: (() => void) | null = null;
258
+ private pressListenersTimer: ReturnType<typeof setTimeout> | null = null;
259
+ /** Passive: a lift is waiting for the browser's own click. */
260
+ private browserClickTimer: ReturnType<typeof setTimeout> | null = null;
261
+ private reconcileTimer: ReturnType<typeof setTimeout> | null = null;
262
+
263
+ constructor(isOn: boolean, geometry: SwitchTouchGeometry) {
264
+ this.isOn = isOn;
265
+ this.isVisuallyOn = isOn;
266
+ this.geometry = geometry;
267
+ }
268
+
269
+ get isAttached(): boolean {
270
+ return this.elements != null;
271
+ }
272
+
273
+ attach(elements: Elements, presentation: SwitchPresentation): void {
274
+ this.detach();
275
+ this.elements = elements;
276
+ this.presentation = presentation;
277
+ this.measureGeometry();
278
+ this.direction = isRtlElement(elements.surface) ? -1 : 1;
279
+ this.isVisuallyOn = this.isOn;
280
+ const host = getGestureHost(elements.surface.ownerDocument);
281
+ if (presentation === 'pointer') {
282
+ // The switch's own look: the gestures decide the value and the CSS
283
+ // shows it.
284
+ this.detachers = this.attachPassive(host, elements);
285
+ return;
286
+ }
287
+ registerSwitchTable();
288
+ this.thumb = createAnimator(elements.thumb);
289
+ // The layer rests clear (its class's opacity 0) while the visuals show
290
+ // the value; from here its opacity is the animator's.
291
+ this.fade = createAnimator(elements.track, {
292
+ pseudoElement: '::after',
293
+ initial: {opacity: 0},
294
+ });
295
+ this.fade.set({opacity: 0});
296
+ this.shadow = createAnimator(elements.thumb, {
297
+ pseudoElement: '::after',
298
+ initial: {opacity: 0},
299
+ });
300
+ this.shadow.set({opacity: 0});
301
+ this.paintFade();
302
+ this.detachers = this.attachPassive(host, elements);
303
+ }
304
+
305
+ /**
306
+ * Passive: UIControl's tracking, which never blocks a scroll. The press
307
+ * begins with the touch, or with the first horizontal move a pan would
308
+ * begin on. Inside a scroller the touch waits for the content delay. Every
309
+ * move steps the flip rule, and the release commits. A touch the browser
310
+ * takes for its scroll yields with no toggle.
311
+ */
312
+ private attachPassive(
313
+ host: ReturnType<typeof getGestureHost>,
314
+ elements: Elements,
315
+ ): (() => void)[] {
316
+ const {surface} = elements;
317
+ const tracker = createPressTracker({
318
+ view: surface,
319
+ getBounds: () => surface.getBoundingClientRect(),
320
+ isEnabled: () => this.isInteractive,
321
+ isBraking: () => host.isScrollBraking(surface),
322
+ // A held switch opens no menu (UISwitch shows none).
323
+ holdsContextMenu: () => this.pressTime != null,
324
+ onControlEvent: (event, touch) => this.onControlEvent(event, touch),
325
+ onHighlightChange: isHighlighted => {
326
+ if (isHighlighted && this.isTracking && this.pressTime == null) {
327
+ this.pressBegan(now());
328
+ }
329
+ },
330
+ });
331
+ // The lift's own click activates the input natively: the switch then
332
+ // clicks nothing itself.
333
+ const onClick = (event: MouseEvent) => {
334
+ if (event.isTrusted && this.browserClickTimer != null) {
335
+ clearTimeout(this.browserClickTimer);
336
+ this.browserClickTimer = null;
337
+ this.scheduleReconcile();
338
+ }
339
+ };
340
+ const listen = (): (() => void) => {
341
+ surface.addEventListener('click', onClick, true);
342
+ return () => {
343
+ surface.removeEventListener('click', onClick, true);
344
+ };
345
+ };
346
+ if (this.presentation === 'pointer') {
347
+ // Under a fine pointer only the press the host arms listens at rest.
348
+ // The switch's click listener comes with a press and goes after its
349
+ // click wait.
350
+ this.listenForPress = listen;
351
+ return [
352
+ host.attachConsumer(surface, tracker),
353
+ () => tracker.dispose(),
354
+ () => this.stopPressListeners(),
355
+ ];
356
+ }
357
+ return [
358
+ host.attachConsumer(surface, tracker),
359
+ () => tracker.dispose(),
360
+ listen(),
361
+ ];
362
+ }
363
+
364
+ /** Under a fine pointer: adds the switch's own listeners for a press. */
365
+ private listenForPress: (() => () => void) | null = null;
366
+
367
+ private startPressListeners(): void {
368
+ if (this.pressListenersTimer != null) {
369
+ clearTimeout(this.pressListenersTimer);
370
+ this.pressListenersTimer = null;
371
+ }
372
+ if (this.pressListeners == null && this.listenForPress != null) {
373
+ this.pressListeners = this.listenForPress();
374
+ }
375
+ }
376
+
377
+ /** The press's click window is over: its listeners go. */
378
+ private endPressListeners(afterMs: number): void {
379
+ if (this.pressListeners == null) {
380
+ return;
381
+ }
382
+ if (this.pressListenersTimer != null) {
383
+ clearTimeout(this.pressListenersTimer);
384
+ }
385
+ this.pressListenersTimer = setTimeout(() => {
386
+ this.pressListenersTimer = null;
387
+ this.stopPressListeners();
388
+ }, afterMs);
389
+ }
390
+
391
+ private stopPressListeners(): void {
392
+ if (this.pressListenersTimer != null) {
393
+ clearTimeout(this.pressListenersTimer);
394
+ this.pressListenersTimer = null;
395
+ }
396
+ this.pressListeners?.();
397
+ this.pressListeners = null;
398
+ }
399
+
400
+ /** Undo everything: listeners, timers, animations and inline styles. */
401
+ detach(): void {
402
+ for (const detach of this.detachers) {
403
+ detach();
404
+ }
405
+ this.detachers = [];
406
+ this.listenForPress = null;
407
+ this.clearTimers();
408
+ for (const animator of [this.thumb, this.fade, this.shadow]) {
409
+ const element = animator?.element;
410
+ animator?.dispose();
411
+ if (element != null) {
412
+ for (const property of ['translate', 'scale', 'opacity']) {
413
+ element.style.removeProperty(property);
414
+ }
415
+ }
416
+ }
417
+ this.elements?.track.style.removeProperty(FADE_FILL);
418
+ this.elements?.track.style.removeProperty(BASE_FILL);
419
+ this.baseHeld = false;
420
+ this.baseHold++;
421
+ this.thumb = this.fade = this.shadow = null;
422
+ this.liftTime = null;
423
+ this.elements = null;
424
+ this.pressTime = null;
425
+ this.drag = null;
426
+ this.isTracking = false;
427
+ }
428
+
429
+ setInteractive(isInteractive: boolean): void {
430
+ this.isInteractive = isInteractive;
431
+ if (!isInteractive && this.pressTime != null) {
432
+ this.abandon(now());
433
+ }
434
+ }
435
+
436
+ /** The track's other state, re-rendered with each value. */
437
+ setOtherTrack(otherTrack: TrackState): void {
438
+ const previous = this.otherTrack;
439
+ this.otherTrack = otherTrack;
440
+ // A held fill was read from the previous other state; a re-render that
441
+ // keeps it keeps the hold.
442
+ if (this.baseHeld && !sameTrackState(previous, otherTrack)) {
443
+ this.landBase(now());
444
+ }
445
+ }
446
+
447
+ setGeometry(geometry: SwitchTouchGeometry): void {
448
+ this.geometry = geometry;
449
+ this.measureGeometry();
450
+ this.thumb?.set({x: 0, scaleX: 1, scaleY: 1});
451
+ }
452
+
453
+ /**
454
+ * The thumb geometry from the rendered boxes. The track's box lets a theme
455
+ * resize the track. The thumb's box is the circle of the current value. It
456
+ * rests by insets from the track's padding box, so padding does not move it.
457
+ */
458
+ private measureGeometry(): void {
459
+ const track = this.elements?.track;
460
+ const thumb = this.elements?.thumb;
461
+ if (track == null) {
462
+ return;
463
+ }
464
+ // Fractional boxes: the track's border box less its border (it has no
465
+ // transform). The thumb's used size comes from its computed style, which
466
+ // its scale does not change.
467
+ const box = track.getBoundingClientRect();
468
+ const trackWidth = box.width - 2 * track.clientLeft;
469
+ const trackHeight = box.height - 2 * track.clientTop;
470
+ if (trackWidth <= 0 || trackHeight <= 0) {
471
+ return;
472
+ }
473
+ const style = thumb == null ? null : getComputedStyle(thumb);
474
+ const thumbWidth = style == null ? NaN : parseFloat(style.width);
475
+ const thumbHeight = style == null ? NaN : parseFloat(style.height);
476
+ this.geometry = switchTouchGeometry(
477
+ trackWidth,
478
+ trackHeight,
479
+ thumbWidth > 0 && thumbHeight > 0
480
+ ? {width: thumbWidth, height: thumbHeight}
481
+ : undefined,
482
+ );
483
+ }
484
+
485
+ /**
486
+ * The rendered value changed. The CSS has already moved the thumb's
487
+ * resting place. So the thumb's delta is rebased first, and nothing moves on
488
+ * screen. Then the visuals head for the new value.
489
+ */
490
+ setValue(isOn: boolean): void {
491
+ if (isOn === this.isOn) {
492
+ return;
493
+ }
494
+ const previous = this.isOn;
495
+ this.isOn = isOn;
496
+ const thumb = this.thumb;
497
+ if (thumb == null) {
498
+ this.isVisuallyOn = isOn;
499
+ return;
500
+ }
501
+ const time = now();
502
+ // The thumb's CSS rest moved with the value: shift it back by as much,
503
+ // so nothing moves on screen. A spring keeps its target through the shift
504
+ // and is sent to the new rest below. A reduced-motion dip shifts whole,
505
+ // its end included, and keeps going.
506
+ const springs = thumb.reducedMotion() === 'off';
507
+ thumb.rebase({
508
+ x: this.direction * (this.rest(previous) - this.rest(isOn)),
509
+ });
510
+ // Each state has its own thumb size, the circle a mouse sees. The box the
511
+ // CSS now renders is the new value's. Its scale takes the old size over
512
+ // and grows or shrinks it from there.
513
+ const before = this.geometry;
514
+ this.measureGeometry();
515
+ if (springs) {
516
+ this.rescaleThumb(before, this.geometry, time);
517
+ }
518
+ // The track now shows the new value's fill. The fade layer swaps to the
519
+ // other fill and to the complement of its opacity. Nothing changes on
520
+ // screen, and the fade keeps heading where the visuals head.
521
+ const fade = this.fade;
522
+ if (fade != null) {
523
+ this.landBase(time);
524
+ // Already heading here (a release, a drag that flipped). The running
525
+ // fade carries the change over the previous fill, held as the track's
526
+ // own until it lands. WebKit plays a curve built in a held frame only
527
+ // once the long task ends. A swap here would step the colour back.
528
+ if (this.isVisuallyOn !== isOn || !this.holdBase()) {
529
+ this.swapFade(time);
530
+ }
531
+ }
532
+ const wasDragging = this.drag != null && !this.released;
533
+ if (wasDragging) {
534
+ // The app changed the value mid-gesture: the value wins and the
535
+ // gesture ends without committing.
536
+ this.drag = null;
537
+ this.released = true;
538
+ this.pressEnded();
539
+ }
540
+ // A release already sent the visuals here. Any other change, the app's or
541
+ // one mid-gesture, sends them now, and a spring heads for the new rest.
542
+ if (
543
+ springs ||
544
+ wasDragging ||
545
+ this.pendingTravel ||
546
+ this.isVisuallyOn !== isOn
547
+ ) {
548
+ const touchRelease = this.pendingTravel;
549
+ this.pendingTravel = false;
550
+ this.showSide(isOn, time, thumb.getVelocity('x', time), touchRelease);
551
+ }
552
+ }
553
+
554
+ // --- Touches ---------------------------------------------------------------
555
+
556
+ private touchDown(time: number): void {
557
+ this.landBase(time);
558
+ this.released = false;
559
+ this.pendingTravel = false;
560
+ this.drag = null;
561
+ this.lastTravel = 0;
562
+ this.downOffset = this.offsetAt(time);
563
+ }
564
+
565
+ /** Passive: a mouse, a pen or a finger on the switch, UIControl's events. */
566
+ private onControlEvent(event: ControlEvent, touch: GestureTouch): void {
567
+ const travel = touch.x - touch.startX;
568
+ // The switch reads the thumb when it handles the event. Its motion has no
569
+ // start of its own. A call that continues motion starts then, from the
570
+ // curve on screen. A call from rest starts at the frame that shows it, as
571
+ // Core Animation starts an animation at its commit. A busy main thread
572
+ // hands the switch an event late and commits its frame late. The motion
573
+ // is delayed, never stepped back or skipped into. The event's own time is
574
+ // the recognizers'.
575
+ const time = now();
576
+ switch (event) {
577
+ case 'touchDown':
578
+ this.isTracking = true;
579
+ this.startPressListeners();
580
+ this.touchDown(time);
581
+ return;
582
+ case 'touchDragInside':
583
+ case 'touchDragOutside':
584
+ if (this.released || !this.isInteractive) {
585
+ return;
586
+ }
587
+ if (this.pressTime == null) {
588
+ if (Math.abs(travel) < PAN_HYSTERESIS) {
589
+ return;
590
+ }
591
+ // A pan would begin here: the switch takes the horizontal drag.
592
+ this.pressBegan(time);
593
+ }
594
+ this.move(time, travel);
595
+ return;
596
+ case 'touchUpInside':
597
+ case 'touchUpOutside':
598
+ this.isTracking = false;
599
+ if (this.released || !this.isInteractive) {
600
+ return;
601
+ }
602
+ // The finger's lift is a step of its own: a flip armed by the last
603
+ // move happens here (a fast swipe flips on the lift).
604
+ this.move(time, travel);
605
+ if (this.drag?.flip.didFlip !== true && event === 'touchUpInside') {
606
+ // A tap, a hold or a short drag: the browser's click activates it.
607
+ // Only a finger's lift can come with no click (a hold). A mouse or a
608
+ // pen gets one whenever it is released on the input. With no click,
609
+ // it was released off the input, inside the tracking slop. The
610
+ // switch is then left alone, as the native checkbox is.
611
+ this.release(time, {
612
+ byBrowserClick: true,
613
+ clicksIfNoBrowserClick: touch.pointerType === 'touch',
614
+ });
615
+ this.endPressListeners(BROWSER_CLICK_WAIT_MS);
616
+ return;
617
+ }
618
+ if (
619
+ touch.pointerType !== 'touch' &&
620
+ (event === 'touchUpOutside' || this.drag?.flip.didFlip !== true)
621
+ ) {
622
+ // A mouse or a pen released outside the tracking bounds, flipped or
623
+ // not, leaves the switch alone, as the native checkbox does. So the
624
+ // press can be taken back (WCAG 2.5.2). Only a finger's press slid
625
+ // off toggles.
626
+ this.released = true;
627
+ this.abandon(time);
628
+ this.endPressListeners(0);
629
+ return;
630
+ }
631
+ // A drag that flipped, or a press slid off and lifted outside, which
632
+ // toggles as UISwitch's does. The switch decides this lift itself, so
633
+ // the shared click guard swallows the browser's click, if one comes.
634
+ // A drag back past the press commits nothing, and its click must not
635
+ // toggle either.
636
+ if (this.elements != null) {
637
+ armClickGuard(this.elements.input.ownerDocument);
638
+ }
639
+ this.release(time);
640
+ this.endPressListeners(0);
641
+ return;
642
+ case 'touchCancel':
643
+ // The browser took the touch for its scroll, or a recognizer above
644
+ // took it: the switch yields.
645
+ this.isTracking = false;
646
+ if (!this.released) {
647
+ this.released = true;
648
+ this.abandon(time);
649
+ }
650
+ this.endPressListeners(0);
651
+ return;
652
+ case 'touchDragEnter':
653
+ case 'touchDragExit':
654
+ return;
655
+ }
656
+ }
657
+
658
+ /**
659
+ * One touch event of a held or dragged switch. It retargets the thumb at the
660
+ * finger (one style write) and steps the flip rule.
661
+ */
662
+ private move(time: number, travel: number | null): void {
663
+ const thumb = this.thumb;
664
+ if (this.isStepping) {
665
+ return;
666
+ }
667
+ this.isStepping = true;
668
+ queueMicrotask(() => {
669
+ this.isStepping = false;
670
+ });
671
+ const x = this.direction * (travel ?? this.lastTravel);
672
+ this.lastTravel = travel ?? this.lastTravel;
673
+ const drag = (this.drag ??= {
674
+ start: this.downOffset,
675
+ flip: startFlip(this.isOn),
676
+ });
677
+ // The thumb under the finger is direct manipulation, which reduced
678
+ // motion leaves alone. Under it the thumb is placed at once: the animator
679
+ // would cross-fade a move. Under a fine pointer nothing follows the
680
+ // pointer. The switch keeps its own look, and the value moves the thumb.
681
+ if (thumb != null) {
682
+ const target = this.toX(dragTarget(drag.start, x, this.geometry));
683
+ if (thumb.reducedMotion() === 'off') {
684
+ thumb.animate({x: target}, {spring: TRACKING});
685
+ } else {
686
+ thumb.set({x: target});
687
+ }
688
+ }
689
+ drag.flip = stepFlip(drag.flip, x, this.geometry);
690
+ if (drag.flip.isVisuallyOn !== this.isVisuallyOn) {
691
+ this.showWell(drag.flip.isVisuallyOn, time);
692
+ }
693
+ }
694
+
695
+ /**
696
+ * The finger lifted after a tap, a hold or a drag. `byBrowserClick`: the
697
+ * browser's own click activates the input, and the switch clicks it only
698
+ * if that click never comes.
699
+ */
700
+ private release(
701
+ time: number,
702
+ {
703
+ byBrowserClick = false,
704
+ clicksIfNoBrowserClick = true,
705
+ }: {byBrowserClick?: boolean; clicksIfNoBrowserClick?: boolean} = {},
706
+ ): void {
707
+ if (this.released) {
708
+ return;
709
+ }
710
+ this.released = true;
711
+ this.clearTimers();
712
+ const commit = commitOnRelease(this.drag?.flip ?? null, this.isOn);
713
+ this.drag = null;
714
+ if (this.pressTime == null) {
715
+ // A tap quicker than a scroller's content delay: the press begins now.
716
+ this.pressBegan(time);
717
+ }
718
+ if (
719
+ commit !== this.isOn &&
720
+ this.thumb != null &&
721
+ this.thumb.reducedMotion({touchRelease: true}) !== 'off'
722
+ ) {
723
+ // Under reduced motion the travel is one cross-fade, played when the
724
+ // value commits (setValue). It lands in the new value's frame. The
725
+ // colour starts now.
726
+ this.pendingTravel = true;
727
+ this.showWell(commit, time, true);
728
+ } else {
729
+ this.showSide(commit, time, undefined, true);
730
+ }
731
+ this.pressEnded(time, true);
732
+ if (byBrowserClick) {
733
+ this.browserClickTimer = setTimeout(() => {
734
+ this.browserClickTimer = null;
735
+ if (clicksIfNoBrowserClick && commit !== this.isOn) {
736
+ this.clickInput();
737
+ }
738
+ this.scheduleReconcile();
739
+ }, BROWSER_CLICK_WAIT_MS);
740
+ return;
741
+ }
742
+ if (commit !== this.isOn) {
743
+ // No click comes from the browser for this lift; the input's own click
744
+ // keeps every native path (form value, onChange, the label).
745
+ this.clickInput();
746
+ }
747
+ this.scheduleReconcile();
748
+ }
749
+
750
+ /**
751
+ * The switch's own click on its input. The shared click guard then swallows
752
+ * the browser's late click for the same lift, so the switch toggles once.
753
+ */
754
+ private clickInput(): void {
755
+ const input = this.elements?.input;
756
+ if (input == null) {
757
+ return;
758
+ }
759
+ armClickGuard(input.ownerDocument);
760
+ withoutClickGuard(() => input.click());
761
+ }
762
+
763
+ /** If the owner kept the old value, the visuals go back to it. */
764
+ private scheduleReconcile(): void {
765
+ if (this.reconcileTimer != null) {
766
+ clearTimeout(this.reconcileTimer);
767
+ }
768
+ this.reconcileTimer = setTimeout(() => {
769
+ this.reconcileTimer = null;
770
+ if (this.drag == null && this.isVisuallyOn !== this.isOn) {
771
+ this.pendingTravel = false;
772
+ this.showSide(this.isOn, now(), undefined, true);
773
+ }
774
+ }, 0);
775
+ }
776
+
777
+ /** The touch ended without a release (cancelled, or the switch turned off). */
778
+ private abandon(time: number): void {
779
+ this.drag = null;
780
+ this.showSide(this.isOn, time, undefined, true);
781
+ this.pressEnded(time, true);
782
+ }
783
+
784
+ // --- Visuals ---------------------------------------------------------------
785
+
786
+ private rest(isOn: boolean): number {
787
+ return isOn ? this.geometry.travel : 0;
788
+ }
789
+
790
+ /** The animator's x for a thumb offset, relative to its CSS place. */
791
+ private toX(offset: number): number {
792
+ return this.direction * (offset - this.rest(this.isOn));
793
+ }
794
+
795
+ /** The thumb's offset on screen at `time`, from the curve. */
796
+ private offsetAt(time: number): number {
797
+ const x = this.thumb?.get('x', time) ?? 0;
798
+ return this.rest(this.isOn) + this.direction * x;
799
+ }
800
+
801
+ private showWell(isOn: boolean, time: number, touchRelease = false): void {
802
+ this.isVisuallyOn = isOn;
803
+ this.paintFade();
804
+ this.animateFade(time, undefined, touchRelease);
805
+ }
806
+
807
+ /**
808
+ * The fade layer heads for the visuals, on UIKit's well springs (on:
809
+ * 0.68 s, off: 0.40 s). It is clear when they show the value. It is opaque,
810
+ * the other fill, when they show the other side.
811
+ */
812
+ private animateFade(
813
+ time: number,
814
+ velocity?: number,
815
+ touchRelease = false,
816
+ ): void {
817
+ // Under reduced motion the animator plays this as its cross-fade.
818
+ this.fade?.animate(
819
+ {opacity: this.isVisuallyOn === this.isOn ? 0 : 1},
820
+ {
821
+ spring: this.isVisuallyOn
822
+ ? switchControl.wellOn
823
+ : switchControl.wellOff,
824
+ velocity: velocity == null ? undefined : {opacity: velocity},
825
+ },
826
+ {touchRelease},
827
+ );
828
+ }
829
+
830
+ /**
831
+ * The track now shows the new value's fill. The fade layer swaps to the
832
+ * other fill and to the complement of its opacity, so nothing changes on
833
+ * screen. It keeps heading where the visuals head, on the running clock.
834
+ */
835
+ private swapFade(time: number): void {
836
+ const fade = this.fade;
837
+ if (fade == null) {
838
+ return;
839
+ }
840
+ const opacity = fade.get('opacity', time);
841
+ const fadeVelocity = fade.getVelocity('opacity', time);
842
+ this.paintFade();
843
+ fade.rebase({opacity: 1 - 2 * opacity});
844
+ this.animateFade(time, -fadeVelocity);
845
+ }
846
+
847
+ /**
848
+ * Holds the track's own fill at the previous value's (the other state's,
849
+ * now) until the fade lands. False when there is nothing to read it from.
850
+ */
851
+ private holdBase(): boolean {
852
+ const track = this.elements?.track;
853
+ const fade = this.fade;
854
+ if (track == null || fade == null || this.otherTrack == null) {
855
+ return false;
856
+ }
857
+ track.style.setProperty(BASE_FILL, readFillOf(track, this.otherTrack));
858
+ this.baseHeld = true;
859
+ const hold = ++this.baseHold;
860
+ void fade.atRest(['opacity']).then(() => {
861
+ if (hold === this.baseHold) {
862
+ this.landBase(now());
863
+ }
864
+ });
865
+ return true;
866
+ }
867
+
868
+ /**
869
+ * Ends a held fill: the track shows its value's fill again and the fade
870
+ * layer swaps under it. Once the fade has landed, the layer is opaque and
871
+ * nothing changes on screen. Called early, by a new touch, value or other
872
+ * state, the swap keeps the picture where it is.
873
+ */
874
+ private landBase(time: number): void {
875
+ if (!this.baseHeld) {
876
+ return;
877
+ }
878
+ this.baseHeld = false;
879
+ this.baseHold++;
880
+ this.elements?.track.style.removeProperty(BASE_FILL);
881
+ this.swapFade(time);
882
+ }
883
+
884
+ /** The fade layer's colour: the track's fill in its other state. */
885
+ private paintFade(): void {
886
+ const track = this.elements?.track;
887
+ if (track == null || this.fade == null || this.otherTrack == null) {
888
+ return;
889
+ }
890
+ track.style.setProperty(FADE_FILL, readFillOf(track, this.otherTrack));
891
+ }
892
+
893
+ /** Track color and thumb head for one side. */
894
+ private showSide(
895
+ isOn: boolean,
896
+ time: number,
897
+ velocity?: number,
898
+ touchRelease = false,
899
+ ): void {
900
+ if (isOn !== this.isVisuallyOn) {
901
+ this.showWell(isOn, time, touchRelease);
902
+ }
903
+ // Under reduced motion the animator cross-fades this travel: the thumb
904
+ // fades out where it is and back in at its rest.
905
+ this.thumb?.animate(
906
+ {x: this.toX(this.rest(isOn))},
907
+ {
908
+ spring: TRAVEL,
909
+ velocity: velocity == null ? undefined : {x: velocity},
910
+ },
911
+ {touchRelease},
912
+ );
913
+ }
914
+
915
+ /** The press began: the switch holds the touch and the thumb lifts. */
916
+ private pressBegan(time: number): void {
917
+ this.pressTime = time;
918
+ this.lift(time);
919
+ }
920
+
921
+ /** The press ended (a release, a cancel or the switch turning off). */
922
+ private pressEnded(time: number = now(), touchRelease = false): void {
923
+ this.pressTime = null;
924
+ this.settle(time, touchRelease);
925
+ }
926
+
927
+ /**
928
+ * The thumb grows about its center into half the gap around it, and its
929
+ * lifted shadow fades in. Under reduced motion nothing lifts: the lift is
930
+ * feedback with no fade of its own.
931
+ */
932
+ private lift(time: number): void {
933
+ if (this.thumb == null || this.thumb.reducedMotion() !== 'off') {
934
+ return;
935
+ }
936
+ this.liftTime = time;
937
+ const lift = this.thumb.animate(
938
+ {scaleX: this.geometry.liftScaleX, scaleY: this.geometry.liftScaleY},
939
+ {spring: switchControl.lift},
940
+ );
941
+ this.shadow?.animate({opacity: 1}, {spring: switchControl.lift});
942
+ // A lift from rest starts at the frame that shows it: the minimum lift
943
+ // counts from there.
944
+ void lift.started.then(start => {
945
+ if (this.liftTime === time) {
946
+ this.liftTime = start;
947
+ }
948
+ });
949
+ }
950
+
951
+ /**
952
+ * The thumb settles back after the press, no sooner than the minimum lift
953
+ * time after it lifted. One curve per property, built now, starts the
954
+ * settle on the compositor's clock.
955
+ */
956
+ private settle(time: number, touchRelease: boolean): void {
957
+ const liftTime = this.liftTime;
958
+ if (liftTime == null) {
959
+ return;
960
+ }
961
+ this.liftTime = null;
962
+ const lift = switchControl.lift;
963
+ const settle = switchControl.settle;
964
+ this.settleAt = settleTime(time, liftTime);
965
+ const hold = (this.settleAt - time) / 1000;
966
+ const play = (
967
+ animator: Animator | null,
968
+ property: 'scaleX' | 'scaleY' | 'opacity',
969
+ lifted: number,
970
+ rest: number,
971
+ threshold: number,
972
+ ) => {
973
+ if (animator == null) {
974
+ return;
975
+ }
976
+ animator.play(
977
+ {
978
+ [property]: liftThenSettle({
979
+ from: animator.get(property, time),
980
+ velocity: animator.getVelocity(property, time),
981
+ lifted,
982
+ rest,
983
+ holdSeconds: hold,
984
+ threshold,
985
+ lift,
986
+ settle,
987
+ }),
988
+ },
989
+ {touchRelease},
990
+ );
991
+ };
992
+ play(this.thumb, 'scaleX', this.geometry.liftScaleX, 1, 1e-4);
993
+ play(this.thumb, 'scaleY', this.geometry.liftScaleY, 1, 1e-4);
994
+ play(this.shadow, 'opacity', 1, 0, 1e-3);
995
+ }
996
+
997
+ /**
998
+ * The thumb's box changed size with the value: the on circle is larger.
999
+ * Its scale carries the size it showed, so nothing jumps. Then it heads for
1000
+ * the new box through the settle, or to the lift a held press keeps.
1001
+ */
1002
+ private rescaleThumb(
1003
+ before: SwitchTouchGeometry,
1004
+ after: SwitchTouchGeometry,
1005
+ time: number,
1006
+ ): void {
1007
+ const thumb = this.thumb;
1008
+ if (
1009
+ thumb == null ||
1010
+ !(before.thumbWidth > 0 && before.thumbHeight > 0) ||
1011
+ !(after.thumbWidth > 0 && after.thumbHeight > 0) ||
1012
+ (before.thumbWidth === after.thumbWidth &&
1013
+ before.thumbHeight === after.thumbHeight)
1014
+ ) {
1015
+ return;
1016
+ }
1017
+ const held = this.liftTime != null;
1018
+ const hold =
1019
+ this.settleAt == null ? 0 : Math.max(0, this.settleAt - time) / 1000;
1020
+ for (const [property, factor, lifted] of [
1021
+ ['scaleX', before.thumbWidth / after.thumbWidth, before.liftScaleX],
1022
+ ['scaleY', before.thumbHeight / after.thumbHeight, before.liftScaleY],
1023
+ ] as const) {
1024
+ const keep = lifted * factor;
1025
+ thumb.play({
1026
+ [property]: liftThenSettle({
1027
+ from: thumb.get(property, time) * factor,
1028
+ velocity: thumb.getVelocity(property, time) * factor,
1029
+ lifted: keep,
1030
+ rest: held ? keep : 1,
1031
+ holdSeconds: held ? 0 : hold,
1032
+ threshold: 1e-4,
1033
+ lift: switchControl.lift,
1034
+ settle: switchControl.settle,
1035
+ }),
1036
+ });
1037
+ }
1038
+ }
1039
+
1040
+ private clearTimers(): void {
1041
+ if (this.browserClickTimer != null) {
1042
+ clearTimeout(this.browserClickTimer);
1043
+ this.browserClickTimer = null;
1044
+ }
1045
+ if (this.reconcileTimer != null) {
1046
+ clearTimeout(this.reconcileTimer);
1047
+ this.reconcileTimer = null;
1048
+ }
1049
+ }
1050
+ }
1051
+
1052
+ /**
1053
+ * Switch's gestures, attached for every pointer, with the touch
1054
+ * presentation under a coarse primary pointer. Its parts
1055
+ * must be rendered (and styled for it) whether or not it is active; it only
1056
+ * animates and listens.
1057
+ */
1058
+ export function useSwitchTouch(
1059
+ parts: SwitchTouchParts,
1060
+ {
1061
+ isOn,
1062
+ isInteractive,
1063
+ isDisabled,
1064
+ trackSize,
1065
+ otherTrackProps,
1066
+ }: SwitchTouchOptions,
1067
+ ): void {
1068
+ const {isTouch} = useTouchPresentation();
1069
+ const [trackWidth, trackHeight] = trackSize;
1070
+ const geometry = useMemo(
1071
+ () => switchTouchGeometry(trackWidth, trackHeight),
1072
+ [trackWidth, trackHeight],
1073
+ );
1074
+ const otherTrack = trackStateOf(otherTrackProps);
1075
+ const [controller] = useState(
1076
+ () => new SwitchTouchController(isOn, geometry),
1077
+ );
1078
+
1079
+ // Syncs the touch controller's animators with the value. It runs first: the
1080
+ // thumb's CSS place and the track's fill have just changed with the value.
1081
+ useIsomorphicLayoutEffect(() => {
1082
+ controller.setOtherTrack(otherTrack);
1083
+ controller.setValue(isOn);
1084
+ });
1085
+
1086
+ // Syncs the controller's thumb geometry with the track's size.
1087
+ useIsomorphicLayoutEffect(() => {
1088
+ controller.setGeometry(geometry);
1089
+ }, [controller, geometry]);
1090
+
1091
+ // Syncs whether the controller's gestures take a press.
1092
+ useIsomorphicLayoutEffect(() => {
1093
+ controller.setInteractive(isInteractive);
1094
+ }, [controller, isInteractive]);
1095
+
1096
+ // Attaches the controller's DOM listeners and animators to the switch's
1097
+ // parts, and detaches them on cleanup.
1098
+ useIsomorphicLayoutEffect(() => {
1099
+ const surface = parts.surface.current;
1100
+ const input = parts.input.current;
1101
+ const thumb = parts.thumb.current;
1102
+ const track = parts.track.current;
1103
+ // A disabled switch takes no press: no listener is active. A busy one
1104
+ // stays attached (setInteractive ignores its presses),
1105
+ // so the change that made it busy keeps its motion.
1106
+ if (
1107
+ isDisabled ||
1108
+ surface == null ||
1109
+ input == null ||
1110
+ thumb == null ||
1111
+ track == null
1112
+ ) {
1113
+ return undefined;
1114
+ }
1115
+ controller.attach(
1116
+ {surface, input, thumb, track},
1117
+ isTouch ? 'touch' : 'pointer',
1118
+ );
1119
+ return () => controller.detach();
1120
+ }, [controller, isTouch, isDisabled, parts]);
1121
+ }