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

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 (131) hide show
  1. package/dist/AppShell/AppShell.d.ts.map +1 -1
  2. package/dist/AppShell/AppShell.js +23 -3
  3. package/dist/BottomSheet/BottomSheetPanel.d.ts.map +1 -1
  4. package/dist/BottomSheet/BottomSheetPanel.js +16 -9
  5. package/dist/Chat/ChatComposerInput.d.ts +9 -0
  6. package/dist/Chat/ChatComposerInput.d.ts.map +1 -1
  7. package/dist/Chat/ChatComposerInput.js +2 -0
  8. package/dist/Chat/ChatMessage.js +2 -2
  9. package/dist/Chat/chatMessage.vars.stylex.d.ts +8 -8
  10. package/dist/Chat/chatMessage.vars.stylex.js +8 -8
  11. package/dist/CommandPalette/CommandPaletteInput.d.ts.map +1 -1
  12. package/dist/CommandPalette/CommandPaletteInput.js +8 -1
  13. package/dist/DateInput/MonthScroller.d.ts.map +1 -1
  14. package/dist/DateInput/MonthScroller.js +72 -2
  15. package/dist/Drawer/Drawer.d.ts.map +1 -1
  16. package/dist/Drawer/Drawer.js +6 -0
  17. package/dist/DropdownMenu/useMenuOverflow.d.ts +13 -3
  18. package/dist/DropdownMenu/useMenuOverflow.d.ts.map +1 -1
  19. package/dist/DropdownMenu/useMenuOverflow.js +31 -8
  20. package/dist/Field/PanelSearchInput.d.ts.map +1 -1
  21. package/dist/Field/PanelSearchInput.js +8 -1
  22. package/dist/Field/textEntryTraits.d.ts +6 -6
  23. package/dist/Field/textEntryTraits.js +6 -6
  24. package/dist/Link/Link.d.ts.map +1 -1
  25. package/dist/Link/Link.js +1 -0
  26. package/dist/Selector/Selector.d.ts.map +1 -1
  27. package/dist/Selector/Selector.js +25 -13
  28. package/dist/Slider/Slider.d.ts.map +1 -1
  29. package/dist/Slider/Slider.js +404 -131
  30. package/dist/Slider/sliderTouchController.d.ts +189 -0
  31. package/dist/Slider/sliderTouchController.d.ts.map +1 -0
  32. package/dist/Slider/sliderTouchController.js +1017 -0
  33. package/dist/Slider/useSliderTouch.d.ts +22 -0
  34. package/dist/Slider/useSliderTouch.d.ts.map +1 -0
  35. package/dist/Slider/useSliderTouch.js +56 -0
  36. package/dist/Typeahead/BaseTypeahead.d.ts.map +1 -1
  37. package/dist/Typeahead/BaseTypeahead.js +19 -9
  38. package/dist/astryx.css +33 -6
  39. package/dist/gestures/GestureArena.d.ts.map +1 -1
  40. package/dist/gestures/GestureArena.js +4 -1
  41. package/dist/gestures/GestureHost.d.ts +72 -2
  42. package/dist/gestures/GestureHost.d.ts.map +1 -1
  43. package/dist/gestures/GestureHost.js +643 -5
  44. package/dist/gestures/GestureRecognizer.d.ts +15 -1
  45. package/dist/gestures/GestureRecognizer.d.ts.map +1 -1
  46. package/dist/gestures/GestureRecognizer.js +12 -0
  47. package/dist/gestures/PanGestureRecognizer.d.ts +8 -0
  48. package/dist/gestures/PanGestureRecognizer.d.ts.map +1 -1
  49. package/dist/gestures/PinchGestureRecognizer.d.ts +5 -1
  50. package/dist/gestures/PinchGestureRecognizer.d.ts.map +1 -1
  51. package/dist/gestures/PinchGestureRecognizer.js +10 -0
  52. package/dist/gestures/ScreenEdgePanGestureRecognizer.d.ts +3 -3
  53. package/dist/gestures/ScreenEdgePanGestureRecognizer.d.ts.map +1 -1
  54. package/dist/gestures/ScreenEdgePanGestureRecognizer.js +7 -5
  55. package/dist/gestures/nativeScroll.d.ts +16 -2
  56. package/dist/gestures/nativeScroll.d.ts.map +1 -1
  57. package/dist/gestures/nativeScroll.js +39 -8
  58. package/dist/gestures/recognizerInternals.d.ts +4 -2
  59. package/dist/gestures/recognizerInternals.d.ts.map +1 -1
  60. package/dist/gestures/types.d.ts +11 -0
  61. package/dist/gestures/types.d.ts.map +1 -1
  62. package/dist/gestures/types.js +7 -0
  63. package/dist/gestures/wheelSegmenter.d.ts +45 -0
  64. package/dist/gestures/wheelSegmenter.d.ts.map +1 -0
  65. package/dist/gestures/wheelSegmenter.js +98 -0
  66. package/dist/motion/animator.d.ts.map +1 -1
  67. package/dist/motion/animator.js +140 -19
  68. package/dist/motion/paging.d.ts +58 -0
  69. package/dist/motion/paging.d.ts.map +1 -0
  70. package/dist/motion/paging.js +128 -0
  71. package/dist/motion/uikit/index.d.ts +2 -1
  72. package/dist/motion/uikit/index.d.ts.map +1 -1
  73. package/dist/motion/uikit/index.js +2 -1
  74. package/dist/motion/uikit/paging.d.ts +160 -0
  75. package/dist/motion/uikit/paging.d.ts.map +1 -0
  76. package/dist/motion/uikit/paging.js +162 -0
  77. package/dist/motion/uikit/scroll.d.ts +0 -19
  78. package/dist/motion/uikit/scroll.d.ts.map +1 -1
  79. package/dist/motion/uikit/scroll.js +2 -21
  80. package/dist/motion/uikit/slider.d.ts +97 -0
  81. package/dist/motion/uikit/slider.d.ts.map +1 -0
  82. package/dist/motion/uikit/slider.js +111 -0
  83. package/dist/motion/uikit/springs.d.ts +46 -1
  84. package/dist/motion/uikit/springs.d.ts.map +1 -1
  85. package/dist/motion/uikit/springs.js +48 -4
  86. package/package.json +5 -5
  87. package/src/AppShell/AppShell.doc.mjs +10 -0
  88. package/src/AppShell/AppShell.tsx +48 -2
  89. package/src/BottomSheet/BottomSheetPanel.tsx +16 -9
  90. package/src/Chat/Chat.doc.mjs +1 -0
  91. package/src/Chat/ChatComposerInput.doc.mjs +11 -0
  92. package/src/Chat/ChatComposerInput.tsx +11 -0
  93. package/src/Chat/ChatMessage.tsx +2 -2
  94. package/src/Chat/chatMessage.vars.stylex.ts +8 -8
  95. package/src/CommandPalette/CommandPaletteInput.tsx +6 -0
  96. package/src/DateInput/MonthScroller.tsx +63 -5
  97. package/src/Drawer/Drawer.tsx +6 -0
  98. package/src/DropdownMenu/useMenuOverflow.ts +44 -8
  99. package/src/Field/PanelSearchInput.tsx +6 -0
  100. package/src/Field/textEntryTraits.ts +6 -6
  101. package/src/Link/Link.tsx +3 -0
  102. package/src/Selector/Selector.tsx +29 -2
  103. package/src/Slider/Slider.doc.mjs +11 -7
  104. package/src/Slider/Slider.tsx +539 -132
  105. package/src/Slider/sliderTouchController.ts +1209 -0
  106. package/src/Slider/useSliderTouch.ts +78 -0
  107. package/src/Typeahead/BaseTypeahead.tsx +16 -8
  108. package/src/gestures/GestureArena.ts +1 -0
  109. package/src/gestures/GestureHost.ts +765 -5
  110. package/src/gestures/GestureRecognizer.ts +28 -0
  111. package/src/gestures/PanGestureRecognizer.ts +8 -0
  112. package/src/gestures/PinchGestureRecognizer.ts +16 -1
  113. package/src/gestures/README.md +63 -0
  114. package/src/gestures/ScreenEdgePanGestureRecognizer.ts +7 -5
  115. package/src/gestures/nativeScroll.ts +49 -9
  116. package/src/gestures/recognizerInternals.ts +9 -2
  117. package/src/gestures/types.ts +12 -0
  118. package/src/gestures/wheelSegmenter.ts +114 -0
  119. package/src/motion/MEASUREMENTS.md +8 -1
  120. package/src/motion/README.md +3 -2
  121. package/src/motion/animator.ts +193 -25
  122. package/src/motion/paging.ts +138 -0
  123. package/src/motion/uikit/index.ts +2 -1
  124. package/src/motion/uikit/paging.ts +162 -0
  125. package/src/motion/uikit/scroll.ts +2 -18
  126. package/src/motion/uikit/slider.ts +105 -0
  127. package/src/motion/uikit/springs.ts +37 -2
  128. package/dist/motion/selectPage.d.ts +0 -26
  129. package/dist/motion/selectPage.d.ts.map +0 -1
  130. package/dist/motion/selectPage.js +0 -55
  131. package/src/motion/selectPage.ts +0 -55
