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