@astryxdesign/core 0.4.2 → 0.4.3-canary.0a9618e
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 +45 -0
- package/README.md +79 -45
- package/dist/Avatar/Avatar.d.ts.map +1 -1
- package/dist/Avatar/Avatar.js +3 -19
- package/dist/Banner/Banner.d.ts +9 -2
- package/dist/Banner/Banner.d.ts.map +1 -1
- package/dist/Banner/Banner.js +84 -33
- package/dist/Banner/index.d.ts +14 -12
- package/dist/Banner/index.d.ts.map +1 -1
- package/dist/Banner/index.js +10 -8
- package/dist/BottomSheet/BottomSheet.d.ts +66 -0
- package/dist/BottomSheet/BottomSheet.d.ts.map +1 -0
- package/dist/BottomSheet/BottomSheet.js +381 -0
- package/dist/BottomSheet/BottomSheetPanel.d.ts +57 -0
- package/dist/BottomSheet/BottomSheetPanel.d.ts.map +1 -0
- package/dist/BottomSheet/BottomSheetPanel.js +395 -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 +25 -0
- package/dist/BottomSheet/useMobileKeyboard.d.ts.map +1 -0
- package/dist/BottomSheet/useMobileKeyboard.js +384 -0
- package/dist/BottomSheet/useSheetGestures.d.ts +170 -0
- package/dist/BottomSheet/useSheetGestures.d.ts.map +1 -0
- package/dist/BottomSheet/useSheetGestures.js +881 -0
- package/dist/Chat/ChatComposerInput.d.ts.map +1 -1
- package/dist/Chat/ChatComposerInput.js +3 -4
- package/dist/Chat/ChatTokenizedText.js +1 -1
- package/dist/ComplexSelector/ComplexSelector.d.ts +38 -4
- package/dist/ComplexSelector/ComplexSelector.d.ts.map +1 -1
- package/dist/ComplexSelector/ComplexSelector.js +98 -31
- package/dist/ComplexSelector/index.d.ts +2 -2
- package/dist/ComplexSelector/index.d.ts.map +1 -1
- package/dist/ComplexSelector/index.js +1 -1
- 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/Markdown/parser.d.ts.map +1 -1
- package/dist/Markdown/parser.js +55 -12
- 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 +10 -1
- package/dist/NumberInput/NumberInput.d.ts.map +1 -1
- package/dist/NumberInput/NumberInput.js +9 -1
- package/dist/PowerSearch/PowerSearch.d.ts.map +1 -1
- package/dist/PowerSearch/PowerSearch.js +4 -1
- package/dist/PowerSearch/PowerSearchEditPopover.js +1 -1
- package/dist/PowerSearch/formatFilterValue.d.ts.map +1 -1
- package/dist/PowerSearch/formatFilterValue.js +2 -4
- package/dist/Selector/Selector.d.ts.map +1 -1
- package/dist/Selector/Selector.js +10 -1
- package/dist/Switch/Switch.d.ts.map +1 -1
- package/dist/Switch/Switch.js +5 -1
- package/dist/Table/columnUtils.d.ts.map +1 -1
- package/dist/Table/columnUtils.js +4 -1
- package/dist/TextArea/TextArea.d.ts +6 -3
- package/dist/TextArea/TextArea.d.ts.map +1 -1
- package/dist/TextArea/TextArea.js +17 -6
- package/dist/TimeInput/TimeInput.d.ts.map +1 -1
- package/dist/TimeInput/TimeInput.js +8 -1
- package/dist/Tooltip/useTooltip.d.ts.map +1 -1
- package/dist/Tooltip/useTooltip.js +4 -1
- package/dist/TreeList/TreeList.js +2 -1
- package/dist/Typeahead/BaseTypeahead.js +1 -1
- package/dist/Typeahead/Typeahead.d.ts.map +1 -1
- package/dist/Typeahead/Typeahead.js +10 -1
- package/dist/astryx.css +25 -2
- 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/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 +30 -7
- package/dist/theme/defineTheme.d.ts.map +1 -1
- package/dist/theme/defineTheme.js +36 -47
- 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/theme/mergeComponents.d.ts +20 -0
- package/dist/theme/mergeComponents.d.ts.map +1 -0
- package/dist/theme/mergeComponents.js +56 -0
- package/dist/theme/onMediaTokens.d.ts +6 -1
- package/dist/theme/onMediaTokens.d.ts.map +1 -1
- package/dist/theme/onMediaTokens.js +11 -3
- package/dist/utils/characters.d.ts +27 -0
- package/dist/utils/characters.d.ts.map +1 -0
- package/dist/utils/characters.js +83 -0
- 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 +2 -0
- package/dist/utils/index.d.ts.map +1 -1
- package/dist/utils/index.js +3 -1
- package/package.json +9 -8
- package/src/Avatar/Avatar.test.tsx +6 -1
- package/src/Avatar/Avatar.tsx +3 -21
- package/src/Banner/Banner.doc.mjs +12 -10
- package/src/Banner/Banner.test.tsx +108 -0
- package/src/Banner/Banner.tsx +124 -38
- package/src/Banner/index.ts +15 -13
- package/src/BottomSheet/BottomSheet.doc.mjs +266 -0
- package/src/BottomSheet/BottomSheet.test.tsx +1865 -0
- package/src/BottomSheet/BottomSheet.tsx +539 -0
- package/src/BottomSheet/BottomSheetPanel.test.tsx +240 -0
- package/src/BottomSheet/BottomSheetPanel.tsx +542 -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 +566 -0
- package/src/BottomSheet/useSheetGestures.test.ts +833 -0
- package/src/BottomSheet/useSheetGestures.ts +1333 -0
- package/src/Chat/ChatComposerInput.tsx +3 -4
- package/src/Chat/ChatTokenizedText.tsx +1 -1
- package/src/ComplexSelector/ComplexSelector.doc.mjs +55 -6
- package/src/ComplexSelector/ComplexSelector.test.tsx +197 -6
- package/src/ComplexSelector/ComplexSelector.tsx +156 -28
- package/src/ComplexSelector/index.ts +3 -1
- 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/Markdown/parser.test.ts +53 -0
- package/src/Markdown/parser.ts +53 -12
- 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 +31 -0
- package/src/MultiSelector/MultiSelector.tsx +10 -1
- package/src/NumberInput/NumberInput.test.tsx +18 -0
- package/src/NumberInput/NumberInput.tsx +9 -1
- package/src/PowerSearch/PowerSearch.test.tsx +48 -3
- package/src/PowerSearch/PowerSearch.tsx +4 -1
- package/src/PowerSearch/PowerSearchEditPopover.tsx +1 -1
- package/src/PowerSearch/formatFilterValue.test.ts +22 -0
- package/src/PowerSearch/formatFilterValue.ts +2 -4
- package/src/Selector/Selector.test.tsx +33 -0
- package/src/Selector/Selector.tsx +10 -1
- package/src/Switch/Switch.test.tsx +16 -0
- package/src/Switch/Switch.tsx +7 -0
- package/src/Table/Table.test.tsx +6 -0
- package/src/Table/columnUtils.ts +3 -1
- package/src/TextArea/TextArea.doc.mjs +1 -1
- package/src/TextArea/TextArea.test.tsx +72 -0
- package/src/TextArea/TextArea.tsx +26 -8
- package/src/TimeInput/TimeInput.test.tsx +23 -0
- package/src/TimeInput/TimeInput.tsx +8 -0
- package/src/Tooltip/useTooltip.tsx +4 -1
- package/src/TreeList/TreeList.doc.mjs +2 -2
- package/src/TreeList/TreeList.tsx +1 -1
- package/src/Typeahead/BaseTypeahead.tsx +1 -1
- package/src/Typeahead/Typeahead.test.tsx +35 -0
- 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/index.ts +1 -0
- package/src/theme/defineTheme.test.ts +127 -0
- package/src/theme/defineTheme.ts +70 -55
- package/src/theme/expandColorScale.test.ts +138 -0
- package/src/theme/expandColorScale.ts +89 -44
- package/src/theme/extensibleAxes.test.ts +365 -0
- package/src/theme/mergeComponents.ts +59 -0
- package/src/theme/onMediaTokens.ts +9 -2
- package/src/utils/characters.test.ts +141 -0
- package/src/utils/characters.ts +88 -0
- package/src/utils/ime.test.ts +32 -0
- package/src/utils/ime.ts +69 -0
- package/src/utils/index.ts +4 -0
- package/dist/astryx.umd.js +0 -147
- package/dist/astryx.umd.js.map +0 -7
|
@@ -0,0 +1,1865 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file BottomSheet.test.tsx
|
|
5
|
+
* @input Uses vitest, @testing-library/react, BottomSheet component
|
|
6
|
+
* @output Unit tests for BottomSheet component behavior
|
|
7
|
+
* @position Core testing; validates BottomSheet.tsx implementation
|
|
8
|
+
*
|
|
9
|
+
* SYNC: When BottomSheet.tsx changes, update tests to match new behavior
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import {describe, it, expect, vi, beforeEach, afterEach} from 'vitest';
|
|
13
|
+
import {act, render, screen, fireEvent} from '@testing-library/react';
|
|
14
|
+
import {createRef, useState} from 'react';
|
|
15
|
+
import {BottomSheet} from './BottomSheet';
|
|
16
|
+
import {BottomSheetSwitcher} from './BottomSheetSwitcher';
|
|
17
|
+
|
|
18
|
+
// jsdom doesn't implement <dialog> open/close or pointer capture; stub them.
|
|
19
|
+
beforeEach(() => {
|
|
20
|
+
HTMLDialogElement.prototype.showModal = vi.fn(function (
|
|
21
|
+
this: HTMLDialogElement,
|
|
22
|
+
) {
|
|
23
|
+
this.setAttribute('open', '');
|
|
24
|
+
});
|
|
25
|
+
HTMLDialogElement.prototype.show = vi.fn(function (this: HTMLDialogElement) {
|
|
26
|
+
this.setAttribute('open', '');
|
|
27
|
+
});
|
|
28
|
+
HTMLDialogElement.prototype.close = vi.fn(function (this: HTMLDialogElement) {
|
|
29
|
+
this.removeAttribute('open');
|
|
30
|
+
});
|
|
31
|
+
if (!Element.prototype.setPointerCapture) {
|
|
32
|
+
Element.prototype.setPointerCapture = vi.fn();
|
|
33
|
+
Element.prototype.releasePointerCapture = vi.fn();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
vi.stubGlobal(
|
|
37
|
+
'matchMedia',
|
|
38
|
+
vi.fn().mockReturnValue({
|
|
39
|
+
matches: false,
|
|
40
|
+
media: '',
|
|
41
|
+
onchange: null,
|
|
42
|
+
addEventListener: vi.fn(),
|
|
43
|
+
removeEventListener: vi.fn(),
|
|
44
|
+
addListener: vi.fn(),
|
|
45
|
+
removeListener: vi.fn(),
|
|
46
|
+
dispatchEvent: vi.fn(),
|
|
47
|
+
}),
|
|
48
|
+
);
|
|
49
|
+
vi.stubGlobal(
|
|
50
|
+
'requestAnimationFrame',
|
|
51
|
+
vi.fn((callback: FrameRequestCallback) => {
|
|
52
|
+
callback(0);
|
|
53
|
+
return 1;
|
|
54
|
+
}),
|
|
55
|
+
);
|
|
56
|
+
vi.stubGlobal('cancelAnimationFrame', vi.fn());
|
|
57
|
+
window.scrollTo = vi.fn();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
afterEach(() => {
|
|
61
|
+
vi.unstubAllGlobals();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
function getSheet(): HTMLElement {
|
|
65
|
+
const sheet = document.querySelector<HTMLElement>('.astryx-bottom-sheet');
|
|
66
|
+
if (!sheet) {
|
|
67
|
+
throw new Error('sheet panel not found');
|
|
68
|
+
}
|
|
69
|
+
return sheet;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function getBody(): HTMLElement {
|
|
73
|
+
const body = getSheet().lastElementChild;
|
|
74
|
+
if (!(body instanceof HTMLElement)) {
|
|
75
|
+
throw new Error('sheet scroll body not found');
|
|
76
|
+
}
|
|
77
|
+
return body;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function getPositioner(): HTMLElement {
|
|
81
|
+
const positioner = getSheet().parentElement;
|
|
82
|
+
if (!(positioner instanceof HTMLElement)) {
|
|
83
|
+
throw new Error('sheet positioner not found');
|
|
84
|
+
}
|
|
85
|
+
return positioner;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function rect({top, bottom}: {top: number; bottom: number}): DOMRect {
|
|
89
|
+
return {
|
|
90
|
+
x: 0,
|
|
91
|
+
y: top,
|
|
92
|
+
top,
|
|
93
|
+
right: 400,
|
|
94
|
+
bottom,
|
|
95
|
+
left: 0,
|
|
96
|
+
width: 400,
|
|
97
|
+
height: bottom - top,
|
|
98
|
+
toJSON: () => ({}),
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function resizeEntry(
|
|
103
|
+
borderBoxHeight: number,
|
|
104
|
+
contentBoxHeight = borderBoxHeight,
|
|
105
|
+
): ResizeObserverEntry {
|
|
106
|
+
// Core typechecks its tests, so this partial stands in for the full entry
|
|
107
|
+
// the way sharedResizeObserver does: only the fields the sheet reads.
|
|
108
|
+
const entry: Partial<ResizeObserverEntry> = {
|
|
109
|
+
borderBoxSize: [{blockSize: borderBoxHeight, inlineSize: 100}],
|
|
110
|
+
contentRect: rect({top: 0, bottom: contentBoxHeight}),
|
|
111
|
+
};
|
|
112
|
+
return entry as ResizeObserverEntry;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function mockVisualViewport(height: number, offsetTop = 0) {
|
|
116
|
+
const viewport = Object.assign(new EventTarget(), {
|
|
117
|
+
height,
|
|
118
|
+
offsetTop,
|
|
119
|
+
});
|
|
120
|
+
vi.stubGlobal('visualViewport', viewport);
|
|
121
|
+
return viewport;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// The layout viewport — `100dvh`, `window.innerHeight` — which is what the
|
|
125
|
+
// sheet's height budget and its detents are measured against. Deliberately
|
|
126
|
+
// separate from the visual viewport above: the mobile keyboard shrinks that
|
|
127
|
+
// one and leaves this one alone, and the sheet has to tell them apart.
|
|
128
|
+
function mockWindowHeight(height: number) {
|
|
129
|
+
vi.stubGlobal('innerHeight', height);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function mockIOSWebKit() {
|
|
133
|
+
const navigatorMock = Object.create(window.navigator);
|
|
134
|
+
Object.defineProperties(navigatorMock, {
|
|
135
|
+
userAgent: {
|
|
136
|
+
configurable: true,
|
|
137
|
+
value:
|
|
138
|
+
'Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15',
|
|
139
|
+
},
|
|
140
|
+
maxTouchPoints: {configurable: true, value: 5},
|
|
141
|
+
});
|
|
142
|
+
vi.stubGlobal('navigator', navigatorMock);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
interface ResizeObserverRecord {
|
|
146
|
+
callback: ResizeObserverCallback;
|
|
147
|
+
observed: Set<Element>;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function mockResizeObserverInstances(): ResizeObserverRecord[] {
|
|
151
|
+
const observers: ResizeObserverRecord[] = [];
|
|
152
|
+
class ResizeObserverMock {
|
|
153
|
+
callback: ResizeObserverCallback;
|
|
154
|
+
observed = new Set<Element>();
|
|
155
|
+
|
|
156
|
+
constructor(callback: ResizeObserverCallback) {
|
|
157
|
+
this.callback = callback;
|
|
158
|
+
observers.push(this);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
observe(target: Element) {
|
|
162
|
+
this.observed.add(target);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
unobserve(target: Element) {
|
|
166
|
+
this.observed.delete(target);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
disconnect() {
|
|
170
|
+
this.observed.clear();
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
vi.stubGlobal('ResizeObserver', ResizeObserverMock);
|
|
174
|
+
return observers;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function finishSheetExit() {
|
|
178
|
+
fireEvent.transitionEnd(getSheet(), {propertyName: 'transform'});
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function ExitHarness({hasScrim}: {hasScrim?: boolean}) {
|
|
182
|
+
const [isOpen, setIsOpen] = useState(true);
|
|
183
|
+
return (
|
|
184
|
+
<BottomSheet
|
|
185
|
+
isOpen={isOpen}
|
|
186
|
+
onOpenChange={setIsOpen}
|
|
187
|
+
label="Filters"
|
|
188
|
+
hasScrim={hasScrim}>
|
|
189
|
+
<button type="button" onClick={() => setIsOpen(false)}>
|
|
190
|
+
Close sheet
|
|
191
|
+
</button>
|
|
192
|
+
</BottomSheet>
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// The grab handle is the panel's first child (decorative, aria-hidden).
|
|
197
|
+
function getHandle(): HTMLElement {
|
|
198
|
+
const handle = getSheet().querySelector<HTMLElement>('[aria-hidden="true"]');
|
|
199
|
+
if (!handle) {
|
|
200
|
+
throw new Error('grab handle not found');
|
|
201
|
+
}
|
|
202
|
+
return handle;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Drive a pointer drag on the grab handle. jsdom PointerEvents don't carry
|
|
206
|
+
// clientY, so dispatch plain events with the coords the handlers read.
|
|
207
|
+
function drag(handle: HTMLElement, points: {y: number}[]) {
|
|
208
|
+
const [down, ...rest] = points;
|
|
209
|
+
fireEvent.pointerDown(handle, {
|
|
210
|
+
pointerId: 1,
|
|
211
|
+
clientY: down.y,
|
|
212
|
+
button: 0,
|
|
213
|
+
isPrimary: true,
|
|
214
|
+
});
|
|
215
|
+
for (const p of rest) {
|
|
216
|
+
fireEvent.pointerMove(handle, {pointerId: 1, clientY: p.y});
|
|
217
|
+
}
|
|
218
|
+
const last = points[points.length - 1];
|
|
219
|
+
fireEvent.pointerUp(handle, {pointerId: 1, clientY: last.y});
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function fireTouchPointer(
|
|
223
|
+
target: Element,
|
|
224
|
+
type: 'pointerdown' | 'pointermove' | 'pointerup',
|
|
225
|
+
{x, y}: {x: number; y: number},
|
|
226
|
+
) {
|
|
227
|
+
const event = new Event(type, {bubbles: true, cancelable: true});
|
|
228
|
+
Object.defineProperties(event, {
|
|
229
|
+
clientX: {value: x},
|
|
230
|
+
clientY: {value: y},
|
|
231
|
+
isPrimary: {value: true},
|
|
232
|
+
pointerId: {value: 1},
|
|
233
|
+
pointerType: {value: 'touch'},
|
|
234
|
+
});
|
|
235
|
+
return fireEvent(target, event);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function fireTimedPointer(
|
|
239
|
+
target: Element,
|
|
240
|
+
type: 'pointerdown' | 'pointermove' | 'pointerup',
|
|
241
|
+
{time, y}: {time: number; y: number},
|
|
242
|
+
) {
|
|
243
|
+
const event = new Event(type, {bubbles: true, cancelable: true});
|
|
244
|
+
Object.defineProperties(event, {
|
|
245
|
+
button: {value: 0},
|
|
246
|
+
clientY: {value: y},
|
|
247
|
+
isPrimary: {value: true},
|
|
248
|
+
pointerId: {value: 1},
|
|
249
|
+
timeStamp: {value: time},
|
|
250
|
+
});
|
|
251
|
+
return fireEvent(target, event);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
describe('BottomSheet', () => {
|
|
255
|
+
it('renders children when open and applies the accessible label', () => {
|
|
256
|
+
render(
|
|
257
|
+
<BottomSheet isOpen onOpenChange={() => {}} label="Filters">
|
|
258
|
+
Sheet content
|
|
259
|
+
</BottomSheet>,
|
|
260
|
+
);
|
|
261
|
+
const dialog = screen.getByRole('dialog');
|
|
262
|
+
expect(dialog).toBeInTheDocument();
|
|
263
|
+
expect(dialog).toHaveAccessibleName('Filters');
|
|
264
|
+
expect(screen.getByText('Sheet content')).toBeInTheDocument();
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it('forwards DOM props and refs to the visual panel, not the dialog host', () => {
|
|
268
|
+
const panelRef = createRef<HTMLDivElement>();
|
|
269
|
+
const onClick = vi.fn();
|
|
270
|
+
render(
|
|
271
|
+
<BottomSheet
|
|
272
|
+
ref={panelRef}
|
|
273
|
+
isOpen
|
|
274
|
+
onOpenChange={() => {}}
|
|
275
|
+
label="Filters"
|
|
276
|
+
data-testid="filters-panel"
|
|
277
|
+
data-sheet-owner="search"
|
|
278
|
+
className="custom-panel"
|
|
279
|
+
onClick={onClick}>
|
|
280
|
+
Content
|
|
281
|
+
</BottomSheet>,
|
|
282
|
+
);
|
|
283
|
+
|
|
284
|
+
const panel = screen.getByTestId('filters-panel');
|
|
285
|
+
const dialog = screen.getByRole('dialog');
|
|
286
|
+
expect(panelRef.current).toBe(panel);
|
|
287
|
+
expect(panel.tagName).toBe('DIV');
|
|
288
|
+
expect(panel).toHaveClass('astryx-bottom-sheet', 'custom-panel');
|
|
289
|
+
expect(panel).toHaveAttribute('data-sheet-owner', 'search');
|
|
290
|
+
expect(dialog).not.toHaveAttribute('data-testid');
|
|
291
|
+
|
|
292
|
+
fireEvent.click(panel);
|
|
293
|
+
expect(onClick).toHaveBeenCalledTimes(1);
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it('keeps consumer content as the last scroll-body child', () => {
|
|
297
|
+
render(
|
|
298
|
+
<BottomSheet isOpen onOpenChange={() => {}} label="Filters">
|
|
299
|
+
<div data-testid="consumer-content">Sheet content</div>
|
|
300
|
+
</BottomSheet>,
|
|
301
|
+
);
|
|
302
|
+
const body = getBody();
|
|
303
|
+
const consumer = screen.getByTestId('consumer-content');
|
|
304
|
+
|
|
305
|
+
expect(body.children).toHaveLength(1);
|
|
306
|
+
expect(body.lastElementChild).toBe(consumer);
|
|
307
|
+
expect(consumer.matches(':last-child')).toBe(true);
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
it('does not show when isOpen is false', () => {
|
|
311
|
+
render(
|
|
312
|
+
<BottomSheet isOpen={false} onOpenChange={() => {}} label="Filters">
|
|
313
|
+
Hidden
|
|
314
|
+
</BottomSheet>,
|
|
315
|
+
);
|
|
316
|
+
expect(HTMLDialogElement.prototype.showModal).not.toHaveBeenCalled();
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
it('opens modally (showModal + aria-modal)', () => {
|
|
320
|
+
render(
|
|
321
|
+
<BottomSheet isOpen onOpenChange={() => {}} label="Filters">
|
|
322
|
+
Content
|
|
323
|
+
</BottomSheet>,
|
|
324
|
+
);
|
|
325
|
+
expect(HTMLDialogElement.prototype.showModal).toHaveBeenCalled();
|
|
326
|
+
expect(screen.getByRole('dialog')).toHaveAttribute('aria-modal', 'true');
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
it('requests close on Escape', () => {
|
|
330
|
+
const onOpenChange = vi.fn();
|
|
331
|
+
render(
|
|
332
|
+
<BottomSheet isOpen onOpenChange={onOpenChange} label="Filters">
|
|
333
|
+
Content
|
|
334
|
+
</BottomSheet>,
|
|
335
|
+
);
|
|
336
|
+
fireEvent.keyDown(screen.getByRole('dialog'), {key: 'Escape'});
|
|
337
|
+
expect(onOpenChange).toHaveBeenCalledWith(false);
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
it('requests close when the scrim (dialog element itself) is clicked', () => {
|
|
341
|
+
const onOpenChange = vi.fn();
|
|
342
|
+
render(
|
|
343
|
+
<BottomSheet isOpen onOpenChange={onOpenChange} label="Filters">
|
|
344
|
+
Content
|
|
345
|
+
</BottomSheet>,
|
|
346
|
+
);
|
|
347
|
+
fireEvent.click(screen.getByRole('dialog'));
|
|
348
|
+
expect(onOpenChange).toHaveBeenCalledWith(false);
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
it('purpose=form blocks scrim and swipe dismissal but allows Escape', () => {
|
|
352
|
+
const onOpenChange = vi.fn();
|
|
353
|
+
render(
|
|
354
|
+
<BottomSheet
|
|
355
|
+
isOpen
|
|
356
|
+
purpose="form"
|
|
357
|
+
onOpenChange={onOpenChange}
|
|
358
|
+
label="Edit profile">
|
|
359
|
+
Content
|
|
360
|
+
</BottomSheet>,
|
|
361
|
+
);
|
|
362
|
+
const dialog = screen.getByRole('dialog');
|
|
363
|
+
|
|
364
|
+
fireEvent.click(dialog);
|
|
365
|
+
drag(getHandle(), [{y: 0}, {y: 40}, {y: 120}]);
|
|
366
|
+
|
|
367
|
+
expect(onOpenChange).not.toHaveBeenCalled();
|
|
368
|
+
expect(dialog).toHaveStyle({'--_sheet-scrim-opacity': '1'});
|
|
369
|
+
|
|
370
|
+
fireEvent.keyDown(dialog, {key: 'Escape'});
|
|
371
|
+
fireEvent(dialog, new Event('cancel', {cancelable: true}));
|
|
372
|
+
|
|
373
|
+
expect(onOpenChange).toHaveBeenCalledTimes(2);
|
|
374
|
+
expect(onOpenChange).toHaveBeenNthCalledWith(1, false);
|
|
375
|
+
expect(onOpenChange).toHaveBeenNthCalledWith(2, false);
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
it('purpose=required blocks every implicit dismissal path', () => {
|
|
379
|
+
const onOpenChange = vi.fn();
|
|
380
|
+
render(
|
|
381
|
+
<BottomSheet
|
|
382
|
+
isOpen
|
|
383
|
+
purpose="required"
|
|
384
|
+
onOpenChange={onOpenChange}
|
|
385
|
+
label="Required action">
|
|
386
|
+
Content
|
|
387
|
+
</BottomSheet>,
|
|
388
|
+
);
|
|
389
|
+
const dialog = screen.getByRole('alertdialog');
|
|
390
|
+
|
|
391
|
+
fireEvent.click(dialog);
|
|
392
|
+
fireEvent.keyDown(dialog, {key: 'Escape'});
|
|
393
|
+
fireEvent(dialog, new Event('cancel', {cancelable: true}));
|
|
394
|
+
drag(getHandle(), [{y: 0}, {y: 40}, {y: 120}]);
|
|
395
|
+
|
|
396
|
+
expect(onOpenChange).not.toHaveBeenCalled();
|
|
397
|
+
expect(dialog).toHaveStyle({'--_sheet-scrim-opacity': '1'});
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
it('keeps a standalone modal sheet presented through its exit animation', () => {
|
|
401
|
+
render(<ExitHarness />);
|
|
402
|
+
const dialog = screen.getByRole('dialog', {name: 'Filters'});
|
|
403
|
+
|
|
404
|
+
fireEvent.click(screen.getByRole('button', {name: 'Close sheet'}));
|
|
405
|
+
|
|
406
|
+
expect(dialog).toHaveAttribute('open');
|
|
407
|
+
expect(dialog).toHaveAttribute('inert');
|
|
408
|
+
expect(dialog).toHaveAttribute('aria-hidden', 'true');
|
|
409
|
+
expect(dialog).not.toHaveAttribute('aria-modal');
|
|
410
|
+
expect(dialog).toHaveStyle({'--_sheet-scrim-opacity': '0'});
|
|
411
|
+
expect(document.body.style.position).toBe('fixed');
|
|
412
|
+
|
|
413
|
+
finishSheetExit();
|
|
414
|
+
|
|
415
|
+
expect(dialog).not.toHaveAttribute('open');
|
|
416
|
+
expect(document.body.style.position).not.toBe('fixed');
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
it('does not dismiss when the sheet surface itself is clicked', () => {
|
|
420
|
+
const onOpenChange = vi.fn();
|
|
421
|
+
render(
|
|
422
|
+
<BottomSheet isOpen onOpenChange={onOpenChange} label="Filters">
|
|
423
|
+
Content
|
|
424
|
+
</BottomSheet>,
|
|
425
|
+
);
|
|
426
|
+
// Only a click that lands on the dialog (the transparent area) dismisses;
|
|
427
|
+
// clicks bubbling up from the sheet must not.
|
|
428
|
+
fireEvent.click(screen.getByText('Content'));
|
|
429
|
+
expect(onOpenChange).not.toHaveBeenCalled();
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
describe('hasScrim={false} (non-modal)', () => {
|
|
433
|
+
it('opens non-modally: show() instead of showModal(), no aria-modal', () => {
|
|
434
|
+
render(
|
|
435
|
+
<BottomSheet
|
|
436
|
+
isOpen
|
|
437
|
+
onOpenChange={() => {}}
|
|
438
|
+
label="Filters"
|
|
439
|
+
hasScrim={false}>
|
|
440
|
+
Content
|
|
441
|
+
</BottomSheet>,
|
|
442
|
+
);
|
|
443
|
+
expect(HTMLDialogElement.prototype.show).toHaveBeenCalled();
|
|
444
|
+
expect(HTMLDialogElement.prototype.showModal).not.toHaveBeenCalled();
|
|
445
|
+
expect(screen.getByRole('dialog')).not.toHaveAttribute('aria-modal');
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
it('does not dismiss when the shell (dialog element itself) is clicked', () => {
|
|
449
|
+
// No scrim: a tap on the transparent shell must pass through to the page,
|
|
450
|
+
// not dismiss the sheet.
|
|
451
|
+
const onOpenChange = vi.fn();
|
|
452
|
+
render(
|
|
453
|
+
<BottomSheet
|
|
454
|
+
isOpen
|
|
455
|
+
onOpenChange={onOpenChange}
|
|
456
|
+
label="Filters"
|
|
457
|
+
hasScrim={false}>
|
|
458
|
+
Content
|
|
459
|
+
</BottomSheet>,
|
|
460
|
+
);
|
|
461
|
+
fireEvent.click(screen.getByRole('dialog'));
|
|
462
|
+
expect(onOpenChange).not.toHaveBeenCalled();
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
it('still closes on Escape while focus is inside', () => {
|
|
466
|
+
const onOpenChange = vi.fn();
|
|
467
|
+
render(
|
|
468
|
+
<BottomSheet
|
|
469
|
+
isOpen
|
|
470
|
+
onOpenChange={onOpenChange}
|
|
471
|
+
label="Filters"
|
|
472
|
+
hasScrim={false}>
|
|
473
|
+
Content
|
|
474
|
+
</BottomSheet>,
|
|
475
|
+
);
|
|
476
|
+
fireEvent.keyDown(screen.getByRole('dialog'), {key: 'Escape'});
|
|
477
|
+
expect(onOpenChange).toHaveBeenCalledWith(false);
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
it('still dismisses on a downward swipe past the threshold', () => {
|
|
481
|
+
const onOpenChange = vi.fn();
|
|
482
|
+
render(
|
|
483
|
+
<BottomSheet
|
|
484
|
+
isOpen
|
|
485
|
+
onOpenChange={onOpenChange}
|
|
486
|
+
label="Filters"
|
|
487
|
+
hasScrim={false}>
|
|
488
|
+
Content
|
|
489
|
+
</BottomSheet>,
|
|
490
|
+
);
|
|
491
|
+
drag(getHandle(), [{y: 0}, {y: 40}, {y: 120}]);
|
|
492
|
+
expect(onOpenChange).toHaveBeenCalledWith(false);
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
it('does not steal focus onto the panel on open', () => {
|
|
496
|
+
render(
|
|
497
|
+
<BottomSheet
|
|
498
|
+
isOpen
|
|
499
|
+
onOpenChange={() => {}}
|
|
500
|
+
label="Filters"
|
|
501
|
+
hasScrim={false}>
|
|
502
|
+
<button type="button">First action</button>
|
|
503
|
+
</BottomSheet>,
|
|
504
|
+
);
|
|
505
|
+
// The background stays interactive, so the sheet must not grab focus.
|
|
506
|
+
expect(document.activeElement).not.toBe(getSheet());
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
it('still honors a descendant with data-autofocus', () => {
|
|
510
|
+
render(
|
|
511
|
+
<BottomSheet
|
|
512
|
+
isOpen
|
|
513
|
+
onOpenChange={() => {}}
|
|
514
|
+
label="Filters"
|
|
515
|
+
hasScrim={false}>
|
|
516
|
+
<input data-autofocus aria-label="Search" />
|
|
517
|
+
</BottomSheet>,
|
|
518
|
+
);
|
|
519
|
+
expect(document.activeElement).toBe(
|
|
520
|
+
screen.getByRole('textbox', {name: 'Search'}),
|
|
521
|
+
);
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
it('keeps a standalone non-modal sheet visible until its exit ends', () => {
|
|
525
|
+
render(<ExitHarness hasScrim={false} />);
|
|
526
|
+
const dialog = screen.getByRole('dialog', {name: 'Filters'});
|
|
527
|
+
|
|
528
|
+
fireEvent.click(screen.getByRole('button', {name: 'Close sheet'}));
|
|
529
|
+
|
|
530
|
+
expect(dialog).toHaveAttribute('open');
|
|
531
|
+
expect(dialog).toHaveAttribute('inert');
|
|
532
|
+
expect(document.body.style.position).not.toBe('fixed');
|
|
533
|
+
|
|
534
|
+
finishSheetExit();
|
|
535
|
+
|
|
536
|
+
expect(dialog).not.toHaveAttribute('open');
|
|
537
|
+
});
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
describe('grab handle', () => {
|
|
541
|
+
it('renders a decorative handle hidden from assistive tech', () => {
|
|
542
|
+
render(
|
|
543
|
+
<BottomSheet isOpen onOpenChange={() => {}} label="Filters">
|
|
544
|
+
Content
|
|
545
|
+
</BottomSheet>,
|
|
546
|
+
);
|
|
547
|
+
const handle = getHandle();
|
|
548
|
+
expect(handle).toHaveAttribute('aria-hidden', 'true');
|
|
549
|
+
});
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
describe('swipe to dismiss', () => {
|
|
553
|
+
it('requests close when dragged past the dismiss threshold', () => {
|
|
554
|
+
const onOpenChange = vi.fn();
|
|
555
|
+
render(
|
|
556
|
+
<BottomSheet isOpen onOpenChange={onOpenChange} label="Filters">
|
|
557
|
+
Content
|
|
558
|
+
</BottomSheet>,
|
|
559
|
+
);
|
|
560
|
+
// No measured height in jsdom (0) -> any downward drag dismisses via
|
|
561
|
+
// the distance branch (offset > 0.25) once released downward.
|
|
562
|
+
drag(getHandle(), [{y: 0}, {y: 40}, {y: 120}]);
|
|
563
|
+
expect(onOpenChange).toHaveBeenCalledWith(false);
|
|
564
|
+
});
|
|
565
|
+
|
|
566
|
+
it('restores the sheet when a context menu interrupts an active drag', () => {
|
|
567
|
+
const onOpenChange = vi.fn();
|
|
568
|
+
render(
|
|
569
|
+
<BottomSheet isOpen onOpenChange={onOpenChange} label="Filters">
|
|
570
|
+
Content
|
|
571
|
+
</BottomSheet>,
|
|
572
|
+
);
|
|
573
|
+
const handle = getHandle();
|
|
574
|
+
const dialog = screen.getByRole('dialog');
|
|
575
|
+
|
|
576
|
+
fireEvent.pointerDown(handle, {
|
|
577
|
+
pointerId: 1,
|
|
578
|
+
clientY: 0,
|
|
579
|
+
button: 0,
|
|
580
|
+
isPrimary: true,
|
|
581
|
+
});
|
|
582
|
+
fireEvent.pointerMove(handle, {pointerId: 1, clientY: 300});
|
|
583
|
+
expect(getSheet().style.transform).toBe('translateY(300px)');
|
|
584
|
+
|
|
585
|
+
expect(fireEvent.contextMenu(handle)).toBe(false);
|
|
586
|
+
|
|
587
|
+
expect(getSheet().style.transform).toBe('');
|
|
588
|
+
expect(dialog).toHaveStyle({'--_sheet-scrim-opacity': '1'});
|
|
589
|
+
expect(onOpenChange).not.toHaveBeenCalled();
|
|
590
|
+
});
|
|
591
|
+
});
|
|
592
|
+
|
|
593
|
+
describe('height', () => {
|
|
594
|
+
it('renders for each named height without error', () => {
|
|
595
|
+
for (const height of ['hug', 'capped', 'tall'] as const) {
|
|
596
|
+
const {unmount} = render(
|
|
597
|
+
<BottomSheet
|
|
598
|
+
isOpen
|
|
599
|
+
onOpenChange={() => {}}
|
|
600
|
+
label="Filters"
|
|
601
|
+
height={height}>
|
|
602
|
+
Content
|
|
603
|
+
</BottomSheet>,
|
|
604
|
+
);
|
|
605
|
+
expect(screen.getByRole('dialog')).toBeInTheDocument();
|
|
606
|
+
unmount();
|
|
607
|
+
}
|
|
608
|
+
});
|
|
609
|
+
|
|
610
|
+
it('accepts a freeform height (number or CSS length)', () => {
|
|
611
|
+
for (const height of [480, '70dvh'] as const) {
|
|
612
|
+
const {unmount} = render(
|
|
613
|
+
<BottomSheet
|
|
614
|
+
isOpen
|
|
615
|
+
onOpenChange={() => {}}
|
|
616
|
+
label="Filters"
|
|
617
|
+
height={height}>
|
|
618
|
+
Content
|
|
619
|
+
</BottomSheet>,
|
|
620
|
+
);
|
|
621
|
+
expect(screen.getByRole('dialog')).toBeInTheDocument();
|
|
622
|
+
unmount();
|
|
623
|
+
}
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
it('keeps the full layout height at the peek detent', () => {
|
|
627
|
+
const observers = mockResizeObserverInstances();
|
|
628
|
+
mockVisualViewport(800);
|
|
629
|
+
mockWindowHeight(800);
|
|
630
|
+
render(
|
|
631
|
+
<BottomSheet
|
|
632
|
+
isOpen
|
|
633
|
+
onOpenChange={() => {}}
|
|
634
|
+
label="Release notes"
|
|
635
|
+
height="tall">
|
|
636
|
+
Content
|
|
637
|
+
</BottomSheet>,
|
|
638
|
+
);
|
|
639
|
+
const sheet = getSheet();
|
|
640
|
+
const sheetObserver = observers.find(instance =>
|
|
641
|
+
instance.observed.has(sheet),
|
|
642
|
+
);
|
|
643
|
+
act(() => {
|
|
644
|
+
sheetObserver?.callback(
|
|
645
|
+
[resizeEntry(784, 736)],
|
|
646
|
+
sheetObserver as unknown as ResizeObserver,
|
|
647
|
+
);
|
|
648
|
+
});
|
|
649
|
+
|
|
650
|
+
// Settle on the shortest 112px peek: offset 736 - 112 = 624.
|
|
651
|
+
fireTimedPointer(getHandle(), 'pointerdown', {time: 0, y: 0});
|
|
652
|
+
fireTimedPointer(getHandle(), 'pointermove', {time: 1000, y: 600});
|
|
653
|
+
fireTimedPointer(getHandle(), 'pointerup', {time: 2000, y: 600});
|
|
654
|
+
fireEvent.transitionEnd(sheet, {propertyName: 'transform'});
|
|
655
|
+
|
|
656
|
+
// A glance state does not reflow the content into a sliver: the sheet
|
|
657
|
+
// keeps its full layout height and slides below the viewport instead.
|
|
658
|
+
expect(sheet.style.height).toBe('');
|
|
659
|
+
expect(sheet.style.transform).toBe('translateY(624px)');
|
|
660
|
+
expect(getBody().style.paddingBlockEnd).toBe('');
|
|
661
|
+
|
|
662
|
+
// Dragging back up off the peek stays transform-only for the same
|
|
663
|
+
// reason — there is no shortened layout to restore first.
|
|
664
|
+
fireTimedPointer(getHandle(), 'pointerdown', {time: 3000, y: 600});
|
|
665
|
+
fireTimedPointer(getHandle(), 'pointermove', {time: 4000, y: 400});
|
|
666
|
+
expect(sheet.style.transform).toBe('translateY(424px)');
|
|
667
|
+
expect(sheet.style.height).toBe('784px');
|
|
668
|
+
expect(getBody().style.paddingBlockEnd).toBe('');
|
|
669
|
+
|
|
670
|
+
// Releasing at the taller p50 detent resizes the scrolling area.
|
|
671
|
+
fireTimedPointer(getHandle(), 'pointerup', {time: 5000, y: 400});
|
|
672
|
+
fireEvent.transitionEnd(sheet, {propertyName: 'transform'});
|
|
673
|
+
expect(sheet.style.height).toBe('448px');
|
|
674
|
+
expect(sheet.style.transform).toBe('');
|
|
675
|
+
});
|
|
676
|
+
|
|
677
|
+
it('swaps height for transform without a transition when released on a detent', () => {
|
|
678
|
+
const observers = mockResizeObserverInstances();
|
|
679
|
+
mockVisualViewport(800);
|
|
680
|
+
mockWindowHeight(800);
|
|
681
|
+
render(
|
|
682
|
+
<BottomSheet
|
|
683
|
+
isOpen
|
|
684
|
+
onOpenChange={() => {}}
|
|
685
|
+
label="Release notes"
|
|
686
|
+
height="tall">
|
|
687
|
+
Content
|
|
688
|
+
</BottomSheet>,
|
|
689
|
+
);
|
|
690
|
+
const sheet = getSheet();
|
|
691
|
+
const sheetObserver = observers.find(instance =>
|
|
692
|
+
instance.observed.has(sheet),
|
|
693
|
+
);
|
|
694
|
+
act(() => {
|
|
695
|
+
sheetObserver?.callback(
|
|
696
|
+
[resizeEntry(784, 736)],
|
|
697
|
+
sheetObserver as unknown as ResizeObserver,
|
|
698
|
+
);
|
|
699
|
+
});
|
|
700
|
+
|
|
701
|
+
// magnetize() lands a slow drag exactly on the p50 detent (offset 336),
|
|
702
|
+
// so the release has no travel left to animate and reconciles at once.
|
|
703
|
+
fireTimedPointer(getHandle(), 'pointerdown', {time: 0, y: 0});
|
|
704
|
+
fireTimedPointer(getHandle(), 'pointermove', {time: 1000, y: 336});
|
|
705
|
+
expect(sheet.style.transform).toBe('translateY(336px)');
|
|
706
|
+
expect(sheet.style.height).toBe('784px');
|
|
707
|
+
|
|
708
|
+
// Hold the reconciliation frame so the intermediate render is visible
|
|
709
|
+
// to the assertions below.
|
|
710
|
+
const reconciliationFrames: FrameRequestCallback[] = [];
|
|
711
|
+
vi.mocked(requestAnimationFrame).mockImplementation(callback => {
|
|
712
|
+
reconciliationFrames.push(callback);
|
|
713
|
+
return reconciliationFrames.length;
|
|
714
|
+
});
|
|
715
|
+
fireTimedPointer(getHandle(), 'pointerup', {time: 2000, y: 336});
|
|
716
|
+
|
|
717
|
+
// Height and transform swap roles in this one render. That is only
|
|
718
|
+
// invisible with transitions off; live, the composited transform would
|
|
719
|
+
// animate the whole 336px swap while the height jumped, and the sheet
|
|
720
|
+
// would lurch away from the detent before coming back to it.
|
|
721
|
+
expect(sheet.style.height).toBe('448px');
|
|
722
|
+
expect(sheet.style.transform).toBe('');
|
|
723
|
+
expect(sheet.style.transition).toBe('none');
|
|
724
|
+
|
|
725
|
+
// Transitions come back for the next gesture.
|
|
726
|
+
act(() => reconciliationFrames.splice(0).forEach(frame => frame(0)));
|
|
727
|
+
expect(sheet.style.transition).toBe('');
|
|
728
|
+
});
|
|
729
|
+
|
|
730
|
+
it('uses transforms while dragging and resizes to the visible snapped height', () => {
|
|
731
|
+
const observers = mockResizeObserverInstances();
|
|
732
|
+
mockVisualViewport(800);
|
|
733
|
+
mockWindowHeight(800);
|
|
734
|
+
render(
|
|
735
|
+
<BottomSheet
|
|
736
|
+
isOpen
|
|
737
|
+
onOpenChange={() => {}}
|
|
738
|
+
label="Release notes"
|
|
739
|
+
height="tall">
|
|
740
|
+
Content
|
|
741
|
+
</BottomSheet>,
|
|
742
|
+
);
|
|
743
|
+
const sheet = getSheet();
|
|
744
|
+
const sheetObserver = observers.find(instance =>
|
|
745
|
+
instance.observed.has(sheet),
|
|
746
|
+
);
|
|
747
|
+
act(() => {
|
|
748
|
+
sheetObserver?.callback(
|
|
749
|
+
// A Tall sheet has 736px visible height in this 800px viewport plus
|
|
750
|
+
// the 48px border-box reserve held below the viewport.
|
|
751
|
+
[resizeEntry(784, 736)],
|
|
752
|
+
sheetObserver as unknown as ResizeObserver,
|
|
753
|
+
);
|
|
754
|
+
});
|
|
755
|
+
|
|
756
|
+
fireTimedPointer(getHandle(), 'pointerdown', {time: 0, y: 0});
|
|
757
|
+
fireTimedPointer(getHandle(), 'pointermove', {time: 1000, y: 240});
|
|
758
|
+
|
|
759
|
+
expect(sheet.style.transform).toBe('translateY(240px)');
|
|
760
|
+
expect(sheet.style.height).toBe('784px');
|
|
761
|
+
expect(sheet.style.transition).toBe('none');
|
|
762
|
+
|
|
763
|
+
fireTimedPointer(getHandle(), 'pointerup', {time: 2000, y: 240});
|
|
764
|
+
// Release remains transform-only until the snap finishes.
|
|
765
|
+
// p50 is a visible 400px sheet: 784 - 48 - 400 = 336px offset.
|
|
766
|
+
expect(sheet.style.transform).toBe('translateY(336px)');
|
|
767
|
+
expect(sheet.style.height).toBe('784px');
|
|
768
|
+
expect(sheet.style.transition).toBe('');
|
|
769
|
+
const reconciliationFrames: FrameRequestCallback[] = [];
|
|
770
|
+
vi.mocked(requestAnimationFrame).mockImplementation(callback => {
|
|
771
|
+
reconciliationFrames.push(callback);
|
|
772
|
+
return reconciliationFrames.length;
|
|
773
|
+
});
|
|
774
|
+
fireEvent.transitionEnd(sheet, {propertyName: 'transform'});
|
|
775
|
+
expect(sheet.style.transform).toBe('');
|
|
776
|
+
expect(sheet.style.height).toBe('448px');
|
|
777
|
+
// The transform reset is transition-free so it cannot produce a second
|
|
778
|
+
// fly-in animation after the snap has already reached its destination.
|
|
779
|
+
expect(sheet.style.transition).toBe('none');
|
|
780
|
+
expect(reconciliationFrames.length).toBeGreaterThan(0);
|
|
781
|
+
act(() => reconciliationFrames.splice(0).forEach(frame => frame(0)));
|
|
782
|
+
expect(sheet.style.transition).toBe('');
|
|
783
|
+
|
|
784
|
+
// Ignore ResizeObserver frames from the height reconciliation. The
|
|
785
|
+
// next drag must still use the original 784px border-box height.
|
|
786
|
+
act(() => {
|
|
787
|
+
sheetObserver?.callback(
|
|
788
|
+
[resizeEntry(500, 452)],
|
|
789
|
+
sheetObserver as unknown as ResizeObserver,
|
|
790
|
+
);
|
|
791
|
+
});
|
|
792
|
+
fireTimedPointer(getHandle(), 'pointerdown', {time: 3000, y: 240});
|
|
793
|
+
fireTimedPointer(getHandle(), 'pointermove', {time: 4000, y: 140});
|
|
794
|
+
expect(sheet.style.transform).toBe('translateY(236px)');
|
|
795
|
+
expect(sheet.style.height).toBe('784px');
|
|
796
|
+
expect(getBody().style.paddingBlockEnd).toBe('336px');
|
|
797
|
+
|
|
798
|
+
// Reversing below the settled point restores the settled height and
|
|
799
|
+
// translates only the distance traveled from that detent. The temporary
|
|
800
|
+
// scroll-preservation inset leaves with the temporary expanded layout.
|
|
801
|
+
fireTimedPointer(getHandle(), 'pointermove', {time: 5000, y: 340});
|
|
802
|
+
expect(sheet.style.transform).toBe('translateY(100px)');
|
|
803
|
+
expect(sheet.style.height).toBe('448px');
|
|
804
|
+
expect(getBody().style.paddingBlockEnd).toBe('');
|
|
805
|
+
});
|
|
806
|
+
|
|
807
|
+
it('reconciles the snapped height immediately when transitions are disabled', () => {
|
|
808
|
+
const observers = mockResizeObserverInstances();
|
|
809
|
+
mockVisualViewport(800);
|
|
810
|
+
mockWindowHeight(800);
|
|
811
|
+
render(
|
|
812
|
+
<BottomSheet
|
|
813
|
+
isOpen
|
|
814
|
+
onOpenChange={() => {}}
|
|
815
|
+
label="Release notes"
|
|
816
|
+
height="tall"
|
|
817
|
+
style={{transition: 'none'}}>
|
|
818
|
+
Content
|
|
819
|
+
</BottomSheet>,
|
|
820
|
+
);
|
|
821
|
+
const sheet = getSheet();
|
|
822
|
+
const sheetObserver = observers.find(instance =>
|
|
823
|
+
instance.observed.has(sheet),
|
|
824
|
+
);
|
|
825
|
+
act(() => {
|
|
826
|
+
sheetObserver?.callback(
|
|
827
|
+
[resizeEntry(784, 736)],
|
|
828
|
+
sheetObserver as unknown as ResizeObserver,
|
|
829
|
+
);
|
|
830
|
+
});
|
|
831
|
+
|
|
832
|
+
fireTimedPointer(getHandle(), 'pointerdown', {time: 0, y: 0});
|
|
833
|
+
fireTimedPointer(getHandle(), 'pointermove', {time: 1000, y: 240});
|
|
834
|
+
fireTimedPointer(getHandle(), 'pointerup', {time: 2000, y: 240});
|
|
835
|
+
|
|
836
|
+
expect(sheet.style.transform).toBe('');
|
|
837
|
+
expect(sheet.style.height).toBe('448px');
|
|
838
|
+
});
|
|
839
|
+
|
|
840
|
+
it('restores the maximum height before an upward drag and reconciles at snap', () => {
|
|
841
|
+
const observers = mockResizeObserverInstances();
|
|
842
|
+
mockVisualViewport(800);
|
|
843
|
+
mockWindowHeight(800);
|
|
844
|
+
render(
|
|
845
|
+
<BottomSheet
|
|
846
|
+
isOpen
|
|
847
|
+
onOpenChange={() => {}}
|
|
848
|
+
label="Release notes"
|
|
849
|
+
height="tall">
|
|
850
|
+
Content
|
|
851
|
+
</BottomSheet>,
|
|
852
|
+
);
|
|
853
|
+
const sheet = getSheet();
|
|
854
|
+
const sheetObserver = observers.find(instance =>
|
|
855
|
+
instance.observed.has(sheet),
|
|
856
|
+
);
|
|
857
|
+
act(() => {
|
|
858
|
+
sheetObserver?.callback(
|
|
859
|
+
[resizeEntry(784, 736)],
|
|
860
|
+
sheetObserver as unknown as ResizeObserver,
|
|
861
|
+
);
|
|
862
|
+
});
|
|
863
|
+
|
|
864
|
+
// Start at the middle 400px detent.
|
|
865
|
+
fireTimedPointer(getHandle(), 'pointerdown', {time: 0, y: 0});
|
|
866
|
+
fireTimedPointer(getHandle(), 'pointermove', {time: 1000, y: 240});
|
|
867
|
+
fireTimedPointer(getHandle(), 'pointerup', {time: 2000, y: 240});
|
|
868
|
+
fireEvent.transitionEnd(sheet, {propertyName: 'transform'});
|
|
869
|
+
expect(sheet.style.height).toBe('448px');
|
|
870
|
+
|
|
871
|
+
// The upward gesture renders the full 784px surface below the viewport
|
|
872
|
+
// and translates it to preserve the visible top edge.
|
|
873
|
+
fireTimedPointer(getHandle(), 'pointerdown', {time: 3000, y: 240});
|
|
874
|
+
fireTimedPointer(getHandle(), 'pointermove', {time: 4000, y: 0});
|
|
875
|
+
expect(sheet.style.transform).toBe('translateY(96px)');
|
|
876
|
+
expect(sheet.style.height).toBe('784px');
|
|
877
|
+
expect(getBody().style.paddingBlockEnd).toBe('336px');
|
|
878
|
+
|
|
879
|
+
// Release first animates only the transform, keeping the source layout
|
|
880
|
+
// and scroll range fixed for the entire snap.
|
|
881
|
+
fireTimedPointer(getHandle(), 'pointerup', {time: 5000, y: 0});
|
|
882
|
+
expect(sheet.style.transform).toBe('');
|
|
883
|
+
expect(sheet.style.height).toBe('784px');
|
|
884
|
+
expect(getBody().style.paddingBlockEnd).toBe('336px');
|
|
885
|
+
|
|
886
|
+
// At transition end, height and preservation spacing reconcile in one
|
|
887
|
+
// render with the same visible geometry.
|
|
888
|
+
fireEvent.transitionEnd(sheet, {propertyName: 'transform'});
|
|
889
|
+
expect(sheet.style.transform).toBe('');
|
|
890
|
+
expect(sheet.style.height).toBe('');
|
|
891
|
+
expect(getBody().style.paddingBlockEnd).toBe('336px');
|
|
892
|
+
|
|
893
|
+
// Once ordinary scrolling brings the content back within its natural
|
|
894
|
+
// range, the retained end padding is no longer needed and is discarded.
|
|
895
|
+
const body = getBody();
|
|
896
|
+
Object.defineProperties(body, {
|
|
897
|
+
clientHeight: {configurable: true, value: 600},
|
|
898
|
+
scrollHeight: {configurable: true, value: 1000},
|
|
899
|
+
scrollTop: {configurable: true, value: 64, writable: true},
|
|
900
|
+
});
|
|
901
|
+
fireEvent.scroll(body);
|
|
902
|
+
expect(body.style.paddingBlockEnd).toBe('');
|
|
903
|
+
});
|
|
904
|
+
|
|
905
|
+
it('keeps the settled height stable while dismissing', () => {
|
|
906
|
+
const observers = mockResizeObserverInstances();
|
|
907
|
+
mockVisualViewport(800);
|
|
908
|
+
mockWindowHeight(800);
|
|
909
|
+
render(<ExitHarness />);
|
|
910
|
+
const sheet = getSheet();
|
|
911
|
+
const sheetObserver = observers.find(instance =>
|
|
912
|
+
instance.observed.has(sheet),
|
|
913
|
+
);
|
|
914
|
+
act(() => {
|
|
915
|
+
sheetObserver?.callback(
|
|
916
|
+
[resizeEntry(784, 736)],
|
|
917
|
+
sheetObserver as unknown as ResizeObserver,
|
|
918
|
+
);
|
|
919
|
+
});
|
|
920
|
+
|
|
921
|
+
// First settle at the middle detent.
|
|
922
|
+
fireTimedPointer(getHandle(), 'pointerdown', {time: 0, y: 0});
|
|
923
|
+
fireTimedPointer(getHandle(), 'pointermove', {time: 1000, y: 240});
|
|
924
|
+
fireTimedPointer(getHandle(), 'pointerup', {time: 2000, y: 240});
|
|
925
|
+
fireEvent.transitionEnd(sheet, {propertyName: 'transform'});
|
|
926
|
+
expect(sheet.style.height).toBe('448px');
|
|
927
|
+
|
|
928
|
+
// A later dismiss stays transform-only and preserves that settled
|
|
929
|
+
// scroll-area height throughout the exit.
|
|
930
|
+
fireTimedPointer(getHandle(), 'pointerdown', {time: 3000, y: 240});
|
|
931
|
+
fireTimedPointer(getHandle(), 'pointermove', {time: 4000, y: 1000});
|
|
932
|
+
expect(sheet.style.transform).toBe('translateY(760px)');
|
|
933
|
+
expect(sheet.style.height).toBe('448px');
|
|
934
|
+
fireTimedPointer(getHandle(), 'pointerup', {time: 5000, y: 1000});
|
|
935
|
+
expect(sheet.style.height).toBe('448px');
|
|
936
|
+
|
|
937
|
+
act(() => {
|
|
938
|
+
sheetObserver?.callback(
|
|
939
|
+
[resizeEntry(200, 152)],
|
|
940
|
+
sheetObserver as unknown as ResizeObserver,
|
|
941
|
+
);
|
|
942
|
+
});
|
|
943
|
+
expect(sheet.style.height).toBe('448px');
|
|
944
|
+
});
|
|
945
|
+
|
|
946
|
+
it('re-resolves the settled detent when the window resizes', () => {
|
|
947
|
+
const observers = mockResizeObserverInstances();
|
|
948
|
+
mockVisualViewport(800);
|
|
949
|
+
mockWindowHeight(800);
|
|
950
|
+
render(
|
|
951
|
+
<BottomSheet
|
|
952
|
+
isOpen
|
|
953
|
+
onOpenChange={() => {}}
|
|
954
|
+
label="Release notes"
|
|
955
|
+
height="tall">
|
|
956
|
+
Content
|
|
957
|
+
</BottomSheet>,
|
|
958
|
+
);
|
|
959
|
+
const sheet = getSheet();
|
|
960
|
+
const sheetObserver = observers.find(instance =>
|
|
961
|
+
instance.observed.has(sheet),
|
|
962
|
+
);
|
|
963
|
+
act(() => {
|
|
964
|
+
sheetObserver?.callback(
|
|
965
|
+
[resizeEntry(784, 736)],
|
|
966
|
+
sheetObserver as unknown as ResizeObserver,
|
|
967
|
+
);
|
|
968
|
+
});
|
|
969
|
+
|
|
970
|
+
// Settle on the half-height detent: 784 - 48 - 400 = 336px of travel.
|
|
971
|
+
fireTimedPointer(getHandle(), 'pointerdown', {time: 0, y: 0});
|
|
972
|
+
fireTimedPointer(getHandle(), 'pointermove', {time: 1000, y: 336});
|
|
973
|
+
fireTimedPointer(getHandle(), 'pointerup', {time: 2000, y: 336});
|
|
974
|
+
const reconciliationFrames: FrameRequestCallback[] = [];
|
|
975
|
+
vi.mocked(requestAnimationFrame).mockImplementation(callback => {
|
|
976
|
+
reconciliationFrames.push(callback);
|
|
977
|
+
return reconciliationFrames.length;
|
|
978
|
+
});
|
|
979
|
+
fireEvent.transitionEnd(sheet, {propertyName: 'transform'});
|
|
980
|
+
act(() => reconciliationFrames.splice(0).forEach(frame => frame(0)));
|
|
981
|
+
// 448px of layout height shows 400px of sheet: half the 800px window.
|
|
982
|
+
expect(sheet.style.height).toBe('448px');
|
|
983
|
+
|
|
984
|
+
// Shrink the window. The sheet's budget is `92dvh`, so its border box
|
|
985
|
+
// follows: 0.92 * 600 + the 48px reserve = 600px.
|
|
986
|
+
vi.spyOn(sheet, 'getBoundingClientRect').mockImplementation(() =>
|
|
987
|
+
rect({
|
|
988
|
+
top: 0,
|
|
989
|
+
bottom: sheet.style.height
|
|
990
|
+
? Number.parseFloat(sheet.style.height)
|
|
991
|
+
: 600,
|
|
992
|
+
}),
|
|
993
|
+
);
|
|
994
|
+
mockWindowHeight(600);
|
|
995
|
+
act(() => {
|
|
996
|
+
window.dispatchEvent(new Event('resize'));
|
|
997
|
+
});
|
|
998
|
+
|
|
999
|
+
// Still the half-height detent, re-resolved against the new window:
|
|
1000
|
+
// 348px of layout height shows 300px, half of 600. Before this, the
|
|
1001
|
+
// sheet kept its 448px and showed 400px — three quarters of the window.
|
|
1002
|
+
expect(sheet.style.height).toBe('348px');
|
|
1003
|
+
expect(sheet.style.transform).toBe('');
|
|
1004
|
+
// Re-anchoring is not a gesture, so it does not animate.
|
|
1005
|
+
expect(sheet.style.transition).toBe('none');
|
|
1006
|
+
act(() => reconciliationFrames.splice(0).forEach(frame => frame(0)));
|
|
1007
|
+
expect(sheet.style.transition).toBe('');
|
|
1008
|
+
});
|
|
1009
|
+
|
|
1010
|
+
it('keeps its detents when the keyboard shrinks the visual viewport', () => {
|
|
1011
|
+
const observers = mockResizeObserverInstances();
|
|
1012
|
+
const viewport = mockVisualViewport(800);
|
|
1013
|
+
mockWindowHeight(800);
|
|
1014
|
+
render(
|
|
1015
|
+
<BottomSheet
|
|
1016
|
+
isOpen
|
|
1017
|
+
onOpenChange={() => {}}
|
|
1018
|
+
label="Release notes"
|
|
1019
|
+
height="tall">
|
|
1020
|
+
Content
|
|
1021
|
+
</BottomSheet>,
|
|
1022
|
+
);
|
|
1023
|
+
const sheet = getSheet();
|
|
1024
|
+
const sheetObserver = observers.find(instance =>
|
|
1025
|
+
instance.observed.has(sheet),
|
|
1026
|
+
);
|
|
1027
|
+
act(() => {
|
|
1028
|
+
sheetObserver?.callback(
|
|
1029
|
+
[resizeEntry(784, 736)],
|
|
1030
|
+
sheetObserver as unknown as ResizeObserver,
|
|
1031
|
+
);
|
|
1032
|
+
});
|
|
1033
|
+
|
|
1034
|
+
fireTimedPointer(getHandle(), 'pointerdown', {time: 0, y: 0});
|
|
1035
|
+
fireTimedPointer(getHandle(), 'pointermove', {time: 1000, y: 336});
|
|
1036
|
+
fireTimedPointer(getHandle(), 'pointerup', {time: 2000, y: 336});
|
|
1037
|
+
fireEvent.transitionEnd(sheet, {propertyName: 'transform'});
|
|
1038
|
+
expect(sheet.style.height).toBe('448px');
|
|
1039
|
+
|
|
1040
|
+
// The keyboard opens: the visual viewport shrinks, the layout viewport
|
|
1041
|
+
// the sheet is measured in does not.
|
|
1042
|
+
act(() => {
|
|
1043
|
+
viewport.height = 500;
|
|
1044
|
+
viewport.dispatchEvent(new Event('resize'));
|
|
1045
|
+
});
|
|
1046
|
+
expect(sheet.style.height).toBe('448px');
|
|
1047
|
+
|
|
1048
|
+
// The next drag still snaps to the window's detents, not to fractions
|
|
1049
|
+
// of the space the keyboard left over.
|
|
1050
|
+
fireTimedPointer(getHandle(), 'pointerdown', {time: 3000, y: 0});
|
|
1051
|
+
fireTimedPointer(getHandle(), 'pointermove', {time: 4000, y: 288});
|
|
1052
|
+
fireTimedPointer(getHandle(), 'pointerup', {time: 5000, y: 288});
|
|
1053
|
+
// The peek of an 800px window: 736 - 112 = 624px of travel.
|
|
1054
|
+
expect(sheet.style.transform).toBe('translateY(624px)');
|
|
1055
|
+
});
|
|
1056
|
+
});
|
|
1057
|
+
|
|
1058
|
+
describe('mobile keyboard', () => {
|
|
1059
|
+
it('prevents native touch focus panning without moving focus early or duplicating events', () => {
|
|
1060
|
+
mockIOSWebKit();
|
|
1061
|
+
const onFocus = vi.fn();
|
|
1062
|
+
const onBlur = vi.fn();
|
|
1063
|
+
render(
|
|
1064
|
+
<BottomSheet
|
|
1065
|
+
isOpen
|
|
1066
|
+
onOpenChange={() => {}}
|
|
1067
|
+
label="Add a comment"
|
|
1068
|
+
height="tall">
|
|
1069
|
+
<input
|
|
1070
|
+
aria-label="Comment"
|
|
1071
|
+
onPointerDown={event => event.stopPropagation()}
|
|
1072
|
+
onFocus={onFocus}
|
|
1073
|
+
onBlur={onBlur}
|
|
1074
|
+
/>
|
|
1075
|
+
</BottomSheet>,
|
|
1076
|
+
);
|
|
1077
|
+
const sheet = getSheet();
|
|
1078
|
+
const input = screen.getByRole('textbox', {name: 'Comment'});
|
|
1079
|
+
const focus = vi.spyOn(input, 'focus');
|
|
1080
|
+
sheet.focus();
|
|
1081
|
+
|
|
1082
|
+
// Pointerdown may still become a scroll gesture, so it must not move
|
|
1083
|
+
// focus or open the keyboard early.
|
|
1084
|
+
fireTouchPointer(input, 'pointerdown', {x: 20, y: 200});
|
|
1085
|
+
expect(focus).not.toHaveBeenCalled();
|
|
1086
|
+
expect(onFocus).not.toHaveBeenCalled();
|
|
1087
|
+
expect(document.activeElement).toBe(sheet);
|
|
1088
|
+
|
|
1089
|
+
// Pointerup confirms a tap and prevents focus panning. The subsequent
|
|
1090
|
+
// native click remains responsible for caret placement.
|
|
1091
|
+
fireTouchPointer(input, 'pointerup', {x: 20, y: 200});
|
|
1092
|
+
expect(focus).toHaveBeenCalledTimes(1);
|
|
1093
|
+
expect(focus).toHaveBeenCalledWith({preventScroll: true});
|
|
1094
|
+
expect(document.activeElement).toBe(input);
|
|
1095
|
+
expect(onFocus).toHaveBeenCalledTimes(1);
|
|
1096
|
+
expect(onBlur).not.toHaveBeenCalled();
|
|
1097
|
+
|
|
1098
|
+
// The native click still runs for caret placement and must not produce a
|
|
1099
|
+
// second focus transition.
|
|
1100
|
+
fireEvent.click(input);
|
|
1101
|
+
expect(onFocus).toHaveBeenCalledTimes(1);
|
|
1102
|
+
expect(onBlur).not.toHaveBeenCalled();
|
|
1103
|
+
});
|
|
1104
|
+
|
|
1105
|
+
it('does not focus when a touch becomes a scroll gesture', () => {
|
|
1106
|
+
mockIOSWebKit();
|
|
1107
|
+
const onPreviousBlur = vi.fn();
|
|
1108
|
+
render(
|
|
1109
|
+
<BottomSheet
|
|
1110
|
+
isOpen
|
|
1111
|
+
onOpenChange={() => {}}
|
|
1112
|
+
label="Add a comment"
|
|
1113
|
+
height="tall">
|
|
1114
|
+
<button type="button" onBlur={onPreviousBlur}>
|
|
1115
|
+
Previous action
|
|
1116
|
+
</button>
|
|
1117
|
+
<input aria-label="Comment" />
|
|
1118
|
+
</BottomSheet>,
|
|
1119
|
+
);
|
|
1120
|
+
const body = getBody();
|
|
1121
|
+
const previous = screen.getByRole('button', {name: 'Previous action'});
|
|
1122
|
+
const input = screen.getByRole('textbox', {name: 'Comment'});
|
|
1123
|
+
const focus = vi.spyOn(input, 'focus');
|
|
1124
|
+
body.scrollTop = 20;
|
|
1125
|
+
previous.focus();
|
|
1126
|
+
|
|
1127
|
+
fireTouchPointer(input, 'pointerdown', {x: 20, y: 200});
|
|
1128
|
+
fireTouchPointer(input, 'pointermove', {x: 20, y: 240});
|
|
1129
|
+
fireTouchPointer(input, 'pointerup', {x: 20, y: 240});
|
|
1130
|
+
|
|
1131
|
+
expect(focus).not.toHaveBeenCalled();
|
|
1132
|
+
expect(document.activeElement).toBe(previous);
|
|
1133
|
+
expect(onPreviousBlur).not.toHaveBeenCalled();
|
|
1134
|
+
});
|
|
1135
|
+
|
|
1136
|
+
it('does not focus when pointer activation is canceled', () => {
|
|
1137
|
+
mockIOSWebKit();
|
|
1138
|
+
const onFocus = vi.fn();
|
|
1139
|
+
const onPreviousBlur = vi.fn();
|
|
1140
|
+
render(
|
|
1141
|
+
<BottomSheet
|
|
1142
|
+
isOpen
|
|
1143
|
+
onOpenChange={() => {}}
|
|
1144
|
+
label="Add a comment"
|
|
1145
|
+
height="tall">
|
|
1146
|
+
<button type="button" onBlur={onPreviousBlur}>
|
|
1147
|
+
Previous action
|
|
1148
|
+
</button>
|
|
1149
|
+
<input
|
|
1150
|
+
aria-label="Comment"
|
|
1151
|
+
onPointerDown={event => event.preventDefault()}
|
|
1152
|
+
onFocus={onFocus}
|
|
1153
|
+
/>
|
|
1154
|
+
</BottomSheet>,
|
|
1155
|
+
);
|
|
1156
|
+
const previous = screen.getByRole('button', {name: 'Previous action'});
|
|
1157
|
+
const input = screen.getByRole('textbox', {name: 'Comment'});
|
|
1158
|
+
const focus = vi.spyOn(input, 'focus');
|
|
1159
|
+
previous.focus();
|
|
1160
|
+
|
|
1161
|
+
const pointerDownAllowed = fireTouchPointer(input, 'pointerdown', {
|
|
1162
|
+
x: 20,
|
|
1163
|
+
y: 200,
|
|
1164
|
+
});
|
|
1165
|
+
fireTouchPointer(input, 'pointerup', {x: 20, y: 200});
|
|
1166
|
+
|
|
1167
|
+
expect(pointerDownAllowed).toBe(false);
|
|
1168
|
+
expect(focus).not.toHaveBeenCalled();
|
|
1169
|
+
expect(document.activeElement).toBe(previous);
|
|
1170
|
+
expect(onFocus).not.toHaveBeenCalled();
|
|
1171
|
+
expect(onPreviousBlur).not.toHaveBeenCalled();
|
|
1172
|
+
});
|
|
1173
|
+
|
|
1174
|
+
it('prevents input-to-input focus panning without duplicating focus or blur events', () => {
|
|
1175
|
+
mockIOSWebKit();
|
|
1176
|
+
const onTitleBlur = vi.fn();
|
|
1177
|
+
const onCommentFocus = vi.fn();
|
|
1178
|
+
render(
|
|
1179
|
+
<BottomSheet
|
|
1180
|
+
isOpen
|
|
1181
|
+
onOpenChange={() => {}}
|
|
1182
|
+
label="Add a comment"
|
|
1183
|
+
height="tall">
|
|
1184
|
+
<input aria-label="Title" onBlur={onTitleBlur} />
|
|
1185
|
+
<input aria-label="Comment" onFocus={onCommentFocus} />
|
|
1186
|
+
</BottomSheet>,
|
|
1187
|
+
);
|
|
1188
|
+
const title = screen.getByRole('textbox', {name: 'Title'});
|
|
1189
|
+
const comment = screen.getByRole('textbox', {name: 'Comment'});
|
|
1190
|
+
title.focus();
|
|
1191
|
+
const focus = vi.spyOn(comment, 'focus');
|
|
1192
|
+
|
|
1193
|
+
fireTouchPointer(comment, 'pointerdown', {x: 20, y: 200});
|
|
1194
|
+
fireTouchPointer(comment, 'pointerup', {x: 20, y: 200});
|
|
1195
|
+
|
|
1196
|
+
expect(focus).toHaveBeenCalledWith({preventScroll: true});
|
|
1197
|
+
expect(document.activeElement).toBe(comment);
|
|
1198
|
+
expect(onTitleBlur).toHaveBeenCalledTimes(1);
|
|
1199
|
+
expect(onCommentFocus).toHaveBeenCalledTimes(1);
|
|
1200
|
+
|
|
1201
|
+
fireEvent.click(comment);
|
|
1202
|
+
expect(onTitleBlur).toHaveBeenCalledTimes(1);
|
|
1203
|
+
expect(onCommentFocus).toHaveBeenCalledTimes(1);
|
|
1204
|
+
});
|
|
1205
|
+
|
|
1206
|
+
it('does not duplicate callbacks during programmatic input-to-input focus', () => {
|
|
1207
|
+
mockIOSWebKit();
|
|
1208
|
+
const onTitleBlur = vi.fn();
|
|
1209
|
+
const onCommentFocus = vi.fn();
|
|
1210
|
+
render(
|
|
1211
|
+
<BottomSheet
|
|
1212
|
+
isOpen
|
|
1213
|
+
onOpenChange={() => {}}
|
|
1214
|
+
label="Add a comment"
|
|
1215
|
+
height="tall">
|
|
1216
|
+
<input aria-label="Title" onBlur={onTitleBlur} />
|
|
1217
|
+
<input aria-label="Comment" onFocus={onCommentFocus} />
|
|
1218
|
+
</BottomSheet>,
|
|
1219
|
+
);
|
|
1220
|
+
const title = screen.getByRole('textbox', {name: 'Title'});
|
|
1221
|
+
const comment = screen.getByRole('textbox', {name: 'Comment'});
|
|
1222
|
+
|
|
1223
|
+
title.focus();
|
|
1224
|
+
comment.focus();
|
|
1225
|
+
|
|
1226
|
+
expect(document.activeElement).toBe(comment);
|
|
1227
|
+
expect(onTitleBlur).toHaveBeenCalledTimes(1);
|
|
1228
|
+
expect(onCommentFocus).toHaveBeenCalledTimes(1);
|
|
1229
|
+
});
|
|
1230
|
+
|
|
1231
|
+
it('does not alter ordinary desktop focus when the viewport is unobstructed', () => {
|
|
1232
|
+
mockVisualViewport(800);
|
|
1233
|
+
mockWindowHeight(800);
|
|
1234
|
+
const onFocus = vi.fn();
|
|
1235
|
+
render(
|
|
1236
|
+
<BottomSheet
|
|
1237
|
+
isOpen
|
|
1238
|
+
onOpenChange={() => {}}
|
|
1239
|
+
label="Add a comment"
|
|
1240
|
+
height="tall">
|
|
1241
|
+
<input aria-label="Comment" onFocus={onFocus} />
|
|
1242
|
+
</BottomSheet>,
|
|
1243
|
+
);
|
|
1244
|
+
const input = screen.getByRole('textbox', {name: 'Comment'});
|
|
1245
|
+
const focus = vi.spyOn(input, 'focus');
|
|
1246
|
+
|
|
1247
|
+
input.focus();
|
|
1248
|
+
|
|
1249
|
+
expect(focus).not.toHaveBeenCalledWith({preventScroll: true});
|
|
1250
|
+
expect(onFocus).toHaveBeenCalledTimes(1);
|
|
1251
|
+
expect(getBody().style.getPropertyValue('--_sheet-keyboard-inset')).toBe(
|
|
1252
|
+
'0px',
|
|
1253
|
+
);
|
|
1254
|
+
});
|
|
1255
|
+
|
|
1256
|
+
it('keeps Tall geometry fixed while extending and cleaning up its internal scroll range', () => {
|
|
1257
|
+
const viewport = mockVisualViewport(500);
|
|
1258
|
+
render(
|
|
1259
|
+
<BottomSheet
|
|
1260
|
+
isOpen
|
|
1261
|
+
onOpenChange={() => {}}
|
|
1262
|
+
label="Add a comment"
|
|
1263
|
+
height="tall">
|
|
1264
|
+
<input aria-label="Comment" />
|
|
1265
|
+
</BottomSheet>,
|
|
1266
|
+
);
|
|
1267
|
+
const sheet = getSheet();
|
|
1268
|
+
const positioner = getPositioner();
|
|
1269
|
+
const body = getBody();
|
|
1270
|
+
const input = screen.getByRole('textbox', {name: 'Comment'});
|
|
1271
|
+
vi.spyOn(sheet, 'getBoundingClientRect').mockReturnValue(
|
|
1272
|
+
rect({top: 0, bottom: 800}),
|
|
1273
|
+
);
|
|
1274
|
+
vi.spyOn(body, 'getBoundingClientRect').mockReturnValue(
|
|
1275
|
+
rect({top: 100, bottom: 800}),
|
|
1276
|
+
);
|
|
1277
|
+
vi.spyOn(input, 'getBoundingClientRect').mockImplementation(() =>
|
|
1278
|
+
rect({
|
|
1279
|
+
top: 660 - body.scrollTop,
|
|
1280
|
+
bottom: 700 - body.scrollTop,
|
|
1281
|
+
}),
|
|
1282
|
+
);
|
|
1283
|
+
|
|
1284
|
+
const initialRect = sheet.getBoundingClientRect();
|
|
1285
|
+
|
|
1286
|
+
input.focus();
|
|
1287
|
+
|
|
1288
|
+
expect(sheet.getBoundingClientRect()).toEqual(initialRect);
|
|
1289
|
+
expect(sheet.style.height).toBe('');
|
|
1290
|
+
expect(sheet.style.getPropertyValue('--_sheet-budget')).toBe('92dvh');
|
|
1291
|
+
expect(positioner.style.getPropertyValue('--_sheet-keyboard-lift')).toBe(
|
|
1292
|
+
'',
|
|
1293
|
+
);
|
|
1294
|
+
// 300px keyboard overlap + 48px room for Android suggestion UI.
|
|
1295
|
+
expect(body.style.getPropertyValue('--_sheet-keyboard-inset')).toBe(
|
|
1296
|
+
'348px',
|
|
1297
|
+
);
|
|
1298
|
+
// Visible bottom is 500px; preserve the same 48px focus gap.
|
|
1299
|
+
expect(body.scrollTop).toBe(248);
|
|
1300
|
+
|
|
1301
|
+
viewport.height = 800;
|
|
1302
|
+
void act(() => viewport.dispatchEvent(new Event('resize')));
|
|
1303
|
+
expect(body.style.getPropertyValue('--_sheet-keyboard-inset')).toBe(
|
|
1304
|
+
'0px',
|
|
1305
|
+
);
|
|
1306
|
+
expect(sheet.getBoundingClientRect()).toEqual(initialRect);
|
|
1307
|
+
expect(sheet.style.height).toBe('');
|
|
1308
|
+
});
|
|
1309
|
+
|
|
1310
|
+
it('smoothly scrolls a focused Tall control above the keyboard', () => {
|
|
1311
|
+
mockVisualViewport(500);
|
|
1312
|
+
render(
|
|
1313
|
+
<BottomSheet
|
|
1314
|
+
isOpen
|
|
1315
|
+
onOpenChange={() => {}}
|
|
1316
|
+
label="Add a comment"
|
|
1317
|
+
height="tall">
|
|
1318
|
+
<input aria-label="Comment" />
|
|
1319
|
+
</BottomSheet>,
|
|
1320
|
+
);
|
|
1321
|
+
const body = getBody();
|
|
1322
|
+
const input = screen.getByRole('textbox', {name: 'Comment'});
|
|
1323
|
+
const scrollBy = vi.fn((options: ScrollToOptions) => {
|
|
1324
|
+
body.scrollTop += options.top ?? 0;
|
|
1325
|
+
});
|
|
1326
|
+
Object.defineProperty(body, 'scrollBy', {
|
|
1327
|
+
configurable: true,
|
|
1328
|
+
value: scrollBy,
|
|
1329
|
+
});
|
|
1330
|
+
vi.spyOn(body, 'getBoundingClientRect').mockReturnValue(
|
|
1331
|
+
rect({top: 100, bottom: 800}),
|
|
1332
|
+
);
|
|
1333
|
+
vi.spyOn(input, 'getBoundingClientRect').mockImplementation(() =>
|
|
1334
|
+
rect({
|
|
1335
|
+
top: 660 - body.scrollTop,
|
|
1336
|
+
bottom: 700 - body.scrollTop,
|
|
1337
|
+
}),
|
|
1338
|
+
);
|
|
1339
|
+
|
|
1340
|
+
input.focus();
|
|
1341
|
+
|
|
1342
|
+
expect(scrollBy).toHaveBeenCalledWith({top: 248, behavior: 'smooth'});
|
|
1343
|
+
});
|
|
1344
|
+
|
|
1345
|
+
it('does not add clearance or scroll when the viewport is unobstructed', () => {
|
|
1346
|
+
mockVisualViewport(800);
|
|
1347
|
+
mockWindowHeight(800);
|
|
1348
|
+
render(
|
|
1349
|
+
<BottomSheet
|
|
1350
|
+
isOpen
|
|
1351
|
+
onOpenChange={() => {}}
|
|
1352
|
+
label="Add a comment"
|
|
1353
|
+
height="tall">
|
|
1354
|
+
<input aria-label="Comment" />
|
|
1355
|
+
</BottomSheet>,
|
|
1356
|
+
);
|
|
1357
|
+
const body = getBody();
|
|
1358
|
+
const input = screen.getByRole('textbox', {name: 'Comment'});
|
|
1359
|
+
vi.spyOn(body, 'getBoundingClientRect').mockReturnValue(
|
|
1360
|
+
rect({top: 100, bottom: 800}),
|
|
1361
|
+
);
|
|
1362
|
+
vi.spyOn(input, 'getBoundingClientRect').mockReturnValue(
|
|
1363
|
+
rect({top: 760, bottom: 790}),
|
|
1364
|
+
);
|
|
1365
|
+
|
|
1366
|
+
input.focus();
|
|
1367
|
+
|
|
1368
|
+
expect(body.style.getPropertyValue('--_sheet-keyboard-inset')).toBe(
|
|
1369
|
+
'0px',
|
|
1370
|
+
);
|
|
1371
|
+
expect(body.scrollTop).toBe(0);
|
|
1372
|
+
});
|
|
1373
|
+
|
|
1374
|
+
it('does not accommodate the keyboard at a shorter Tall detent', () => {
|
|
1375
|
+
mockIOSWebKit();
|
|
1376
|
+
const observers = mockResizeObserverInstances();
|
|
1377
|
+
const viewport = mockVisualViewport(800);
|
|
1378
|
+
mockWindowHeight(800);
|
|
1379
|
+
render(
|
|
1380
|
+
<BottomSheet
|
|
1381
|
+
isOpen
|
|
1382
|
+
onOpenChange={() => {}}
|
|
1383
|
+
label="Add a comment"
|
|
1384
|
+
height="tall">
|
|
1385
|
+
<input aria-label="Comment" />
|
|
1386
|
+
</BottomSheet>,
|
|
1387
|
+
);
|
|
1388
|
+
const sheet = getSheet();
|
|
1389
|
+
const body = getBody();
|
|
1390
|
+
const input = screen.getByRole('textbox', {name: 'Comment'});
|
|
1391
|
+
const sheetObserver = observers.find(instance =>
|
|
1392
|
+
instance.observed.has(sheet),
|
|
1393
|
+
);
|
|
1394
|
+
expect(sheetObserver).toBeDefined();
|
|
1395
|
+
act(() => {
|
|
1396
|
+
sheetObserver?.callback(
|
|
1397
|
+
[resizeEntry(784)],
|
|
1398
|
+
sheetObserver as unknown as ResizeObserver,
|
|
1399
|
+
);
|
|
1400
|
+
});
|
|
1401
|
+
|
|
1402
|
+
fireTimedPointer(getHandle(), 'pointerdown', {time: 0, y: 0});
|
|
1403
|
+
fireTimedPointer(getHandle(), 'pointermove', {time: 1000, y: 240});
|
|
1404
|
+
fireTimedPointer(getHandle(), 'pointerup', {time: 2000, y: 240});
|
|
1405
|
+
fireEvent.transitionEnd(sheet, {propertyName: 'transform'});
|
|
1406
|
+
// Settled at the p50 detent: 400px of visible sheet plus the 48px
|
|
1407
|
+
// border-box reserve held below the viewport.
|
|
1408
|
+
expect(sheet.style.transform).toBe('');
|
|
1409
|
+
expect(sheet.style.height).toBe('448px');
|
|
1410
|
+
|
|
1411
|
+
vi.spyOn(body, 'getBoundingClientRect').mockReturnValue(
|
|
1412
|
+
rect({top: 500, bottom: 1200}),
|
|
1413
|
+
);
|
|
1414
|
+
vi.spyOn(input, 'getBoundingClientRect').mockReturnValue(
|
|
1415
|
+
rect({top: 700, bottom: 740}),
|
|
1416
|
+
);
|
|
1417
|
+
viewport.height = 500;
|
|
1418
|
+
const focus = vi.spyOn(input, 'focus');
|
|
1419
|
+
fireTouchPointer(input, 'pointerdown', {x: 20, y: 700});
|
|
1420
|
+
fireTouchPointer(input, 'pointerup', {x: 20, y: 700});
|
|
1421
|
+
expect(focus).not.toHaveBeenCalled();
|
|
1422
|
+
|
|
1423
|
+
focus.mockRestore();
|
|
1424
|
+
input.focus();
|
|
1425
|
+
void act(() => viewport.dispatchEvent(new Event('resize')));
|
|
1426
|
+
|
|
1427
|
+
expect(body.style.getPropertyValue('--_sheet-keyboard-inset')).toBe(
|
|
1428
|
+
'0px',
|
|
1429
|
+
);
|
|
1430
|
+
expect(body.scrollTop).toBe(0);
|
|
1431
|
+
// The keyboard moved nothing: the sheet still rests at that detent.
|
|
1432
|
+
expect(sheet.style.transform).toBe('');
|
|
1433
|
+
expect(sheet.style.height).toBe('448px');
|
|
1434
|
+
});
|
|
1435
|
+
|
|
1436
|
+
it.each([
|
|
1437
|
+
['Hug', 'hug'],
|
|
1438
|
+
['Capped', 'capped'],
|
|
1439
|
+
['numeric', 480],
|
|
1440
|
+
['custom CSS', '70dvh'],
|
|
1441
|
+
] as const)(
|
|
1442
|
+
'does not add keyboard behavior to a %s height',
|
|
1443
|
+
(_label, height) => {
|
|
1444
|
+
mockIOSWebKit();
|
|
1445
|
+
const viewport = mockVisualViewport(500);
|
|
1446
|
+
render(
|
|
1447
|
+
<BottomSheet
|
|
1448
|
+
isOpen
|
|
1449
|
+
onOpenChange={() => {}}
|
|
1450
|
+
label="Add a comment"
|
|
1451
|
+
height={height}>
|
|
1452
|
+
<input aria-label="Comment" />
|
|
1453
|
+
</BottomSheet>,
|
|
1454
|
+
);
|
|
1455
|
+
const sheet = getSheet();
|
|
1456
|
+
const positioner = getPositioner();
|
|
1457
|
+
const body = getBody();
|
|
1458
|
+
const input = screen.getByRole('textbox', {name: 'Comment'});
|
|
1459
|
+
const focus = vi.spyOn(input, 'focus');
|
|
1460
|
+
const sheetFocus = vi.spyOn(sheet, 'focus');
|
|
1461
|
+
vi.spyOn(body, 'getBoundingClientRect').mockReturnValue(
|
|
1462
|
+
rect({top: 400, bottom: 800}),
|
|
1463
|
+
);
|
|
1464
|
+
vi.spyOn(input, 'getBoundingClientRect').mockReturnValue(
|
|
1465
|
+
rect({top: 700, bottom: 740}),
|
|
1466
|
+
);
|
|
1467
|
+
body.scrollTop = 20;
|
|
1468
|
+
|
|
1469
|
+
fireEvent.touchStart(input);
|
|
1470
|
+
fireTouchPointer(input, 'pointerdown', {x: 20, y: 200});
|
|
1471
|
+
fireTouchPointer(input, 'pointerup', {x: 20, y: 200});
|
|
1472
|
+
fireEvent.pointerDown(input, {pointerId: 1, clientY: 200});
|
|
1473
|
+
body.scrollTop = 120;
|
|
1474
|
+
fireEvent.focus(input, {relatedTarget: null});
|
|
1475
|
+
void act(() => viewport.dispatchEvent(new Event('resize')));
|
|
1476
|
+
|
|
1477
|
+
expect(body.scrollTop).toBe(120);
|
|
1478
|
+
expect(focus).not.toHaveBeenCalled();
|
|
1479
|
+
expect(sheetFocus).not.toHaveBeenCalled();
|
|
1480
|
+
expect(body.style.getPropertyValue('--_sheet-keyboard-inset')).toBe('');
|
|
1481
|
+
expect(
|
|
1482
|
+
positioner.style.getPropertyValue('--_sheet-keyboard-lift'),
|
|
1483
|
+
).toBe('');
|
|
1484
|
+
expect(sheet.style.height).toBe('');
|
|
1485
|
+
},
|
|
1486
|
+
);
|
|
1487
|
+
|
|
1488
|
+
it.each([
|
|
1489
|
+
['standalone no-scrim', 'standalone', false],
|
|
1490
|
+
['modal switcher', 'switcher', true],
|
|
1491
|
+
['no-scrim switcher', 'switcher', false],
|
|
1492
|
+
] as const)(
|
|
1493
|
+
'supports Tall internal keyboard scrolling in a %s presentation',
|
|
1494
|
+
(_label, host, hasScrim) => {
|
|
1495
|
+
mockVisualViewport(500);
|
|
1496
|
+
const content = <input aria-label="Comment" />;
|
|
1497
|
+
const result =
|
|
1498
|
+
host === 'standalone'
|
|
1499
|
+
? render(
|
|
1500
|
+
<BottomSheet
|
|
1501
|
+
isOpen
|
|
1502
|
+
onOpenChange={() => {}}
|
|
1503
|
+
label="Add a comment"
|
|
1504
|
+
height="tall"
|
|
1505
|
+
hasScrim={hasScrim}>
|
|
1506
|
+
{content}
|
|
1507
|
+
</BottomSheet>,
|
|
1508
|
+
)
|
|
1509
|
+
: render(
|
|
1510
|
+
<BottomSheetSwitcher
|
|
1511
|
+
activeSheet="comment"
|
|
1512
|
+
onActiveSheetChange={() => {}}
|
|
1513
|
+
hasScrim={hasScrim}>
|
|
1514
|
+
<BottomSheet
|
|
1515
|
+
sheetId="comment"
|
|
1516
|
+
label="Add a comment"
|
|
1517
|
+
height="tall">
|
|
1518
|
+
{content}
|
|
1519
|
+
</BottomSheet>
|
|
1520
|
+
</BottomSheetSwitcher>,
|
|
1521
|
+
);
|
|
1522
|
+
const body = getBody();
|
|
1523
|
+
const input = screen.getByRole('textbox', {name: 'Comment'});
|
|
1524
|
+
vi.spyOn(body, 'getBoundingClientRect').mockReturnValue(
|
|
1525
|
+
rect({top: 100, bottom: 800}),
|
|
1526
|
+
);
|
|
1527
|
+
vi.spyOn(input, 'getBoundingClientRect').mockImplementation(() =>
|
|
1528
|
+
rect({
|
|
1529
|
+
top: 660 - body.scrollTop,
|
|
1530
|
+
bottom: 700 - body.scrollTop,
|
|
1531
|
+
}),
|
|
1532
|
+
);
|
|
1533
|
+
|
|
1534
|
+
input.focus();
|
|
1535
|
+
|
|
1536
|
+
expect(body.style.getPropertyValue('--_sheet-keyboard-inset')).toBe(
|
|
1537
|
+
'348px',
|
|
1538
|
+
);
|
|
1539
|
+
expect(body.scrollTop).toBe(248);
|
|
1540
|
+
result.unmount();
|
|
1541
|
+
},
|
|
1542
|
+
);
|
|
1543
|
+
|
|
1544
|
+
it('re-reveals a focused Tall control when content layout changes', () => {
|
|
1545
|
+
const observers = mockResizeObserverInstances();
|
|
1546
|
+
mockVisualViewport(500);
|
|
1547
|
+
render(
|
|
1548
|
+
<BottomSheet
|
|
1549
|
+
isOpen
|
|
1550
|
+
onOpenChange={() => {}}
|
|
1551
|
+
label="Add a comment"
|
|
1552
|
+
height="tall">
|
|
1553
|
+
<input aria-label="Comment" />
|
|
1554
|
+
</BottomSheet>,
|
|
1555
|
+
);
|
|
1556
|
+
const body = getBody();
|
|
1557
|
+
const input = screen.getByRole('textbox', {name: 'Comment'});
|
|
1558
|
+
let layoutShift = 0;
|
|
1559
|
+
vi.spyOn(body, 'getBoundingClientRect').mockReturnValue(
|
|
1560
|
+
rect({top: 100, bottom: 800}),
|
|
1561
|
+
);
|
|
1562
|
+
vi.spyOn(input, 'getBoundingClientRect').mockImplementation(() =>
|
|
1563
|
+
rect({
|
|
1564
|
+
top: 660 + layoutShift - body.scrollTop,
|
|
1565
|
+
bottom: 700 + layoutShift - body.scrollTop,
|
|
1566
|
+
}),
|
|
1567
|
+
);
|
|
1568
|
+
|
|
1569
|
+
input.focus();
|
|
1570
|
+
expect(body.scrollTop).toBe(248);
|
|
1571
|
+
|
|
1572
|
+
layoutShift = 200;
|
|
1573
|
+
const observer = observers.find(instance => instance.observed.has(input));
|
|
1574
|
+
expect(observer).toBeDefined();
|
|
1575
|
+
act(() => {
|
|
1576
|
+
observer?.callback([], observer as unknown as ResizeObserver);
|
|
1577
|
+
});
|
|
1578
|
+
|
|
1579
|
+
expect(body.scrollTop).toBe(448);
|
|
1580
|
+
});
|
|
1581
|
+
|
|
1582
|
+
it('retains Tall keyboard scroll space during travel until the viewport recovers', () => {
|
|
1583
|
+
const viewport = mockVisualViewport(500);
|
|
1584
|
+
render(
|
|
1585
|
+
<BottomSheet
|
|
1586
|
+
isOpen
|
|
1587
|
+
onOpenChange={() => {}}
|
|
1588
|
+
label="Add a comment"
|
|
1589
|
+
height="tall">
|
|
1590
|
+
<input aria-label="Comment" />
|
|
1591
|
+
</BottomSheet>,
|
|
1592
|
+
);
|
|
1593
|
+
const sheet = getSheet();
|
|
1594
|
+
const positioner = getPositioner();
|
|
1595
|
+
const body = getBody();
|
|
1596
|
+
const input = screen.getByRole('textbox', {name: 'Comment'});
|
|
1597
|
+
vi.spyOn(sheet, 'getBoundingClientRect').mockReturnValue(
|
|
1598
|
+
rect({top: 0, bottom: 800}),
|
|
1599
|
+
);
|
|
1600
|
+
vi.spyOn(body, 'getBoundingClientRect').mockReturnValue(
|
|
1601
|
+
rect({top: 100, bottom: 800}),
|
|
1602
|
+
);
|
|
1603
|
+
vi.spyOn(input, 'getBoundingClientRect').mockImplementation(() =>
|
|
1604
|
+
rect({
|
|
1605
|
+
top: 660 - body.scrollTop,
|
|
1606
|
+
bottom: 700 - body.scrollTop,
|
|
1607
|
+
}),
|
|
1608
|
+
);
|
|
1609
|
+
|
|
1610
|
+
input.focus();
|
|
1611
|
+
expect(body.scrollTop).toBe(248);
|
|
1612
|
+
expect(body.style.getPropertyValue('--_sheet-keyboard-inset')).toBe(
|
|
1613
|
+
'348px',
|
|
1614
|
+
);
|
|
1615
|
+
|
|
1616
|
+
const pointerDownAllowed = fireEvent.pointerDown(getHandle(), {
|
|
1617
|
+
pointerId: 1,
|
|
1618
|
+
clientY: 0,
|
|
1619
|
+
button: 0,
|
|
1620
|
+
isPrimary: true,
|
|
1621
|
+
});
|
|
1622
|
+
expect(pointerDownAllowed).toBe(false);
|
|
1623
|
+
expect(document.activeElement).toBe(input);
|
|
1624
|
+
|
|
1625
|
+
fireEvent.pointerMove(getHandle(), {pointerId: 1, clientY: 40});
|
|
1626
|
+
|
|
1627
|
+
expect(document.activeElement).toBe(sheet);
|
|
1628
|
+
expect(positioner.style.getPropertyValue('--_sheet-keyboard-lift')).toBe(
|
|
1629
|
+
'',
|
|
1630
|
+
);
|
|
1631
|
+
expect(body.style.getPropertyValue('--_sheet-keyboard-inset')).toBe(
|
|
1632
|
+
'348px',
|
|
1633
|
+
);
|
|
1634
|
+
expect(body.scrollTop).toBe(248);
|
|
1635
|
+
|
|
1636
|
+
viewport.height = 800;
|
|
1637
|
+
void act(() => viewport.dispatchEvent(new Event('resize')));
|
|
1638
|
+
expect(body.style.getPropertyValue('--_sheet-keyboard-inset')).toBe(
|
|
1639
|
+
'0px',
|
|
1640
|
+
);
|
|
1641
|
+
});
|
|
1642
|
+
|
|
1643
|
+
it('retains Tall keyboard scroll space after blur until the viewport recovers', () => {
|
|
1644
|
+
const viewport = mockVisualViewport(500);
|
|
1645
|
+
render(
|
|
1646
|
+
<BottomSheet
|
|
1647
|
+
isOpen
|
|
1648
|
+
onOpenChange={() => {}}
|
|
1649
|
+
label="Add a comment"
|
|
1650
|
+
height="tall">
|
|
1651
|
+
<input aria-label="Comment" />
|
|
1652
|
+
</BottomSheet>,
|
|
1653
|
+
);
|
|
1654
|
+
const body = getBody();
|
|
1655
|
+
const input = screen.getByRole('textbox', {name: 'Comment'});
|
|
1656
|
+
vi.spyOn(body, 'getBoundingClientRect').mockReturnValue(
|
|
1657
|
+
rect({top: 100, bottom: 800}),
|
|
1658
|
+
);
|
|
1659
|
+
vi.spyOn(input, 'getBoundingClientRect').mockImplementation(() =>
|
|
1660
|
+
rect({
|
|
1661
|
+
top: 660 - body.scrollTop,
|
|
1662
|
+
bottom: 700 - body.scrollTop,
|
|
1663
|
+
}),
|
|
1664
|
+
);
|
|
1665
|
+
input.focus();
|
|
1666
|
+
expect(body.style.getPropertyValue('--_sheet-keyboard-inset')).toBe(
|
|
1667
|
+
'348px',
|
|
1668
|
+
);
|
|
1669
|
+
|
|
1670
|
+
input.blur();
|
|
1671
|
+
|
|
1672
|
+
expect(body.style.getPropertyValue('--_sheet-keyboard-inset')).toBe(
|
|
1673
|
+
'348px',
|
|
1674
|
+
);
|
|
1675
|
+
|
|
1676
|
+
viewport.height = 800;
|
|
1677
|
+
void act(() => viewport.dispatchEvent(new Event('resize')));
|
|
1678
|
+
expect(body.style.getPropertyValue('--_sheet-keyboard-inset')).toBe(
|
|
1679
|
+
'0px',
|
|
1680
|
+
);
|
|
1681
|
+
});
|
|
1682
|
+
|
|
1683
|
+
it('blurs the focused Tall field and retains its inset until the viewport recovers', () => {
|
|
1684
|
+
const viewport = mockVisualViewport(500);
|
|
1685
|
+
const onOpenChange = vi.fn();
|
|
1686
|
+
const content = (isOpen: boolean) => (
|
|
1687
|
+
<BottomSheet
|
|
1688
|
+
isOpen={isOpen}
|
|
1689
|
+
onOpenChange={onOpenChange}
|
|
1690
|
+
label="Add a comment"
|
|
1691
|
+
height="tall">
|
|
1692
|
+
<input aria-label="Comment" />
|
|
1693
|
+
</BottomSheet>
|
|
1694
|
+
);
|
|
1695
|
+
const {rerender} = render(content(true));
|
|
1696
|
+
const body = getBody();
|
|
1697
|
+
const input = screen.getByRole('textbox', {name: 'Comment'});
|
|
1698
|
+
vi.spyOn(body, 'getBoundingClientRect').mockReturnValue(
|
|
1699
|
+
rect({top: 100, bottom: 800}),
|
|
1700
|
+
);
|
|
1701
|
+
vi.spyOn(input, 'getBoundingClientRect').mockImplementation(() =>
|
|
1702
|
+
rect({
|
|
1703
|
+
top: 660 - body.scrollTop,
|
|
1704
|
+
bottom: 700 - body.scrollTop,
|
|
1705
|
+
}),
|
|
1706
|
+
);
|
|
1707
|
+
input.focus();
|
|
1708
|
+
expect(body.style.getPropertyValue('--_sheet-keyboard-inset')).toBe(
|
|
1709
|
+
'348px',
|
|
1710
|
+
);
|
|
1711
|
+
|
|
1712
|
+
rerender(content(false));
|
|
1713
|
+
|
|
1714
|
+
expect(document.activeElement).not.toBe(input);
|
|
1715
|
+
expect(body.style.getPropertyValue('--_sheet-keyboard-inset')).toBe(
|
|
1716
|
+
'348px',
|
|
1717
|
+
);
|
|
1718
|
+
|
|
1719
|
+
viewport.height = 800;
|
|
1720
|
+
void act(() => viewport.dispatchEvent(new Event('resize')));
|
|
1721
|
+
expect(body.style.getPropertyValue('--_sheet-keyboard-inset')).toBe(
|
|
1722
|
+
'0px',
|
|
1723
|
+
);
|
|
1724
|
+
});
|
|
1725
|
+
|
|
1726
|
+
it('retains Tall keyboard scroll space through a switcher handoff until the viewport recovers', () => {
|
|
1727
|
+
const viewport = mockVisualViewport(500);
|
|
1728
|
+
const content = (activeSheet: string) => (
|
|
1729
|
+
<BottomSheetSwitcher
|
|
1730
|
+
activeSheet={activeSheet}
|
|
1731
|
+
onActiveSheetChange={() => {}}>
|
|
1732
|
+
<BottomSheet sheetId="comment" label="Add a comment" height="tall">
|
|
1733
|
+
<input aria-label="Comment" />
|
|
1734
|
+
</BottomSheet>
|
|
1735
|
+
<BottomSheet sheetId="confirmation" label="Confirmation">
|
|
1736
|
+
Confirmation
|
|
1737
|
+
</BottomSheet>
|
|
1738
|
+
</BottomSheetSwitcher>
|
|
1739
|
+
);
|
|
1740
|
+
const {rerender} = render(content('comment'));
|
|
1741
|
+
const body = getBody();
|
|
1742
|
+
const input = screen.getByRole('textbox', {name: 'Comment'});
|
|
1743
|
+
vi.spyOn(body, 'getBoundingClientRect').mockReturnValue(
|
|
1744
|
+
rect({top: 100, bottom: 800}),
|
|
1745
|
+
);
|
|
1746
|
+
vi.spyOn(input, 'getBoundingClientRect').mockImplementation(() =>
|
|
1747
|
+
rect({
|
|
1748
|
+
top: 660 - body.scrollTop,
|
|
1749
|
+
bottom: 700 - body.scrollTop,
|
|
1750
|
+
}),
|
|
1751
|
+
);
|
|
1752
|
+
input.focus();
|
|
1753
|
+
expect(body.style.getPropertyValue('--_sheet-keyboard-inset')).toBe(
|
|
1754
|
+
'348px',
|
|
1755
|
+
);
|
|
1756
|
+
|
|
1757
|
+
rerender(content('confirmation'));
|
|
1758
|
+
|
|
1759
|
+
expect(document.activeElement).not.toBe(input);
|
|
1760
|
+
expect(body.style.getPropertyValue('--_sheet-keyboard-inset')).toBe(
|
|
1761
|
+
'348px',
|
|
1762
|
+
);
|
|
1763
|
+
|
|
1764
|
+
viewport.height = 800;
|
|
1765
|
+
void act(() => viewport.dispatchEvent(new Event('resize')));
|
|
1766
|
+
expect(body.style.getPropertyValue('--_sheet-keyboard-inset')).toBe(
|
|
1767
|
+
'0px',
|
|
1768
|
+
);
|
|
1769
|
+
});
|
|
1770
|
+
});
|
|
1771
|
+
|
|
1772
|
+
describe('focus restore', () => {
|
|
1773
|
+
function Harness() {
|
|
1774
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
1775
|
+
return (
|
|
1776
|
+
<>
|
|
1777
|
+
<button type="button" onClick={() => setIsOpen(true)}>
|
|
1778
|
+
Open sheet
|
|
1779
|
+
</button>
|
|
1780
|
+
<BottomSheet isOpen={isOpen} onOpenChange={setIsOpen} label="Filters">
|
|
1781
|
+
<button type="button" onClick={() => setIsOpen(false)}>
|
|
1782
|
+
Done
|
|
1783
|
+
</button>
|
|
1784
|
+
</BottomSheet>
|
|
1785
|
+
</>
|
|
1786
|
+
);
|
|
1787
|
+
}
|
|
1788
|
+
|
|
1789
|
+
it('restores focus to the opener after close', () => {
|
|
1790
|
+
render(<Harness />);
|
|
1791
|
+
const opener = screen.getByRole('button', {name: 'Open sheet'});
|
|
1792
|
+
opener.focus();
|
|
1793
|
+
fireEvent.click(opener);
|
|
1794
|
+
fireEvent.click(screen.getByRole('button', {name: 'Done'}));
|
|
1795
|
+
finishSheetExit();
|
|
1796
|
+
|
|
1797
|
+
expect(document.activeElement).toBe(opener);
|
|
1798
|
+
});
|
|
1799
|
+
});
|
|
1800
|
+
|
|
1801
|
+
describe('initial focus', () => {
|
|
1802
|
+
it('focuses the sheet panel on open, not the first control', () => {
|
|
1803
|
+
render(
|
|
1804
|
+
<BottomSheet isOpen onOpenChange={() => {}} label="Filters">
|
|
1805
|
+
<button type="button">First action</button>
|
|
1806
|
+
</BottomSheet>,
|
|
1807
|
+
);
|
|
1808
|
+
const panel = getSheet();
|
|
1809
|
+
expect(document.activeElement).toBe(panel);
|
|
1810
|
+
expect(document.activeElement).not.toBe(
|
|
1811
|
+
screen.getByRole('button', {name: 'First action'}),
|
|
1812
|
+
);
|
|
1813
|
+
});
|
|
1814
|
+
|
|
1815
|
+
it('honors a descendant with data-autofocus', () => {
|
|
1816
|
+
render(
|
|
1817
|
+
<BottomSheet isOpen onOpenChange={() => {}} label="Filters">
|
|
1818
|
+
<input data-autofocus aria-label="Search" />
|
|
1819
|
+
</BottomSheet>,
|
|
1820
|
+
);
|
|
1821
|
+
expect(document.activeElement).toBe(
|
|
1822
|
+
screen.getByRole('textbox', {name: 'Search'}),
|
|
1823
|
+
);
|
|
1824
|
+
});
|
|
1825
|
+
});
|
|
1826
|
+
|
|
1827
|
+
describe('accessible name', () => {
|
|
1828
|
+
it('warns in development when label is empty', () => {
|
|
1829
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
1830
|
+
render(
|
|
1831
|
+
<BottomSheet isOpen onOpenChange={() => {}} label="">
|
|
1832
|
+
Content
|
|
1833
|
+
</BottomSheet>,
|
|
1834
|
+
);
|
|
1835
|
+
expect(
|
|
1836
|
+
warn.mock.calls.some(args => String(args[0]).includes('BottomSheet')),
|
|
1837
|
+
).toBe(true);
|
|
1838
|
+
warn.mockRestore();
|
|
1839
|
+
});
|
|
1840
|
+
});
|
|
1841
|
+
|
|
1842
|
+
describe('reduced motion', () => {
|
|
1843
|
+
it('opens without throwing when prefers-reduced-motion is set', () => {
|
|
1844
|
+
vi.stubGlobal(
|
|
1845
|
+
'matchMedia',
|
|
1846
|
+
vi.fn().mockReturnValue({
|
|
1847
|
+
matches: true,
|
|
1848
|
+
media: '(prefers-reduced-motion: reduce)',
|
|
1849
|
+
onchange: null,
|
|
1850
|
+
addEventListener: vi.fn(),
|
|
1851
|
+
removeEventListener: vi.fn(),
|
|
1852
|
+
addListener: vi.fn(),
|
|
1853
|
+
removeListener: vi.fn(),
|
|
1854
|
+
dispatchEvent: vi.fn(),
|
|
1855
|
+
}),
|
|
1856
|
+
);
|
|
1857
|
+
render(
|
|
1858
|
+
<BottomSheet isOpen onOpenChange={() => {}} label="Filters">
|
|
1859
|
+
Content
|
|
1860
|
+
</BottomSheet>,
|
|
1861
|
+
);
|
|
1862
|
+
expect(screen.getByRole('dialog')).toBeInTheDocument();
|
|
1863
|
+
});
|
|
1864
|
+
});
|
|
1865
|
+
});
|