react-native-nitro-modal 0.1.5 → 0.2.0
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/README.md +17 -7
- package/lib/module/NitroModal.js +1 -2
- package/lib/module/NitroModal.js.map +1 -1
- package/lib/module/NitroModalView.js +4 -3
- package/lib/module/NitroModalView.js.map +1 -1
- package/lib/module/NitroModalView.native.js +3 -0
- package/lib/module/NitroModalView.native.js.map +1 -1
- package/lib/module/NitroModalView.web.js +129 -0
- package/lib/module/NitroModalView.web.js.map +1 -0
- package/lib/module/web/AnimatedValue.js +123 -0
- package/lib/module/web/AnimatedValue.js.map +1 -0
- package/lib/module/web/ModalConfig.js +26 -0
- package/lib/module/web/ModalConfig.js.map +1 -0
- package/lib/module/web/ModalController.js +279 -0
- package/lib/module/web/ModalController.js.map +1 -0
- package/lib/module/web/ModalPresenter.js +245 -0
- package/lib/module/web/ModalPresenter.js.map +1 -0
- package/lib/module/web/PopupPresenter.js +117 -0
- package/lib/module/web/PopupPresenter.js.map +1 -0
- package/lib/module/web/SheetPresenter.js +469 -0
- package/lib/module/web/SheetPresenter.js.map +1 -0
- package/lib/module/web/dom.js +125 -0
- package/lib/module/web/dom.js.map +1 -0
- package/lib/typescript/src/NitroModal.d.ts +2 -2
- package/lib/typescript/src/NitroModal.d.ts.map +1 -1
- package/lib/typescript/src/NitroModalView.d.ts +2 -1
- package/lib/typescript/src/NitroModalView.d.ts.map +1 -1
- package/lib/typescript/src/NitroModalView.native.d.ts +1 -0
- package/lib/typescript/src/NitroModalView.native.d.ts.map +1 -1
- package/lib/typescript/src/NitroModalView.web.d.ts +6 -0
- package/lib/typescript/src/NitroModalView.web.d.ts.map +1 -0
- package/lib/typescript/src/web/AnimatedValue.d.ts +48 -0
- package/lib/typescript/src/web/AnimatedValue.d.ts.map +1 -0
- package/lib/typescript/src/web/ModalConfig.d.ts +24 -0
- package/lib/typescript/src/web/ModalConfig.d.ts.map +1 -0
- package/lib/typescript/src/web/ModalController.d.ts +65 -0
- package/lib/typescript/src/web/ModalController.d.ts.map +1 -0
- package/lib/typescript/src/web/ModalPresenter.d.ts +98 -0
- package/lib/typescript/src/web/ModalPresenter.d.ts.map +1 -0
- package/lib/typescript/src/web/PopupPresenter.d.ts +20 -0
- package/lib/typescript/src/web/PopupPresenter.d.ts.map +1 -0
- package/lib/typescript/src/web/SheetPresenter.d.ts +65 -0
- package/lib/typescript/src/web/SheetPresenter.d.ts.map +1 -0
- package/lib/typescript/src/web/dom.d.ts +40 -0
- package/lib/typescript/src/web/dom.d.ts.map +1 -0
- package/package.json +3 -2
- package/src/NitroModal.tsx +3 -4
- package/src/NitroModalView.native.tsx +4 -0
- package/src/NitroModalView.tsx +9 -4
- package/src/NitroModalView.web.tsx +138 -0
- package/src/web/AnimatedValue.ts +189 -0
- package/src/web/ModalConfig.ts +57 -0
- package/src/web/ModalController.ts +305 -0
- package/src/web/ModalPresenter.ts +298 -0
- package/src/web/PopupPresenter.ts +129 -0
- package/src/web/SheetPresenter.ts +582 -0
- package/src/web/dom.ts +154 -0
|
@@ -0,0 +1,582 @@
|
|
|
1
|
+
/// <reference lib="dom" />
|
|
2
|
+
|
|
3
|
+
import type { SheetDetent } from '../NitroModal.nitro';
|
|
4
|
+
import { AnimatedValue, INSTANT, type Motion } from './AnimatedValue';
|
|
5
|
+
import {
|
|
6
|
+
findVerticalScroller,
|
|
7
|
+
isTextInput,
|
|
8
|
+
prefersReducedMotion,
|
|
9
|
+
suppressNextClick,
|
|
10
|
+
type Insets,
|
|
11
|
+
type Size,
|
|
12
|
+
} from './dom';
|
|
13
|
+
import type { ModalConfig } from './ModalConfig';
|
|
14
|
+
import { ModalPresenter, type PresenterListener } from './ModalPresenter';
|
|
15
|
+
|
|
16
|
+
/** Gap kept above the sheet at its tallest. */
|
|
17
|
+
const TOP_GAP = 10;
|
|
18
|
+
/**
|
|
19
|
+
* Extra sheet height below the viewport so it stays attached to the bottom
|
|
20
|
+
* edge while rubber-banding upward.
|
|
21
|
+
*/
|
|
22
|
+
const OVERSCROLL = 400;
|
|
23
|
+
const DEFAULT_CORNER_RADIUS = 16;
|
|
24
|
+
const MAX_WIDTH = 640;
|
|
25
|
+
/** Mouse movement before a press turns into a drag. */
|
|
26
|
+
const POINTER_SLOP = 6;
|
|
27
|
+
/** Pointer samples used for the release velocity. */
|
|
28
|
+
const VELOCITY_WINDOW_MS = 100;
|
|
29
|
+
|
|
30
|
+
const ENTER: Motion = { type: 'spring', response: 0.4, dampingRatio: 0.9 };
|
|
31
|
+
const SETTLE: Motion = { type: 'spring', response: 0.4, dampingRatio: 0.9 };
|
|
32
|
+
/** Underdamped and stopped at the bottom edge, so the exit ends on a fast frame instead of a slow tail. */
|
|
33
|
+
const EXIT: Motion = { type: 'spring', response: 0.45, dampingRatio: 0.8 };
|
|
34
|
+
|
|
35
|
+
interface Drag {
|
|
36
|
+
input: 'touch' | 'pointer';
|
|
37
|
+
/** Touch identifier or pointer id. */
|
|
38
|
+
id: number;
|
|
39
|
+
startX: number;
|
|
40
|
+
startY: number;
|
|
41
|
+
lastY: number;
|
|
42
|
+
/** The innermost vertical scroll container under the start point. */
|
|
43
|
+
scroller: HTMLElement | null;
|
|
44
|
+
/** The sheet claimed the gesture and follows it. */
|
|
45
|
+
active: boolean;
|
|
46
|
+
/** Unconstrained sheet top, before rubber-banding. */
|
|
47
|
+
rawTop: number;
|
|
48
|
+
/** Whether the last movement moved the sheet rather than the scroller. */
|
|
49
|
+
sheetDrove: boolean;
|
|
50
|
+
samples: { time: number; y: number }[];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function sheetWidth(available: number): number {
|
|
54
|
+
return Math.min(available, MAX_WIDTH);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Tallest height the content may occupy (excludes the bottom safe area). */
|
|
58
|
+
function maximumDetentValue(viewportHeight: number, insets: Insets): number {
|
|
59
|
+
return Math.max(0, viewportHeight - insets.top - insets.bottom - TOP_GAP);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Height the content may occupy at the largest of `detents`. */
|
|
63
|
+
function contentHeight(detents: SheetDetent[], maximum: number): number {
|
|
64
|
+
if (
|
|
65
|
+
detents.length === 0 ||
|
|
66
|
+
detents.includes('large') ||
|
|
67
|
+
detents.includes('fitContent')
|
|
68
|
+
) {
|
|
69
|
+
return maximum;
|
|
70
|
+
}
|
|
71
|
+
return detents.includes('medium') ? maximum * 0.5 : maximum * 0.25;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Best guess of the content area before the sheet is on screen. */
|
|
75
|
+
export function sheetContentArea(
|
|
76
|
+
detents: SheetDetent[],
|
|
77
|
+
viewport: Size,
|
|
78
|
+
insets: Insets
|
|
79
|
+
): Size {
|
|
80
|
+
return {
|
|
81
|
+
width: sheetWidth(viewport.width),
|
|
82
|
+
height: contentHeight(detents, maximumDetentValue(viewport.height, insets)),
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function rubberBand(offset: number, dimension: number): number {
|
|
87
|
+
return (1 - 1 / ((offset * 0.55) / dimension + 1)) * dimension;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function findTouch(touches: TouchList, id: number): Touch | undefined {
|
|
91
|
+
for (let i = 0; i < touches.length; i++) {
|
|
92
|
+
const touch = touches.item(i);
|
|
93
|
+
if (touch?.identifier === id) return touch;
|
|
94
|
+
}
|
|
95
|
+
return undefined;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* `bottomSheet` mode: an edge-attached sheet with detents, dragging,
|
|
100
|
+
* swipe-to-dismiss and hand-off to scrollable content, like the iOS
|
|
101
|
+
* implementation.
|
|
102
|
+
*/
|
|
103
|
+
export class SheetPresenter extends ModalPresenter {
|
|
104
|
+
private selectedIndex = 0;
|
|
105
|
+
/** The sheet sits below the viewport (before presenting / while dismissing). */
|
|
106
|
+
private isOffscreen = true;
|
|
107
|
+
private drag: Drag | null = null;
|
|
108
|
+
/** Vertical velocity of the swipe that requested the dismissal. */
|
|
109
|
+
private releaseVelocity = 0;
|
|
110
|
+
/** The sheet's top edge in the viewport. */
|
|
111
|
+
private readonly top = new AnimatedValue(0, 0.5, (value) =>
|
|
112
|
+
this.render(value)
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
constructor(config: ModalConfig, listener: PresenterListener) {
|
|
116
|
+
super(config, listener);
|
|
117
|
+
this.selectedIndex = Math.min(
|
|
118
|
+
Math.max(config.initialDetentIndex, 0),
|
|
119
|
+
this.detents.length - 1
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
snapToDetent(index: number) {
|
|
124
|
+
if (index < 0 || index >= this.detents.length) return;
|
|
125
|
+
this.selectedIndex = index;
|
|
126
|
+
this.animateToRest();
|
|
127
|
+
this.listener.onDetentChange(index);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
protected didShow() {
|
|
131
|
+
const { surface } = this.elements!;
|
|
132
|
+
surface.addEventListener('touchstart', this.handleTouchStart, {
|
|
133
|
+
passive: true,
|
|
134
|
+
});
|
|
135
|
+
surface.addEventListener('touchmove', this.handleTouchMove, {
|
|
136
|
+
passive: false,
|
|
137
|
+
});
|
|
138
|
+
surface.addEventListener('touchend', this.handleTouchEnd);
|
|
139
|
+
surface.addEventListener('touchcancel', this.handleTouchEnd);
|
|
140
|
+
surface.addEventListener('pointerdown', this.handlePointerDown);
|
|
141
|
+
this.layout();
|
|
142
|
+
this.top.set(this.viewport.height);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
protected animateIn() {
|
|
146
|
+
this.isOffscreen = false;
|
|
147
|
+
this.settle(ENTER);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
protected animateOut(onEnd: () => void) {
|
|
151
|
+
this.cancelDrag();
|
|
152
|
+
this.isOffscreen = true;
|
|
153
|
+
const velocity = this.releaseVelocity;
|
|
154
|
+
this.releaseVelocity = 0;
|
|
155
|
+
this.top.animateTo(this.viewport.height, this.motion(EXIT), {
|
|
156
|
+
velocity: velocity > 0 ? velocity : undefined,
|
|
157
|
+
stopAtTarget: true,
|
|
158
|
+
onEnd,
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
protected configDidChange() {
|
|
163
|
+
this.selectedIndex = Math.min(this.selectedIndex, this.detents.length - 1);
|
|
164
|
+
this.layout();
|
|
165
|
+
this.animateToRest();
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
protected viewportDidChange() {
|
|
169
|
+
this.layout();
|
|
170
|
+
this.animateToRest();
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
protected contentSizeDidChange() {
|
|
174
|
+
if (this.config.detents.includes('fitContent')) {
|
|
175
|
+
this.animateToRest();
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
protected stopAnimations() {
|
|
180
|
+
this.top.stop();
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
protected teardown() {
|
|
184
|
+
super.teardown();
|
|
185
|
+
this.cancelDrag();
|
|
186
|
+
const surface = this.elements?.surface;
|
|
187
|
+
surface?.removeEventListener('touchstart', this.handleTouchStart);
|
|
188
|
+
surface?.removeEventListener('touchmove', this.handleTouchMove);
|
|
189
|
+
surface?.removeEventListener('touchend', this.handleTouchEnd);
|
|
190
|
+
surface?.removeEventListener('touchcancel', this.handleTouchEnd);
|
|
191
|
+
surface?.removeEventListener('pointerdown', this.handlePointerDown);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Layout
|
|
195
|
+
|
|
196
|
+
private get detents(): SheetDetent[] {
|
|
197
|
+
return this.config.detents.length > 0
|
|
198
|
+
? this.config.detents
|
|
199
|
+
: ['fitContent'];
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
private get maximumDetentValue(): number {
|
|
203
|
+
return maximumDetentValue(this.viewport.height, this.insets);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
private get keyboardLift(): number {
|
|
207
|
+
return this.config.keyboardBehavior === 'none'
|
|
208
|
+
? 0
|
|
209
|
+
: Math.max(0, this.keyboardHeight - this.insets.bottom);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
private height(detent: SheetDetent): number {
|
|
213
|
+
const maximum = this.maximumDetentValue;
|
|
214
|
+
switch (detent) {
|
|
215
|
+
case 'small':
|
|
216
|
+
return maximum * 0.25;
|
|
217
|
+
case 'medium':
|
|
218
|
+
return maximum * 0.5;
|
|
219
|
+
case 'large':
|
|
220
|
+
return maximum;
|
|
221
|
+
case 'fitContent':
|
|
222
|
+
return Math.min(Math.max(this.contentSize.height, 1), maximum);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Sheet top when resting on `detents[index]`. The bottom safe area is added below the content. */
|
|
227
|
+
private restingTop(index: number): number {
|
|
228
|
+
const { detents } = this;
|
|
229
|
+
const detent =
|
|
230
|
+
detents[Math.min(Math.max(index, 0), detents.length - 1)] ?? 'fitContent';
|
|
231
|
+
const top =
|
|
232
|
+
this.viewport.height -
|
|
233
|
+
this.insets.bottom -
|
|
234
|
+
this.keyboardLift -
|
|
235
|
+
this.height(detent);
|
|
236
|
+
return Math.max(top, this.insets.top + TOP_GAP);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
private get detentTops(): number[] {
|
|
240
|
+
return this.detents.map((_, index) => this.restingTop(index));
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
private layout() {
|
|
244
|
+
const { surface, content, grabber } = this.elements!;
|
|
245
|
+
const width = sheetWidth(this.viewport.width);
|
|
246
|
+
const radius = `${this.config.cornerRadius ?? DEFAULT_CORNER_RADIUS}px`;
|
|
247
|
+
Object.assign(surface.style, {
|
|
248
|
+
left: `${(this.viewport.width - width) / 2}px`,
|
|
249
|
+
top: '0px',
|
|
250
|
+
width: `${width}px`,
|
|
251
|
+
height: `${this.viewport.height + OVERSCROLL}px`,
|
|
252
|
+
borderTopLeftRadius: radius,
|
|
253
|
+
borderTopRightRadius: radius,
|
|
254
|
+
borderBottomLeftRadius: '0px',
|
|
255
|
+
borderBottomRightRadius: '0px',
|
|
256
|
+
});
|
|
257
|
+
content.style.width = `${width}px`;
|
|
258
|
+
content.style.height = `${this.maximumDetentValue}px`;
|
|
259
|
+
grabber.style.display = this.config.grabberVisible ? '' : 'none';
|
|
260
|
+
|
|
261
|
+
let height = contentHeight(this.config.detents, this.maximumDetentValue);
|
|
262
|
+
if (this.config.keyboardBehavior === 'resize') {
|
|
263
|
+
height -= this.keyboardLift;
|
|
264
|
+
}
|
|
265
|
+
this.reportContentArea(width, Math.max(height, 0));
|
|
266
|
+
this.render(this.top.value);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
private render(top: number) {
|
|
270
|
+
if (!this.elements) return;
|
|
271
|
+
this.elements.surface.style.transform = `translate3d(0, ${top}px, 0)`;
|
|
272
|
+
this.elements.backdrop.style.opacity = String(this.backdropAlpha(top));
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** Fully dimmed at every detent; fades out below the lowest one. */
|
|
276
|
+
private backdropAlpha(top: number): number {
|
|
277
|
+
const bottom = this.viewport.height;
|
|
278
|
+
const lowest = Math.max(...this.detentTops);
|
|
279
|
+
if (!(bottom > lowest)) return top >= bottom ? 0 : 1;
|
|
280
|
+
return Math.min(Math.max((bottom - top) / (bottom - lowest), 0), 1);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
private motion(motion: Motion): Motion {
|
|
284
|
+
return prefersReducedMotion() ? INSTANT : motion;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
private settle(motion: Motion, velocity?: number) {
|
|
288
|
+
this.top.animateTo(
|
|
289
|
+
this.restingTop(this.selectedIndex),
|
|
290
|
+
this.motion(motion),
|
|
291
|
+
{
|
|
292
|
+
velocity,
|
|
293
|
+
// Keeps the enter's completion when a re-layout takes it over.
|
|
294
|
+
onEnd: () => this.notifyPresented(),
|
|
295
|
+
}
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
private animateToRest() {
|
|
300
|
+
if (!this.elements || this.drag?.active || this.isOffscreen) return;
|
|
301
|
+
this.settle(SETTLE);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Dragging
|
|
305
|
+
|
|
306
|
+
private canDrag(): boolean {
|
|
307
|
+
return !this.entering && !this.isDismissing && !this.isOffscreen;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
private beginDrag(
|
|
311
|
+
input: Drag['input'],
|
|
312
|
+
id: number,
|
|
313
|
+
x: number,
|
|
314
|
+
y: number,
|
|
315
|
+
target: EventTarget | null
|
|
316
|
+
) {
|
|
317
|
+
this.drag = {
|
|
318
|
+
input,
|
|
319
|
+
id,
|
|
320
|
+
startX: x,
|
|
321
|
+
startY: y,
|
|
322
|
+
lastY: y,
|
|
323
|
+
scroller: findVerticalScroller(target, this.elements!.surface),
|
|
324
|
+
active: false,
|
|
325
|
+
rawTop: 0,
|
|
326
|
+
sheetDrove: false,
|
|
327
|
+
samples: [],
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** Decides who handles a gesture once it moves: the sheet, or the content (scrolling, horizontal swipes). */
|
|
332
|
+
private claim(
|
|
333
|
+
drag: Drag,
|
|
334
|
+
x: number,
|
|
335
|
+
y: number,
|
|
336
|
+
slop: number
|
|
337
|
+
): 'pending' | 'sheet' | 'content' {
|
|
338
|
+
const dx = x - drag.startX;
|
|
339
|
+
const dy = y - drag.startY;
|
|
340
|
+
if (Math.abs(dx) <= slop && Math.abs(dy) <= slop) return 'pending';
|
|
341
|
+
if (Math.abs(dx) > Math.abs(dy)) return 'content';
|
|
342
|
+
const { scroller } = drag;
|
|
343
|
+
if (!scroller) return 'sheet';
|
|
344
|
+
// Down: scroll back to the top before collapsing the sheet. Up: expand
|
|
345
|
+
// the sheet before scrolling.
|
|
346
|
+
if (dy > 0) return scroller.scrollTop <= 0 ? 'sheet' : 'content';
|
|
347
|
+
return this.top.value > Math.min(...this.detentTops) + 0.5
|
|
348
|
+
? 'sheet'
|
|
349
|
+
: 'content';
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
private activate(drag: Drag) {
|
|
353
|
+
drag.active = true;
|
|
354
|
+
this.top.stop();
|
|
355
|
+
drag.rawTop = this.top.value;
|
|
356
|
+
const { surface } = this.elements!;
|
|
357
|
+
// A native scroll on an ancestor makes React Native Web cancel the press
|
|
358
|
+
// under the pointer, so dragging from a button doesn't leave it pressed.
|
|
359
|
+
surface.dispatchEvent(new Event('scroll'));
|
|
360
|
+
if (drag.input === 'pointer') {
|
|
361
|
+
surface.style.setProperty('user-select', 'none');
|
|
362
|
+
surface.style.setProperty('-webkit-user-select', 'none');
|
|
363
|
+
window.getSelection()?.removeAllRanges();
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
private moveDrag(drag: Drag, y: number) {
|
|
368
|
+
const delta = y - drag.lastY;
|
|
369
|
+
drag.lastY = y;
|
|
370
|
+
const time = performance.now();
|
|
371
|
+
drag.samples.push({ time, y });
|
|
372
|
+
while (
|
|
373
|
+
drag.samples.length > 2 &&
|
|
374
|
+
time - drag.samples[0]!.time > VELOCITY_WINDOW_MS
|
|
375
|
+
) {
|
|
376
|
+
drag.samples.shift();
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
const { scroller } = drag;
|
|
380
|
+
if (scroller) {
|
|
381
|
+
// The browser's own scrolling is prevented for this gesture, so the
|
|
382
|
+
// content is scrolled here whenever the sheet can't move.
|
|
383
|
+
const minTop = Math.min(...this.detentTops);
|
|
384
|
+
const drive =
|
|
385
|
+
delta < 0 ? drag.rawTop > minTop + 0.5 : scroller.scrollTop <= 0;
|
|
386
|
+
if (drive) {
|
|
387
|
+
drag.rawTop = Math.max(drag.rawTop + delta, minTop);
|
|
388
|
+
} else {
|
|
389
|
+
scroller.scrollTop -= delta;
|
|
390
|
+
}
|
|
391
|
+
drag.sheetDrove = drive;
|
|
392
|
+
} else {
|
|
393
|
+
drag.rawTop += delta;
|
|
394
|
+
drag.sheetDrove = true;
|
|
395
|
+
}
|
|
396
|
+
this.top.set(this.constrained(drag.rawTop));
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
private release(drag: Drag) {
|
|
400
|
+
this.stopTracking();
|
|
401
|
+
this.drag = null;
|
|
402
|
+
if (!drag.active) return;
|
|
403
|
+
if (drag.input === 'pointer') {
|
|
404
|
+
suppressNextClick();
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
const velocity = drag.sheetDrove ? this.velocity(drag) : 0;
|
|
408
|
+
const top = this.top.value;
|
|
409
|
+
const tops = this.detentTops;
|
|
410
|
+
const lowest = Math.max(...tops);
|
|
411
|
+
// Where a fling of this velocity would come to rest.
|
|
412
|
+
const projected = top + velocity * 0.2;
|
|
413
|
+
|
|
414
|
+
if (
|
|
415
|
+
this.config.dismissOnSwipe &&
|
|
416
|
+
velocity >= 0 &&
|
|
417
|
+
projected > lowest + (this.viewport.height - lowest) / 2
|
|
418
|
+
) {
|
|
419
|
+
this.releaseVelocity = velocity;
|
|
420
|
+
this.listener.onSwipeDismiss();
|
|
421
|
+
if (this.isDismissing) return;
|
|
422
|
+
this.releaseVelocity = 0;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
const index = tops.reduce(
|
|
426
|
+
(best, candidate, i) =>
|
|
427
|
+
Math.abs(candidate - projected) < Math.abs(tops[best]! - projected)
|
|
428
|
+
? i
|
|
429
|
+
: best,
|
|
430
|
+
0
|
|
431
|
+
);
|
|
432
|
+
if (index !== this.selectedIndex) {
|
|
433
|
+
this.selectedIndex = index;
|
|
434
|
+
this.listener.onDetentChange(index);
|
|
435
|
+
}
|
|
436
|
+
this.settle(SETTLE, velocity);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/** Abandons the gesture without settling (the exit or the content takes over). */
|
|
440
|
+
private cancelDrag() {
|
|
441
|
+
this.stopTracking();
|
|
442
|
+
this.drag = null;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
private stopTracking() {
|
|
446
|
+
window.removeEventListener('pointermove', this.handlePointerMove);
|
|
447
|
+
window.removeEventListener('pointerup', this.handlePointerUp);
|
|
448
|
+
window.removeEventListener('pointercancel', this.handlePointerUp);
|
|
449
|
+
const surface = this.elements?.surface;
|
|
450
|
+
surface?.style.removeProperty('user-select');
|
|
451
|
+
surface?.style.removeProperty('-webkit-user-select');
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
private velocity(drag: Drag): number {
|
|
455
|
+
const now = performance.now();
|
|
456
|
+
const samples = drag.samples.filter(
|
|
457
|
+
(sample) => now - sample.time <= VELOCITY_WINDOW_MS
|
|
458
|
+
);
|
|
459
|
+
const first = samples[0];
|
|
460
|
+
const last = samples[samples.length - 1];
|
|
461
|
+
if (!first || !last || last.time <= first.time) return 0;
|
|
462
|
+
return ((last.y - first.y) / (last.time - first.time)) * 1000;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/** Rubber-bands above the tallest detent, and below the lowest one when swiping can't dismiss. */
|
|
466
|
+
private constrained(top: number): number {
|
|
467
|
+
const tops = this.detentTops;
|
|
468
|
+
const minTop = Math.min(...tops);
|
|
469
|
+
const maxTop = Math.max(...tops);
|
|
470
|
+
const dimension = Math.max(this.viewport.height, 1);
|
|
471
|
+
if (top < minTop) {
|
|
472
|
+
return minTop - rubberBand(minTop - top, dimension);
|
|
473
|
+
}
|
|
474
|
+
if (!this.config.dismissOnSwipe && top > maxTop) {
|
|
475
|
+
return maxTop + rubberBand(top - maxTop, dimension);
|
|
476
|
+
}
|
|
477
|
+
return top;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// Touch input. Touch events (rather than pointer events) let the sheet
|
|
481
|
+
// cancel the browser's scrolling when it claims the gesture.
|
|
482
|
+
|
|
483
|
+
private handleTouchStart = (event: TouchEvent) => {
|
|
484
|
+
if (this.drag) {
|
|
485
|
+
// A second finger before the sheet moved: leave it to the browser.
|
|
486
|
+
if (this.drag.input === 'touch' && !this.drag.active) {
|
|
487
|
+
this.drag = null;
|
|
488
|
+
}
|
|
489
|
+
return;
|
|
490
|
+
}
|
|
491
|
+
const touch = event.touches.length === 1 ? event.touches[0] : undefined;
|
|
492
|
+
if (!touch || !this.canDrag()) return;
|
|
493
|
+
this.beginDrag(
|
|
494
|
+
'touch',
|
|
495
|
+
touch.identifier,
|
|
496
|
+
touch.clientX,
|
|
497
|
+
touch.clientY,
|
|
498
|
+
event.target
|
|
499
|
+
);
|
|
500
|
+
};
|
|
501
|
+
|
|
502
|
+
private handleTouchMove = (event: TouchEvent) => {
|
|
503
|
+
const drag = this.drag;
|
|
504
|
+
if (drag?.input !== 'touch') return;
|
|
505
|
+
const touch = findTouch(event.changedTouches, drag.id);
|
|
506
|
+
if (!touch) return;
|
|
507
|
+
if (!drag.active) {
|
|
508
|
+
// Once the browser has started scrolling, the gesture is its.
|
|
509
|
+
const owner = event.cancelable
|
|
510
|
+
? this.claim(drag, touch.clientX, touch.clientY, 0)
|
|
511
|
+
: 'content';
|
|
512
|
+
if (owner === 'pending') return;
|
|
513
|
+
if (owner === 'content') {
|
|
514
|
+
this.drag = null;
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
this.activate(drag);
|
|
518
|
+
}
|
|
519
|
+
if (event.cancelable) {
|
|
520
|
+
event.preventDefault();
|
|
521
|
+
}
|
|
522
|
+
this.moveDrag(drag, touch.clientY);
|
|
523
|
+
};
|
|
524
|
+
|
|
525
|
+
private handleTouchEnd = (event: TouchEvent) => {
|
|
526
|
+
const drag = this.drag;
|
|
527
|
+
if (drag?.input !== 'touch' || !findTouch(event.changedTouches, drag.id))
|
|
528
|
+
return;
|
|
529
|
+
this.release(drag);
|
|
530
|
+
};
|
|
531
|
+
|
|
532
|
+
// Mouse and pen input.
|
|
533
|
+
|
|
534
|
+
private handlePointerDown = (event: PointerEvent) => {
|
|
535
|
+
if (
|
|
536
|
+
event.pointerType === 'touch' ||
|
|
537
|
+
event.button !== 0 ||
|
|
538
|
+
this.drag ||
|
|
539
|
+
!this.canDrag() ||
|
|
540
|
+
// Leave text selection in inputs alone.
|
|
541
|
+
isTextInput(event.target)
|
|
542
|
+
) {
|
|
543
|
+
return;
|
|
544
|
+
}
|
|
545
|
+
this.beginDrag(
|
|
546
|
+
'pointer',
|
|
547
|
+
event.pointerId,
|
|
548
|
+
event.clientX,
|
|
549
|
+
event.clientY,
|
|
550
|
+
event.target
|
|
551
|
+
);
|
|
552
|
+
window.addEventListener('pointermove', this.handlePointerMove);
|
|
553
|
+
window.addEventListener('pointerup', this.handlePointerUp);
|
|
554
|
+
window.addEventListener('pointercancel', this.handlePointerUp);
|
|
555
|
+
};
|
|
556
|
+
|
|
557
|
+
private handlePointerMove = (event: PointerEvent) => {
|
|
558
|
+
const drag = this.drag;
|
|
559
|
+
if (drag?.input !== 'pointer' || event.pointerId !== drag.id) return;
|
|
560
|
+
if (!drag.active) {
|
|
561
|
+
const owner = this.claim(
|
|
562
|
+
drag,
|
|
563
|
+
event.clientX,
|
|
564
|
+
event.clientY,
|
|
565
|
+
POINTER_SLOP
|
|
566
|
+
);
|
|
567
|
+
if (owner === 'pending') return;
|
|
568
|
+
if (owner === 'content') {
|
|
569
|
+
this.cancelDrag();
|
|
570
|
+
return;
|
|
571
|
+
}
|
|
572
|
+
this.activate(drag);
|
|
573
|
+
}
|
|
574
|
+
this.moveDrag(drag, event.clientY);
|
|
575
|
+
};
|
|
576
|
+
|
|
577
|
+
private handlePointerUp = (event: PointerEvent) => {
|
|
578
|
+
const drag = this.drag;
|
|
579
|
+
if (drag?.input !== 'pointer' || event.pointerId !== drag.id) return;
|
|
580
|
+
this.release(drag);
|
|
581
|
+
};
|
|
582
|
+
}
|