@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.
- package/dist/AppShell/AppShell.d.ts.map +1 -1
- package/dist/AppShell/AppShell.js +23 -3
- package/dist/BottomSheet/BottomSheetPanel.d.ts.map +1 -1
- package/dist/BottomSheet/BottomSheetPanel.js +16 -9
- package/dist/Chat/ChatComposerInput.d.ts +9 -0
- package/dist/Chat/ChatComposerInput.d.ts.map +1 -1
- package/dist/Chat/ChatComposerInput.js +2 -0
- package/dist/Chat/ChatMessage.js +2 -2
- package/dist/Chat/chatMessage.vars.stylex.d.ts +8 -8
- package/dist/Chat/chatMessage.vars.stylex.js +8 -8
- package/dist/CommandPalette/CommandPaletteInput.d.ts.map +1 -1
- package/dist/CommandPalette/CommandPaletteInput.js +8 -1
- package/dist/DateInput/MonthScroller.d.ts.map +1 -1
- package/dist/DateInput/MonthScroller.js +72 -2
- package/dist/Drawer/Drawer.d.ts.map +1 -1
- package/dist/Drawer/Drawer.js +6 -0
- package/dist/DropdownMenu/useMenuOverflow.d.ts +13 -3
- package/dist/DropdownMenu/useMenuOverflow.d.ts.map +1 -1
- package/dist/DropdownMenu/useMenuOverflow.js +31 -8
- package/dist/Field/PanelSearchInput.d.ts.map +1 -1
- package/dist/Field/PanelSearchInput.js +8 -1
- package/dist/Field/textEntryTraits.d.ts +6 -6
- package/dist/Field/textEntryTraits.js +6 -6
- package/dist/Link/Link.d.ts.map +1 -1
- package/dist/Link/Link.js +1 -0
- package/dist/Selector/Selector.d.ts.map +1 -1
- package/dist/Selector/Selector.js +25 -13
- package/dist/Slider/Slider.d.ts.map +1 -1
- package/dist/Slider/Slider.js +404 -131
- package/dist/Slider/sliderTouchController.d.ts +189 -0
- package/dist/Slider/sliderTouchController.d.ts.map +1 -0
- package/dist/Slider/sliderTouchController.js +1017 -0
- package/dist/Slider/useSliderTouch.d.ts +22 -0
- package/dist/Slider/useSliderTouch.d.ts.map +1 -0
- package/dist/Slider/useSliderTouch.js +56 -0
- package/dist/Typeahead/BaseTypeahead.d.ts.map +1 -1
- package/dist/Typeahead/BaseTypeahead.js +19 -9
- package/dist/astryx.css +33 -6
- package/dist/gestures/GestureArena.d.ts.map +1 -1
- package/dist/gestures/GestureArena.js +4 -1
- package/dist/gestures/GestureHost.d.ts +72 -2
- package/dist/gestures/GestureHost.d.ts.map +1 -1
- package/dist/gestures/GestureHost.js +643 -5
- package/dist/gestures/GestureRecognizer.d.ts +15 -1
- package/dist/gestures/GestureRecognizer.d.ts.map +1 -1
- package/dist/gestures/GestureRecognizer.js +12 -0
- package/dist/gestures/PanGestureRecognizer.d.ts +8 -0
- package/dist/gestures/PanGestureRecognizer.d.ts.map +1 -1
- package/dist/gestures/PinchGestureRecognizer.d.ts +5 -1
- package/dist/gestures/PinchGestureRecognizer.d.ts.map +1 -1
- package/dist/gestures/PinchGestureRecognizer.js +10 -0
- package/dist/gestures/ScreenEdgePanGestureRecognizer.d.ts +3 -3
- package/dist/gestures/ScreenEdgePanGestureRecognizer.d.ts.map +1 -1
- package/dist/gestures/ScreenEdgePanGestureRecognizer.js +7 -5
- package/dist/gestures/nativeScroll.d.ts +16 -2
- package/dist/gestures/nativeScroll.d.ts.map +1 -1
- package/dist/gestures/nativeScroll.js +39 -8
- package/dist/gestures/recognizerInternals.d.ts +4 -2
- package/dist/gestures/recognizerInternals.d.ts.map +1 -1
- package/dist/gestures/types.d.ts +11 -0
- package/dist/gestures/types.d.ts.map +1 -1
- package/dist/gestures/types.js +7 -0
- package/dist/gestures/wheelSegmenter.d.ts +45 -0
- package/dist/gestures/wheelSegmenter.d.ts.map +1 -0
- package/dist/gestures/wheelSegmenter.js +98 -0
- package/dist/motion/animator.d.ts.map +1 -1
- package/dist/motion/animator.js +140 -19
- package/dist/motion/paging.d.ts +58 -0
- package/dist/motion/paging.d.ts.map +1 -0
- package/dist/motion/paging.js +128 -0
- package/dist/motion/uikit/index.d.ts +2 -1
- package/dist/motion/uikit/index.d.ts.map +1 -1
- package/dist/motion/uikit/index.js +2 -1
- package/dist/motion/uikit/paging.d.ts +160 -0
- package/dist/motion/uikit/paging.d.ts.map +1 -0
- package/dist/motion/uikit/paging.js +162 -0
- package/dist/motion/uikit/scroll.d.ts +0 -19
- package/dist/motion/uikit/scroll.d.ts.map +1 -1
- package/dist/motion/uikit/scroll.js +2 -21
- package/dist/motion/uikit/slider.d.ts +97 -0
- package/dist/motion/uikit/slider.d.ts.map +1 -0
- package/dist/motion/uikit/slider.js +111 -0
- package/dist/motion/uikit/springs.d.ts +46 -1
- package/dist/motion/uikit/springs.d.ts.map +1 -1
- package/dist/motion/uikit/springs.js +48 -4
- package/package.json +5 -5
- package/src/AppShell/AppShell.doc.mjs +10 -0
- package/src/AppShell/AppShell.tsx +48 -2
- package/src/BottomSheet/BottomSheetPanel.tsx +16 -9
- package/src/Chat/Chat.doc.mjs +1 -0
- package/src/Chat/ChatComposerInput.doc.mjs +11 -0
- package/src/Chat/ChatComposerInput.tsx +11 -0
- package/src/Chat/ChatMessage.tsx +2 -2
- package/src/Chat/chatMessage.vars.stylex.ts +8 -8
- package/src/CommandPalette/CommandPaletteInput.tsx +6 -0
- package/src/DateInput/MonthScroller.tsx +63 -5
- package/src/Drawer/Drawer.tsx +6 -0
- package/src/DropdownMenu/useMenuOverflow.ts +44 -8
- package/src/Field/PanelSearchInput.tsx +6 -0
- package/src/Field/textEntryTraits.ts +6 -6
- package/src/Link/Link.tsx +3 -0
- package/src/Selector/Selector.tsx +29 -2
- package/src/Slider/Slider.doc.mjs +11 -7
- package/src/Slider/Slider.tsx +539 -132
- package/src/Slider/sliderTouchController.ts +1209 -0
- package/src/Slider/useSliderTouch.ts +78 -0
- package/src/Typeahead/BaseTypeahead.tsx +16 -8
- package/src/gestures/GestureArena.ts +1 -0
- package/src/gestures/GestureHost.ts +765 -5
- package/src/gestures/GestureRecognizer.ts +28 -0
- package/src/gestures/PanGestureRecognizer.ts +8 -0
- package/src/gestures/PinchGestureRecognizer.ts +16 -1
- package/src/gestures/README.md +63 -0
- package/src/gestures/ScreenEdgePanGestureRecognizer.ts +7 -5
- package/src/gestures/nativeScroll.ts +49 -9
- package/src/gestures/recognizerInternals.ts +9 -2
- package/src/gestures/types.ts +12 -0
- package/src/gestures/wheelSegmenter.ts +114 -0
- package/src/motion/MEASUREMENTS.md +8 -1
- package/src/motion/README.md +3 -2
- package/src/motion/animator.ts +193 -25
- package/src/motion/paging.ts +138 -0
- package/src/motion/uikit/index.ts +2 -1
- package/src/motion/uikit/paging.ts +162 -0
- package/src/motion/uikit/scroll.ts +2 -18
- package/src/motion/uikit/slider.ts +105 -0
- package/src/motion/uikit/springs.ts +37 -2
- package/dist/motion/selectPage.d.ts +0 -26
- package/dist/motion/selectPage.d.ts.map +0 -1
- package/dist/motion/selectPage.js +0 -55
- package/src/motion/selectPage.ts +0 -55
|
@@ -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
|
+
}
|