@@ -0,0 +1,1209 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file sliderTouchController.ts
5
+ * @input The slider's control (and its size), thumbs, fill parts and value
6
+ * model; the gesture recognizers, the animator and the UIKit slider table
7
+ * @output Exports `SliderTouchController`, `sliderCoastCurve` and the
8
+ * `SliderTouchModel` and `SliderTouchParts` types
9
+ * @position Internal to Slider: useSliderTouch attaches it while enabled.
10
+ *
11
+ * SYNC: When modified, update:
12
+ * - /packages/core/src/Slider/useSliderTouch.ts
13
+ * - /packages/core/src/Slider/Slider.tsx
14
+ * - /packages/core/src/Slider/useSliderTouch.test.tsx
15
+ */
16
+
17
+ import {getGestureHost} from '../gestures/GestureHost';
18
+ import type {GestureRecognizer} from '../gestures/GestureRecognizer';
19
+ import {playHaptic} from '../gestures/haptics';
20
+ import {PanGestureRecognizer} from '../gestures/PanGestureRecognizer';
21
+ import {
22
+ PRESS_PROFILES,
23
+ createPressTracker,
24
+ type ControlEvent,
25
+ } from '../gestures/PressTracker';
26
+ import type {SwipeGestureRecognizer} from '../gestures/SwipeGestureRecognizer';
27
+ import {lockSelection} from '../gestures/selectionLock';
28
+ import {TapGestureRecognizer} from '../gestures/TapGestureRecognizer';
29
+ import type {PointerKind} from '../gestures/types';
30
+ import {isRtlElement} from '../hooks/isRtlElement';
31
+ import {
32
+ createAnimator,
33
+ type Animator,
34
+ type MotionCurve,
35
+ type MotionHandle,
36
+ } from '../motion/animator';
37
+ import {projectDistance} from '../motion/projection';
38
+ import {resolveMotion} from '../motion/resolveMotion';
39
+ import {solveSpring, toSpring, type SpringDescription} from '../motion/spring';
40
+ import {
41
+ SLIDER_DROP_SPRING,
42
+ SLIDER_LIFT_SPRING,
43
+ SLIDER_RELEASE_SPRING,
44
+ registerSpringsTable,
45
+ } from '../motion/uikit/springs';
46
+ import {UIKIT_CONTROL, UIKIT_PAN} from '../motion/uikit/gestures';
47
+ import {slider as UIKIT_SLIDER} from '../motion/uikit/slider';
48
+ import {observeResize} from '../utils/sharedResizeObserver';
49
+
50
+ /** Frames of a coast before it reports values (the touch hand-off limit). */
51
+ const HAND_OFF_FRAMES = 3;
52
+
53
+ /** A coast shorter than this (px) and slower than 1 px/s is not played. */
54
+ const MIN_COAST_PX = 0.5;
55
+
56
+ /** The parts the controller moves. */
57
+ export interface SliderTouchParts {
58
+ readonly control: HTMLElement;
59
+ readonly thumbs: ReadonlyArray<HTMLElement>;
60
+ /** The fill's mover (a range's start) and the fill itself. */
61
+ readonly fillMover: HTMLElement | null;
62
+ readonly fill: HTMLElement | null;
63
+ }
64
+
65
+ /** What the controller needs from Slider. Read fresh on every use. */
66
+ export interface SliderTouchModel {
67
+ readonly min: number;
68
+ readonly max: number;
69
+ readonly isRange: boolean;
70
+ readonly isHorizontal: boolean;
71
+ readonly isDisabled: boolean;
72
+ /** The thumb's size along the travel, px: the travel is inset by half of it. */
73
+ readonly thumbSize: number;
74
+ /** The values the owner rendered. */
75
+ values(): ReadonlyArray<number>;
76
+ /** What thumb `index` becomes for `raw`: snapped, clamped, kept apart. */
77
+ constrain(index: number, raw: number): number;
78
+ /** The range thumb `index` may cover, unsnapped (its ends and the gap). */
79
+ bounds(index: number): readonly [number, number];
80
+ /**
81
+ * `onChange` for thumb `index`. `from` holds the start values when one
82
+ * move changes both range thumbs, as a drag passed between coincident
83
+ * thumbs does.
84
+ */
85
+ change(index: number, value: number, from?: ReadonlyArray<number>): void;
86
+ /** Thumb `index` is under a finger (pressed paint, focus, tooltip), or none. */
87
+ press(index: number | null): void;
88
+ /**
89
+ * The interaction ended. Report thumb `index`'s last `value` through
90
+ * `onChange`, or null when the interaction ends on no value of its own.
91
+ * Report it even when a frame's report already carried it: these handlers
92
+ * run outside React's events, so that report's render can be pending.
93
+ * Release the pressed paint. Then have the owner render it all now, so the
94
+ * parts can return to the stylesheet and `onChangeEnd` carries what the
95
+ * owner rendered.
96
+ */
97
+ commit(index: number, value: number | null): void;
98
+ /** `onChangeEnd`, with the values the owner rendered. */
99
+ reportEnd(): void;
100
+ /**
101
+ * The value a press at client point (`x`, `y`) on the track means: a
102
+ * mark's value on a mark, else the point along the travel.
103
+ */
104
+ valueAt(x: number, y: number): number;
105
+ /** The thumb a track press at `value` moves. */
106
+ thumbFor(value: number): number;
107
+ }
108
+
109
+ /** The travel's geometry, read once when a finger lands. */
110
+ interface Geometry {
111
+ /** The track's length along the travel, CSS px. */
112
+ readonly length: number;
113
+ /** The thumb's travel: the track minus one thumb. */
114
+ readonly travel: number;
115
+ readonly axis: 'x' | 'y';
116
+ /** Screen direction of increasing value along `axis`. */
117
+ readonly sign: 1 | -1;
118
+ /** The control's client rect, for the release's inside test. */
119
+ readonly rect: DOMRect;
120
+ }
121
+
122
+ interface Coast {
123
+ handles: ReadonlyArray<MotionHandle>;
124
+ readonly target: number;
125
+ frames: number;
126
+ raf: number;
127
+ /** The coast in the thumb's px along `travel`, from `at`. */
128
+ curve: MotionCurve;
129
+ travel: number;
130
+ readonly at: number;
131
+ readonly touchRelease: boolean;
132
+ }
133
+
134
+ interface Interaction {
135
+ /** The thumb whose touch target holds the finger. */
136
+ readonly owner: number;
137
+ /** The thumb whose value moves (a coincident pair may hand it over). */
138
+ index: number;
139
+ /** The track as last measured: its length can change mid-gesture. */
140
+ geometry: Geometry;
141
+ /** The value when the pan began. Null until it does. */
142
+ startValue: number | null;
143
+ /**
144
+ * Where the pan's threshold lies along the axis, px from the touch-down
145
+ * point. For a finger, it is the hysteresis on the side the pan began. For
146
+ * a mouse, whose pan begins in any direction, it is the threshold's
147
+ * projection on the axis.
148
+ */
149
+ beganOffset: number;
150
+ /** The last value reported (or rendered when the finger landed). */
151
+ value: number;
152
+ /** Whether the finger is inside the control's tracking bounds. */
153
+ isInside: boolean;
154
+ /** The end the value sits at, for the once-per-entry haptic. */
155
+ atEnd: 'min' | 'max' | null;
156
+ /** The value changed during this interaction. */
157
+ changed: boolean;
158
+ coast: Coast | null;
159
+ /** The pan ended or was cancelled (the release is under way or done). */
160
+ released: boolean;
161
+ /**
162
+ * A mouse holds the thumb: it pans as a finger does, with no lift and no
163
+ * coast, as a mouse drag behaved before.
164
+ */
165
+ readonly isMouse: boolean;
166
+ /**
167
+ * A finger's release coasts. A mouse's or a pen's drag ends at its release,
168
+ * with no coast.
169
+ */
170
+ readonly coasts: boolean;
171
+ /**
172
+ * The drag began on two range thumbs that share a value. It picks its
173
+ * thumb by its first movement and may pass to the other thumb.
174
+ */
175
+ readonly beganCoincident: boolean;
176
+ }
177
+
178
+ /**
179
+ * The release coast as a curve in the thumb's own px, clamped to the
180
+ * thumb's range. It is a spring from `from` to `to` with `velocity` (px/s).
181
+ * Its value stops at an end instead of passing it, as UISlider clamps its
182
+ * value and the thumb follows it.
183
+ */
184
+ export function sliderCoastCurve({
185
+ from,
186
+ to,
187
+ velocity,
188
+ lower,
189
+ upper,
190
+ spring: description = SLIDER_RELEASE_SPRING,
191
+ }: {
192
+ from: number;
193
+ to: number;
194
+ velocity: number;
195
+ lower: number;
196
+ upper: number;
197
+ /** The release spring, as the thumb's theme resolves it. */
198
+ spring?: SpringDescription;
199
+ }): MotionCurve {
200
+ const spring = solveSpring(toSpring(description), {
201
+ from,
202
+ to,
203
+ velocity,
204
+ });
205
+ const lo = Math.min(lower, upper);
206
+ const hi = Math.max(lower, upper);
207
+ const duration = Math.min(Math.max(spring.settleTime(0.05), 1 / 120), 3);
208
+ return {
209
+ duration,
210
+ at: t => (t >= duration ? to : Math.min(Math.max(spring.value(t), lo), hi)),
211
+ velocity: t => {
212
+ if (t >= duration) {
213
+ return 0;
214
+ }
215
+ const value = spring.value(t);
216
+ return value <= lo || value >= hi ? 0 : spring.velocity(t);
217
+ },
218
+ };
219
+ }
220
+
221
+ /**
222
+ * Where the browser draws an element's translate on one axis in this frame,
223
+ * px: its computed style, which follows the running animation. Null when it
224
+ * draws none.
225
+ */
226
+ function drawnTranslate(element: HTMLElement, axis: 'x' | 'y'): number | null {
227
+ const translate =
228
+ element.ownerDocument.defaultView?.getComputedStyle(element).translate ??
229
+ '';
230
+ if (translate === '' || translate === 'none') {
231
+ return null;
232
+ }
233
+ const [x = '0', y = '0'] = translate.split(' ');
234
+ const px = parseFloat(axis === 'x' ? x : y);
235
+ return Number.isFinite(px) ? px : null;
236
+ }
237
+
238
+ /** UISlider's touch tracking for one Slider (both thumbs of a range). */
239
+ export class SliderTouchController {
240
+ private model: SliderTouchModel;
241
+ private readonly getParts: () => SliderTouchParts | null;
242
+ private readonly interactions = new Map<number, Interaction>();
243
+ private readonly animators = new Map<HTMLElement, Animator>();
244
+ /** The owner unmounted the slider: a cancel reports nothing then. */
245
+ private disposed = false;
246
+ /** Thumbs whose current touch is a mouse: Slider's own handlers take it. */
247
+ private readonly mouseOwners = new Set<number>();
248
+ /** The track's tap (see handleTrackTap). Null while detached. */
249
+ private trackTap: TapGestureRecognizer | null = null;
250
+
251
+ constructor(
252
+ model: SliderTouchModel,
253
+ getParts: () => SliderTouchParts | null,
254
+ ) {
255
+ this.model = model;
256
+ this.getParts = getParts;
257
+ }
258
+
259
+ setModel(model: SliderTouchModel): void {
260
+ this.model = model;
261
+ }
262
+
263
+ /**
264
+ * Attaches the recognizers to the thumbs for every pointer. Under a
265
+ * coarse primary pointer each thumb's `::before` is its touch target.
266
+ */
267
+ attach(): () => void {
268
+ this.disposed = false;
269
+ const parts = this.getParts();
270
+ const targets = parts?.thumbs ?? [];
271
+ if (parts == null || targets.length === 0) {
272
+ return () => {};
273
+ }
274
+ // The coast plays a motion role: register the springs table, once.
275
+ registerSpringsTable();
276
+ const host = getGestureHost(parts.control.ownerDocument);
277
+ const detachers: (() => void)[] = [];
278
+ targets.forEach((hit, owner) => {
279
+ const tracker = createPressTracker({
280
+ getBounds: () =>
281
+ this.getParts()?.control.getBoundingClientRect() ?? null,
282
+ view: hit,
283
+ highlightDelay: PRESS_PROFILES.control.highlightDelay,
284
+ quickTapFlash: PRESS_PROFILES.control.quickTapFlash,
285
+ onControlEvent: (event, touch) =>
286
+ this.handleControlEvent(owner, event, touch.pointerType),
287
+ onHighlightChange: isHighlighted =>
288
+ this.handleHighlight(owner, isHighlighted),
289
+ vetoesAncestorRecognizer: recognizer => this.vetoes(recognizer),
290
+ isEnabled: () => !this.model.isDisabled,
291
+ });
292
+ const pan = new PanGestureRecognizer({
293
+ name: `slider-thumb-${owner}`,
294
+ direction: this.model.isHorizontal ? 'horizontal' : 'vertical',
295
+ maximumNumberOfTouches: 1,
296
+ cancelsTouchesInView: false,
297
+ allowedPointerTypes: ['touch', 'pen'],
298
+ // A held thumb does something, so a held finger never starts a text
299
+ // selection or the callout on it. The gesture host takes this lock
300
+ // for each held press and releases it on the lift.
301
+ selectionLock: lockSelection,
302
+ });
303
+ // A mouse drag on a thumb is a pan too, claimed after the same
304
+ // hysteresis but in any direction. A mouse has no page scroll to yield
305
+ // to, so a drag that starts off the travel still moves the thumb.
306
+ const mousePan = new PanGestureRecognizer({
307
+ name: `slider-thumb-${owner}-mouse`,
308
+ maximumNumberOfTouches: 1,
309
+ cancelsTouchesInView: false,
310
+ allowedPointerTypes: ['mouse'],
311
+ });
312
+ const removeListener = pan.addListener(current =>
313
+ this.handlePan(owner, current),
314
+ );
315
+ const removeMouseListener = mousePan.addListener(current =>
316
+ this.handlePan(owner, current),
317
+ );
318
+ detachers.push(
319
+ host.attachConsumer(hit, tracker),
320
+ host.attach(hit, pan),
321
+ host.attach(hit, mousePan),
322
+ removeListener,
323
+ removeMouseListener,
324
+ () => tracker.dispose(),
325
+ );
326
+ });
327
+ // A tap on the track (or a mark) sets the value when it lifts. So a drag
328
+ // is not the only pointer way to operate the slider (WCAG 2.2 SC 2.5.7).
329
+ // UISlider ignores track taps. This departs from it.
330
+ // A touch that moves past the tap's allowance, or that the page takes to
331
+ // scroll, is no tap.
332
+ const tap = new TapGestureRecognizer({
333
+ name: 'slider-track-tap',
334
+ allowedPointerTypes: ['touch', 'pen'],
335
+ cancelsTouchesInView: false,
336
+ // A touch that travels as far as a thumb's pan needs to begin is a
337
+ // swipe, not a tap (UITapGestureRecognizer would allow 45 pt).
338
+ allowableMovement: UIKIT_PAN.hysteresis.value,
339
+ // A finger held on the track does nothing. So the tap takes no
340
+ // selection lock: the control's own `user-select: none` already keeps
341
+ // its text unselectable.
342
+ });
343
+ this.trackTap = tap;
344
+ const removeTapListener = tap.addListener(current => {
345
+ if (current.state === 'ended') {
346
+ this.handleTrackTap(current);
347
+ }
348
+ });
349
+ detachers.push(host.attach(parts.control, tap), removeTapListener, () => {
350
+ this.trackTap = null;
351
+ });
352
+ // A layout can change the track's length mid-gesture, as when a value
353
+ // label beside it takes a digit. The drawn parts follow before the next
354
+ // paint, so nothing jumps at the hand-back.
355
+ detachers.push(
356
+ observeResize(parts.control, () => this.followTrackLength()),
357
+ );
358
+ return () => {
359
+ // Interactions end first, so the cancel the recognizers send as they
360
+ // detach finds nothing left to report.
361
+ const ended = this.endSilently();
362
+ for (const detach of detachers) {
363
+ detach();
364
+ }
365
+ this.cancelLater(ended);
366
+ };
367
+ }
368
+
369
+ /** Stops every interaction and animation without reporting (unmount). */
370
+ dispose(): void {
371
+ this.disposed = true;
372
+ this.endSilently();
373
+ for (const animator of this.animators.values()) {
374
+ animator.dispose();
375
+ }
376
+ this.animators.clear();
377
+ }
378
+
379
+ /**
380
+ * Ends every live interaction without a word to the owner, and returns
381
+ * them. It runs from an Effect cleanup, where `onChange`, `onChangeEnd` and
382
+ * a synchronous render have no place. A coast stops where it is drawn, and
383
+ * the parts return to the stylesheet at the value the owner last rendered.
384
+ */
385
+ private endSilently(): ReadonlyArray<Interaction> {
386
+ if (this.interactions.size === 0) {
387
+ return [];
388
+ }
389
+ const ended = [...this.interactions.values()];
390
+ const view = this.getParts()?.control.ownerDocument.defaultView;
391
+ for (const interaction of this.interactions.values()) {
392
+ const coast = interaction.coast;
393
+ if (coast != null) {
394
+ interaction.coast = null;
395
+ view?.cancelAnimationFrame(coast.raf);
396
+ for (const handle of coast.handles) {
397
+ handle.stop();
398
+ }
399
+ }
400
+ interaction.released = true;
401
+ }
402
+ this.interactions.clear();
403
+ this.mouseOwners.clear();
404
+ this.handBack();
405
+ return ended;
406
+ }
407
+
408
+ /**
409
+ * Ends the interactions a detach cancelled while the slider stays mounted,
410
+ * as when it is disabled mid-drag. Each releases its pressed paint. If any
411
+ * changed the value, `onChangeEnd` reports once. This waits until the
412
+ * cleanup's task ends, and an unmount in the same commit drops it.
413
+ */
414
+ private cancelLater(ended: ReadonlyArray<Interaction>): void {
415
+ if (ended.length === 0) {
416
+ return;
417
+ }
418
+ queueMicrotask(() => {
419
+ if (this.disposed) {
420
+ return;
421
+ }
422
+ for (const interaction of ended) {
423
+ this.model.commit(interaction.index, null);
424
+ }
425
+ if (ended.some(interaction => interaction.changed)) {
426
+ this.model.reportEnd();
427
+ }
428
+ });
429
+ }
430
+
431
+ /**
432
+ * After the owner rendered an ended interaction's final value, the thumb
433
+ * and the fill go back to the positions Slider's CSS gives them. Parts that
434
+ * a coast of another thumb still drives stay with it.
435
+ */
436
+ private handBack(): void {
437
+ for (const [element, animator] of this.animators) {
438
+ if (
439
+ [...this.interactions.values()].some(
440
+ interaction =>
441
+ (interaction.coast != null || !interaction.released) &&
442
+ this.partsOf(interaction).has(element),
443
+ )
444
+ ) {
445
+ continue;
446
+ }
447
+ // Ends the animator's translate at identity, then removes the inline
448
+ // property. So the class's progress-driven translate shows again.
449
+ animator.set({x: 0, y: 0});
450
+ element.style.removeProperty('translate');
451
+ // A Reduced Motion cross-fade wrote the opacity: end it at 1 and hand it
452
+ // back too (a caught fade stops part-way).
453
+ if (element.style.getPropertyValue('opacity') !== '') {
454
+ animator.set({opacity: 1});
455
+ element.style.removeProperty('opacity');
456
+ }
457
+ }
458
+ }
459
+
460
+ // -- events ----------------------------------------------------------------
461
+
462
+ private handleControlEvent(
463
+ owner: number,
464
+ event: ControlEvent,
465
+ pointerType: PointerKind,
466
+ ): void {
467
+ if (event === 'touchDown') {
468
+ // A mouse press holds the thumb too. Its highlight never lifts
469
+ // the thumb (handleHighlight).
470
+ const isMouse = pointerType === 'mouse';
471
+ if (isMouse) {
472
+ this.mouseOwners.add(owner);
473
+ } else {
474
+ this.mouseOwners.delete(owner);
475
+ }
476
+ this.begin(owner, isMouse, pointerType === 'touch');
477
+ return;
478
+ }
479
+ const interaction = this.interactions.get(owner);
480
+ if (interaction == null) {
481
+ return;
482
+ }
483
+ switch (event) {
484
+ case 'touchDragInside':
485
+ case 'touchDragEnter':
486
+ interaction.isInside = true;
487
+ return;
488
+ case 'touchDragOutside':
489
+ case 'touchDragExit':
490
+ interaction.isInside = false;
491
+ return;
492
+ case 'touchUpInside':
493
+ case 'touchUpOutside':
494
+ interaction.isInside = event === 'touchUpInside';
495
+ // A press that never became a pan ends here (a tap or a hold).
496
+ if (interaction.startValue == null) {
497
+ this.finish(interaction, true);
498
+ }
499
+ return;
500
+ case 'touchCancel':
501
+ // The browser took the touch to scroll, or the press was cancelled
502
+ // before the pan began. A begun pan reports its own cancel.
503
+ if (interaction.startValue == null) {
504
+ this.finish(interaction, false);
505
+ }
506
+ return;
507
+ default:
508
+ return;
509
+ }
510
+ }
511
+
512
+ private handleHighlight(owner: number, isHighlighted: boolean): void {
513
+ if (this.mouseOwners.has(owner)) {
514
+ return;
515
+ }
516
+ const interaction = this.interactions.get(owner);
517
+ const index = interaction?.index ?? owner;
518
+ if (isHighlighted && interaction != null && !interaction.released) {
519
+ this.lift(index);
520
+ } else if (!isHighlighted && interaction == null) {
521
+ this.drop(index);
522
+ }
523
+ }
524
+
525
+ private handlePan(owner: number, pan: PanGestureRecognizer): void {
526
+ const interaction = this.interactions.get(owner);
527
+ if (interaction == null || interaction.released) {
528
+ return;
529
+ }
530
+ switch (pan.state) {
531
+ case 'began':
532
+ interaction.startValue = interaction.value;
533
+ {
534
+ const total = pan.totalTranslation();
535
+ const along = total[interaction.geometry.axis];
536
+ const distance = Math.hypot(total.x, total.y);
537
+ interaction.beganOffset = interaction.isMouse
538
+ ? distance > 0
539
+ ? (pan.hysteresis * along) / distance
540
+ : 0
541
+ : (along < 0 ? -1 : 1) * pan.hysteresis;
542
+ }
543
+ if (!interaction.isMouse) {
544
+ this.lift(interaction.index);
545
+ }
546
+ this.track(interaction, pan);
547
+ return;
548
+ case 'changed':
549
+ this.track(interaction, pan);
550
+ return;
551
+ case 'ended':
552
+ interaction.released = true;
553
+ this.release(interaction, pan);
554
+ return;
555
+ case 'cancelled':
556
+ interaction.released = true;
557
+ this.finish(interaction, true);
558
+ return;
559
+ case 'possible':
560
+ case 'failed':
561
+ return;
562
+ }
563
+ }
564
+
565
+ /** UISlider's veto: an ancestor's pan or swipe along the travel. */
566
+ private vetoes(recognizer: GestureRecognizer): boolean {
567
+ // A touch on a thumb is the thumb's: a tap there changes nothing.
568
+ if (recognizer === this.trackTap) {
569
+ return true;
570
+ }
571
+ const horizontal = this.model.isHorizontal;
572
+ // Told apart by kind, never by class: a screen-edge pan is a pan.
573
+ if (recognizer.kind === 'swipe') {
574
+ return (recognizer as SwipeGestureRecognizer).directions.some(
575
+ direction =>
576
+ horizontal
577
+ ? direction === 'left' || direction === 'right'
578
+ : direction === 'up' || direction === 'down',
579
+ );
580
+ }
581
+ if (recognizer.kind === 'pan' || recognizer.kind === 'screenEdgePan') {
582
+ const {x, y} = (recognizer as PanGestureRecognizer).translation();
583
+ return horizontal
584
+ ? Math.abs(x) >= Math.abs(y)
585
+ : Math.abs(y) >= Math.abs(x);
586
+ }
587
+ return false;
588
+ }
589
+
590
+ // -- the interaction -------------------------------------------------------
591
+
592
+ private begin(owner: number, isMouse = false, coasts = false): void {
593
+ const parts = this.getParts();
594
+ if (parts == null || this.model.isDisabled) {
595
+ return;
596
+ }
597
+ // A finger on a coasting thumb stops it where it is: the coast ends on
598
+ // that value, which the owner renders and onChangeEnd reports.
599
+ for (const other of [...this.interactions.values()]) {
600
+ if (other.coast != null) {
601
+ this.finish(other, true, this.stopCoast(other));
602
+ }
603
+ }
604
+ const geometry = this.measure(parts);
605
+ const values = this.model.values();
606
+ const value = values[owner] ?? this.model.min;
607
+ this.interactions.set(owner, {
608
+ owner,
609
+ index: owner,
610
+ geometry,
611
+ startValue: null,
612
+ beganOffset: 0,
613
+ value,
614
+ isInside: true,
615
+ atEnd: this.endOf(value),
616
+ changed: false,
617
+ coast: null,
618
+ released: false,
619
+ isMouse,
620
+ coasts,
621
+ beganCoincident: this.model.isRange && values[0] === values[1],
622
+ });
623
+ this.model.press(owner);
624
+ }
625
+
626
+ private track(interaction: Interaction, pan: PanGestureRecognizer): void {
627
+ const {geometry} = interaction;
628
+ const {min, max, isRange} = this.model;
629
+ // A collapsed track has no length to map the travel onto.
630
+ if (geometry.length <= 0) {
631
+ return;
632
+ }
633
+ // UISlider counts the translation from the threshold itself (the
634
+ // touch-down point plus the hysteresis, taken along the axis). So the
635
+ // travel of the event that crossed it already counts.
636
+ const translation =
637
+ (pan.totalTranslation()[geometry.axis] - interaction.beganOffset) *
638
+ geometry.sign;
639
+ const startValue = interaction.startValue ?? interaction.value;
640
+ const raw =
641
+ startValue +
642
+ (translation / geometry.length) * (max - min) * UIKIT_SLIDER.trackingGain;
643
+ // A drag that began on two range thumbs sharing a value: its first
644
+ // movement, which crosses the threshold, picks the thumb that can follow
645
+ // it. The thumb on top cannot pass the other. Coming back past the other
646
+ // thumb's value hands the drag to that thumb, which moves on. A drag that
647
+ // began apart stops at the other thumb.
648
+ let from: ReadonlyArray<number> | undefined;
649
+ if (isRange && interaction.beganCoincident && raw !== startValue) {
650
+ const values = this.model.values();
651
+ const current = interaction.index;
652
+ const index =
653
+ values[0] === values[1] && !interaction.changed
654
+ ? raw < startValue
655
+ ? 0
656
+ : 1
657
+ : current === 1 && raw < (values[0] ?? raw)
658
+ ? 0
659
+ : current === 0 && raw > (values[1] ?? raw)
660
+ ? 1
661
+ : current;
662
+ if (index !== current) {
663
+ // Passing back over the other thumb leaves this one against it,
664
+ // however far the move went, and the other moves on: one change
665
+ // reports both.
666
+ if (interaction.changed) {
667
+ const against = this.model.constrain(current, values[index] ?? raw);
668
+ interaction.value = against;
669
+ this.place(interaction);
670
+ const next = [...values];
671
+ next[current] = against;
672
+ from = next;
673
+ }
674
+ this.drop(current);
675
+ interaction.index = index;
676
+ interaction.value = values[index] ?? interaction.value;
677
+ if (!interaction.isMouse) {
678
+ this.lift(index);
679
+ }
680
+ this.model.press(index);
681
+ }
682
+ }
683
+ this.record(interaction, this.model.constrain(interaction.index, raw), {
684
+ from,
685
+ });
686
+ this.place(interaction);
687
+ }
688
+
689
+ /**
690
+ * Writes the moving thumb's and the fill's positions for the
691
+ * interaction's value straight to their translates: one visual update per
692
+ * move, with no component render. The parts go back to
693
+ * the stylesheet when the interaction ends (`handBack`).
694
+ */
695
+ private place(interaction: Interaction): void {
696
+ const {geometry} = interaction;
697
+ const {min, max} = this.model;
698
+ const range = max - min;
699
+ const pxPerValue = range > 0 ? geometry.travel / range : 0;
700
+ const px = (interaction.value - min) * pxPerValue;
701
+ for (const {element, at} of this.partMaps(interaction)) {
702
+ this.animatorFor(element).set({[geometry.axis]: geometry.sign * at(px)});
703
+ }
704
+ }
705
+
706
+ /**
707
+ * Records a new value and plays the end haptic on entering an end.
708
+ * Reports it through `onChange` unless the interaction's end commits it.
709
+ * Returns whether the value changed.
710
+ */
711
+ private record(
712
+ interaction: Interaction,
713
+ value: number,
714
+ {
715
+ commits = false,
716
+ from,
717
+ }: {commits?: boolean; from?: ReadonlyArray<number>} = {},
718
+ ): boolean {
719
+ if (value === interaction.value) {
720
+ return false;
721
+ }
722
+ interaction.value = value;
723
+ interaction.changed = true;
724
+ if (!commits) {
725
+ this.model.change(interaction.index, value, from);
726
+ }
727
+ const end = this.endOf(value);
728
+ // UISlider's end haptic belongs to a finger's or a pen's drag and its
729
+ // coast. A mouse and a track tap play none.
730
+ if (
731
+ end != null &&
732
+ end !== interaction.atEnd &&
733
+ !interaction.isMouse &&
734
+ interaction.startValue != null
735
+ ) {
736
+ playHaptic(UIKIT_SLIDER.endHaptic);
737
+ }
738
+ interaction.atEnd = end;
739
+ return true;
740
+ }
741
+
742
+ private report(interaction: Interaction, value: number): void {
743
+ this.record(interaction, value);
744
+ }
745
+
746
+ private release(interaction: Interaction, pan: PanGestureRecognizer): void {
747
+ // A mouse's or a pen's drag ends at its release, where the pointer lets
748
+ // go: only a finger's release coasts. A pen's
749
+ // thumb, lifted while held, drops back first.
750
+ if (!interaction.coasts) {
751
+ if (!interaction.isMouse) {
752
+ this.drop(interaction.index);
753
+ }
754
+ this.model.press(null);
755
+ this.finish(interaction, true);
756
+ return;
757
+ }
758
+ const {geometry, index} = interaction;
759
+ const {min, max} = this.model;
760
+ const range = max - min;
761
+ this.drop(index);
762
+ this.model.press(null);
763
+ const location = pan.location();
764
+ const expansion = UIKIT_CONTROL.trackingExpansion.value;
765
+ const inside =
766
+ location.x >= geometry.rect.left - expansion &&
767
+ location.x <= geometry.rect.right + expansion &&
768
+ location.y >= geometry.rect.top - expansion &&
769
+ location.y <= geometry.rect.bottom + expansion;
770
+ interaction.isInside = inside;
771
+ // Finger velocity along increasing value, px/s.
772
+ const velocity = pan.velocity()[geometry.axis] * geometry.sign;
773
+ const projected = inside
774
+ ? UIKIT_SLIDER.releaseProjectionShare *
775
+ projectDistance(velocity, UIKIT_SLIDER.releaseProjectionRate)
776
+ : 0;
777
+ const from = interaction.value;
778
+ // Value per px of finger travel. A collapsed track maps none, so it
779
+ // coasts nowhere.
780
+ const valuePerPx =
781
+ geometry.length > 0
782
+ ? (range * UIKIT_SLIDER.trackingGain) / geometry.length
783
+ : 0;
784
+ const target = this.model.constrain(index, from + projected * valuePerPx);
785
+ // In the thumb's px: the value's velocity mapped onto the travel.
786
+ const pxPerValue = range > 0 ? geometry.travel / range : 0;
787
+ const valueVelocity = velocity * valuePerPx;
788
+ this.coastTo(interaction, target, valueVelocity * pxPerValue, true);
789
+ }
790
+
791
+ /**
792
+ * A tap on the track: the thumb the tap's value picks springs there, on
793
+ * the release spring from rest. It reports as a coast does, and
794
+ * `onChangeEnd` follows. A tap on the thumb's own value changes nothing.
795
+ */
796
+ private handleTrackTap(tap: TapGestureRecognizer): void {
797
+ const parts = this.getParts();
798
+ if (parts == null || this.model.isDisabled || this.interactions.size > 0) {
799
+ return;
800
+ }
801
+ const {x, y} = tap.location();
802
+ const raw = this.model.valueAt(x, y);
803
+ const index = this.model.thumbFor(raw);
804
+ const target = this.model.constrain(index, raw);
805
+ const value = this.model.values()[index] ?? this.model.min;
806
+ if (target === value) {
807
+ return;
808
+ }
809
+ const interaction: Interaction = {
810
+ owner: index,
811
+ index,
812
+ geometry: this.measure(parts),
813
+ startValue: null,
814
+ beganOffset: 0,
815
+ value,
816
+ isInside: true,
817
+ atEnd: this.endOf(value),
818
+ changed: false,
819
+ coast: null,
820
+ released: true,
821
+ isMouse: false,
822
+ coasts: false,
823
+ beganCoincident: false,
824
+ };
825
+ this.interactions.set(index, interaction);
826
+ // A tap's value change happens in place. So under Reduce Motion it
827
+ // applies at once, with no spring and no cross-fade.
828
+ const thumb = parts.thumbs[index];
829
+ if (thumb != null && this.animatorFor(thumb).reducedMotion() !== 'off') {
830
+ this.finish(interaction, true, target);
831
+ return;
832
+ }
833
+ this.coastTo(interaction, target, 0, false);
834
+ }
835
+
836
+ /**
837
+ * Springs the interaction's thumb from its value to `target`, on the
838
+ * compositor and clamped, starting at `velocityPx` (px/s along the
839
+ * travel). `onChange` follows from the coast's fourth frame, and the
840
+ * interaction ends (`onChangeEnd`) on its last. `touchRelease` means a
841
+ * finger's release started it. Then the coast continues the throw and
842
+ * keeps moving under Reduce Motion. A tap's spring never runs there
843
+ * (`handleTrackTap` sets the value at once).
844
+ */
845
+ private coastTo(
846
+ interaction: Interaction,
847
+ target: number,
848
+ velocityPx: number,
849
+ touchRelease: boolean,
850
+ ): void {
851
+ const {geometry, index} = interaction;
852
+ const {min, max} = this.model;
853
+ const range = max - min;
854
+ const pxPerValue = range > 0 ? geometry.travel / range : 0;
855
+ const from = interaction.value;
856
+ const fromPx = (from - min) * pxPerValue;
857
+ const toPx = (target - min) * pxPerValue;
858
+ const [lowerValue, upperValue] = this.model.bounds(index);
859
+ const curve = sliderCoastCurve({
860
+ from: fromPx,
861
+ to: toPx,
862
+ velocity: velocityPx,
863
+ lower: (lowerValue - min) * pxPerValue,
864
+ upper: (upperValue - min) * pxPerValue,
865
+ // The coast plays the `springs.sliderRelease` role, resolved as it
866
+ // starts.
867
+ spring: resolveMotion(this.getParts()?.control, SLIDER_RELEASE_SPRING),
868
+ });
869
+ const moves =
870
+ Math.abs(toPx - fromPx) >= MIN_COAST_PX ||
871
+ Math.max(Math.abs(curve.at(0.05) - fromPx), 0) >= MIN_COAST_PX;
872
+ if (!moves) {
873
+ this.finish(interaction, true, target);
874
+ return;
875
+ }
876
+ const at = performance.now();
877
+ const handles = this.playCoast(interaction, curve, touchRelease, at);
878
+ if (handles.length === 0) {
879
+ this.finish(interaction, true, target);
880
+ return;
881
+ }
882
+ const coast: Coast = {
883
+ handles,
884
+ target,
885
+ frames: 0,
886
+ raf: 0,
887
+ curve,
888
+ travel: geometry.travel,
889
+ at,
890
+ touchRelease,
891
+ };
892
+ interaction.coast = coast;
893
+ const view = this.getParts()?.control.ownerDocument.defaultView;
894
+ const step = () => {
895
+ if (interaction.coast !== coast) {
896
+ return;
897
+ }
898
+ coast.frames += 1;
899
+ if (coast.frames > HAND_OFF_FRAMES) {
900
+ const {axis, travel} = interaction.geometry;
901
+ const px = Math.abs(coast.handles[0]?.value(axis) ?? 0);
902
+ const value =
903
+ range > 0 && travel > 0
904
+ ? min + (px / travel) * range
905
+ : interaction.value;
906
+ this.report(interaction, this.model.constrain(index, value));
907
+ }
908
+ coast.raf = view?.requestAnimationFrame(step) ?? 0;
909
+ };
910
+ coast.raf = view?.requestAnimationFrame(step) ?? 0;
911
+ this.endCoastWithThumb(interaction, coast);
912
+ }
913
+
914
+ /** Ends the coast when its thumb's motion finishes, not when it stops. */
915
+ private endCoastWithThumb(interaction: Interaction, coast: Coast): void {
916
+ const thumbHandle = coast.handles[0];
917
+ void thumbHandle?.finished.then(finished => {
918
+ if (
919
+ interaction.coast !== coast ||
920
+ coast.handles[0] !== thumbHandle ||
921
+ !finished
922
+ ) {
923
+ return;
924
+ }
925
+ this.getParts()?.control.ownerDocument.defaultView?.cancelAnimationFrame(
926
+ coast.raf,
927
+ );
928
+ interaction.coast = null;
929
+ this.finish(interaction, true, coast.target);
930
+ });
931
+ }
932
+
933
+ /**
934
+ * After the track's length changed: every live interaction measures it
935
+ * again. A drag writes its parts for the new length, and a coast plays
936
+ * on from where its curve is now, scaled to the new travel.
937
+ */
938
+ private followTrackLength(): void {
939
+ const parts = this.getParts();
940
+ if (parts == null || this.interactions.size === 0) {
941
+ return;
942
+ }
943
+ for (const interaction of this.interactions.values()) {
944
+ const geometry = this.measure(parts);
945
+ if (geometry.length === interaction.geometry.length) {
946
+ continue;
947
+ }
948
+ interaction.geometry = geometry;
949
+ const {coast} = interaction;
950
+ if (coast != null) {
951
+ const scale = coast.travel > 0 ? geometry.travel / coast.travel : 1;
952
+ const {curve} = coast;
953
+ coast.curve = {
954
+ duration: curve.duration,
955
+ at: t => curve.at(t) * scale,
956
+ velocity: t => curve.velocity(t) * scale,
957
+ };
958
+ coast.travel = geometry.travel;
959
+ coast.handles = this.playCoast(
960
+ interaction,
961
+ coast.curve,
962
+ coast.touchRelease,
963
+ coast.at,
964
+ );
965
+ this.endCoastWithThumb(interaction, coast);
966
+ } else if (!interaction.released) {
967
+ this.place(interaction);
968
+ }
969
+ }
970
+ }
971
+
972
+ /**
973
+ * Plays the coast on the thumb and the fill parts, on one clock that
974
+ * started at `at`. A re-mapped coast plays on from where its curve is.
975
+ */
976
+ private playCoast(
977
+ interaction: Interaction,
978
+ curve: MotionCurve,
979
+ touchRelease: boolean,
980
+ at: number,
981
+ ): MotionHandle[] {
982
+ const {axis, sign} = interaction.geometry;
983
+ const handles: MotionHandle[] = [];
984
+ for (const {element, at: position, slope} of this.partMaps(interaction)) {
985
+ const partCurve: MotionCurve = {
986
+ duration: curve.duration,
987
+ at: t => sign * position(curve.at(t)),
988
+ velocity: t => sign * slope * curve.velocity(t),
989
+ };
990
+ const animator = this.animatorFor(element);
991
+ animator.set({[axis]: partCurve.at(0)});
992
+ // A release's coast continues the throw: momentum, which keeps moving
993
+ // under Reduce Motion. A tap's spring is a
994
+ // transition.
995
+ handles.push(
996
+ touchRelease
997
+ ? animator.decay({[axis]: partCurve}, {at, touchRelease})
998
+ : animator.play({[axis]: partCurve}, {at}),
999
+ );
1000
+ }
1001
+ return handles;
1002
+ }
1003
+
1004
+ /**
1005
+ * The parts that move with the interaction's thumb. Each has its translate
1006
+ * as a function of the thumb's px along the travel (`p`), and that
1007
+ * function's slope. The formulas are the same as Slider's CSS.
1008
+ */
1009
+ private partMaps(interaction: Interaction): {
1010
+ element: HTMLElement;
1011
+ at: (p: number) => number;
1012
+ slope: number;
1013
+ }[] {
1014
+ const parts = this.getParts();
1015
+ if (parts == null) {
1016
+ return [];
1017
+ }
1018
+ const {geometry, index} = interaction;
1019
+ const {length, travel} = geometry;
1020
+ const inset = this.model.thumbSize / 2;
1021
+ const values = this.model.values();
1022
+ const {min, max, isRange} = this.model;
1023
+ const range = max - min;
1024
+ const pxPerValue = range > 0 ? travel / range : 0;
1025
+ const otherPx = ((values[index === 0 ? 1 : 0] ?? min) - min) * pxPerValue;
1026
+ const maps: {
1027
+ element: HTMLElement;
1028
+ at: (p: number) => number;
1029
+ slope: number;
1030
+ }[] = [];
1031
+ const add = (
1032
+ element: HTMLElement | null | undefined,
1033
+ at: (p: number) => number,
1034
+ slope: number,
1035
+ ) => {
1036
+ if (element != null) {
1037
+ maps.push({element, at, slope});
1038
+ }
1039
+ };
1040
+ add(parts.thumbs[index], p => p, 1);
1041
+ if (isRange) {
1042
+ if (index === 0) {
1043
+ add(parts.fillMover, p => inset + p, 1);
1044
+ add(parts.fill, p => otherPx - p - length, -1);
1045
+ } else {
1046
+ add(parts.fill, p => p - otherPx - length, 1);
1047
+ }
1048
+ } else {
1049
+ add(parts.fill, p => inset + p - length, 1);
1050
+ }
1051
+ return maps;
1052
+ }
1053
+
1054
+ /**
1055
+ * Stops a coast where the browser draws the thumb in this frame. Returns
1056
+ * the value there for `finish` to end on, or null when the travel has no
1057
+ * length. The animator's own reading can run a frame ahead of or behind
1058
+ * what is drawn, and a finger stops what it sees.
1059
+ */
1060
+ private stopCoast(interaction: Interaction): number | null {
1061
+ const coast = interaction.coast;
1062
+ if (coast == null) {
1063
+ return null;
1064
+ }
1065
+ interaction.coast = null;
1066
+ this.getParts()?.control.ownerDocument.defaultView?.cancelAnimationFrame(
1067
+ coast.raf,
1068
+ );
1069
+ const {geometry, index} = interaction;
1070
+ const {min, max} = this.model;
1071
+ const thumb = this.getParts()?.thumbs[index];
1072
+ const drawn = thumb == null ? null : drawnTranslate(thumb, geometry.axis);
1073
+ const px = Math.abs(drawn ?? coast.handles[0]?.value(geometry.axis) ?? 0);
1074
+ for (const handle of coast.handles) {
1075
+ handle.stop();
1076
+ }
1077
+ const range = max - min;
1078
+ return range > 0 && geometry.travel > 0
1079
+ ? this.model.constrain(index, min + (px / geometry.travel) * range)
1080
+ : null;
1081
+ }
1082
+
1083
+ /**
1084
+ * Ends an interaction from the handler that ends it: the lift, the cancel
1085
+ * or the coast's last frame. The owner renders the last value at once, and
1086
+ * the parts return to the stylesheet. `onChangeEnd` then reports what the
1087
+ * owner rendered, unless the browser took the touch before any drag
1088
+ * (`report` false).
1089
+ */
1090
+ private finish(
1091
+ interaction: Interaction,
1092
+ report: boolean,
1093
+ finalValue: number | null = null,
1094
+ ): void {
1095
+ if (this.interactions.get(interaction.owner) !== interaction) {
1096
+ return;
1097
+ }
1098
+ this.interactions.delete(interaction.owner);
1099
+ if (!interaction.released) {
1100
+ this.drop(interaction.index);
1101
+ }
1102
+ interaction.released = true;
1103
+ if (finalValue != null) {
1104
+ this.record(interaction, finalValue, {commits: true});
1105
+ }
1106
+ // The owner renders the final value from this handler even when a
1107
+ // frame's report already carried it: these handlers run outside React's
1108
+ // events. So that report's render can still be pending, and onChangeEnd
1109
+ // reads what the owner rendered.
1110
+ this.model.commit(interaction.index, finalValue);
1111
+ this.handBack();
1112
+ if (report) {
1113
+ this.model.reportEnd();
1114
+ }
1115
+ }
1116
+
1117
+ // -- the thumb's lift --------------------------------------------------------
1118
+
1119
+ private lift(index: number): void {
1120
+ const thumb = this.getParts()?.thumbs[index];
1121
+ if (thumb == null) {
1122
+ return;
1123
+ }
1124
+ const animator = this.animatorFor(thumb);
1125
+ // The lift is feedback with no fade of its own. Under Reduce Motion the
1126
+ // press shows in the pressed paint alone, and a change of state never
1127
+ // scales.
1128
+ if (animator.reducedMotion() !== 'off') {
1129
+ return;
1130
+ }
1131
+ animator.animate(
1132
+ {scale: UIKIT_SLIDER.liftScale},
1133
+ {spring: resolveMotion(this.getParts()?.control, SLIDER_LIFT_SPRING)},
1134
+ );
1135
+ }
1136
+
1137
+ private drop(index: number): void {
1138
+ const thumb = this.getParts()?.thumbs[index];
1139
+ if (thumb == null) {
1140
+ return;
1141
+ }
1142
+ const animator = this.animators.get(thumb);
1143
+ if (animator == null || animator.get('scale') === 1) {
1144
+ return;
1145
+ }
1146
+ animator.animate(
1147
+ {scale: 1},
1148
+ {spring: resolveMotion(this.getParts()?.control, SLIDER_DROP_SPRING)},
1149
+ );
1150
+ }
1151
+
1152
+ // -- helpers -----------------------------------------------------------------
1153
+
1154
+ private measure(parts: SliderTouchParts): Geometry {
1155
+ const {control} = parts;
1156
+ const horizontal = this.model.isHorizontal;
1157
+ // The layout size (unrounded, untransformed): the size the CSS's
1158
+ // container units resolve against.
1159
+ const style = control.ownerDocument.defaultView?.getComputedStyle(control);
1160
+ const rect = control.getBoundingClientRect();
1161
+ const used = (horizontal ? style?.width : style?.height) ?? '';
1162
+ const length = used.endsWith('px')
1163
+ ? parseFloat(used)
1164
+ : horizontal
1165
+ ? rect.width
1166
+ : rect.height;
1167
+ return {
1168
+ length,
1169
+ travel: Math.max(0, length - this.model.thumbSize),
1170
+ axis: horizontal ? 'x' : 'y',
1171
+ sign: horizontal ? (isRtlElement(control) ? -1 : 1) : -1,
1172
+ rect,
1173
+ };
1174
+ }
1175
+
1176
+ private endOf(value: number): 'min' | 'max' | null {
1177
+ if (value <= this.model.min) {
1178
+ return 'min';
1179
+ }
1180
+ if (value >= this.model.max) {
1181
+ return 'max';
1182
+ }
1183
+ return null;
1184
+ }
1185
+
1186
+ private animatorFor(element: HTMLElement): Animator {
1187
+ let animator = this.animators.get(element);
1188
+ if (animator == null) {
1189
+ animator = createAnimator(element);
1190
+ this.animators.set(element, animator);
1191
+ }
1192
+ return animator;
1193
+ }
1194
+
1195
+ private partsOf(interaction: Interaction): Set<HTMLElement> {
1196
+ const parts = this.getParts();
1197
+ const set = new Set<HTMLElement>();
1198
+ if (parts == null) {
1199
+ return set;
1200
+ }
1201
+ const thumb = parts.thumbs[interaction.index];
1202
+ for (const element of [thumb, parts.fillMover, parts.fill]) {
1203
+ if (element != null) {
1204
+ set.add(element);
1205
+ }
1206
+ }
1207
+ return set;
1208
+ }
1209
+ }