@astryxdesign/core 0.4.3 → 0.4.4-canary.0f4d45f
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/CHANGELOG.md +60 -0
- package/README.md +79 -45
- package/dist/Banner/Banner.d.ts +3 -0
- package/dist/Banner/Banner.d.ts.map +1 -1
- package/dist/Banner/Banner.js +13 -3
- package/dist/BottomSheet/BottomSheet.d.ts +66 -0
- package/dist/BottomSheet/BottomSheet.d.ts.map +1 -0
- package/dist/BottomSheet/BottomSheet.js +392 -0
- package/dist/BottomSheet/BottomSheetPanel.d.ts +60 -0
- package/dist/BottomSheet/BottomSheetPanel.d.ts.map +1 -0
- package/dist/BottomSheet/BottomSheetPanel.js +398 -0
- package/dist/BottomSheet/BottomSheetSwitcher.d.ts +61 -0
- package/dist/BottomSheet/BottomSheetSwitcher.d.ts.map +1 -0
- package/dist/BottomSheet/BottomSheetSwitcher.js +410 -0
- package/dist/BottomSheet/BottomSheetSwitcherContext.d.ts +21 -0
- package/dist/BottomSheet/BottomSheetSwitcherContext.d.ts.map +1 -0
- package/dist/BottomSheet/BottomSheetSwitcherContext.js +13 -0
- package/dist/BottomSheet/index.d.ts +11 -0
- package/dist/BottomSheet/index.d.ts.map +1 -0
- package/dist/BottomSheet/index.js +12 -0
- package/dist/BottomSheet/snapOffsets.d.ts +72 -0
- package/dist/BottomSheet/snapOffsets.d.ts.map +1 -0
- package/dist/BottomSheet/snapOffsets.js +130 -0
- package/dist/BottomSheet/useMobileKeyboard.d.ts +35 -0
- package/dist/BottomSheet/useMobileKeyboard.d.ts.map +1 -0
- package/dist/BottomSheet/useMobileKeyboard.js +373 -0
- package/dist/BottomSheet/useSheetGestures.d.ts +180 -0
- package/dist/BottomSheet/useSheetGestures.d.ts.map +1 -0
- package/dist/BottomSheet/useSheetGestures.js +989 -0
- package/dist/Chat/ChatComposerInput.d.ts.map +1 -1
- package/dist/Chat/ChatComposerInput.js +3 -4
- package/dist/ComplexSelector/ComplexSelector.d.ts.map +1 -1
- package/dist/ComplexSelector/ComplexSelector.js +12 -6
- package/dist/ContextMenu/ContextMenu.d.ts.map +1 -1
- package/dist/ContextMenu/ContextMenu.js +4 -2
- package/dist/DateInput/DateInput.d.ts.map +1 -1
- package/dist/DateInput/DateInput.js +9 -1
- package/dist/DateTimeInput/DateTimeInput.d.ts.map +1 -1
- package/dist/DateTimeInput/DateTimeInput.js +31 -1
- package/dist/Dialog/Dialog.d.ts.map +1 -1
- package/dist/Dialog/Dialog.js +2 -1
- package/dist/Field/InputClearButton.d.ts +2 -1
- package/dist/Field/InputClearButton.d.ts.map +1 -1
- package/dist/Field/InputClearButton.js +6 -1
- package/dist/Lightbox/Lightbox.d.ts.map +1 -1
- package/dist/Lightbox/Lightbox.js +9 -3
- package/dist/MobileNav/MobileNav.d.ts +12 -0
- package/dist/MobileNav/MobileNav.d.ts.map +1 -1
- package/dist/MobileNav/MobileNav.js +102 -30
- package/dist/MultiSelector/MultiSelector.d.ts.map +1 -1
- package/dist/MultiSelector/MultiSelector.js +21 -7
- package/dist/NumberInput/NumberInput.d.ts.map +1 -1
- package/dist/NumberInput/NumberInput.js +9 -1
- package/dist/PowerSearch/PowerSearchEditPopover.js +1 -1
- package/dist/Selector/Selector.d.ts.map +1 -1
- package/dist/Selector/Selector.js +14 -3
- package/dist/StatusDot/StatusDot.d.ts +43 -3
- package/dist/StatusDot/StatusDot.d.ts.map +1 -1
- package/dist/StatusDot/StatusDot.js +42 -5
- package/dist/Switch/Switch.d.ts.map +1 -1
- package/dist/Switch/Switch.js +5 -1
- package/dist/Table/plugins/rowExpansion/useTableRowExpansion.d.ts.map +1 -1
- package/dist/Table/plugins/rowExpansion/useTableRowExpansion.js +7 -2
- package/dist/TimeInput/TimeInput.d.ts.map +1 -1
- package/dist/TimeInput/TimeInput.js +8 -1
- package/dist/Tokenizer/Tokenizer.js +11 -5
- package/dist/Tooltip/useTooltip.d.ts.map +1 -1
- package/dist/Tooltip/useTooltip.js +4 -1
- package/dist/Typeahead/BaseTypeahead.d.ts.map +1 -1
- package/dist/Typeahead/BaseTypeahead.js +5 -3
- package/dist/Typeahead/Typeahead.d.ts.map +1 -1
- package/dist/Typeahead/Typeahead.js +10 -1
- package/dist/astryx.css +21 -0
- package/dist/hooks/index.d.ts +8 -1
- package/dist/hooks/index.d.ts.map +1 -1
- package/dist/hooks/index.js +8 -1
- package/dist/hooks/useFocusTrap.d.ts +0 -11
- package/dist/hooks/useFocusTrap.d.ts.map +1 -1
- package/dist/hooks/useFocusTrap.js +1 -11
- package/dist/hooks/useInputStatusIcon.d.ts.map +1 -1
- package/dist/hooks/useInputStatusIcon.js +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/theme/defineTheme.d.ts +22 -4
- package/dist/theme/defineTheme.d.ts.map +1 -1
- package/dist/theme/expandColorScale.d.ts +17 -2
- package/dist/theme/expandColorScale.d.ts.map +1 -1
- package/dist/theme/expandColorScale.js +63 -33
- package/dist/utils/ime.d.ts +5 -0
- package/dist/utils/ime.d.ts.map +1 -0
- package/dist/utils/ime.js +63 -0
- package/dist/utils/index.d.ts +1 -0
- package/dist/utils/index.d.ts.map +1 -1
- package/dist/utils/index.js +2 -1
- package/locales/af-ZA.json +958 -0
- package/locales/ar-SA.json +998 -0
- package/locales/ca-ES.json +974 -0
- package/locales/cs-CZ.json +990 -0
- package/locales/da-DK.json +966 -0
- package/locales/de-DE.json +966 -0
- package/locales/el-GR.json +990 -0
- package/locales/en.json +48 -0
- package/locales/es-ES.json +978 -0
- package/locales/fi-FI.json +990 -0
- package/locales/fr-FR.json +928 -0
- package/locales/he-IL.json +990 -0
- package/locales/hu-HU.json +990 -0
- package/locales/it-IT.json +978 -0
- package/locales/ja-JP.json +998 -0
- package/locales/ko-KR.json +990 -0
- package/locales/nl-NL.json +954 -0
- package/locales/no-NO.json +978 -0
- package/locales/pl-PL.json +982 -0
- package/locales/pseudo.json +36 -0
- package/locales/pt-BR.json +982 -0
- package/locales/pt-PT.json +982 -0
- package/locales/ro-RO.json +982 -0
- package/locales/ru-RU.json +990 -0
- package/locales/sr-SP.json +990 -0
- package/locales/sv-SE.json +986 -0
- package/locales/tr-TR.json +990 -0
- package/locales/uk-UA.json +990 -0
- package/locales/vi-VN.json +986 -0
- package/locales/zh-CN.json +998 -0
- package/locales/zh-TW.json +998 -0
- package/package.json +9 -8
- package/src/Banner/Banner.doc.mjs +3 -3
- package/src/Banner/Banner.test.tsx +40 -0
- package/src/Banner/Banner.tsx +27 -2
- package/src/BottomSheet/BottomSheet.doc.mjs +266 -0
- package/src/BottomSheet/BottomSheet.test.tsx +2114 -0
- package/src/BottomSheet/BottomSheet.tsx +545 -0
- package/src/BottomSheet/BottomSheetPanel.test.tsx +240 -0
- package/src/BottomSheet/BottomSheetPanel.tsx +547 -0
- package/src/BottomSheet/BottomSheetSwitcher.doc.mjs +219 -0
- package/src/BottomSheet/BottomSheetSwitcher.test.tsx +850 -0
- package/src/BottomSheet/BottomSheetSwitcher.tsx +628 -0
- package/src/BottomSheet/BottomSheetSwitcherContext.ts +51 -0
- package/src/BottomSheet/index.ts +15 -0
- package/src/BottomSheet/snapOffsets.test.ts +187 -0
- package/src/BottomSheet/snapOffsets.ts +159 -0
- package/src/BottomSheet/useMobileKeyboard.ts +499 -0
- package/src/BottomSheet/useSheetGestures.test.ts +1059 -0
- package/src/BottomSheet/useSheetGestures.ts +1459 -0
- package/src/Chat/ChatComposerInput.tsx +3 -4
- package/src/ComplexSelector/ComplexSelector.tsx +12 -5
- package/src/ContextMenu/ContextMenu.tsx +4 -2
- package/src/DateInput/DateInput.test.tsx +21 -0
- package/src/DateInput/DateInput.tsx +9 -1
- package/src/DateTimeInput/DateTimeInput.doc.mjs +4 -0
- package/src/DateTimeInput/DateTimeInput.test.tsx +137 -0
- package/src/DateTimeInput/DateTimeInput.tsx +39 -2
- package/src/Dialog/Dialog.tsx +2 -1
- package/src/Field/Field.doc.mjs +1 -0
- package/src/Field/InputClearButton.test.tsx +25 -0
- package/src/Field/InputClearButton.tsx +4 -1
- package/src/FileInput/FileInput.test.tsx +53 -9
- package/src/Lightbox/Lightbox.test.tsx +49 -28
- package/src/Lightbox/Lightbox.tsx +7 -3
- package/src/MobileNav/MobileNav.tsx +128 -37
- package/src/MobileNav/MobileNavCloseEdgeCases.test.tsx +426 -0
- package/src/MobileNav/MobileNavCloseTiming.test.ts +58 -0
- package/src/MobileNav/MobileNavCloseVisibility.test.tsx +172 -0
- package/src/MultiSelector/MultiSelector.test.tsx +195 -51
- package/src/MultiSelector/MultiSelector.tsx +22 -8
- package/src/NumberInput/NumberInput.test.tsx +18 -0
- package/src/NumberInput/NumberInput.tsx +9 -1
- package/src/PowerSearch/PowerSearchEditPopover.tsx +1 -1
- package/src/Selector/Selector.test.tsx +85 -26
- package/src/Selector/Selector.tsx +13 -4
- package/src/StatusDot/StatusDot.doc.mjs +13 -0
- package/src/StatusDot/StatusDot.test.tsx +115 -8
- package/src/StatusDot/StatusDot.tsx +74 -9
- package/src/Switch/Switch.test.tsx +16 -0
- package/src/Switch/Switch.tsx +7 -0
- package/src/Table/plugins/rowExpansion/useTableRowExpansion.tsx +20 -2
- package/src/TimeInput/TimeInput.test.tsx +23 -0
- package/src/TimeInput/TimeInput.tsx +8 -0
- package/src/Tokenizer/Tokenizer.test.tsx +50 -32
- package/src/Tokenizer/Tokenizer.tsx +5 -5
- package/src/Tooltip/useTooltip.tsx +4 -1
- package/src/Typeahead/BaseTypeahead.tsx +10 -3
- package/src/Typeahead/Typeahead.test.tsx +116 -7
- package/src/Typeahead/Typeahead.tsx +10 -1
- package/src/__tests__/stubMatchMedia.test.ts +79 -0
- package/src/__tests__/stubMatchMedia.ts +95 -0
- package/src/hooks/index.ts +9 -5
- package/src/hooks/useFocusTrap.ts +1 -14
- package/src/hooks/useInputStatusIcon.test.tsx +19 -0
- package/src/hooks/useInputStatusIcon.tsx +7 -0
- package/src/index.ts +1 -0
- package/src/theme/defineTheme.ts +22 -4
- package/src/theme/expandColorScale.test.ts +138 -0
- package/src/theme/expandColorScale.ts +89 -44
- package/src/utils/ime.test.ts +32 -0
- package/src/utils/ime.ts +69 -0
- package/src/utils/index.ts +2 -0
- package/dist/astryx.umd.js +0 -147
- package/dist/astryx.umd.js.map +0 -7
|
@@ -0,0 +1,1059 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file useSheetGestures.test.ts
|
|
5
|
+
* @input Uses vitest, @testing-library/react renderHook, useSheetGestures
|
|
6
|
+
* @output Unit tests for useSheetGestures behavior
|
|
7
|
+
* @position Testing; validates useSheetGestures.ts implementation
|
|
8
|
+
*
|
|
9
|
+
* SYNC: When useSheetGestures.ts changes, update tests to match new behavior
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import {describe, it, expect, vi, beforeEach, afterEach} from 'vitest';
|
|
13
|
+
import {act, renderHook} from '@testing-library/react';
|
|
14
|
+
import {
|
|
15
|
+
useSheetGestures,
|
|
16
|
+
type UseSheetGesturesOptions,
|
|
17
|
+
} from './useSheetGestures';
|
|
18
|
+
|
|
19
|
+
const SHEET_HEIGHT = 400;
|
|
20
|
+
|
|
21
|
+
// A fake pointer event object good enough for the handlers, which only read
|
|
22
|
+
// pointerId, clientY, timeStamp, preventDefault, and currentTarget's capture +
|
|
23
|
+
// measure APIs.
|
|
24
|
+
function pointerEvent(
|
|
25
|
+
clientY: number,
|
|
26
|
+
timeStamp: number,
|
|
27
|
+
target: HTMLElement,
|
|
28
|
+
pointerId = 1,
|
|
29
|
+
button = 0,
|
|
30
|
+
isPrimary = true,
|
|
31
|
+
) {
|
|
32
|
+
return {
|
|
33
|
+
pointerId,
|
|
34
|
+
button,
|
|
35
|
+
isPrimary,
|
|
36
|
+
clientY,
|
|
37
|
+
timeStamp,
|
|
38
|
+
currentTarget: target,
|
|
39
|
+
preventDefault: vi.fn(),
|
|
40
|
+
} as unknown as React.PointerEvent;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function makeTarget(): HTMLElement {
|
|
44
|
+
const el = document.createElement('div');
|
|
45
|
+
el.setPointerCapture = vi.fn();
|
|
46
|
+
el.releasePointerCapture = vi.fn();
|
|
47
|
+
el.getBoundingClientRect = () => ({height: SHEET_HEIGHT}) as DOMRect;
|
|
48
|
+
return el;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
beforeEach(() => {
|
|
52
|
+
vi.stubGlobal(
|
|
53
|
+
'matchMedia',
|
|
54
|
+
vi.fn().mockReturnValue({
|
|
55
|
+
matches: false,
|
|
56
|
+
addEventListener: vi.fn(),
|
|
57
|
+
removeEventListener: vi.fn(),
|
|
58
|
+
}),
|
|
59
|
+
);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
afterEach(() => {
|
|
63
|
+
vi.unstubAllGlobals();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
function setup(options: Partial<UseSheetGesturesOptions> = {}) {
|
|
67
|
+
const onDismiss = vi.fn();
|
|
68
|
+
const hook = renderHook(
|
|
69
|
+
(props: UseSheetGesturesOptions) => useSheetGestures(props),
|
|
70
|
+
{
|
|
71
|
+
initialProps: {
|
|
72
|
+
isOpen: true,
|
|
73
|
+
onDismiss,
|
|
74
|
+
...options,
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
);
|
|
78
|
+
return {hook, onDismiss};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
type Hook = {
|
|
82
|
+
result: {current: ReturnType<typeof useSheetGestures>};
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
function down(hook: Hook, y: number, t: number, target: HTMLElement) {
|
|
86
|
+
// Register the sheet element the way the component does on mount, so the
|
|
87
|
+
// hook can measure its height (it no longer queries the DOM for it).
|
|
88
|
+
act(() => hook.result.current.sheetRef(target));
|
|
89
|
+
act(() =>
|
|
90
|
+
hook.result.current.handleProps.onPointerDown(pointerEvent(y, t, target)),
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
function move(hook: Hook, y: number, t: number, target: HTMLElement) {
|
|
94
|
+
act(() =>
|
|
95
|
+
hook.result.current.handleProps.onPointerMove(pointerEvent(y, t, target)),
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
function up(hook: Hook, y: number, t: number, target: HTMLElement) {
|
|
99
|
+
act(() =>
|
|
100
|
+
hook.result.current.handleProps.onPointerUp(pointerEvent(y, t, target)),
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
describe('useSheetGestures', () => {
|
|
105
|
+
it('dismisses on a fast downward flick (swipe-to-close)', () => {
|
|
106
|
+
const {hook, onDismiss} = setup({snapHeights: () => [200]});
|
|
107
|
+
const t = makeTarget();
|
|
108
|
+
down(hook, 0, 0, t);
|
|
109
|
+
// 60px in 20ms = 3px/ms, and 60px > the flick distance floor.
|
|
110
|
+
move(hook, 60, 20, t);
|
|
111
|
+
up(hook, 60, 22, t);
|
|
112
|
+
expect(onDismiss).toHaveBeenCalledTimes(1);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('settles instead of dismissing on a downward flick when canDismiss is false', () => {
|
|
116
|
+
const onSnap = vi.fn();
|
|
117
|
+
const onScrimOpacity = vi.fn();
|
|
118
|
+
const {hook, onDismiss} = setup({
|
|
119
|
+
canDismiss: false,
|
|
120
|
+
snapHeights: () => [200],
|
|
121
|
+
onSnap,
|
|
122
|
+
onScrimOpacity,
|
|
123
|
+
});
|
|
124
|
+
const t = makeTarget();
|
|
125
|
+
down(hook, 0, 0, t);
|
|
126
|
+
move(hook, 60, 20, t);
|
|
127
|
+
up(hook, 60, 22, t);
|
|
128
|
+
|
|
129
|
+
expect(onDismiss).not.toHaveBeenCalled();
|
|
130
|
+
expect(hook.result.current.settledOffset).toBe(200);
|
|
131
|
+
expect(onSnap).toHaveBeenLastCalledWith(200);
|
|
132
|
+
expect(onScrimOpacity).toHaveBeenLastCalledWith(0.3);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('expands to the tallest detent on a fast upward flick', () => {
|
|
136
|
+
const onSnap = vi.fn();
|
|
137
|
+
const {hook, onDismiss} = setup({snapHeights: () => [200], onSnap});
|
|
138
|
+
const t = makeTarget();
|
|
139
|
+
// Start rested at a lower detent by first snapping there via a slow drag.
|
|
140
|
+
down(hook, 0, 0, t);
|
|
141
|
+
move(hook, 200, 400, t); // slow drag down toward the 200px detent
|
|
142
|
+
up(hook, 200, 800, t);
|
|
143
|
+
onSnap.mockClear();
|
|
144
|
+
// Now flick up fast: 60px in 20ms = 3px/ms, over the distance floor.
|
|
145
|
+
down(hook, 200, 1000, t);
|
|
146
|
+
move(hook, 140, 1020, t);
|
|
147
|
+
up(hook, 140, 1022, t);
|
|
148
|
+
expect(onDismiss).not.toHaveBeenCalled();
|
|
149
|
+
expect(onSnap).toHaveBeenLastCalledWith(SHEET_HEIGHT); // full height
|
|
150
|
+
expect(hook.result.current.settledOffset).toBe(0);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('does not flick-dismiss on a fast but short nudge', () => {
|
|
154
|
+
const {hook, onDismiss} = setup({snapHeights: () => [200]});
|
|
155
|
+
const t = makeTarget();
|
|
156
|
+
// Fast (20px/10ms = 2px/ms) but only 20px of travel — under the distance
|
|
157
|
+
// floor, so it settles rather than dismissing.
|
|
158
|
+
down(hook, 0, 0, t);
|
|
159
|
+
move(hook, 20, 10, t);
|
|
160
|
+
up(hook, 20, 12, t);
|
|
161
|
+
expect(onDismiss).not.toHaveBeenCalled();
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('settles to the nearest detent on a slow drag', () => {
|
|
165
|
+
const onSnap = vi.fn();
|
|
166
|
+
const {hook, onDismiss} = setup({snapHeights: () => [200], onSnap});
|
|
167
|
+
const t = makeTarget();
|
|
168
|
+
// Detents: full=400 (offset 0) and 200 (offset 200). Drag slowly to ~180
|
|
169
|
+
// offset -> nearest is the 200px detent (offset 200).
|
|
170
|
+
down(hook, 0, 0, t);
|
|
171
|
+
move(hook, 90, 300, t);
|
|
172
|
+
move(hook, 180, 700, t);
|
|
173
|
+
up(hook, 180, 1100, t);
|
|
174
|
+
expect(onDismiss).not.toHaveBeenCalled();
|
|
175
|
+
expect(onSnap).toHaveBeenLastCalledWith(200);
|
|
176
|
+
expect(hook.result.current.settledOffset).toBe(200);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('excludes the offscreen block-end reserve from visible detent heights', () => {
|
|
180
|
+
const onSnap = vi.fn();
|
|
181
|
+
const {hook} = setup({
|
|
182
|
+
snapHeights: () => [200],
|
|
183
|
+
offscreenBlockEndInset: 48,
|
|
184
|
+
onSnap,
|
|
185
|
+
});
|
|
186
|
+
const t = makeTarget();
|
|
187
|
+
|
|
188
|
+
// The 400px border box has 352px visible. A 200px visible detent therefore
|
|
189
|
+
// rests at offset 152, not 200; the 48px reserve remains below the viewport.
|
|
190
|
+
down(hook, 0, 0, t);
|
|
191
|
+
move(hook, 150, 700, t);
|
|
192
|
+
up(hook, 150, 1100, t);
|
|
193
|
+
|
|
194
|
+
expect(hook.result.current.settledOffset).toBe(152);
|
|
195
|
+
expect(onSnap).toHaveBeenLastCalledWith(200);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('keeps the full layout height at the peek detent', () => {
|
|
199
|
+
// Three detents on the 400px sheet: full (0), mid 240 (offset 160), and
|
|
200
|
+
// the 80px peek (offset 320).
|
|
201
|
+
const {hook} = setup({snapHeights: () => [80, 240]});
|
|
202
|
+
const t = makeTarget();
|
|
203
|
+
|
|
204
|
+
// Settle at the mid detent: the scrolling area takes that travel as
|
|
205
|
+
// layout height.
|
|
206
|
+
down(hook, 0, 0, t);
|
|
207
|
+
move(hook, 150, 700, t);
|
|
208
|
+
up(hook, 150, 1100, t);
|
|
209
|
+
expect(hook.result.current.settledOffset).toBe(160);
|
|
210
|
+
expect(hook.result.current.settledLayoutOffset).toBe(160);
|
|
211
|
+
|
|
212
|
+
// Settle at the peek: a glance state keeps the sheet's full layout height
|
|
213
|
+
// and slides it below the viewport, so no layout travel is reported.
|
|
214
|
+
down(hook, 150, 2000, t);
|
|
215
|
+
move(hook, 310, 2700, t);
|
|
216
|
+
up(hook, 310, 3100, t);
|
|
217
|
+
expect(hook.result.current.settledOffset).toBe(320);
|
|
218
|
+
expect(hook.result.current.settledLayoutOffset).toBe(0);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it('treats the only collapsed stop as a peek, like the scrim does', () => {
|
|
222
|
+
const {hook} = setup({snapHeights: () => [200]});
|
|
223
|
+
const t = makeTarget();
|
|
224
|
+
down(hook, 0, 0, t);
|
|
225
|
+
move(hook, 180, 700, t);
|
|
226
|
+
up(hook, 180, 1100, t);
|
|
227
|
+
expect(hook.result.current.settledOffset).toBe(200);
|
|
228
|
+
expect(hook.result.current.settledLayoutOffset).toBe(0);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it('uses the visible shortest detent height for the dismiss overshoot', () => {
|
|
232
|
+
const {hook, onDismiss} = setup({
|
|
233
|
+
snapHeights: () => [200],
|
|
234
|
+
offscreenBlockEndInset: 48,
|
|
235
|
+
});
|
|
236
|
+
const t = makeTarget();
|
|
237
|
+
|
|
238
|
+
// Visible full height is 352px, so the 200px stop is offset 152. Its 40%
|
|
239
|
+
// dismiss overshoot ends at 232px; the hidden reserve must not extend it.
|
|
240
|
+
down(hook, 0, 0, t);
|
|
241
|
+
move(hook, 235, 1000, t);
|
|
242
|
+
up(hook, 235, 2000, t);
|
|
243
|
+
|
|
244
|
+
expect(onDismiss).toHaveBeenCalledTimes(1);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it('a downward drag from a middle detent never snaps back up past it', () => {
|
|
248
|
+
// Three detents on a 600px sheet: full (offset 0), mid 360 (offset 240),
|
|
249
|
+
// short 160 (offset 440). Rest at the mid detent, then drag DOWN a modest
|
|
250
|
+
// amount that's still closer to mid than to short. It must not snap back
|
|
251
|
+
// UP to full — only to mid (stay) or further down.
|
|
252
|
+
const onSnap = vi.fn();
|
|
253
|
+
const {hook} = setup({snapHeights: () => [160, 360], onSnap});
|
|
254
|
+
const t = makeTarget();
|
|
255
|
+
t.getBoundingClientRect = () => ({height: 600}) as DOMRect;
|
|
256
|
+
// Drag to the mid detent (offset 240).
|
|
257
|
+
down(hook, 0, 0, t);
|
|
258
|
+
move(hook, 120, 300, t);
|
|
259
|
+
move(hook, 240, 700, t);
|
|
260
|
+
up(hook, 240, 1100, t);
|
|
261
|
+
expect(hook.result.current.settledOffset).toBe(240);
|
|
262
|
+
onSnap.mockClear();
|
|
263
|
+
// Small slow drag DOWN from mid: offset 240 -> ~300.
|
|
264
|
+
down(hook, 300, 2000, t);
|
|
265
|
+
move(hook, 340, 2200, t);
|
|
266
|
+
move(hook, 360, 2600, t);
|
|
267
|
+
up(hook, 360, 3000, t);
|
|
268
|
+
// Must not have jumped back up to full (offset 0).
|
|
269
|
+
expect(hook.result.current.settledOffset).not.toBe(0);
|
|
270
|
+
expect(hook.result.current.settledOffset).toBeGreaterThanOrEqual(240);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it('dismisses when dragged well below the shortest detent', () => {
|
|
274
|
+
const {hook, onDismiss} = setup({snapHeights: () => [200]});
|
|
275
|
+
const t = makeTarget();
|
|
276
|
+
// Shortest detent offset = 200 (height 200). Drag slowly past it by
|
|
277
|
+
// >40% of 200 -> offset ~320 dismisses.
|
|
278
|
+
down(hook, 0, 0, t);
|
|
279
|
+
move(hook, 160, 400, t);
|
|
280
|
+
move(hook, 330, 900, t);
|
|
281
|
+
up(hook, 330, 1400, t);
|
|
282
|
+
expect(onDismiss).toHaveBeenCalledTimes(1);
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it('rebounds to the shortest detent when canDismiss is false', () => {
|
|
286
|
+
const onScrimOpacity = vi.fn();
|
|
287
|
+
const {hook, onDismiss} = setup({
|
|
288
|
+
canDismiss: false,
|
|
289
|
+
snapHeights: () => [200],
|
|
290
|
+
onScrimOpacity,
|
|
291
|
+
});
|
|
292
|
+
const t = makeTarget();
|
|
293
|
+
down(hook, 0, 0, t);
|
|
294
|
+
move(hook, 160, 400, t);
|
|
295
|
+
move(hook, 330, 900, t);
|
|
296
|
+
up(hook, 330, 1400, t);
|
|
297
|
+
|
|
298
|
+
expect(onDismiss).not.toHaveBeenCalled();
|
|
299
|
+
expect(hook.result.current.settledOffset).toBe(200);
|
|
300
|
+
expect(onScrimOpacity).toHaveBeenLastCalledWith(0.3);
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
it('fades the scrim from full toward the peek floor as it collapses onto the peek detent', () => {
|
|
304
|
+
const onScrimOpacity = vi.fn();
|
|
305
|
+
// Sheet 400, detents at 200 and 300 -> offsets [0, 100, 200]. The fade
|
|
306
|
+
// spans the mid detent (offset 100) to the peek detent (offset 200):
|
|
307
|
+
// full at/above 100, thinned to the peek floor (0.3) at/below 200.
|
|
308
|
+
const {hook} = setup({snapHeights: () => [200, 300], onScrimOpacity});
|
|
309
|
+
const t = makeTarget();
|
|
310
|
+
down(hook, 0, 0, t);
|
|
311
|
+
move(hook, 60, 200, t); // offset 60, above the mid detent -> full
|
|
312
|
+
move(hook, 150, 600, t); // offset 150, halfway -> between 1 and 0.3 (~0.65)
|
|
313
|
+
move(hook, 220, 1000, t); // offset 220, past the peek -> peek floor
|
|
314
|
+
const values = onScrimOpacity.mock.calls.map(c => Number(c[0]));
|
|
315
|
+
expect(values[0]).toBe(1);
|
|
316
|
+
expect(values[1]).toBeGreaterThan(0.55);
|
|
317
|
+
expect(values[1]).toBeLessThan(0.75);
|
|
318
|
+
expect(values[values.length - 1]).toBeCloseTo(0.3);
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it('thins the scrim to the peek floor when settled at the peek detent (still modal)', () => {
|
|
322
|
+
const onScrimOpacity = vi.fn();
|
|
323
|
+
const {hook} = setup({snapHeights: () => [200, 300], onScrimOpacity});
|
|
324
|
+
const t = makeTarget();
|
|
325
|
+
// Slow drag down to the peek detent (offset 200).
|
|
326
|
+
down(hook, 0, 0, t);
|
|
327
|
+
move(hook, 100, 400, t);
|
|
328
|
+
move(hook, 200, 900, t);
|
|
329
|
+
up(hook, 200, 1400, t);
|
|
330
|
+
expect(hook.result.current.settledOffset).toBe(200);
|
|
331
|
+
// Last reported opacity (on settle) is the peek floor, not fully hidden —
|
|
332
|
+
// the sheet is still modal, so the backdrop keeps a minimum dim.
|
|
333
|
+
const values = onScrimOpacity.mock.calls.map(c => Number(c[0]));
|
|
334
|
+
expect(values[values.length - 1]).toBeCloseTo(0.3);
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
it('magnetically settles the live drag onto a nearby detent', () => {
|
|
338
|
+
// Detents for a 400px sheet with snaps 300 and 200 -> visible-height snaps
|
|
339
|
+
// become offsets [0, 100, 200]. Approaching the middle detent (offset 100)
|
|
340
|
+
// from just below (raw ~90) — inside MAGNET_RANGE and BELOW the shortest
|
|
341
|
+
// detent so the between-detents magnet applies — should ease toward 100.
|
|
342
|
+
const {hook} = setup({snapHeights: () => [200, 300]});
|
|
343
|
+
const t = makeTarget();
|
|
344
|
+
down(hook, 0, 0, t);
|
|
345
|
+
move(hook, 90, 100, t); // raw offset 90, 10px from the 100 detent
|
|
346
|
+
const off = hook.result.current.dragOffset;
|
|
347
|
+
expect(off).toBeGreaterThan(90); // pulled up toward 100
|
|
348
|
+
expect(off).toBeLessThanOrEqual(100);
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
it('translates the surface live during a drag', () => {
|
|
352
|
+
const {hook} = setup({snapHeights: () => [200]});
|
|
353
|
+
const t = makeTarget();
|
|
354
|
+
down(hook, 0, 0, t);
|
|
355
|
+
move(hook, 50, 100, t);
|
|
356
|
+
expect(hook.result.current.dragOffset).toBe(50);
|
|
357
|
+
expect(hook.result.current.isDragging).toBe(true);
|
|
358
|
+
expect(hook.result.current.contentProps.style.transform).toBe(
|
|
359
|
+
'translateY(50px)',
|
|
360
|
+
);
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
it('returns to the settled detent when a context menu interrupts a drag', () => {
|
|
364
|
+
const onScrimOpacity = vi.fn();
|
|
365
|
+
const {hook, onDismiss} = setup({
|
|
366
|
+
snapHeights: () => [200, 300],
|
|
367
|
+
onScrimOpacity,
|
|
368
|
+
});
|
|
369
|
+
const target = makeTarget();
|
|
370
|
+
down(hook, 0, 0, target);
|
|
371
|
+
move(hook, 300, 1000, target);
|
|
372
|
+
|
|
373
|
+
const preventDefault = vi.fn();
|
|
374
|
+
act(() =>
|
|
375
|
+
hook.result.current.handleProps.onContextMenu({
|
|
376
|
+
currentTarget: target,
|
|
377
|
+
preventDefault,
|
|
378
|
+
} as unknown as React.MouseEvent),
|
|
379
|
+
);
|
|
380
|
+
|
|
381
|
+
expect(preventDefault).toHaveBeenCalledTimes(1);
|
|
382
|
+
expect(hook.result.current.isDragging).toBe(false);
|
|
383
|
+
expect(hook.result.current.contentProps.style.transform).toBeUndefined();
|
|
384
|
+
expect(onScrimOpacity).toHaveBeenLastCalledWith(1);
|
|
385
|
+
expect(onDismiss).not.toHaveBeenCalled();
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
it('returns to the settled detent when pointer capture is lost', () => {
|
|
389
|
+
const {hook, onDismiss} = setup({snapHeights: () => [200]});
|
|
390
|
+
const target = makeTarget();
|
|
391
|
+
down(hook, 0, 0, target);
|
|
392
|
+
move(hook, 300, 1000, target);
|
|
393
|
+
|
|
394
|
+
act(() =>
|
|
395
|
+
hook.result.current.handleProps.onLostPointerCapture(
|
|
396
|
+
pointerEvent(300, 1000, target),
|
|
397
|
+
),
|
|
398
|
+
);
|
|
399
|
+
|
|
400
|
+
expect(hook.result.current.isDragging).toBe(false);
|
|
401
|
+
expect(hook.result.current.contentProps.style.transform).toBeUndefined();
|
|
402
|
+
expect(onDismiss).not.toHaveBeenCalled();
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
it('does not start a drag from a secondary pointer button', () => {
|
|
406
|
+
const {hook} = setup();
|
|
407
|
+
const target = makeTarget();
|
|
408
|
+
act(() => hook.result.current.sheetRef(target));
|
|
409
|
+
|
|
410
|
+
act(() =>
|
|
411
|
+
hook.result.current.handleProps.onPointerDown(
|
|
412
|
+
pointerEvent(0, 0, target, 1, 2),
|
|
413
|
+
),
|
|
414
|
+
);
|
|
415
|
+
|
|
416
|
+
expect(hook.result.current.isDragging).toBe(false);
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
it('rubber-bands an upward drag past the fully-open position', () => {
|
|
420
|
+
const {hook} = setup({snapHeights: () => [200]});
|
|
421
|
+
const t = makeTarget();
|
|
422
|
+
down(hook, 100, 0, t);
|
|
423
|
+
move(hook, 20, 100, t); // drag up 80px past the top of a rested-at-top sheet
|
|
424
|
+
// Overscroll is allowed but damped: a resisted fraction of the raw -80,
|
|
425
|
+
// and negative (above the top), not the raw distance and not clamped to 0.
|
|
426
|
+
const off = hook.result.current.dragOffset;
|
|
427
|
+
expect(off).toBeLessThan(0);
|
|
428
|
+
expect(off).toBeGreaterThan(-80); // resisted, so smaller in magnitude
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
it('dismisses a single-height sheet on a slow drag past the floor', () => {
|
|
432
|
+
const {hook, onDismiss} = setup(); // no snapHeights
|
|
433
|
+
const t = makeTarget();
|
|
434
|
+
// Only detent is the full 400px height (offset 0). A slow drag past 40%
|
|
435
|
+
// of it (>160px) with no detent to catch it dismisses.
|
|
436
|
+
down(hook, 0, 0, t);
|
|
437
|
+
move(hook, 120, 400, t);
|
|
438
|
+
move(hook, 240, 900, t);
|
|
439
|
+
up(hook, 240, 1400, t);
|
|
440
|
+
expect(onDismiss).toHaveBeenCalledTimes(1);
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
describe('body overscroll', () => {
|
|
444
|
+
// A scrollable body element nested inside the sheet, with a settable
|
|
445
|
+
// scrollTop so we can simulate "at the top" vs "scrolled".
|
|
446
|
+
function makeBody(scrollTop: number) {
|
|
447
|
+
const body = document.createElement('div');
|
|
448
|
+
Object.defineProperty(body, 'scrollTop', {
|
|
449
|
+
value: scrollTop,
|
|
450
|
+
writable: true,
|
|
451
|
+
});
|
|
452
|
+
body.setPointerCapture = vi.fn();
|
|
453
|
+
body.releasePointerCapture = vi.fn();
|
|
454
|
+
body.getBoundingClientRect = () => ({height: SHEET_HEIGHT}) as DOMRect;
|
|
455
|
+
return body;
|
|
456
|
+
}
|
|
457
|
+
function bodyDown(hook: Hook, y: number, t: number, el: HTMLElement) {
|
|
458
|
+
// Register a sheet element so the hook can measure height (it reads the
|
|
459
|
+
// tracked node, not the DOM). The body stands in with the right height.
|
|
460
|
+
act(() => hook.result.current.sheetRef(el));
|
|
461
|
+
act(() =>
|
|
462
|
+
hook.result.current.bodyProps.onPointerDown(pointerEvent(y, t, el)),
|
|
463
|
+
);
|
|
464
|
+
}
|
|
465
|
+
function bodyMove(hook: Hook, y: number, t: number, el: HTMLElement) {
|
|
466
|
+
act(() =>
|
|
467
|
+
hook.result.current.bodyProps.onPointerMove(pointerEvent(y, t, el)),
|
|
468
|
+
);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
it('promotes a top-overscroll pull-down into a sheet drag', () => {
|
|
472
|
+
const {hook} = setup({snapHeights: () => [200]});
|
|
473
|
+
const body = makeBody(0); // at the top
|
|
474
|
+
bodyDown(hook, 0, 0, body);
|
|
475
|
+
bodyMove(hook, 40, 100, body); // pull down while at the top
|
|
476
|
+
expect(hook.result.current.isDragging).toBe(true);
|
|
477
|
+
expect(hook.result.current.dragOffset).toBe(40);
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
it('does not hijack scrolling when the body is scrolled down', () => {
|
|
481
|
+
const {hook} = setup({snapHeights: () => [200]});
|
|
482
|
+
const body = makeBody(120); // scrolled, not at the top
|
|
483
|
+
bodyDown(hook, 0, 0, body);
|
|
484
|
+
bodyMove(hook, 40, 100, body);
|
|
485
|
+
expect(hook.result.current.isDragging).toBe(false);
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
it('does not start a drag on an upward move from the top', () => {
|
|
489
|
+
const {hook} = setup({snapHeights: () => [200]});
|
|
490
|
+
const body = makeBody(0);
|
|
491
|
+
bodyDown(hook, 100, 0, body);
|
|
492
|
+
bodyMove(hook, 60, 100, body); // moving up = scrolling intent
|
|
493
|
+
expect(hook.result.current.isDragging).toBe(false);
|
|
494
|
+
});
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
describe('body touch handoff', () => {
|
|
498
|
+
// Attach a real DOM node via the body ref, then dispatch touch events. The
|
|
499
|
+
// hook's non-passive touchmove listener is how the handoff works on touch
|
|
500
|
+
// devices (pointer events get cancelled once native panning starts).
|
|
501
|
+
function makeScroller(opts: {
|
|
502
|
+
scrollTop: number;
|
|
503
|
+
clientHeight: number;
|
|
504
|
+
scrollHeight: number;
|
|
505
|
+
}) {
|
|
506
|
+
const sheet = makeTarget();
|
|
507
|
+
const el = document.createElement('div');
|
|
508
|
+
Object.defineProperty(el, 'scrollTop', {
|
|
509
|
+
value: opts.scrollTop,
|
|
510
|
+
writable: true,
|
|
511
|
+
});
|
|
512
|
+
Object.defineProperty(el, 'clientHeight', {value: opts.clientHeight});
|
|
513
|
+
Object.defineProperty(el, 'scrollHeight', {value: opts.scrollHeight});
|
|
514
|
+
el.getBoundingClientRect = () => ({height: SHEET_HEIGHT}) as DOMRect;
|
|
515
|
+
sheet.appendChild(el);
|
|
516
|
+
document.body.appendChild(sheet);
|
|
517
|
+
return el;
|
|
518
|
+
}
|
|
519
|
+
function touch(el: HTMLElement, type: string, y: number, id = 1) {
|
|
520
|
+
const ev = new Event(type, {bubbles: true, cancelable: true});
|
|
521
|
+
// jsdom lacks TouchEvent; attach the fields the handler reads.
|
|
522
|
+
Object.defineProperty(ev, 'changedTouches', {
|
|
523
|
+
value: [{identifier: id, clientY: y}],
|
|
524
|
+
});
|
|
525
|
+
Object.defineProperty(ev, 'touches', {
|
|
526
|
+
value:
|
|
527
|
+
type === 'touchend' || type === 'touchcancel'
|
|
528
|
+
? []
|
|
529
|
+
: [{identifier: id, clientY: y}],
|
|
530
|
+
});
|
|
531
|
+
Object.defineProperty(ev, 'currentTarget', {value: el});
|
|
532
|
+
el.dispatchEvent(ev);
|
|
533
|
+
return ev;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
it('promotes a top pull-down (touch) into a sheet drag', () => {
|
|
537
|
+
const {hook} = setup({snapHeights: () => [200]});
|
|
538
|
+
const el = makeScroller({
|
|
539
|
+
scrollTop: 0,
|
|
540
|
+
clientHeight: 200,
|
|
541
|
+
scrollHeight: 800,
|
|
542
|
+
});
|
|
543
|
+
act(() => hook.result.current.sheetRef(el));
|
|
544
|
+
act(() => hook.result.current.bodyProps.ref(el));
|
|
545
|
+
act(() => {
|
|
546
|
+
touch(el, 'touchstart', 0);
|
|
547
|
+
touch(el, 'touchmove', 50);
|
|
548
|
+
});
|
|
549
|
+
expect(hook.result.current.isDragging).toBe(true);
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
it('promotes a bottom pull-up (touch) into a sheet drag that travels', () => {
|
|
553
|
+
const {hook} = setup({snapHeights: () => [200]});
|
|
554
|
+
// scrolled to the bottom: scrollTop + clientHeight === scrollHeight
|
|
555
|
+
const el = makeScroller({
|
|
556
|
+
scrollTop: 600,
|
|
557
|
+
clientHeight: 200,
|
|
558
|
+
scrollHeight: 800,
|
|
559
|
+
});
|
|
560
|
+
act(() => hook.result.current.sheetRef(el));
|
|
561
|
+
act(() => hook.result.current.bodyProps.ref(el));
|
|
562
|
+
// Settle at the lower detent first, so expanding has somewhere to go.
|
|
563
|
+
down(hook, 0, 0, el);
|
|
564
|
+
move(hook, 180, 700, el);
|
|
565
|
+
up(hook, 180, 1100, el);
|
|
566
|
+
expect(hook.result.current.settledOffset).toBe(200);
|
|
567
|
+
|
|
568
|
+
act(() => {
|
|
569
|
+
touch(el, 'touchstart', 300);
|
|
570
|
+
touch(el, 'touchmove', 250); // pull up
|
|
571
|
+
});
|
|
572
|
+
expect(hook.result.current.isDragging).toBe(true);
|
|
573
|
+
// The handoff is only worth taking if the sheet follows the finger:
|
|
574
|
+
// 50px of pull expands 50px toward the tallest detent.
|
|
575
|
+
expect(hook.result.current.dragOffset).toBe(150);
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
it('leaves a bottom pull-up with the scroller at the tallest detent', () => {
|
|
579
|
+
// Regression: the sheet is already fully expanded, so an upward pull has
|
|
580
|
+
// nowhere to expand to. Promoting it produced a rubber-band the release
|
|
581
|
+
// threw straight back, and stole the gesture from the scroller.
|
|
582
|
+
const {hook} = setup({snapHeights: () => [200]});
|
|
583
|
+
const el = makeScroller({
|
|
584
|
+
scrollTop: 600,
|
|
585
|
+
clientHeight: 200,
|
|
586
|
+
scrollHeight: 800,
|
|
587
|
+
});
|
|
588
|
+
act(() => hook.result.current.sheetRef(el));
|
|
589
|
+
act(() => hook.result.current.bodyProps.ref(el));
|
|
590
|
+
expect(hook.result.current.settledOffset).toBe(0);
|
|
591
|
+
|
|
592
|
+
act(() => {
|
|
593
|
+
touch(el, 'touchstart', 300);
|
|
594
|
+
touch(el, 'touchmove', 250); // pull up
|
|
595
|
+
});
|
|
596
|
+
expect(hook.result.current.isDragging).toBe(false);
|
|
597
|
+
expect(hook.result.current.dragOffset).toBe(0);
|
|
598
|
+
});
|
|
599
|
+
|
|
600
|
+
it('does not preventDefault a bottom pull-up at the tallest detent', () => {
|
|
601
|
+
// The captured gesture was the worse half of the bug: once promoted,
|
|
602
|
+
// every later touchmove was preventDefault()ed, so reversing downward to
|
|
603
|
+
// scroll back collapsed the sheet instead of scrolling.
|
|
604
|
+
const {hook} = setup({snapHeights: () => [200]});
|
|
605
|
+
const el = makeScroller({
|
|
606
|
+
scrollTop: 600,
|
|
607
|
+
clientHeight: 200,
|
|
608
|
+
scrollHeight: 800,
|
|
609
|
+
});
|
|
610
|
+
act(() => hook.result.current.sheetRef(el));
|
|
611
|
+
act(() => hook.result.current.bodyProps.ref(el));
|
|
612
|
+
|
|
613
|
+
let pullUp: Event | undefined;
|
|
614
|
+
let reverseDown: Event | undefined;
|
|
615
|
+
act(() => {
|
|
616
|
+
touch(el, 'touchstart', 300);
|
|
617
|
+
pullUp = touch(el, 'touchmove', 250);
|
|
618
|
+
reverseDown = touch(el, 'touchmove', 400);
|
|
619
|
+
});
|
|
620
|
+
expect(pullUp?.defaultPrevented).toBe(false);
|
|
621
|
+
expect(reverseDown?.defaultPrevented).toBe(false);
|
|
622
|
+
expect(hook.result.current.isDragging).toBe(false);
|
|
623
|
+
});
|
|
624
|
+
|
|
625
|
+
it('finishes the active drag when another ended touch is listed first', () => {
|
|
626
|
+
const {hook} = setup({snapHeights: () => [200]});
|
|
627
|
+
const el = makeScroller({
|
|
628
|
+
scrollTop: 0,
|
|
629
|
+
clientHeight: 200,
|
|
630
|
+
scrollHeight: 800,
|
|
631
|
+
});
|
|
632
|
+
act(() => hook.result.current.sheetRef(el));
|
|
633
|
+
act(() => hook.result.current.bodyProps.ref(el));
|
|
634
|
+
act(() => {
|
|
635
|
+
touch(el, 'touchstart', 0, 2);
|
|
636
|
+
touch(el, 'touchmove', 300, 2);
|
|
637
|
+
|
|
638
|
+
const end = new Event('touchend', {
|
|
639
|
+
bubbles: true,
|
|
640
|
+
cancelable: true,
|
|
641
|
+
});
|
|
642
|
+
Object.defineProperty(end, 'changedTouches', {
|
|
643
|
+
value: [
|
|
644
|
+
{identifier: 1, clientY: 100},
|
|
645
|
+
{identifier: 2, clientY: 300},
|
|
646
|
+
],
|
|
647
|
+
});
|
|
648
|
+
Object.defineProperty(end, 'touches', {value: []});
|
|
649
|
+
el.dispatchEvent(end);
|
|
650
|
+
});
|
|
651
|
+
|
|
652
|
+
expect(hook.result.current.isDragging).toBe(false);
|
|
653
|
+
});
|
|
654
|
+
|
|
655
|
+
it('leaves native scrolling alone in the middle of the content', () => {
|
|
656
|
+
const {hook} = setup({snapHeights: () => [200]});
|
|
657
|
+
const el = makeScroller({
|
|
658
|
+
scrollTop: 300,
|
|
659
|
+
clientHeight: 200,
|
|
660
|
+
scrollHeight: 800,
|
|
661
|
+
});
|
|
662
|
+
act(() => hook.result.current.sheetRef(el));
|
|
663
|
+
act(() => hook.result.current.bodyProps.ref(el));
|
|
664
|
+
act(() => {
|
|
665
|
+
touch(el, 'touchstart', 300);
|
|
666
|
+
touch(el, 'touchmove', 250);
|
|
667
|
+
});
|
|
668
|
+
expect(hook.result.current.isDragging).toBe(false);
|
|
669
|
+
});
|
|
670
|
+
});
|
|
671
|
+
|
|
672
|
+
describe('body touch handoff mid-gesture', () => {
|
|
673
|
+
// The reported bug: the finger lands mid-content, swipes up, REACHES the
|
|
674
|
+
// end of the content, and keeps pulling in the same continuous gesture.
|
|
675
|
+
// Arming decided at touchstart can never see that, and by the time the
|
|
676
|
+
// scroller is at its end the browser owns the gesture — every remaining
|
|
677
|
+
// touchmove is non-cancelable, so preventDefault() is not available to
|
|
678
|
+
// promote with. These cases pin the anchored, cancel-free handoff instead.
|
|
679
|
+
function makeScroller(opts: {
|
|
680
|
+
scrollTop: number;
|
|
681
|
+
clientHeight: number;
|
|
682
|
+
scrollHeight: number;
|
|
683
|
+
}) {
|
|
684
|
+
const sheet = makeTarget();
|
|
685
|
+
const el = document.createElement('div');
|
|
686
|
+
Object.defineProperty(el, 'scrollTop', {
|
|
687
|
+
value: opts.scrollTop,
|
|
688
|
+
writable: true,
|
|
689
|
+
});
|
|
690
|
+
Object.defineProperty(el, 'clientHeight', {value: opts.clientHeight});
|
|
691
|
+
Object.defineProperty(el, 'scrollHeight', {value: opts.scrollHeight});
|
|
692
|
+
el.getBoundingClientRect = () => ({height: SHEET_HEIGHT}) as DOMRect;
|
|
693
|
+
sheet.appendChild(el);
|
|
694
|
+
document.body.appendChild(sheet);
|
|
695
|
+
return el;
|
|
696
|
+
}
|
|
697
|
+
function touch(el: HTMLElement, type: string, y: number, id = 1) {
|
|
698
|
+
const ev = new Event(type, {bubbles: true, cancelable: true});
|
|
699
|
+
Object.defineProperty(ev, 'changedTouches', {
|
|
700
|
+
value: [{identifier: id, clientY: y}],
|
|
701
|
+
});
|
|
702
|
+
Object.defineProperty(ev, 'touches', {
|
|
703
|
+
value:
|
|
704
|
+
type === 'touchend' || type === 'touchcancel'
|
|
705
|
+
? []
|
|
706
|
+
: [{identifier: id, clientY: y}],
|
|
707
|
+
});
|
|
708
|
+
Object.defineProperty(ev, 'currentTarget', {value: el});
|
|
709
|
+
el.dispatchEvent(ev);
|
|
710
|
+
return ev;
|
|
711
|
+
}
|
|
712
|
+
// A scroller with 600px of scrollable content, resting one detent down so
|
|
713
|
+
// an upward pull has somewhere to expand to.
|
|
714
|
+
function midDetentScroller(hook: Hook, scrollTop: number) {
|
|
715
|
+
const el = makeScroller({
|
|
716
|
+
scrollTop,
|
|
717
|
+
clientHeight: 200,
|
|
718
|
+
scrollHeight: 800,
|
|
719
|
+
});
|
|
720
|
+
act(() => hook.result.current.sheetRef(el));
|
|
721
|
+
act(() => hook.result.current.bodyProps.ref(el));
|
|
722
|
+
down(hook, 0, 0, el);
|
|
723
|
+
move(hook, 180, 700, el);
|
|
724
|
+
up(hook, 180, 1100, el);
|
|
725
|
+
expect(hook.result.current.settledOffset).toBe(200);
|
|
726
|
+
return el;
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
// iOS Safari raises PointerEvents for a finger under the SAME numeric id
|
|
730
|
+
// it puts in `Touch.identifier`, so a drag the touch path starts is keyed
|
|
731
|
+
// to a live pointer. WebKit takes that pointer's capture straight back,
|
|
732
|
+
// and the `lostpointercapture` that follows used to cancel the drag one
|
|
733
|
+
// event after it began — leaving the sheet inert for the rest of the pull
|
|
734
|
+
// while the handle still worked. Every browser that keeps the two id
|
|
735
|
+
// spaces apart hides this, which is why it only showed up on device.
|
|
736
|
+
it('survives a lostpointercapture for the finger driving it', () => {
|
|
737
|
+
const {hook} = setup({snapHeights: () => [200]});
|
|
738
|
+
const el = midDetentScroller(hook, 600); // parked at the content end
|
|
739
|
+
act(() => {
|
|
740
|
+
touch(el, 'touchstart', 500, 7);
|
|
741
|
+
touch(el, 'touchmove', 450, 7); // promotes at the armed bottom edge
|
|
742
|
+
});
|
|
743
|
+
expect(hook.result.current.isDragging).toBe(true);
|
|
744
|
+
act(() => {
|
|
745
|
+
hook.result.current.bodyProps.onLostPointerCapture(
|
|
746
|
+
pointerEvent(450, 0, el, 7), // same id as the touch: iOS does this
|
|
747
|
+
);
|
|
748
|
+
});
|
|
749
|
+
expect(hook.result.current.isDragging).toBe(true);
|
|
750
|
+
act(() => {
|
|
751
|
+
touch(el, 'touchmove', 400, 7);
|
|
752
|
+
});
|
|
753
|
+
// 100px of pull past the touchstart, from the 200px detent.
|
|
754
|
+
expect(hook.result.current.dragOffset).toBe(100);
|
|
755
|
+
});
|
|
756
|
+
|
|
757
|
+
// `pointercancel` for that same finger arrives the moment WebKit claims
|
|
758
|
+
// the gesture. Ending the drag on it settles the sheet mid-pull.
|
|
759
|
+
it('does not end the touch drag on a pointercancel for that finger', () => {
|
|
760
|
+
const {hook} = setup({snapHeights: () => [200]});
|
|
761
|
+
const el = midDetentScroller(hook, 600);
|
|
762
|
+
act(() => {
|
|
763
|
+
touch(el, 'touchstart', 500, 7);
|
|
764
|
+
touch(el, 'touchmove', 450, 7);
|
|
765
|
+
});
|
|
766
|
+
act(() => {
|
|
767
|
+
hook.result.current.bodyProps.onPointerCancel(
|
|
768
|
+
pointerEvent(450, 0, el, 7),
|
|
769
|
+
);
|
|
770
|
+
});
|
|
771
|
+
expect(hook.result.current.isDragging).toBe(true);
|
|
772
|
+
});
|
|
773
|
+
|
|
774
|
+
it('hands off when the content runs out under a moving finger', () => {
|
|
775
|
+
const {hook} = setup({snapHeights: () => [200]});
|
|
776
|
+
const el = midDetentScroller(hook, 300); // finger lands mid-content
|
|
777
|
+
act(() => {
|
|
778
|
+
touch(el, 'touchstart', 500);
|
|
779
|
+
el.scrollTop = 450; // the swipe scrolls the content...
|
|
780
|
+
touch(el, 'touchmove', 350);
|
|
781
|
+
el.scrollTop = 600; // ...until it runs out at the bottom
|
|
782
|
+
touch(el, 'touchmove', 200); // the content ended here
|
|
783
|
+
touch(el, 'touchmove', 150); // 50px BEYOND the end
|
|
784
|
+
});
|
|
785
|
+
expect(hook.result.current.isDragging).toBe(true);
|
|
786
|
+
// Anchored where the content ran out, so only the 50px past it moves the
|
|
787
|
+
// sheet. Anchoring at touchstart would have thrown it 350px instead.
|
|
788
|
+
expect(hook.result.current.dragOffset).toBe(150);
|
|
789
|
+
});
|
|
790
|
+
|
|
791
|
+
it('does not preventDefault the mid-gesture handoff', () => {
|
|
792
|
+
// By this point in the gesture the events are non-cancelable anyway, and
|
|
793
|
+
// there is nothing to cancel: the scroller is clamped at its end.
|
|
794
|
+
// Claiming the gesture would only strand the content under the finger.
|
|
795
|
+
const {hook} = setup({snapHeights: () => [200]});
|
|
796
|
+
const el = midDetentScroller(hook, 300);
|
|
797
|
+
let atEnd: Event | undefined;
|
|
798
|
+
let past: Event | undefined;
|
|
799
|
+
let further: Event | undefined;
|
|
800
|
+
act(() => {
|
|
801
|
+
touch(el, 'touchstart', 500);
|
|
802
|
+
el.scrollTop = 600;
|
|
803
|
+
atEnd = touch(el, 'touchmove', 200);
|
|
804
|
+
past = touch(el, 'touchmove', 150);
|
|
805
|
+
further = touch(el, 'touchmove', 100);
|
|
806
|
+
});
|
|
807
|
+
expect(hook.result.current.isDragging).toBe(true);
|
|
808
|
+
expect(atEnd?.defaultPrevented).toBe(false);
|
|
809
|
+
expect(past?.defaultPrevented).toBe(false);
|
|
810
|
+
expect(further?.defaultPrevented).toBe(false);
|
|
811
|
+
});
|
|
812
|
+
|
|
813
|
+
it('gives the gesture back when the finger returns to the content end', () => {
|
|
814
|
+
const {hook} = setup({snapHeights: () => [200]});
|
|
815
|
+
const el = midDetentScroller(hook, 300);
|
|
816
|
+
act(() => {
|
|
817
|
+
touch(el, 'touchstart', 500);
|
|
818
|
+
el.scrollTop = 600;
|
|
819
|
+
touch(el, 'touchmove', 200); // the content ended here
|
|
820
|
+
touch(el, 'touchmove', 150);
|
|
821
|
+
});
|
|
822
|
+
expect(hook.result.current.dragOffset).toBe(150);
|
|
823
|
+
|
|
824
|
+
act(() => {
|
|
825
|
+
touch(el, 'touchmove', 210); // back below the anchor
|
|
826
|
+
});
|
|
827
|
+
// The native scroll was never cancelled, so it resumes from here. Two
|
|
828
|
+
// things moving at once is the failure mode; the sheet yields.
|
|
829
|
+
expect(hook.result.current.isDragging).toBe(false);
|
|
830
|
+
expect(hook.result.current.settledOffset).toBe(200);
|
|
831
|
+
|
|
832
|
+
act(() => {
|
|
833
|
+
el.scrollTop = 600; // scrolled back to the end
|
|
834
|
+
touch(el, 'touchmove', 190); // and pulls past it again
|
|
835
|
+
touch(el, 'touchmove', 140);
|
|
836
|
+
});
|
|
837
|
+
expect(hook.result.current.isDragging).toBe(true);
|
|
838
|
+
expect(hook.result.current.dragOffset).toBe(150);
|
|
839
|
+
});
|
|
840
|
+
|
|
841
|
+
it('does not hand off on reaching the end without travelling past it', () => {
|
|
842
|
+
// Landing on the last pixel is not a pull, and the momentum tail after
|
|
843
|
+
// the finger lifts fires no touchmove at all, so it can never promote.
|
|
844
|
+
const {hook} = setup({snapHeights: () => [200]});
|
|
845
|
+
const el = midDetentScroller(hook, 300);
|
|
846
|
+
act(() => {
|
|
847
|
+
touch(el, 'touchstart', 500);
|
|
848
|
+
el.scrollTop = 600;
|
|
849
|
+
touch(el, 'touchmove', 200);
|
|
850
|
+
touch(el, 'touchmove', 199); // jitter, not a pull
|
|
851
|
+
touch(el, 'touchend', 199);
|
|
852
|
+
el.scrollTop = 600; // momentum carries on with no finger down
|
|
853
|
+
});
|
|
854
|
+
expect(hook.result.current.isDragging).toBe(false);
|
|
855
|
+
expect(hook.result.current.settledOffset).toBe(200);
|
|
856
|
+
});
|
|
857
|
+
|
|
858
|
+
it('leaves the mid-gesture pull to the content at the tallest detent', () => {
|
|
859
|
+
// Same #5161 rule as the touchstart-armed edge: with no taller detent
|
|
860
|
+
// there is nothing to expand into, so the scroller keeps the gesture.
|
|
861
|
+
const {hook} = setup({snapHeights: () => [200]});
|
|
862
|
+
const el = makeScroller({
|
|
863
|
+
scrollTop: 300,
|
|
864
|
+
clientHeight: 200,
|
|
865
|
+
scrollHeight: 800,
|
|
866
|
+
});
|
|
867
|
+
act(() => hook.result.current.sheetRef(el));
|
|
868
|
+
act(() => hook.result.current.bodyProps.ref(el));
|
|
869
|
+
expect(hook.result.current.settledOffset).toBe(0);
|
|
870
|
+
|
|
871
|
+
let past: Event | undefined;
|
|
872
|
+
act(() => {
|
|
873
|
+
touch(el, 'touchstart', 500);
|
|
874
|
+
el.scrollTop = 600;
|
|
875
|
+
touch(el, 'touchmove', 200);
|
|
876
|
+
past = touch(el, 'touchmove', 100);
|
|
877
|
+
});
|
|
878
|
+
expect(hook.result.current.isDragging).toBe(false);
|
|
879
|
+
expect(hook.result.current.dragOffset).toBe(0);
|
|
880
|
+
expect(past?.defaultPrevented).toBe(false);
|
|
881
|
+
});
|
|
882
|
+
|
|
883
|
+
it('does not hand off while the content can still scroll', () => {
|
|
884
|
+
const {hook} = setup({snapHeights: () => [200]});
|
|
885
|
+
const el = midDetentScroller(hook, 0);
|
|
886
|
+
act(() => {
|
|
887
|
+
touch(el, 'touchstart', 500);
|
|
888
|
+
el.scrollTop = 200;
|
|
889
|
+
touch(el, 'touchmove', 300);
|
|
890
|
+
el.scrollTop = 400;
|
|
891
|
+
touch(el, 'touchmove', 100); // 400px of pull, all of it scrolling
|
|
892
|
+
});
|
|
893
|
+
expect(hook.result.current.isDragging).toBe(false);
|
|
894
|
+
expect(hook.result.current.settledOffset).toBe(200);
|
|
895
|
+
});
|
|
896
|
+
});
|
|
897
|
+
|
|
898
|
+
describe('viewport resize', () => {
|
|
899
|
+
// The panel pins the sheet's height in px at a resizing detent; everywhere
|
|
900
|
+
// else the height comes from CSS (`92dvh`), which follows the viewport.
|
|
901
|
+
// Model both, so a measurement taken while pinned reads the pin back —
|
|
902
|
+
// that is the trap the hook has to work around.
|
|
903
|
+
const SNAP_FRACTIONS = [0.14, 0.5, 0.92];
|
|
904
|
+
const RESERVED_BELOW_FLOOR = 48;
|
|
905
|
+
|
|
906
|
+
function viewportHarness(initialViewport: number) {
|
|
907
|
+
let viewport = initialViewport;
|
|
908
|
+
const el = document.createElement('div');
|
|
909
|
+
el.setPointerCapture = vi.fn();
|
|
910
|
+
el.releasePointerCapture = vi.fn();
|
|
911
|
+
el.getBoundingClientRect = () =>
|
|
912
|
+
({
|
|
913
|
+
height: el.style.height
|
|
914
|
+
? Number.parseFloat(el.style.height)
|
|
915
|
+
: 0.92 * viewport + RESERVED_BELOW_FLOOR,
|
|
916
|
+
}) as DOMRect;
|
|
917
|
+
|
|
918
|
+
const onSnap = vi.fn();
|
|
919
|
+
const {hook} = setup({
|
|
920
|
+
offscreenBlockEndInset: RESERVED_BELOW_FLOOR,
|
|
921
|
+
snapHeights: () => SNAP_FRACTIONS.map(f => f * viewport),
|
|
922
|
+
onSnap,
|
|
923
|
+
});
|
|
924
|
+
act(() => hook.result.current.sheetRef(el));
|
|
925
|
+
|
|
926
|
+
// What BottomSheetPanel renders at rest.
|
|
927
|
+
const paint = () => {
|
|
928
|
+
const {sheetHeight, settledLayoutOffset} = hook.result.current;
|
|
929
|
+
el.style.height =
|
|
930
|
+
settledLayoutOffset > 0
|
|
931
|
+
? `${sheetHeight - settledLayoutOffset}px`
|
|
932
|
+
: '';
|
|
933
|
+
};
|
|
934
|
+
const dragTo = (offset: number) => {
|
|
935
|
+
// Anchor anywhere; what matters is the delta from where it rests, so
|
|
936
|
+
// this drags in whichever direction the target detent lies.
|
|
937
|
+
const anchor = 1000;
|
|
938
|
+
const delta = offset - hook.result.current.settledOffset;
|
|
939
|
+
act(() =>
|
|
940
|
+
hook.result.current.handleProps.onPointerDown(
|
|
941
|
+
pointerEvent(anchor, 0, el),
|
|
942
|
+
),
|
|
943
|
+
);
|
|
944
|
+
// Slow: well under the flick threshold, so the release settles.
|
|
945
|
+
act(() =>
|
|
946
|
+
hook.result.current.handleProps.onPointerMove(
|
|
947
|
+
pointerEvent(anchor + delta, 2000, el),
|
|
948
|
+
),
|
|
949
|
+
);
|
|
950
|
+
act(() =>
|
|
951
|
+
hook.result.current.handleProps.onPointerUp(
|
|
952
|
+
pointerEvent(anchor + delta, 2400, el),
|
|
953
|
+
),
|
|
954
|
+
);
|
|
955
|
+
// The panel resolves the settle when the transform transition ends.
|
|
956
|
+
act(() => hook.result.current.completeScrollAreaSettle());
|
|
957
|
+
paint();
|
|
958
|
+
};
|
|
959
|
+
const resizeTo = (next: number) => {
|
|
960
|
+
viewport = next;
|
|
961
|
+
act(() => {
|
|
962
|
+
window.dispatchEvent(new Event('resize'));
|
|
963
|
+
});
|
|
964
|
+
paint();
|
|
965
|
+
};
|
|
966
|
+
// What the user sees: the border box, less the part reserved below the
|
|
967
|
+
// viewport floor and the part translated past it.
|
|
968
|
+
const visibleHeight = () =>
|
|
969
|
+
hook.result.current.sheetHeight -
|
|
970
|
+
RESERVED_BELOW_FLOOR -
|
|
971
|
+
hook.result.current.settledOffset;
|
|
972
|
+
|
|
973
|
+
return {hook, el, onSnap, dragTo, resizeTo, visibleHeight};
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
it('re-resolves the settled detent against the new viewport', () => {
|
|
977
|
+
// 900px viewport: detents at 0 / 378 / 702, so the half-height stop
|
|
978
|
+
// shows 450px of sheet.
|
|
979
|
+
const {hook, dragTo, resizeTo, visibleHeight, onSnap} =
|
|
980
|
+
viewportHarness(900);
|
|
981
|
+
dragTo(378);
|
|
982
|
+
expect(hook.result.current.settledOffset).toBe(378);
|
|
983
|
+
expect(visibleHeight()).toBe(450);
|
|
984
|
+
|
|
985
|
+
resizeTo(600);
|
|
986
|
+
|
|
987
|
+
// Still the half-height stop, now half of 600 — not the 450px (75% of
|
|
988
|
+
// the window) it was frozen at before.
|
|
989
|
+
expect(visibleHeight()).toBe(300);
|
|
990
|
+
expect(hook.result.current.settledOffset).toBe(252);
|
|
991
|
+
expect(onSnap).toHaveBeenLastCalledWith(300);
|
|
992
|
+
});
|
|
993
|
+
|
|
994
|
+
it('keeps the peek detent on screen when the viewport shrinks', () => {
|
|
995
|
+
const {hook, dragTo, resizeTo, visibleHeight} = viewportHarness(900);
|
|
996
|
+
dragTo(702);
|
|
997
|
+
expect(hook.result.current.settledOffset).toBe(702);
|
|
998
|
+
// The peek keeps its full height and slides, so its offset is the whole
|
|
999
|
+
// of its travel — and 702px of travel does not fit in a 600px window.
|
|
1000
|
+
expect(hook.result.current.settledLayoutOffset).toBe(0);
|
|
1001
|
+
|
|
1002
|
+
resizeTo(600);
|
|
1003
|
+
|
|
1004
|
+
expect(hook.result.current.settledOffset).toBe(468);
|
|
1005
|
+
expect(hook.result.current.settledOffset).toBeLessThan(
|
|
1006
|
+
hook.result.current.sheetHeight,
|
|
1007
|
+
);
|
|
1008
|
+
expect(visibleHeight()).toBe(84);
|
|
1009
|
+
});
|
|
1010
|
+
|
|
1011
|
+
it('refreshes the fully open height the next drag measures against', () => {
|
|
1012
|
+
const {hook, dragTo, resizeTo} = viewportHarness(900);
|
|
1013
|
+
dragTo(378);
|
|
1014
|
+
expect(hook.result.current.sheetHeight).toBe(876);
|
|
1015
|
+
|
|
1016
|
+
resizeTo(600);
|
|
1017
|
+
|
|
1018
|
+
// Measured through the pinned height the panel had written for the old
|
|
1019
|
+
// viewport, so this is the natural budget and not the stale pin.
|
|
1020
|
+
expect(hook.result.current.sheetHeight).toBe(600);
|
|
1021
|
+
|
|
1022
|
+
// Dragging back up lands on fully open rather than overshooting past it
|
|
1023
|
+
// toward where fully open used to be.
|
|
1024
|
+
dragTo(0);
|
|
1025
|
+
expect(hook.result.current.settledOffset).toBe(0);
|
|
1026
|
+
expect(hook.result.current.settledLayoutOffset).toBe(0);
|
|
1027
|
+
});
|
|
1028
|
+
|
|
1029
|
+
it('leaves a drag in progress alone', () => {
|
|
1030
|
+
const {hook, el} = viewportHarness(900);
|
|
1031
|
+
act(() =>
|
|
1032
|
+
hook.result.current.handleProps.onPointerDown(pointerEvent(0, 0, el)),
|
|
1033
|
+
);
|
|
1034
|
+
act(() =>
|
|
1035
|
+
hook.result.current.handleProps.onPointerMove(
|
|
1036
|
+
pointerEvent(200, 2000, el),
|
|
1037
|
+
),
|
|
1038
|
+
);
|
|
1039
|
+
|
|
1040
|
+
act(() => {
|
|
1041
|
+
window.dispatchEvent(new Event('resize'));
|
|
1042
|
+
});
|
|
1043
|
+
|
|
1044
|
+
expect(hook.result.current.isDragging).toBe(true);
|
|
1045
|
+
expect(hook.result.current.sheetHeight).toBe(876);
|
|
1046
|
+
expect(hook.result.current.settledOffset).toBe(0);
|
|
1047
|
+
});
|
|
1048
|
+
|
|
1049
|
+
it('stops re-resolving once the sheet closes', () => {
|
|
1050
|
+
const {hook, dragTo, resizeTo} = viewportHarness(900);
|
|
1051
|
+
dragTo(378);
|
|
1052
|
+
act(() => hook.rerender({isOpen: false, onDismiss: vi.fn()}));
|
|
1053
|
+
|
|
1054
|
+
resizeTo(600);
|
|
1055
|
+
|
|
1056
|
+
expect(hook.result.current.sheetHeight).toBe(876);
|
|
1057
|
+
});
|
|
1058
|
+
});
|
|
1059
|
+
});
|