@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,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
|
+
}
|