@astryxdesign/core 0.7.6 → 0.7.7-canary.36f8ea1

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