@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.
Files changed (200) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/README.md +79 -45
  3. package/dist/Banner/Banner.d.ts +3 -0
  4. package/dist/Banner/Banner.d.ts.map +1 -1
  5. package/dist/Banner/Banner.js +13 -3
  6. package/dist/BottomSheet/BottomSheet.d.ts +66 -0
  7. package/dist/BottomSheet/BottomSheet.d.ts.map +1 -0
  8. package/dist/BottomSheet/BottomSheet.js +392 -0
  9. package/dist/BottomSheet/BottomSheetPanel.d.ts +60 -0
  10. package/dist/BottomSheet/BottomSheetPanel.d.ts.map +1 -0
  11. package/dist/BottomSheet/BottomSheetPanel.js +398 -0
  12. package/dist/BottomSheet/BottomSheetSwitcher.d.ts +61 -0
  13. package/dist/BottomSheet/BottomSheetSwitcher.d.ts.map +1 -0
  14. package/dist/BottomSheet/BottomSheetSwitcher.js +410 -0
  15. package/dist/BottomSheet/BottomSheetSwitcherContext.d.ts +21 -0
  16. package/dist/BottomSheet/BottomSheetSwitcherContext.d.ts.map +1 -0
  17. package/dist/BottomSheet/BottomSheetSwitcherContext.js +13 -0
  18. package/dist/BottomSheet/index.d.ts +11 -0
  19. package/dist/BottomSheet/index.d.ts.map +1 -0
  20. package/dist/BottomSheet/index.js +12 -0
  21. package/dist/BottomSheet/snapOffsets.d.ts +72 -0
  22. package/dist/BottomSheet/snapOffsets.d.ts.map +1 -0
  23. package/dist/BottomSheet/snapOffsets.js +130 -0
  24. package/dist/BottomSheet/useMobileKeyboard.d.ts +35 -0
  25. package/dist/BottomSheet/useMobileKeyboard.d.ts.map +1 -0
  26. package/dist/BottomSheet/useMobileKeyboard.js +373 -0
  27. package/dist/BottomSheet/useSheetGestures.d.ts +180 -0
  28. package/dist/BottomSheet/useSheetGestures.d.ts.map +1 -0
  29. package/dist/BottomSheet/useSheetGestures.js +989 -0
  30. package/dist/Chat/ChatComposerInput.d.ts.map +1 -1
  31. package/dist/Chat/ChatComposerInput.js +3 -4
  32. package/dist/ComplexSelector/ComplexSelector.d.ts.map +1 -1
  33. package/dist/ComplexSelector/ComplexSelector.js +12 -6
  34. package/dist/ContextMenu/ContextMenu.d.ts.map +1 -1
  35. package/dist/ContextMenu/ContextMenu.js +4 -2
  36. package/dist/DateInput/DateInput.d.ts.map +1 -1
  37. package/dist/DateInput/DateInput.js +9 -1
  38. package/dist/DateTimeInput/DateTimeInput.d.ts.map +1 -1
  39. package/dist/DateTimeInput/DateTimeInput.js +31 -1
  40. package/dist/Dialog/Dialog.d.ts.map +1 -1
  41. package/dist/Dialog/Dialog.js +2 -1
  42. package/dist/Field/InputClearButton.d.ts +2 -1
  43. package/dist/Field/InputClearButton.d.ts.map +1 -1
  44. package/dist/Field/InputClearButton.js +6 -1
  45. package/dist/Lightbox/Lightbox.d.ts.map +1 -1
  46. package/dist/Lightbox/Lightbox.js +9 -3
  47. package/dist/MobileNav/MobileNav.d.ts +12 -0
  48. package/dist/MobileNav/MobileNav.d.ts.map +1 -1
  49. package/dist/MobileNav/MobileNav.js +102 -30
  50. package/dist/MultiSelector/MultiSelector.d.ts.map +1 -1
  51. package/dist/MultiSelector/MultiSelector.js +21 -7
  52. package/dist/NumberInput/NumberInput.d.ts.map +1 -1
  53. package/dist/NumberInput/NumberInput.js +9 -1
  54. package/dist/PowerSearch/PowerSearchEditPopover.js +1 -1
  55. package/dist/Selector/Selector.d.ts.map +1 -1
  56. package/dist/Selector/Selector.js +14 -3
  57. package/dist/StatusDot/StatusDot.d.ts +43 -3
  58. package/dist/StatusDot/StatusDot.d.ts.map +1 -1
  59. package/dist/StatusDot/StatusDot.js +42 -5
  60. package/dist/Switch/Switch.d.ts.map +1 -1
  61. package/dist/Switch/Switch.js +5 -1
  62. package/dist/Table/plugins/rowExpansion/useTableRowExpansion.d.ts.map +1 -1
  63. package/dist/Table/plugins/rowExpansion/useTableRowExpansion.js +7 -2
  64. package/dist/TimeInput/TimeInput.d.ts.map +1 -1
  65. package/dist/TimeInput/TimeInput.js +8 -1
  66. package/dist/Tokenizer/Tokenizer.js +11 -5
  67. package/dist/Tooltip/useTooltip.d.ts.map +1 -1
  68. package/dist/Tooltip/useTooltip.js +4 -1
  69. package/dist/Typeahead/BaseTypeahead.d.ts.map +1 -1
  70. package/dist/Typeahead/BaseTypeahead.js +5 -3
  71. package/dist/Typeahead/Typeahead.d.ts.map +1 -1
  72. package/dist/Typeahead/Typeahead.js +10 -1
  73. package/dist/astryx.css +21 -0
  74. package/dist/hooks/index.d.ts +8 -1
  75. package/dist/hooks/index.d.ts.map +1 -1
  76. package/dist/hooks/index.js +8 -1
  77. package/dist/hooks/useFocusTrap.d.ts +0 -11
  78. package/dist/hooks/useFocusTrap.d.ts.map +1 -1
  79. package/dist/hooks/useFocusTrap.js +1 -11
  80. package/dist/hooks/useInputStatusIcon.d.ts.map +1 -1
  81. package/dist/hooks/useInputStatusIcon.js +1 -0
  82. package/dist/index.d.ts +1 -0
  83. package/dist/index.d.ts.map +1 -1
  84. package/dist/index.js +1 -0
  85. package/dist/theme/defineTheme.d.ts +22 -4
  86. package/dist/theme/defineTheme.d.ts.map +1 -1
  87. package/dist/theme/expandColorScale.d.ts +17 -2
  88. package/dist/theme/expandColorScale.d.ts.map +1 -1
  89. package/dist/theme/expandColorScale.js +63 -33
  90. package/dist/utils/ime.d.ts +5 -0
  91. package/dist/utils/ime.d.ts.map +1 -0
  92. package/dist/utils/ime.js +63 -0
  93. package/dist/utils/index.d.ts +1 -0
  94. package/dist/utils/index.d.ts.map +1 -1
  95. package/dist/utils/index.js +2 -1
  96. package/locales/af-ZA.json +958 -0
  97. package/locales/ar-SA.json +998 -0
  98. package/locales/ca-ES.json +974 -0
  99. package/locales/cs-CZ.json +990 -0
  100. package/locales/da-DK.json +966 -0
  101. package/locales/de-DE.json +966 -0
  102. package/locales/el-GR.json +990 -0
  103. package/locales/en.json +48 -0
  104. package/locales/es-ES.json +978 -0
  105. package/locales/fi-FI.json +990 -0
  106. package/locales/fr-FR.json +928 -0
  107. package/locales/he-IL.json +990 -0
  108. package/locales/hu-HU.json +990 -0
  109. package/locales/it-IT.json +978 -0
  110. package/locales/ja-JP.json +998 -0
  111. package/locales/ko-KR.json +990 -0
  112. package/locales/nl-NL.json +954 -0
  113. package/locales/no-NO.json +978 -0
  114. package/locales/pl-PL.json +982 -0
  115. package/locales/pseudo.json +36 -0
  116. package/locales/pt-BR.json +982 -0
  117. package/locales/pt-PT.json +982 -0
  118. package/locales/ro-RO.json +982 -0
  119. package/locales/ru-RU.json +990 -0
  120. package/locales/sr-SP.json +990 -0
  121. package/locales/sv-SE.json +986 -0
  122. package/locales/tr-TR.json +990 -0
  123. package/locales/uk-UA.json +990 -0
  124. package/locales/vi-VN.json +986 -0
  125. package/locales/zh-CN.json +998 -0
  126. package/locales/zh-TW.json +998 -0
  127. package/package.json +9 -8
  128. package/src/Banner/Banner.doc.mjs +3 -3
  129. package/src/Banner/Banner.test.tsx +40 -0
  130. package/src/Banner/Banner.tsx +27 -2
  131. package/src/BottomSheet/BottomSheet.doc.mjs +266 -0
  132. package/src/BottomSheet/BottomSheet.test.tsx +2114 -0
  133. package/src/BottomSheet/BottomSheet.tsx +545 -0
  134. package/src/BottomSheet/BottomSheetPanel.test.tsx +240 -0
  135. package/src/BottomSheet/BottomSheetPanel.tsx +547 -0
  136. package/src/BottomSheet/BottomSheetSwitcher.doc.mjs +219 -0
  137. package/src/BottomSheet/BottomSheetSwitcher.test.tsx +850 -0
  138. package/src/BottomSheet/BottomSheetSwitcher.tsx +628 -0
  139. package/src/BottomSheet/BottomSheetSwitcherContext.ts +51 -0
  140. package/src/BottomSheet/index.ts +15 -0
  141. package/src/BottomSheet/snapOffsets.test.ts +187 -0
  142. package/src/BottomSheet/snapOffsets.ts +159 -0
  143. package/src/BottomSheet/useMobileKeyboard.ts +499 -0
  144. package/src/BottomSheet/useSheetGestures.test.ts +1059 -0
  145. package/src/BottomSheet/useSheetGestures.ts +1459 -0
  146. package/src/Chat/ChatComposerInput.tsx +3 -4
  147. package/src/ComplexSelector/ComplexSelector.tsx +12 -5
  148. package/src/ContextMenu/ContextMenu.tsx +4 -2
  149. package/src/DateInput/DateInput.test.tsx +21 -0
  150. package/src/DateInput/DateInput.tsx +9 -1
  151. package/src/DateTimeInput/DateTimeInput.doc.mjs +4 -0
  152. package/src/DateTimeInput/DateTimeInput.test.tsx +137 -0
  153. package/src/DateTimeInput/DateTimeInput.tsx +39 -2
  154. package/src/Dialog/Dialog.tsx +2 -1
  155. package/src/Field/Field.doc.mjs +1 -0
  156. package/src/Field/InputClearButton.test.tsx +25 -0
  157. package/src/Field/InputClearButton.tsx +4 -1
  158. package/src/FileInput/FileInput.test.tsx +53 -9
  159. package/src/Lightbox/Lightbox.test.tsx +49 -28
  160. package/src/Lightbox/Lightbox.tsx +7 -3
  161. package/src/MobileNav/MobileNav.tsx +128 -37
  162. package/src/MobileNav/MobileNavCloseEdgeCases.test.tsx +426 -0
  163. package/src/MobileNav/MobileNavCloseTiming.test.ts +58 -0
  164. package/src/MobileNav/MobileNavCloseVisibility.test.tsx +172 -0
  165. package/src/MultiSelector/MultiSelector.test.tsx +195 -51
  166. package/src/MultiSelector/MultiSelector.tsx +22 -8
  167. package/src/NumberInput/NumberInput.test.tsx +18 -0
  168. package/src/NumberInput/NumberInput.tsx +9 -1
  169. package/src/PowerSearch/PowerSearchEditPopover.tsx +1 -1
  170. package/src/Selector/Selector.test.tsx +85 -26
  171. package/src/Selector/Selector.tsx +13 -4
  172. package/src/StatusDot/StatusDot.doc.mjs +13 -0
  173. package/src/StatusDot/StatusDot.test.tsx +115 -8
  174. package/src/StatusDot/StatusDot.tsx +74 -9
  175. package/src/Switch/Switch.test.tsx +16 -0
  176. package/src/Switch/Switch.tsx +7 -0
  177. package/src/Table/plugins/rowExpansion/useTableRowExpansion.tsx +20 -2
  178. package/src/TimeInput/TimeInput.test.tsx +23 -0
  179. package/src/TimeInput/TimeInput.tsx +8 -0
  180. package/src/Tokenizer/Tokenizer.test.tsx +50 -32
  181. package/src/Tokenizer/Tokenizer.tsx +5 -5
  182. package/src/Tooltip/useTooltip.tsx +4 -1
  183. package/src/Typeahead/BaseTypeahead.tsx +10 -3
  184. package/src/Typeahead/Typeahead.test.tsx +116 -7
  185. package/src/Typeahead/Typeahead.tsx +10 -1
  186. package/src/__tests__/stubMatchMedia.test.ts +79 -0
  187. package/src/__tests__/stubMatchMedia.ts +95 -0
  188. package/src/hooks/index.ts +9 -5
  189. package/src/hooks/useFocusTrap.ts +1 -14
  190. package/src/hooks/useInputStatusIcon.test.tsx +19 -0
  191. package/src/hooks/useInputStatusIcon.tsx +7 -0
  192. package/src/index.ts +1 -0
  193. package/src/theme/defineTheme.ts +22 -4
  194. package/src/theme/expandColorScale.test.ts +138 -0
  195. package/src/theme/expandColorScale.ts +89 -44
  196. package/src/utils/ime.test.ts +32 -0
  197. package/src/utils/ime.ts +69 -0
  198. package/src/utils/index.ts +2 -0
  199. package/dist/astryx.umd.js +0 -147
  200. package/dist/astryx.umd.js.map +0 -7
@@ -0,0 +1,2114 @@
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 fireTimedPointer(
223
+ target: Element,
224
+ type: 'pointerdown' | 'pointermove' | 'pointerup',
225
+ {time, y}: {time: number; y: number},
226
+ ) {
227
+ const event = new Event(type, {bubbles: true, cancelable: true});
228
+ Object.defineProperties(event, {
229
+ button: {value: 0},
230
+ clientY: {value: y},
231
+ isPrimary: {value: true},
232
+ pointerId: {value: 1},
233
+ timeStamp: {value: time},
234
+ });
235
+ return fireEvent(target, event);
236
+ }
237
+
238
+ describe('BottomSheet', () => {
239
+ it('renders children when open and applies the accessible label', () => {
240
+ render(
241
+ <BottomSheet isOpen onOpenChange={() => {}} label="Filters">
242
+ Sheet content
243
+ </BottomSheet>,
244
+ );
245
+ const dialog = screen.getByRole('dialog');
246
+ expect(dialog).toBeInTheDocument();
247
+ expect(dialog).toHaveAccessibleName('Filters');
248
+ expect(screen.getByText('Sheet content')).toBeInTheDocument();
249
+ });
250
+
251
+ it('forwards DOM props and refs to the visual panel, not the dialog host', () => {
252
+ const panelRef = createRef<HTMLDivElement>();
253
+ const onClick = vi.fn();
254
+ render(
255
+ <BottomSheet
256
+ ref={panelRef}
257
+ isOpen
258
+ onOpenChange={() => {}}
259
+ label="Filters"
260
+ data-testid="filters-panel"
261
+ data-sheet-owner="search"
262
+ className="custom-panel"
263
+ onClick={onClick}>
264
+ Content
265
+ </BottomSheet>,
266
+ );
267
+
268
+ const panel = screen.getByTestId('filters-panel');
269
+ const dialog = screen.getByRole('dialog');
270
+ expect(panelRef.current).toBe(panel);
271
+ expect(panel.tagName).toBe('DIV');
272
+ expect(panel).toHaveClass('astryx-bottom-sheet', 'custom-panel');
273
+ expect(panel).toHaveAttribute('data-sheet-owner', 'search');
274
+ expect(dialog).not.toHaveAttribute('data-testid');
275
+
276
+ fireEvent.click(panel);
277
+ expect(onClick).toHaveBeenCalledTimes(1);
278
+ });
279
+
280
+ it('keeps consumer content as the last scroll-body child', () => {
281
+ render(
282
+ <BottomSheet isOpen onOpenChange={() => {}} label="Filters">
283
+ <div data-testid="consumer-content">Sheet content</div>
284
+ </BottomSheet>,
285
+ );
286
+ const body = getBody();
287
+ const consumer = screen.getByTestId('consumer-content');
288
+
289
+ expect(body.children).toHaveLength(1);
290
+ expect(body.lastElementChild).toBe(consumer);
291
+ expect(consumer.matches(':last-child')).toBe(true);
292
+ });
293
+
294
+ it('does not show when isOpen is false', () => {
295
+ render(
296
+ <BottomSheet isOpen={false} onOpenChange={() => {}} label="Filters">
297
+ Hidden
298
+ </BottomSheet>,
299
+ );
300
+ expect(HTMLDialogElement.prototype.showModal).not.toHaveBeenCalled();
301
+ });
302
+
303
+ it('opens modally (showModal + aria-modal)', () => {
304
+ render(
305
+ <BottomSheet isOpen onOpenChange={() => {}} label="Filters">
306
+ Content
307
+ </BottomSheet>,
308
+ );
309
+ expect(HTMLDialogElement.prototype.showModal).toHaveBeenCalled();
310
+ expect(screen.getByRole('dialog')).toHaveAttribute('aria-modal', 'true');
311
+ });
312
+
313
+ it('requests close on Escape', () => {
314
+ const onOpenChange = vi.fn();
315
+ render(
316
+ <BottomSheet isOpen onOpenChange={onOpenChange} label="Filters">
317
+ Content
318
+ </BottomSheet>,
319
+ );
320
+ fireEvent.keyDown(screen.getByRole('dialog'), {key: 'Escape'});
321
+ expect(onOpenChange).toHaveBeenCalledWith(false);
322
+ });
323
+
324
+ it('requests close when the scrim (dialog element itself) is clicked', () => {
325
+ const onOpenChange = vi.fn();
326
+ render(
327
+ <BottomSheet isOpen onOpenChange={onOpenChange} label="Filters">
328
+ Content
329
+ </BottomSheet>,
330
+ );
331
+ fireEvent.click(screen.getByRole('dialog'));
332
+ expect(onOpenChange).toHaveBeenCalledWith(false);
333
+ });
334
+
335
+ it('purpose=form blocks scrim and swipe dismissal but allows Escape', () => {
336
+ const onOpenChange = vi.fn();
337
+ render(
338
+ <BottomSheet
339
+ isOpen
340
+ purpose="form"
341
+ onOpenChange={onOpenChange}
342
+ label="Edit profile">
343
+ Content
344
+ </BottomSheet>,
345
+ );
346
+ const dialog = screen.getByRole('dialog');
347
+
348
+ fireEvent.click(dialog);
349
+ drag(getHandle(), [{y: 0}, {y: 40}, {y: 120}]);
350
+
351
+ expect(onOpenChange).not.toHaveBeenCalled();
352
+ expect(dialog).toHaveStyle({'--_sheet-scrim-opacity': '1'});
353
+
354
+ fireEvent.keyDown(dialog, {key: 'Escape'});
355
+ fireEvent(dialog, new Event('cancel', {cancelable: true}));
356
+
357
+ expect(onOpenChange).toHaveBeenCalledTimes(2);
358
+ expect(onOpenChange).toHaveBeenNthCalledWith(1, false);
359
+ expect(onOpenChange).toHaveBeenNthCalledWith(2, false);
360
+ });
361
+
362
+ it('purpose=required blocks every implicit dismissal path', () => {
363
+ const onOpenChange = vi.fn();
364
+ render(
365
+ <BottomSheet
366
+ isOpen
367
+ purpose="required"
368
+ onOpenChange={onOpenChange}
369
+ label="Required action">
370
+ Content
371
+ </BottomSheet>,
372
+ );
373
+ const dialog = screen.getByRole('alertdialog');
374
+
375
+ fireEvent.click(dialog);
376
+ fireEvent.keyDown(dialog, {key: 'Escape'});
377
+ fireEvent(dialog, new Event('cancel', {cancelable: true}));
378
+ drag(getHandle(), [{y: 0}, {y: 40}, {y: 120}]);
379
+
380
+ expect(onOpenChange).not.toHaveBeenCalled();
381
+ expect(dialog).toHaveStyle({'--_sheet-scrim-opacity': '1'});
382
+ });
383
+
384
+ it('keeps a standalone modal sheet presented through its exit animation', () => {
385
+ render(<ExitHarness />);
386
+ const dialog = screen.getByRole('dialog', {name: 'Filters'});
387
+
388
+ fireEvent.click(screen.getByRole('button', {name: 'Close sheet'}));
389
+
390
+ expect(dialog).toHaveAttribute('open');
391
+ expect(dialog).toHaveAttribute('inert');
392
+ expect(dialog).toHaveAttribute('aria-hidden', 'true');
393
+ expect(dialog).not.toHaveAttribute('aria-modal');
394
+ expect(dialog).toHaveStyle({'--_sheet-scrim-opacity': '0'});
395
+ expect(document.body.style.position).toBe('fixed');
396
+
397
+ finishSheetExit();
398
+
399
+ expect(dialog).not.toHaveAttribute('open');
400
+ expect(document.body.style.position).not.toBe('fixed');
401
+ });
402
+
403
+ it('does not dismiss when the sheet surface itself is clicked', () => {
404
+ const onOpenChange = vi.fn();
405
+ render(
406
+ <BottomSheet isOpen onOpenChange={onOpenChange} label="Filters">
407
+ Content
408
+ </BottomSheet>,
409
+ );
410
+ // Only a click that lands on the dialog (the transparent area) dismisses;
411
+ // clicks bubbling up from the sheet must not.
412
+ fireEvent.click(screen.getByText('Content'));
413
+ expect(onOpenChange).not.toHaveBeenCalled();
414
+ });
415
+
416
+ describe('hasScrim={false} (non-modal)', () => {
417
+ it('opens non-modally: show() instead of showModal(), no aria-modal', () => {
418
+ render(
419
+ <BottomSheet
420
+ isOpen
421
+ onOpenChange={() => {}}
422
+ label="Filters"
423
+ hasScrim={false}>
424
+ Content
425
+ </BottomSheet>,
426
+ );
427
+ expect(HTMLDialogElement.prototype.show).toHaveBeenCalled();
428
+ expect(HTMLDialogElement.prototype.showModal).not.toHaveBeenCalled();
429
+ expect(screen.getByRole('dialog')).not.toHaveAttribute('aria-modal');
430
+ });
431
+
432
+ it('does not dismiss when the shell (dialog element itself) is clicked', () => {
433
+ // No scrim: a tap on the transparent shell must pass through to the page,
434
+ // not dismiss the sheet.
435
+ const onOpenChange = vi.fn();
436
+ render(
437
+ <BottomSheet
438
+ isOpen
439
+ onOpenChange={onOpenChange}
440
+ label="Filters"
441
+ hasScrim={false}>
442
+ Content
443
+ </BottomSheet>,
444
+ );
445
+ fireEvent.click(screen.getByRole('dialog'));
446
+ expect(onOpenChange).not.toHaveBeenCalled();
447
+ });
448
+
449
+ it('still closes on Escape while focus is inside', () => {
450
+ const onOpenChange = vi.fn();
451
+ render(
452
+ <BottomSheet
453
+ isOpen
454
+ onOpenChange={onOpenChange}
455
+ label="Filters"
456
+ hasScrim={false}>
457
+ Content
458
+ </BottomSheet>,
459
+ );
460
+ fireEvent.keyDown(screen.getByRole('dialog'), {key: 'Escape'});
461
+ expect(onOpenChange).toHaveBeenCalledWith(false);
462
+ });
463
+
464
+ it('still dismisses on a downward swipe past the threshold', () => {
465
+ const onOpenChange = vi.fn();
466
+ render(
467
+ <BottomSheet
468
+ isOpen
469
+ onOpenChange={onOpenChange}
470
+ label="Filters"
471
+ hasScrim={false}>
472
+ Content
473
+ </BottomSheet>,
474
+ );
475
+ drag(getHandle(), [{y: 0}, {y: 40}, {y: 120}]);
476
+ expect(onOpenChange).toHaveBeenCalledWith(false);
477
+ });
478
+
479
+ it('does not steal focus onto the panel on open', () => {
480
+ render(
481
+ <BottomSheet
482
+ isOpen
483
+ onOpenChange={() => {}}
484
+ label="Filters"
485
+ hasScrim={false}>
486
+ <button type="button">First action</button>
487
+ </BottomSheet>,
488
+ );
489
+ // The background stays interactive, so the sheet must not grab focus.
490
+ expect(document.activeElement).not.toBe(getSheet());
491
+ });
492
+
493
+ it('still honors a descendant with data-autofocus', () => {
494
+ render(
495
+ <BottomSheet
496
+ isOpen
497
+ onOpenChange={() => {}}
498
+ label="Filters"
499
+ hasScrim={false}>
500
+ <input data-autofocus aria-label="Search" />
501
+ </BottomSheet>,
502
+ );
503
+ expect(document.activeElement).toBe(
504
+ screen.getByRole('textbox', {name: 'Search'}),
505
+ );
506
+ });
507
+
508
+ it('keeps a standalone non-modal sheet visible until its exit ends', () => {
509
+ render(<ExitHarness hasScrim={false} />);
510
+ const dialog = screen.getByRole('dialog', {name: 'Filters'});
511
+
512
+ fireEvent.click(screen.getByRole('button', {name: 'Close sheet'}));
513
+
514
+ expect(dialog).toHaveAttribute('open');
515
+ expect(dialog).toHaveAttribute('inert');
516
+ expect(document.body.style.position).not.toBe('fixed');
517
+
518
+ finishSheetExit();
519
+
520
+ expect(dialog).not.toHaveAttribute('open');
521
+ });
522
+ });
523
+
524
+ describe('grab handle', () => {
525
+ it('renders a decorative handle hidden from assistive tech', () => {
526
+ render(
527
+ <BottomSheet isOpen onOpenChange={() => {}} label="Filters">
528
+ Content
529
+ </BottomSheet>,
530
+ );
531
+ const handle = getHandle();
532
+ expect(handle).toHaveAttribute('aria-hidden', 'true');
533
+ });
534
+ });
535
+
536
+ describe('swipe to dismiss', () => {
537
+ it('requests close when dragged past the dismiss threshold', () => {
538
+ const onOpenChange = vi.fn();
539
+ render(
540
+ <BottomSheet isOpen onOpenChange={onOpenChange} label="Filters">
541
+ Content
542
+ </BottomSheet>,
543
+ );
544
+ // No measured height in jsdom (0) -> any downward drag dismisses via
545
+ // the distance branch (offset > 0.25) once released downward.
546
+ drag(getHandle(), [{y: 0}, {y: 40}, {y: 120}]);
547
+ expect(onOpenChange).toHaveBeenCalledWith(false);
548
+ });
549
+
550
+ it('restores the sheet when a context menu interrupts an active drag', () => {
551
+ const onOpenChange = vi.fn();
552
+ render(
553
+ <BottomSheet isOpen onOpenChange={onOpenChange} label="Filters">
554
+ Content
555
+ </BottomSheet>,
556
+ );
557
+ const handle = getHandle();
558
+ const dialog = screen.getByRole('dialog');
559
+
560
+ fireEvent.pointerDown(handle, {
561
+ pointerId: 1,
562
+ clientY: 0,
563
+ button: 0,
564
+ isPrimary: true,
565
+ });
566
+ fireEvent.pointerMove(handle, {pointerId: 1, clientY: 300});
567
+ expect(getSheet().style.transform).toBe('translateY(300px)');
568
+
569
+ expect(fireEvent.contextMenu(handle)).toBe(false);
570
+
571
+ expect(getSheet().style.transform).toBe('');
572
+ expect(dialog).toHaveStyle({'--_sheet-scrim-opacity': '1'});
573
+ expect(onOpenChange).not.toHaveBeenCalled();
574
+ });
575
+ });
576
+
577
+ describe('height', () => {
578
+ it('renders for each named height without error', () => {
579
+ for (const height of ['hug', 'capped', 'tall'] as const) {
580
+ const {unmount} = render(
581
+ <BottomSheet
582
+ isOpen
583
+ onOpenChange={() => {}}
584
+ label="Filters"
585
+ height={height}>
586
+ Content
587
+ </BottomSheet>,
588
+ );
589
+ expect(screen.getByRole('dialog')).toBeInTheDocument();
590
+ unmount();
591
+ }
592
+ });
593
+
594
+ it('accepts a freeform height (number or CSS length)', () => {
595
+ for (const height of [480, '70dvh'] 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('keeps the full layout height at the peek detent', () => {
611
+ const observers = mockResizeObserverInstances();
612
+ mockVisualViewport(800);
613
+ mockWindowHeight(800);
614
+ render(
615
+ <BottomSheet
616
+ isOpen
617
+ onOpenChange={() => {}}
618
+ label="Release notes"
619
+ height="tall">
620
+ Content
621
+ </BottomSheet>,
622
+ );
623
+ const sheet = getSheet();
624
+ const sheetObserver = observers.find(instance =>
625
+ instance.observed.has(sheet),
626
+ );
627
+ act(() => {
628
+ sheetObserver?.callback(
629
+ [resizeEntry(784, 736)],
630
+ sheetObserver as unknown as ResizeObserver,
631
+ );
632
+ });
633
+
634
+ // Settle on the shortest 112px peek: offset 736 - 112 = 624.
635
+ fireTimedPointer(getHandle(), 'pointerdown', {time: 0, y: 0});
636
+ fireTimedPointer(getHandle(), 'pointermove', {time: 1000, y: 600});
637
+ fireTimedPointer(getHandle(), 'pointerup', {time: 2000, y: 600});
638
+ fireEvent.transitionEnd(sheet, {propertyName: 'transform'});
639
+
640
+ // A glance state does not reflow the content into a sliver: the sheet
641
+ // keeps its full layout height and slides below the viewport instead.
642
+ expect(sheet.style.height).toBe('');
643
+ expect(sheet.style.transform).toBe('translateY(624px)');
644
+ expect(getBody().style.paddingBlockEnd).toBe('');
645
+
646
+ // Dragging back up off the peek stays transform-only for the same
647
+ // reason — there is no shortened layout to restore first.
648
+ fireTimedPointer(getHandle(), 'pointerdown', {time: 3000, y: 600});
649
+ fireTimedPointer(getHandle(), 'pointermove', {time: 4000, y: 400});
650
+ expect(sheet.style.transform).toBe('translateY(424px)');
651
+ expect(sheet.style.height).toBe('784px');
652
+ expect(getBody().style.paddingBlockEnd).toBe('');
653
+
654
+ // Releasing at the taller p50 detent resizes the scrolling area.
655
+ fireTimedPointer(getHandle(), 'pointerup', {time: 5000, y: 400});
656
+ fireEvent.transitionEnd(sheet, {propertyName: 'transform'});
657
+ expect(sheet.style.height).toBe('448px');
658
+ expect(sheet.style.transform).toBe('');
659
+ });
660
+
661
+ it('swaps height for transform without a transition when released on a detent', () => {
662
+ const observers = mockResizeObserverInstances();
663
+ mockVisualViewport(800);
664
+ mockWindowHeight(800);
665
+ render(
666
+ <BottomSheet
667
+ isOpen
668
+ onOpenChange={() => {}}
669
+ label="Release notes"
670
+ height="tall">
671
+ Content
672
+ </BottomSheet>,
673
+ );
674
+ const sheet = getSheet();
675
+ const sheetObserver = observers.find(instance =>
676
+ instance.observed.has(sheet),
677
+ );
678
+ act(() => {
679
+ sheetObserver?.callback(
680
+ [resizeEntry(784, 736)],
681
+ sheetObserver as unknown as ResizeObserver,
682
+ );
683
+ });
684
+
685
+ // magnetize() lands a slow drag exactly on the p50 detent (offset 336),
686
+ // so the release has no travel left to animate and reconciles at once.
687
+ fireTimedPointer(getHandle(), 'pointerdown', {time: 0, y: 0});
688
+ fireTimedPointer(getHandle(), 'pointermove', {time: 1000, y: 336});
689
+ expect(sheet.style.transform).toBe('translateY(336px)');
690
+ expect(sheet.style.height).toBe('784px');
691
+
692
+ // Hold the reconciliation frame so the intermediate render is visible
693
+ // to the assertions below.
694
+ const reconciliationFrames: FrameRequestCallback[] = [];
695
+ vi.mocked(requestAnimationFrame).mockImplementation(callback => {
696
+ reconciliationFrames.push(callback);
697
+ return reconciliationFrames.length;
698
+ });
699
+ fireTimedPointer(getHandle(), 'pointerup', {time: 2000, y: 336});
700
+
701
+ // Height and transform swap roles in this one render. That is only
702
+ // invisible with transitions off; live, the composited transform would
703
+ // animate the whole 336px swap while the height jumped, and the sheet
704
+ // would lurch away from the detent before coming back to it.
705
+ expect(sheet.style.height).toBe('448px');
706
+ expect(sheet.style.transform).toBe('');
707
+ expect(sheet.style.transition).toBe('none');
708
+
709
+ // Transitions come back for the next gesture.
710
+ act(() => reconciliationFrames.splice(0).forEach(frame => frame(0)));
711
+ expect(sheet.style.transition).toBe('');
712
+ });
713
+
714
+ it('uses transforms while dragging and resizes to the visible snapped height', () => {
715
+ const observers = mockResizeObserverInstances();
716
+ mockVisualViewport(800);
717
+ mockWindowHeight(800);
718
+ render(
719
+ <BottomSheet
720
+ isOpen
721
+ onOpenChange={() => {}}
722
+ label="Release notes"
723
+ height="tall">
724
+ Content
725
+ </BottomSheet>,
726
+ );
727
+ const sheet = getSheet();
728
+ const sheetObserver = observers.find(instance =>
729
+ instance.observed.has(sheet),
730
+ );
731
+ act(() => {
732
+ sheetObserver?.callback(
733
+ // A Tall sheet has 736px visible height in this 800px viewport plus
734
+ // the 48px border-box reserve held below the viewport.
735
+ [resizeEntry(784, 736)],
736
+ sheetObserver as unknown as ResizeObserver,
737
+ );
738
+ });
739
+
740
+ fireTimedPointer(getHandle(), 'pointerdown', {time: 0, y: 0});
741
+ fireTimedPointer(getHandle(), 'pointermove', {time: 1000, y: 240});
742
+
743
+ expect(sheet.style.transform).toBe('translateY(240px)');
744
+ expect(sheet.style.height).toBe('784px');
745
+ expect(sheet.style.transition).toBe('none');
746
+
747
+ fireTimedPointer(getHandle(), 'pointerup', {time: 2000, y: 240});
748
+ // Release remains transform-only until the snap finishes.
749
+ // p50 is a visible 400px sheet: 784 - 48 - 400 = 336px offset.
750
+ expect(sheet.style.transform).toBe('translateY(336px)');
751
+ expect(sheet.style.height).toBe('784px');
752
+ expect(sheet.style.transition).toBe('');
753
+ const reconciliationFrames: FrameRequestCallback[] = [];
754
+ vi.mocked(requestAnimationFrame).mockImplementation(callback => {
755
+ reconciliationFrames.push(callback);
756
+ return reconciliationFrames.length;
757
+ });
758
+ fireEvent.transitionEnd(sheet, {propertyName: 'transform'});
759
+ expect(sheet.style.transform).toBe('');
760
+ expect(sheet.style.height).toBe('448px');
761
+ // The transform reset is transition-free so it cannot produce a second
762
+ // fly-in animation after the snap has already reached its destination.
763
+ expect(sheet.style.transition).toBe('none');
764
+ expect(reconciliationFrames.length).toBeGreaterThan(0);
765
+ act(() => reconciliationFrames.splice(0).forEach(frame => frame(0)));
766
+ expect(sheet.style.transition).toBe('');
767
+
768
+ // Ignore ResizeObserver frames from the height reconciliation. The
769
+ // next drag must still use the original 784px border-box height.
770
+ act(() => {
771
+ sheetObserver?.callback(
772
+ [resizeEntry(500, 452)],
773
+ sheetObserver as unknown as ResizeObserver,
774
+ );
775
+ });
776
+ fireTimedPointer(getHandle(), 'pointerdown', {time: 3000, y: 240});
777
+ fireTimedPointer(getHandle(), 'pointermove', {time: 4000, y: 140});
778
+ expect(sheet.style.transform).toBe('translateY(236px)');
779
+ expect(sheet.style.height).toBe('784px');
780
+ expect(getBody().style.paddingBlockEnd).toBe('336px');
781
+
782
+ // Reversing below the settled point restores the settled height and
783
+ // translates only the distance traveled from that detent. The temporary
784
+ // scroll-preservation inset leaves with the temporary expanded layout.
785
+ fireTimedPointer(getHandle(), 'pointermove', {time: 5000, y: 340});
786
+ expect(sheet.style.transform).toBe('translateY(100px)');
787
+ expect(sheet.style.height).toBe('448px');
788
+ expect(getBody().style.paddingBlockEnd).toBe('');
789
+ });
790
+
791
+ it('reconciles the snapped height immediately when transitions are disabled', () => {
792
+ const observers = mockResizeObserverInstances();
793
+ mockVisualViewport(800);
794
+ mockWindowHeight(800);
795
+ render(
796
+ <BottomSheet
797
+ isOpen
798
+ onOpenChange={() => {}}
799
+ label="Release notes"
800
+ height="tall"
801
+ style={{transition: 'none'}}>
802
+ Content
803
+ </BottomSheet>,
804
+ );
805
+ const sheet = getSheet();
806
+ const sheetObserver = observers.find(instance =>
807
+ instance.observed.has(sheet),
808
+ );
809
+ act(() => {
810
+ sheetObserver?.callback(
811
+ [resizeEntry(784, 736)],
812
+ sheetObserver as unknown as ResizeObserver,
813
+ );
814
+ });
815
+
816
+ fireTimedPointer(getHandle(), 'pointerdown', {time: 0, y: 0});
817
+ fireTimedPointer(getHandle(), 'pointermove', {time: 1000, y: 240});
818
+ fireTimedPointer(getHandle(), 'pointerup', {time: 2000, y: 240});
819
+
820
+ expect(sheet.style.transform).toBe('');
821
+ expect(sheet.style.height).toBe('448px');
822
+ });
823
+
824
+ it('restores the maximum height before an upward drag and reconciles at snap', () => {
825
+ const observers = mockResizeObserverInstances();
826
+ mockVisualViewport(800);
827
+ mockWindowHeight(800);
828
+ render(
829
+ <BottomSheet
830
+ isOpen
831
+ onOpenChange={() => {}}
832
+ label="Release notes"
833
+ height="tall">
834
+ Content
835
+ </BottomSheet>,
836
+ );
837
+ const sheet = getSheet();
838
+ const sheetObserver = observers.find(instance =>
839
+ instance.observed.has(sheet),
840
+ );
841
+ act(() => {
842
+ sheetObserver?.callback(
843
+ [resizeEntry(784, 736)],
844
+ sheetObserver as unknown as ResizeObserver,
845
+ );
846
+ });
847
+
848
+ // Start at the middle 400px detent.
849
+ fireTimedPointer(getHandle(), 'pointerdown', {time: 0, y: 0});
850
+ fireTimedPointer(getHandle(), 'pointermove', {time: 1000, y: 240});
851
+ fireTimedPointer(getHandle(), 'pointerup', {time: 2000, y: 240});
852
+ fireEvent.transitionEnd(sheet, {propertyName: 'transform'});
853
+ expect(sheet.style.height).toBe('448px');
854
+
855
+ // The upward gesture renders the full 784px surface below the viewport
856
+ // and translates it to preserve the visible top edge.
857
+ fireTimedPointer(getHandle(), 'pointerdown', {time: 3000, y: 240});
858
+ fireTimedPointer(getHandle(), 'pointermove', {time: 4000, y: 0});
859
+ expect(sheet.style.transform).toBe('translateY(96px)');
860
+ expect(sheet.style.height).toBe('784px');
861
+ expect(getBody().style.paddingBlockEnd).toBe('336px');
862
+
863
+ // Release first animates only the transform, keeping the source layout
864
+ // and scroll range fixed for the entire snap.
865
+ fireTimedPointer(getHandle(), 'pointerup', {time: 5000, y: 0});
866
+ expect(sheet.style.transform).toBe('');
867
+ expect(sheet.style.height).toBe('784px');
868
+ expect(getBody().style.paddingBlockEnd).toBe('336px');
869
+
870
+ // At transition end, height and preservation spacing reconcile in one
871
+ // render with the same visible geometry.
872
+ fireEvent.transitionEnd(sheet, {propertyName: 'transform'});
873
+ expect(sheet.style.transform).toBe('');
874
+ expect(sheet.style.height).toBe('');
875
+ expect(getBody().style.paddingBlockEnd).toBe('336px');
876
+
877
+ // Once ordinary scrolling brings the content back within its natural
878
+ // range, the retained end padding is no longer needed and is discarded.
879
+ const body = getBody();
880
+ Object.defineProperties(body, {
881
+ clientHeight: {configurable: true, value: 600},
882
+ scrollHeight: {configurable: true, value: 1000},
883
+ scrollTop: {configurable: true, value: 64, writable: true},
884
+ });
885
+ fireEvent.scroll(body);
886
+ expect(body.style.paddingBlockEnd).toBe('');
887
+ });
888
+
889
+ it('keeps the settled height stable while dismissing', () => {
890
+ const observers = mockResizeObserverInstances();
891
+ mockVisualViewport(800);
892
+ mockWindowHeight(800);
893
+ render(<ExitHarness />);
894
+ const sheet = getSheet();
895
+ const sheetObserver = observers.find(instance =>
896
+ instance.observed.has(sheet),
897
+ );
898
+ act(() => {
899
+ sheetObserver?.callback(
900
+ [resizeEntry(784, 736)],
901
+ sheetObserver as unknown as ResizeObserver,
902
+ );
903
+ });
904
+
905
+ // First settle at the middle detent.
906
+ fireTimedPointer(getHandle(), 'pointerdown', {time: 0, y: 0});
907
+ fireTimedPointer(getHandle(), 'pointermove', {time: 1000, y: 240});
908
+ fireTimedPointer(getHandle(), 'pointerup', {time: 2000, y: 240});
909
+ fireEvent.transitionEnd(sheet, {propertyName: 'transform'});
910
+ expect(sheet.style.height).toBe('448px');
911
+
912
+ // A later dismiss stays transform-only and preserves that settled
913
+ // scroll-area height throughout the exit.
914
+ fireTimedPointer(getHandle(), 'pointerdown', {time: 3000, y: 240});
915
+ fireTimedPointer(getHandle(), 'pointermove', {time: 4000, y: 1000});
916
+ expect(sheet.style.transform).toBe('translateY(760px)');
917
+ expect(sheet.style.height).toBe('448px');
918
+ fireTimedPointer(getHandle(), 'pointerup', {time: 5000, y: 1000});
919
+ expect(sheet.style.height).toBe('448px');
920
+
921
+ act(() => {
922
+ sheetObserver?.callback(
923
+ [resizeEntry(200, 152)],
924
+ sheetObserver as unknown as ResizeObserver,
925
+ );
926
+ });
927
+ expect(sheet.style.height).toBe('448px');
928
+ });
929
+
930
+ it('re-resolves the settled detent when the window resizes', () => {
931
+ const observers = mockResizeObserverInstances();
932
+ mockVisualViewport(800);
933
+ mockWindowHeight(800);
934
+ render(
935
+ <BottomSheet
936
+ isOpen
937
+ onOpenChange={() => {}}
938
+ label="Release notes"
939
+ height="tall">
940
+ Content
941
+ </BottomSheet>,
942
+ );
943
+ const sheet = getSheet();
944
+ const sheetObserver = observers.find(instance =>
945
+ instance.observed.has(sheet),
946
+ );
947
+ act(() => {
948
+ sheetObserver?.callback(
949
+ [resizeEntry(784, 736)],
950
+ sheetObserver as unknown as ResizeObserver,
951
+ );
952
+ });
953
+
954
+ // Settle on the half-height detent: 784 - 48 - 400 = 336px of travel.
955
+ fireTimedPointer(getHandle(), 'pointerdown', {time: 0, y: 0});
956
+ fireTimedPointer(getHandle(), 'pointermove', {time: 1000, y: 336});
957
+ fireTimedPointer(getHandle(), 'pointerup', {time: 2000, y: 336});
958
+ const reconciliationFrames: FrameRequestCallback[] = [];
959
+ vi.mocked(requestAnimationFrame).mockImplementation(callback => {
960
+ reconciliationFrames.push(callback);
961
+ return reconciliationFrames.length;
962
+ });
963
+ fireEvent.transitionEnd(sheet, {propertyName: 'transform'});
964
+ act(() => reconciliationFrames.splice(0).forEach(frame => frame(0)));
965
+ // 448px of layout height shows 400px of sheet: half the 800px window.
966
+ expect(sheet.style.height).toBe('448px');
967
+
968
+ // Shrink the window. The sheet's budget is `92dvh`, so its border box
969
+ // follows: 0.92 * 600 + the 48px reserve = 600px.
970
+ vi.spyOn(sheet, 'getBoundingClientRect').mockImplementation(() =>
971
+ rect({
972
+ top: 0,
973
+ bottom: sheet.style.height
974
+ ? Number.parseFloat(sheet.style.height)
975
+ : 600,
976
+ }),
977
+ );
978
+ mockWindowHeight(600);
979
+ act(() => {
980
+ window.dispatchEvent(new Event('resize'));
981
+ });
982
+
983
+ // Still the half-height detent, re-resolved against the new window:
984
+ // 348px of layout height shows 300px, half of 600. Before this, the
985
+ // sheet kept its 448px and showed 400px — three quarters of the window.
986
+ expect(sheet.style.height).toBe('348px');
987
+ expect(sheet.style.transform).toBe('');
988
+ // Re-anchoring is not a gesture, so it does not animate.
989
+ expect(sheet.style.transition).toBe('none');
990
+ act(() => reconciliationFrames.splice(0).forEach(frame => frame(0)));
991
+ expect(sheet.style.transition).toBe('');
992
+ });
993
+
994
+ it('keeps its detents when the keyboard shrinks the visual viewport', () => {
995
+ const observers = mockResizeObserverInstances();
996
+ const viewport = mockVisualViewport(800);
997
+ mockWindowHeight(800);
998
+ render(
999
+ <BottomSheet
1000
+ isOpen
1001
+ onOpenChange={() => {}}
1002
+ label="Release notes"
1003
+ height="tall">
1004
+ Content
1005
+ </BottomSheet>,
1006
+ );
1007
+ const sheet = getSheet();
1008
+ const sheetObserver = observers.find(instance =>
1009
+ instance.observed.has(sheet),
1010
+ );
1011
+ act(() => {
1012
+ sheetObserver?.callback(
1013
+ [resizeEntry(784, 736)],
1014
+ sheetObserver as unknown as ResizeObserver,
1015
+ );
1016
+ });
1017
+
1018
+ fireTimedPointer(getHandle(), 'pointerdown', {time: 0, y: 0});
1019
+ fireTimedPointer(getHandle(), 'pointermove', {time: 1000, y: 336});
1020
+ fireTimedPointer(getHandle(), 'pointerup', {time: 2000, y: 336});
1021
+ fireEvent.transitionEnd(sheet, {propertyName: 'transform'});
1022
+ expect(sheet.style.height).toBe('448px');
1023
+
1024
+ // The keyboard opens: the visual viewport shrinks, the layout viewport
1025
+ // the sheet is measured in does not.
1026
+ act(() => {
1027
+ viewport.height = 500;
1028
+ viewport.dispatchEvent(new Event('resize'));
1029
+ });
1030
+ expect(sheet.style.height).toBe('448px');
1031
+
1032
+ // The next drag still snaps to the window's detents, not to fractions
1033
+ // of the space the keyboard left over.
1034
+ fireTimedPointer(getHandle(), 'pointerdown', {time: 3000, y: 0});
1035
+ fireTimedPointer(getHandle(), 'pointermove', {time: 4000, y: 288});
1036
+ fireTimedPointer(getHandle(), 'pointerup', {time: 5000, y: 288});
1037
+ // The peek of an 800px window: 736 - 112 = 624px of travel.
1038
+ expect(sheet.style.transform).toBe('translateY(624px)');
1039
+ });
1040
+ });
1041
+
1042
+ describe('mobile keyboard', () => {
1043
+ it('claims a transition between fields and delivers it with preventScroll', () => {
1044
+ mockIOSWebKit();
1045
+ render(
1046
+ <BottomSheet
1047
+ isOpen
1048
+ onOpenChange={() => {}}
1049
+ label="Add a comment"
1050
+ height="tall">
1051
+ <input aria-label="Title" />
1052
+ <input aria-label="Comment" />
1053
+ </BottomSheet>,
1054
+ );
1055
+ const title = screen.getByRole('textbox', {name: 'Title'});
1056
+ const comment = screen.getByRole('textbox', {name: 'Comment'});
1057
+ title.focus();
1058
+ const focus = vi.spyOn(comment, 'focus');
1059
+
1060
+ // The keyboard's Next, Tab, and a programmatic focus() all arrive as this
1061
+ // one transition, named by relatedTarget on the outgoing blur.
1062
+ fireEvent.blur(title, {relatedTarget: comment});
1063
+
1064
+ // Delivered by us, with the browser's reveal refused.
1065
+ expect(focus).toHaveBeenCalledWith({preventScroll: true});
1066
+ });
1067
+
1068
+ it('does not park focus on a sheet that is closing', () => {
1069
+ mockIOSWebKit();
1070
+ const content = (isOpen: boolean) => (
1071
+ <BottomSheet
1072
+ isOpen={isOpen}
1073
+ onOpenChange={() => {}}
1074
+ label="Add a comment"
1075
+ height="tall">
1076
+ <input aria-label="Comment" />
1077
+ </BottomSheet>
1078
+ );
1079
+ const {rerender} = render(content(true));
1080
+ const sheet = getSheet();
1081
+ const input = screen.getByRole('textbox', {name: 'Comment'});
1082
+ input.focus();
1083
+ const sheetFocus = vi.spyOn(sheet, 'focus');
1084
+
1085
+ // Closing blurs the field as well, and that blur names no destination —
1086
+ // the same shape as Done. There is no next tap to keep claimable here,
1087
+ // and the host is about to hand focus back to whatever opened the sheet.
1088
+ void act(() => rerender(content(false)));
1089
+
1090
+ expect(sheetFocus).not.toHaveBeenCalled();
1091
+ });
1092
+
1093
+ it('autofocuses a field without letting the browser reveal it', () => {
1094
+ mockIOSWebKit();
1095
+ const focus = vi.spyOn(HTMLInputElement.prototype, 'focus');
1096
+ render(
1097
+ <BottomSheet
1098
+ isOpen
1099
+ onOpenChange={() => {}}
1100
+ label="Add a comment"
1101
+ height="tall">
1102
+ <input aria-label="Comment" data-autofocus />
1103
+ </BottomSheet>,
1104
+ );
1105
+ // A prototype spy outlives this test unless it is put back by hand.
1106
+ const calls = [...focus.mock.calls];
1107
+ focus.mockRestore();
1108
+ const input = screen.getByRole('textbox', {name: 'Comment'});
1109
+
1110
+ // Opening focuses the field itself, so there is no transition to claim —
1111
+ // nothing was focused to blur. The presenting call has to refuse the
1112
+ // reveal on its own, or the browser scrolls the page to show a field the
1113
+ // sheet was about to show anyway.
1114
+ expect(document.activeElement).toBe(input);
1115
+ expect(calls).toContainEqual([{preventScroll: true}]);
1116
+ });
1117
+
1118
+ it('parks focus on the sheet when the keyboard Done button takes it', () => {
1119
+ mockIOSWebKit();
1120
+ render(
1121
+ <BottomSheet
1122
+ isOpen
1123
+ onOpenChange={() => {}}
1124
+ label="Add a comment"
1125
+ height="tall">
1126
+ <input aria-label="Comment" />
1127
+ </BottomSheet>,
1128
+ );
1129
+ const sheet = getSheet();
1130
+ const input = screen.getByRole('textbox', {name: 'Comment'});
1131
+ input.focus();
1132
+ const sheetFocus = vi.spyOn(sheet, 'focus');
1133
+
1134
+ // Done dismisses the keyboard and drops focus on the body. Left there,
1135
+ // the field is still document.activeElement on the next tap, so no
1136
+ // transition fires and the browser reveals it its own way. Parking focus
1137
+ // on the sheet keeps the next tap a transition this hook can claim.
1138
+ fireEvent.blur(input, {relatedTarget: null});
1139
+
1140
+ expect(sheetFocus).toHaveBeenCalledWith({preventScroll: true});
1141
+ });
1142
+
1143
+ it('does not alter ordinary desktop focus when the viewport is unobstructed', () => {
1144
+ mockVisualViewport(800);
1145
+ mockWindowHeight(800);
1146
+ const onFocus = vi.fn();
1147
+ render(
1148
+ <BottomSheet
1149
+ isOpen
1150
+ onOpenChange={() => {}}
1151
+ label="Add a comment"
1152
+ height="tall">
1153
+ <input aria-label="Comment" onFocus={onFocus} />
1154
+ </BottomSheet>,
1155
+ );
1156
+ const input = screen.getByRole('textbox', {name: 'Comment'});
1157
+ const focus = vi.spyOn(input, 'focus');
1158
+
1159
+ input.focus();
1160
+
1161
+ expect(focus).not.toHaveBeenCalledWith({preventScroll: true});
1162
+ expect(onFocus).toHaveBeenCalledTimes(1);
1163
+ expect(getBody().style.getPropertyValue('--_sheet-keyboard-inset')).toBe(
1164
+ '0px',
1165
+ );
1166
+ });
1167
+
1168
+ it('keeps Tall geometry fixed while extending and cleaning up its internal scroll range', () => {
1169
+ const viewport = mockVisualViewport(500);
1170
+ render(
1171
+ <BottomSheet
1172
+ isOpen
1173
+ onOpenChange={() => {}}
1174
+ label="Add a comment"
1175
+ height="tall">
1176
+ <input aria-label="Comment" />
1177
+ </BottomSheet>,
1178
+ );
1179
+ const sheet = getSheet();
1180
+ const positioner = getPositioner();
1181
+ const body = getBody();
1182
+ const input = screen.getByRole('textbox', {name: 'Comment'});
1183
+ vi.spyOn(sheet, 'getBoundingClientRect').mockReturnValue(
1184
+ rect({top: 0, bottom: 800}),
1185
+ );
1186
+ vi.spyOn(body, 'getBoundingClientRect').mockReturnValue(
1187
+ rect({top: 100, bottom: 800}),
1188
+ );
1189
+ vi.spyOn(input, 'getBoundingClientRect').mockImplementation(() =>
1190
+ rect({
1191
+ top: 660 - body.scrollTop,
1192
+ bottom: 700 - body.scrollTop,
1193
+ }),
1194
+ );
1195
+
1196
+ const initialRect = sheet.getBoundingClientRect();
1197
+
1198
+ input.focus();
1199
+
1200
+ expect(sheet.getBoundingClientRect()).toEqual(initialRect);
1201
+ expect(sheet.style.height).toBe('');
1202
+ expect(sheet.style.getPropertyValue('--_sheet-budget')).toBe('92dvh');
1203
+ expect(positioner.style.getPropertyValue('--_sheet-keyboard-lift')).toBe(
1204
+ '',
1205
+ );
1206
+ // 300px keyboard overlap + 48px room for Android suggestion UI.
1207
+ expect(body.style.getPropertyValue('--_sheet-keyboard-inset')).toBe(
1208
+ '348px',
1209
+ );
1210
+ // Visible bottom is 500px; preserve the same 48px focus gap.
1211
+ expect(body.scrollTop).toBe(248);
1212
+
1213
+ viewport.height = 800;
1214
+ void act(() => viewport.dispatchEvent(new Event('resize')));
1215
+ expect(body.style.getPropertyValue('--_sheet-keyboard-inset')).toBe(
1216
+ '0px',
1217
+ );
1218
+ expect(sheet.getBoundingClientRect()).toEqual(initialRect);
1219
+ expect(sheet.style.height).toBe('');
1220
+ });
1221
+
1222
+ it('smoothly scrolls a focused Tall control above the keyboard', () => {
1223
+ mockVisualViewport(500);
1224
+ render(
1225
+ <BottomSheet
1226
+ isOpen
1227
+ onOpenChange={() => {}}
1228
+ label="Add a comment"
1229
+ height="tall">
1230
+ <input aria-label="Comment" />
1231
+ </BottomSheet>,
1232
+ );
1233
+ const body = getBody();
1234
+ const input = screen.getByRole('textbox', {name: 'Comment'});
1235
+ const scrollBy = vi.fn((options: ScrollToOptions) => {
1236
+ body.scrollTop += options.top ?? 0;
1237
+ });
1238
+ Object.defineProperty(body, 'scrollBy', {
1239
+ configurable: true,
1240
+ value: scrollBy,
1241
+ });
1242
+ vi.spyOn(body, 'getBoundingClientRect').mockReturnValue(
1243
+ rect({top: 100, bottom: 800}),
1244
+ );
1245
+ vi.spyOn(input, 'getBoundingClientRect').mockImplementation(() =>
1246
+ rect({
1247
+ top: 660 - body.scrollTop,
1248
+ bottom: 700 - body.scrollTop,
1249
+ }),
1250
+ );
1251
+
1252
+ input.focus();
1253
+
1254
+ expect(scrollBy).toHaveBeenCalledWith({top: 248, behavior: 'smooth'});
1255
+ });
1256
+
1257
+ it('does not add clearance or scroll when the viewport is unobstructed', () => {
1258
+ mockVisualViewport(800);
1259
+ mockWindowHeight(800);
1260
+ render(
1261
+ <BottomSheet
1262
+ isOpen
1263
+ onOpenChange={() => {}}
1264
+ label="Add a comment"
1265
+ height="tall">
1266
+ <input aria-label="Comment" />
1267
+ </BottomSheet>,
1268
+ );
1269
+ const body = getBody();
1270
+ const input = screen.getByRole('textbox', {name: 'Comment'});
1271
+ vi.spyOn(body, 'getBoundingClientRect').mockReturnValue(
1272
+ rect({top: 100, bottom: 800}),
1273
+ );
1274
+ vi.spyOn(input, 'getBoundingClientRect').mockReturnValue(
1275
+ rect({top: 760, bottom: 790}),
1276
+ );
1277
+
1278
+ input.focus();
1279
+
1280
+ expect(body.style.getPropertyValue('--_sheet-keyboard-inset')).toBe(
1281
+ '0px',
1282
+ );
1283
+ expect(body.scrollTop).toBe(0);
1284
+ });
1285
+
1286
+ it('does not accommodate the keyboard at a shorter Tall detent', () => {
1287
+ mockIOSWebKit();
1288
+ const observers = mockResizeObserverInstances();
1289
+ const viewport = mockVisualViewport(800);
1290
+ mockWindowHeight(800);
1291
+ render(
1292
+ <BottomSheet
1293
+ isOpen
1294
+ onOpenChange={() => {}}
1295
+ label="Add a comment"
1296
+ height="tall">
1297
+ <input aria-label="Comment" />
1298
+ </BottomSheet>,
1299
+ );
1300
+ const sheet = getSheet();
1301
+ const body = getBody();
1302
+ const input = screen.getByRole('textbox', {name: 'Comment'});
1303
+ const sheetObserver = observers.find(instance =>
1304
+ instance.observed.has(sheet),
1305
+ );
1306
+ expect(sheetObserver).toBeDefined();
1307
+ act(() => {
1308
+ sheetObserver?.callback(
1309
+ [resizeEntry(784)],
1310
+ sheetObserver as unknown as ResizeObserver,
1311
+ );
1312
+ });
1313
+
1314
+ fireTimedPointer(getHandle(), 'pointerdown', {time: 0, y: 0});
1315
+ fireTimedPointer(getHandle(), 'pointermove', {time: 1000, y: 240});
1316
+ fireTimedPointer(getHandle(), 'pointerup', {time: 2000, y: 240});
1317
+ fireEvent.transitionEnd(sheet, {propertyName: 'transform'});
1318
+ // Settled at the p50 detent: 400px of visible sheet plus the 48px
1319
+ // border-box reserve held below the viewport.
1320
+ expect(sheet.style.transform).toBe('');
1321
+ expect(sheet.style.height).toBe('448px');
1322
+
1323
+ vi.spyOn(body, 'getBoundingClientRect').mockReturnValue(
1324
+ rect({top: 500, bottom: 1200}),
1325
+ );
1326
+ vi.spyOn(input, 'getBoundingClientRect').mockReturnValue(
1327
+ rect({top: 700, bottom: 740}),
1328
+ );
1329
+ viewport.height = 500;
1330
+ const focus = vi.spyOn(input, 'focus');
1331
+ // A transition the browser would drive: at a shorter detent the hook
1332
+ // must not claim it, so nothing focuses the field but the caller.
1333
+ fireEvent.blur(sheet, {relatedTarget: input});
1334
+ expect(focus).not.toHaveBeenCalled();
1335
+
1336
+ focus.mockRestore();
1337
+ input.focus();
1338
+ void act(() => viewport.dispatchEvent(new Event('resize')));
1339
+
1340
+ expect(body.style.getPropertyValue('--_sheet-keyboard-inset')).toBe(
1341
+ '0px',
1342
+ );
1343
+ expect(body.scrollTop).toBe(0);
1344
+ // The keyboard moved nothing: the sheet still rests at that detent.
1345
+ expect(sheet.style.transform).toBe('');
1346
+ expect(sheet.style.height).toBe('448px');
1347
+ });
1348
+
1349
+ it.each([
1350
+ ['Hug', 'hug'],
1351
+ ['Capped', 'capped'],
1352
+ ['numeric', 480],
1353
+ ['custom CSS', '70dvh'],
1354
+ ] as const)(
1355
+ 'does not add keyboard behavior to a %s height',
1356
+ (_label, height) => {
1357
+ mockIOSWebKit();
1358
+ const viewport = mockVisualViewport(500);
1359
+ render(
1360
+ <BottomSheet
1361
+ isOpen
1362
+ onOpenChange={() => {}}
1363
+ label="Add a comment"
1364
+ height={height}>
1365
+ <input aria-label="Comment" />
1366
+ </BottomSheet>,
1367
+ );
1368
+ const sheet = getSheet();
1369
+ const positioner = getPositioner();
1370
+ const body = getBody();
1371
+ const input = screen.getByRole('textbox', {name: 'Comment'});
1372
+ const focus = vi.spyOn(input, 'focus');
1373
+ const sheetFocus = vi.spyOn(sheet, 'focus');
1374
+ vi.spyOn(body, 'getBoundingClientRect').mockReturnValue(
1375
+ rect({top: 400, bottom: 800}),
1376
+ );
1377
+ vi.spyOn(input, 'getBoundingClientRect').mockReturnValue(
1378
+ rect({top: 700, bottom: 740}),
1379
+ );
1380
+ body.scrollTop = 20;
1381
+
1382
+ fireEvent.touchStart(input);
1383
+ // These heights opt out, so a browser-driven transition is left alone.
1384
+ fireEvent.blur(sheet, {relatedTarget: input});
1385
+ fireEvent.pointerDown(input, {pointerId: 1, clientY: 200});
1386
+ body.scrollTop = 120;
1387
+ fireEvent.focus(input, {relatedTarget: null});
1388
+ void act(() => viewport.dispatchEvent(new Event('resize')));
1389
+
1390
+ expect(body.scrollTop).toBe(120);
1391
+ expect(focus).not.toHaveBeenCalled();
1392
+ expect(sheetFocus).not.toHaveBeenCalled();
1393
+ expect(body.style.getPropertyValue('--_sheet-keyboard-inset')).toBe('');
1394
+ expect(
1395
+ positioner.style.getPropertyValue('--_sheet-keyboard-lift'),
1396
+ ).toBe('');
1397
+ expect(sheet.style.height).toBe('');
1398
+ },
1399
+ );
1400
+
1401
+ it.each([
1402
+ ['standalone no-scrim', 'standalone', false],
1403
+ ['modal switcher', 'switcher', true],
1404
+ ['no-scrim switcher', 'switcher', false],
1405
+ ] as const)(
1406
+ 'supports Tall internal keyboard scrolling in a %s presentation',
1407
+ (_label, host, hasScrim) => {
1408
+ mockVisualViewport(500);
1409
+ const content = <input aria-label="Comment" />;
1410
+ const result =
1411
+ host === 'standalone'
1412
+ ? render(
1413
+ <BottomSheet
1414
+ isOpen
1415
+ onOpenChange={() => {}}
1416
+ label="Add a comment"
1417
+ height="tall"
1418
+ hasScrim={hasScrim}>
1419
+ {content}
1420
+ </BottomSheet>,
1421
+ )
1422
+ : render(
1423
+ <BottomSheetSwitcher
1424
+ activeSheet="comment"
1425
+ onActiveSheetChange={() => {}}
1426
+ hasScrim={hasScrim}>
1427
+ <BottomSheet
1428
+ sheetId="comment"
1429
+ label="Add a comment"
1430
+ height="tall">
1431
+ {content}
1432
+ </BottomSheet>
1433
+ </BottomSheetSwitcher>,
1434
+ );
1435
+ const body = getBody();
1436
+ const input = screen.getByRole('textbox', {name: 'Comment'});
1437
+ vi.spyOn(body, 'getBoundingClientRect').mockReturnValue(
1438
+ rect({top: 100, bottom: 800}),
1439
+ );
1440
+ vi.spyOn(input, 'getBoundingClientRect').mockImplementation(() =>
1441
+ rect({
1442
+ top: 660 - body.scrollTop,
1443
+ bottom: 700 - body.scrollTop,
1444
+ }),
1445
+ );
1446
+
1447
+ input.focus();
1448
+
1449
+ expect(body.style.getPropertyValue('--_sheet-keyboard-inset')).toBe(
1450
+ '348px',
1451
+ );
1452
+ expect(body.scrollTop).toBe(248);
1453
+ result.unmount();
1454
+ },
1455
+ );
1456
+
1457
+ it('re-reveals a focused Tall control when content layout changes', () => {
1458
+ const observers = mockResizeObserverInstances();
1459
+ mockVisualViewport(500);
1460
+ render(
1461
+ <BottomSheet
1462
+ isOpen
1463
+ onOpenChange={() => {}}
1464
+ label="Add a comment"
1465
+ height="tall">
1466
+ <input aria-label="Comment" />
1467
+ </BottomSheet>,
1468
+ );
1469
+ const body = getBody();
1470
+ const input = screen.getByRole('textbox', {name: 'Comment'});
1471
+ let layoutShift = 0;
1472
+ vi.spyOn(body, 'getBoundingClientRect').mockReturnValue(
1473
+ rect({top: 100, bottom: 800}),
1474
+ );
1475
+ vi.spyOn(input, 'getBoundingClientRect').mockImplementation(() =>
1476
+ rect({
1477
+ top: 660 + layoutShift - body.scrollTop,
1478
+ bottom: 700 + layoutShift - body.scrollTop,
1479
+ }),
1480
+ );
1481
+
1482
+ input.focus();
1483
+ expect(body.scrollTop).toBe(248);
1484
+
1485
+ layoutShift = 200;
1486
+ const observer = observers.find(instance => instance.observed.has(input));
1487
+ expect(observer).toBeDefined();
1488
+ act(() => {
1489
+ observer?.callback([], observer as unknown as ResizeObserver);
1490
+ });
1491
+
1492
+ expect(body.scrollTop).toBe(448);
1493
+ });
1494
+
1495
+ it('retains Tall keyboard scroll space during travel until the viewport recovers', () => {
1496
+ const viewport = mockVisualViewport(500);
1497
+ render(
1498
+ <BottomSheet
1499
+ isOpen
1500
+ onOpenChange={() => {}}
1501
+ label="Add a comment"
1502
+ height="tall">
1503
+ <input aria-label="Comment" />
1504
+ </BottomSheet>,
1505
+ );
1506
+ const sheet = getSheet();
1507
+ const positioner = getPositioner();
1508
+ const body = getBody();
1509
+ const input = screen.getByRole('textbox', {name: 'Comment'});
1510
+ vi.spyOn(sheet, 'getBoundingClientRect').mockReturnValue(
1511
+ rect({top: 0, bottom: 800}),
1512
+ );
1513
+ vi.spyOn(body, 'getBoundingClientRect').mockReturnValue(
1514
+ rect({top: 100, bottom: 800}),
1515
+ );
1516
+ vi.spyOn(input, 'getBoundingClientRect').mockImplementation(() =>
1517
+ rect({
1518
+ top: 660 - body.scrollTop,
1519
+ bottom: 700 - body.scrollTop,
1520
+ }),
1521
+ );
1522
+
1523
+ input.focus();
1524
+ expect(body.scrollTop).toBe(248);
1525
+ expect(body.style.getPropertyValue('--_sheet-keyboard-inset')).toBe(
1526
+ '348px',
1527
+ );
1528
+
1529
+ const pointerDownAllowed = fireEvent.pointerDown(getHandle(), {
1530
+ pointerId: 1,
1531
+ clientY: 0,
1532
+ button: 0,
1533
+ isPrimary: true,
1534
+ });
1535
+ expect(pointerDownAllowed).toBe(false);
1536
+ expect(document.activeElement).toBe(input);
1537
+
1538
+ fireEvent.pointerMove(getHandle(), {pointerId: 1, clientY: 40});
1539
+
1540
+ expect(document.activeElement).toBe(sheet);
1541
+ expect(positioner.style.getPropertyValue('--_sheet-keyboard-lift')).toBe(
1542
+ '',
1543
+ );
1544
+ expect(body.style.getPropertyValue('--_sheet-keyboard-inset')).toBe(
1545
+ '348px',
1546
+ );
1547
+ expect(body.scrollTop).toBe(248);
1548
+
1549
+ viewport.height = 800;
1550
+ void act(() => viewport.dispatchEvent(new Event('resize')));
1551
+ expect(body.style.getPropertyValue('--_sheet-keyboard-inset')).toBe(
1552
+ '0px',
1553
+ );
1554
+ });
1555
+
1556
+ it('retains Tall keyboard scroll space after blur until the viewport recovers', () => {
1557
+ const viewport = mockVisualViewport(500);
1558
+ render(
1559
+ <BottomSheet
1560
+ isOpen
1561
+ onOpenChange={() => {}}
1562
+ label="Add a comment"
1563
+ height="tall">
1564
+ <input aria-label="Comment" />
1565
+ </BottomSheet>,
1566
+ );
1567
+ const body = getBody();
1568
+ const input = screen.getByRole('textbox', {name: 'Comment'});
1569
+ vi.spyOn(body, 'getBoundingClientRect').mockReturnValue(
1570
+ rect({top: 100, bottom: 800}),
1571
+ );
1572
+ vi.spyOn(input, 'getBoundingClientRect').mockImplementation(() =>
1573
+ rect({
1574
+ top: 660 - body.scrollTop,
1575
+ bottom: 700 - body.scrollTop,
1576
+ }),
1577
+ );
1578
+ input.focus();
1579
+ expect(body.style.getPropertyValue('--_sheet-keyboard-inset')).toBe(
1580
+ '348px',
1581
+ );
1582
+
1583
+ input.blur();
1584
+
1585
+ expect(body.style.getPropertyValue('--_sheet-keyboard-inset')).toBe(
1586
+ '348px',
1587
+ );
1588
+
1589
+ viewport.height = 800;
1590
+ void act(() => viewport.dispatchEvent(new Event('resize')));
1591
+ expect(body.style.getPropertyValue('--_sheet-keyboard-inset')).toBe(
1592
+ '0px',
1593
+ );
1594
+ });
1595
+
1596
+ it('blurs the focused Tall field and retains its inset until the viewport recovers', () => {
1597
+ const viewport = mockVisualViewport(500);
1598
+ const onOpenChange = vi.fn();
1599
+ const content = (isOpen: boolean) => (
1600
+ <BottomSheet
1601
+ isOpen={isOpen}
1602
+ onOpenChange={onOpenChange}
1603
+ label="Add a comment"
1604
+ height="tall">
1605
+ <input aria-label="Comment" />
1606
+ </BottomSheet>
1607
+ );
1608
+ const {rerender} = render(content(true));
1609
+ const body = getBody();
1610
+ const input = screen.getByRole('textbox', {name: 'Comment'});
1611
+ vi.spyOn(body, 'getBoundingClientRect').mockReturnValue(
1612
+ rect({top: 100, bottom: 800}),
1613
+ );
1614
+ vi.spyOn(input, 'getBoundingClientRect').mockImplementation(() =>
1615
+ rect({
1616
+ top: 660 - body.scrollTop,
1617
+ bottom: 700 - body.scrollTop,
1618
+ }),
1619
+ );
1620
+ input.focus();
1621
+ expect(body.style.getPropertyValue('--_sheet-keyboard-inset')).toBe(
1622
+ '348px',
1623
+ );
1624
+
1625
+ rerender(content(false));
1626
+
1627
+ expect(document.activeElement).not.toBe(input);
1628
+ expect(body.style.getPropertyValue('--_sheet-keyboard-inset')).toBe(
1629
+ '348px',
1630
+ );
1631
+
1632
+ viewport.height = 800;
1633
+ void act(() => viewport.dispatchEvent(new Event('resize')));
1634
+ expect(body.style.getPropertyValue('--_sheet-keyboard-inset')).toBe(
1635
+ '0px',
1636
+ );
1637
+ });
1638
+
1639
+ it('retains Tall keyboard scroll space through a switcher handoff until the viewport recovers', () => {
1640
+ const viewport = mockVisualViewport(500);
1641
+ const content = (activeSheet: string) => (
1642
+ <BottomSheetSwitcher
1643
+ activeSheet={activeSheet}
1644
+ onActiveSheetChange={() => {}}>
1645
+ <BottomSheet sheetId="comment" label="Add a comment" height="tall">
1646
+ <input aria-label="Comment" />
1647
+ </BottomSheet>
1648
+ <BottomSheet sheetId="confirmation" label="Confirmation">
1649
+ Confirmation
1650
+ </BottomSheet>
1651
+ </BottomSheetSwitcher>
1652
+ );
1653
+ const {rerender} = render(content('comment'));
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
+ rerender(content('confirmation'));
1671
+
1672
+ expect(document.activeElement).not.toBe(input);
1673
+ expect(body.style.getPropertyValue('--_sheet-keyboard-inset')).toBe(
1674
+ '348px',
1675
+ );
1676
+
1677
+ viewport.height = 800;
1678
+ void act(() => viewport.dispatchEvent(new Event('resize')));
1679
+ expect(body.style.getPropertyValue('--_sheet-keyboard-inset')).toBe(
1680
+ '0px',
1681
+ );
1682
+ });
1683
+
1684
+ it('puts back a document scroll the browser makes to reveal a field', () => {
1685
+ mockVisualViewport(377);
1686
+ render(
1687
+ <BottomSheet
1688
+ isOpen
1689
+ onOpenChange={() => {}}
1690
+ label="Add a comment"
1691
+ height="tall">
1692
+ <input aria-label="Comment" />
1693
+ </BottomSheet>,
1694
+ );
1695
+ const body = getBody();
1696
+ const input = screen.getByRole('textbox', {name: 'Comment'});
1697
+ vi.spyOn(body, 'getBoundingClientRect').mockReturnValue(
1698
+ rect({top: 57, bottom: 714}),
1699
+ );
1700
+ vi.spyOn(input, 'getBoundingClientRect').mockImplementation(() =>
1701
+ rect({top: 600 - body.scrollTop, bottom: 640 - body.scrollTop}),
1702
+ );
1703
+ input.focus();
1704
+ const scrollTo = vi.mocked(window.scrollTo);
1705
+ scrollTo.mockClear();
1706
+ const scrolledBy = body.scrollTop;
1707
+
1708
+ // The page numbers an iPhone 17 produces when the browser reveals a
1709
+ // focused field in a scroll-locked, fixed sheet: it scrolls the DOCUMENT,
1710
+ // and the sheet — fixed — travels with it.
1711
+ Object.defineProperty(window, 'scrollY', {
1712
+ configurable: true,
1713
+ value: 337,
1714
+ });
1715
+ fireEvent.scroll(window);
1716
+
1717
+ expect(scrollTo).toHaveBeenCalledWith(0, 0);
1718
+ // …and the control is still inside the safe area afterwards: the sheet's
1719
+ // own scroller holds it there, so putting the document back does not
1720
+ // hide what the browser was trying to reveal.
1721
+ expect(input.getBoundingClientRect().bottom).toBeLessThanOrEqual(
1722
+ 377 - 48,
1723
+ );
1724
+ expect(body.scrollTop).toBe(scrolledBy);
1725
+ Object.defineProperty(window, 'scrollY', {configurable: true, value: 0});
1726
+ });
1727
+
1728
+ it('leaves the document alone when no keyboard is measured', () => {
1729
+ mockVisualViewport(800);
1730
+ render(
1731
+ <BottomSheet
1732
+ isOpen
1733
+ onOpenChange={() => {}}
1734
+ label="Add a comment"
1735
+ height="tall">
1736
+ <input aria-label="Comment" />
1737
+ </BottomSheet>,
1738
+ );
1739
+ const body = getBody();
1740
+ const input = screen.getByRole('textbox', {name: 'Comment'});
1741
+ vi.spyOn(body, 'getBoundingClientRect').mockReturnValue(
1742
+ rect({top: 57, bottom: 700}),
1743
+ );
1744
+ vi.spyOn(input, 'getBoundingClientRect').mockReturnValue(
1745
+ rect({top: 600, bottom: 640}),
1746
+ );
1747
+ input.focus();
1748
+ const scrollTo = vi.mocked(window.scrollTo);
1749
+ scrollTo.mockClear();
1750
+
1751
+ // An ordinary page scroll with no keyboard up is the user's, not the
1752
+ // browser's, and a non-modal sheet leaves the page scrollable.
1753
+ Object.defineProperty(window, 'scrollY', {
1754
+ configurable: true,
1755
+ value: 120,
1756
+ });
1757
+ fireEvent.scroll(window);
1758
+
1759
+ expect(scrollTo).not.toHaveBeenCalled();
1760
+ Object.defineProperty(window, 'scrollY', {configurable: true, value: 0});
1761
+ });
1762
+
1763
+ it('delivers a browser-driven transition itself, then reveals the field', () => {
1764
+ mockIOSWebKit();
1765
+ mockVisualViewport(500);
1766
+ render(
1767
+ <BottomSheet
1768
+ isOpen
1769
+ onOpenChange={() => {}}
1770
+ label="Add a comment"
1771
+ height="tall">
1772
+ <input aria-label="Title" />
1773
+ <input aria-label="Comment" />
1774
+ </BottomSheet>,
1775
+ );
1776
+ const body = getBody();
1777
+ const title = screen.getByRole('textbox', {name: 'Title'});
1778
+ const comment = screen.getByRole('textbox', {name: 'Comment'});
1779
+ const scrolls: {
1780
+ top: number;
1781
+ behavior?: ScrollBehavior;
1782
+ focusLanded: boolean;
1783
+ }[] = [];
1784
+ Object.defineProperty(body, 'scrollBy', {
1785
+ configurable: true,
1786
+ value: (options: ScrollToOptions) => {
1787
+ body.scrollTop += options.top ?? 0;
1788
+ scrolls.push({
1789
+ top: options.top ?? 0,
1790
+ behavior: options.behavior,
1791
+ focusLanded: document.activeElement === comment,
1792
+ });
1793
+ },
1794
+ });
1795
+ vi.spyOn(body, 'getBoundingClientRect').mockReturnValue(
1796
+ rect({top: 100, bottom: 800}),
1797
+ );
1798
+ vi.spyOn(title, 'getBoundingClientRect').mockImplementation(() =>
1799
+ rect({top: 150 - body.scrollTop, bottom: 190 - body.scrollTop}),
1800
+ );
1801
+ vi.spyOn(comment, 'getBoundingClientRect').mockImplementation(() =>
1802
+ rect({top: 660 - body.scrollTop, bottom: 700 - body.scrollTop}),
1803
+ );
1804
+
1805
+ // The first field opens the keyboard and is already inside the safe
1806
+ // area, so nothing has to move for it.
1807
+ title.focus();
1808
+ expect(body.style.getPropertyValue('--_sheet-keyboard-inset')).toBe(
1809
+ '348px',
1810
+ );
1811
+ expect(scrolls).toEqual([]);
1812
+
1813
+ // A keyboard accessory "Next" or a Tab arrives as a blur naming the
1814
+ // destination. We deliver that focus with preventScroll — refusing the
1815
+ // browser's reveal — and then reveal the field ourselves, inside the
1816
+ // sheet.
1817
+ fireEvent.blur(title, {relatedTarget: comment});
1818
+
1819
+ expect(document.activeElement).toBe(comment);
1820
+ expect(scrolls).toEqual([
1821
+ {top: 248, behavior: 'smooth', focusLanded: true},
1822
+ ]);
1823
+ expect(body.scrollTop).toBe(248);
1824
+ });
1825
+
1826
+ it('does not scroll a browser-driven transition when the viewport is unobstructed', () => {
1827
+ mockIOSWebKit();
1828
+ mockVisualViewport(800);
1829
+ render(
1830
+ <BottomSheet
1831
+ isOpen
1832
+ onOpenChange={() => {}}
1833
+ label="Add a comment"
1834
+ height="tall">
1835
+ <input aria-label="Title" />
1836
+ <input aria-label="Comment" />
1837
+ </BottomSheet>,
1838
+ );
1839
+ const body = getBody();
1840
+ const title = screen.getByRole('textbox', {name: 'Title'});
1841
+ const comment = screen.getByRole('textbox', {name: 'Comment'});
1842
+ const scrolls: number[] = [];
1843
+ Object.defineProperty(body, 'scrollBy', {
1844
+ configurable: true,
1845
+ // Apply the scroll, as the real scroller would: a reveal that has
1846
+ // already happened must read as no distance left to travel, otherwise
1847
+ // every later reveal looks like a fresh one.
1848
+ value: (options: ScrollToOptions) => {
1849
+ body.scrollTop += options.top ?? 0;
1850
+ scrolls.push(options.top ?? 0);
1851
+ },
1852
+ });
1853
+ vi.spyOn(body, 'getBoundingClientRect').mockReturnValue(
1854
+ rect({top: 100, bottom: 800}),
1855
+ );
1856
+ vi.spyOn(title, 'getBoundingClientRect').mockReturnValue(
1857
+ rect({top: 150, bottom: 190}),
1858
+ );
1859
+ // Below the body's visible area, so a reveal has somewhere to travel.
1860
+ vi.spyOn(comment, 'getBoundingClientRect').mockImplementation(() =>
1861
+ rect({top: 860 - body.scrollTop, bottom: 900 - body.scrollTop}),
1862
+ );
1863
+
1864
+ title.focus();
1865
+ scrolls.length = 0;
1866
+ const scrollTo = vi.mocked(window.scrollTo);
1867
+ scrollTo.mockClear();
1868
+ fireEvent.blur(title, {relatedTarget: comment});
1869
+
1870
+ // With no keyboard there is no clearance to leave and nothing to race,
1871
+ // but the control still has to end up visible — brought there once, by
1872
+ // the sheet's own scroller, with the page left alone.
1873
+ expect(scrolls).toEqual([100]);
1874
+ expect(scrollTo).not.toHaveBeenCalled();
1875
+ });
1876
+
1877
+ it('leaves the page alone behind a non-modal sheet', () => {
1878
+ const layoutBottom = window.innerHeight;
1879
+ mockVisualViewport(500);
1880
+ render(
1881
+ <BottomSheet
1882
+ isOpen
1883
+ onOpenChange={() => {}}
1884
+ label="Add a comment"
1885
+ height="tall"
1886
+ hasScrim={false}>
1887
+ <input aria-label="Comment" />
1888
+ </BottomSheet>,
1889
+ );
1890
+ const body = getBody();
1891
+ const input = screen.getByRole('textbox', {name: 'Comment'});
1892
+ vi.spyOn(body, 'getBoundingClientRect').mockReturnValue(
1893
+ rect({top: 100, bottom: layoutBottom}),
1894
+ );
1895
+ vi.spyOn(input, 'getBoundingClientRect').mockImplementation(() =>
1896
+ rect({top: 600 - body.scrollTop, bottom: 640 - body.scrollTop}),
1897
+ );
1898
+ input.focus();
1899
+ const scrollTo = vi.mocked(window.scrollTo);
1900
+ scrollTo.mockClear();
1901
+
1902
+ // Without a scrim the page behind stays scrollable, so a document scroll
1903
+ // is the user's. Putting it back would fight them.
1904
+ Object.defineProperty(window, 'scrollY', {
1905
+ configurable: true,
1906
+ value: 200,
1907
+ });
1908
+ fireEvent.scroll(window);
1909
+
1910
+ expect(scrollTo).not.toHaveBeenCalled();
1911
+ Object.defineProperty(window, 'scrollY', {configurable: true, value: 0});
1912
+ });
1913
+
1914
+ it('holds the keyboard scroll range through a pan, on the blur path', () => {
1915
+ // A fully expanded Tall sheet — the only shape this hook runs in — is
1916
+ // pinned to the layout viewport bottom, so the body's bottom IS
1917
+ // innerHeight. Giving it a cushion below that would hide every bug in
1918
+ // this file: the cushion, not the measurement, would keep the overlap
1919
+ // positive under a pan.
1920
+ const layoutBottom = window.innerHeight;
1921
+ const viewport = mockVisualViewport(500);
1922
+ render(
1923
+ <BottomSheet
1924
+ isOpen
1925
+ onOpenChange={() => {}}
1926
+ label="Add a comment"
1927
+ height="tall">
1928
+ <input aria-label="Comment" />
1929
+ </BottomSheet>,
1930
+ );
1931
+ const body = getBody();
1932
+ const input = screen.getByRole('textbox', {name: 'Comment'});
1933
+ vi.spyOn(body, 'getBoundingClientRect').mockReturnValue(
1934
+ rect({top: 100, bottom: layoutBottom}),
1935
+ );
1936
+ vi.spyOn(input, 'getBoundingClientRect').mockImplementation(() =>
1937
+ rect({top: 660 - body.scrollTop, bottom: 700 - body.scrollTop}),
1938
+ );
1939
+ input.focus();
1940
+ const inset = body.style.getPropertyValue('--_sheet-keyboard-inset');
1941
+ expect(inset).toBe(`${layoutBottom - (500 - 48)}px`);
1942
+
1943
+ // The browser pans the page up to reveal a field: the same 500px of
1944
+ // visible page, now offset so its bottom edge coincides with the layout
1945
+ // viewport bottom — with the keyboard still on screen. Read the bottom
1946
+ // and that is indistinguishable from the keyboard closing.
1947
+ viewport.offsetTop = window.innerHeight - 500;
1948
+ input.blur();
1949
+
1950
+ // The keyboard did not change size, so neither does the scroll range.
1951
+ expect(body.style.getPropertyValue('--_sheet-keyboard-inset')).toBe(
1952
+ inset,
1953
+ );
1954
+ });
1955
+
1956
+ it('keeps defending a pinned sheet after the browser has panned it once', () => {
1957
+ // The regression that made the device symptom permanent: one pan read as
1958
+ // "no keyboard" cleared the inset AND cleared hasKeyboardLayout, which
1959
+ // disarms the head start below — so every subsequent reveal panned, and
1960
+ // the sheet never recovered.
1961
+ const layoutBottom = window.innerHeight;
1962
+ mockIOSWebKit();
1963
+ const viewport = mockVisualViewport(500);
1964
+ render(
1965
+ <BottomSheet
1966
+ isOpen
1967
+ onOpenChange={() => {}}
1968
+ label="Add a comment"
1969
+ height="tall">
1970
+ <input aria-label="Title" />
1971
+ <input aria-label="Comment" />
1972
+ </BottomSheet>,
1973
+ );
1974
+ const body = getBody();
1975
+ const title = screen.getByRole('textbox', {name: 'Title'});
1976
+ const comment = screen.getByRole('textbox', {name: 'Comment'});
1977
+ const scrolls: {top: number; focusLanded: boolean}[] = [];
1978
+ Object.defineProperty(body, 'scrollBy', {
1979
+ configurable: true,
1980
+ value: (options: ScrollToOptions) => {
1981
+ body.scrollTop += options.top ?? 0;
1982
+ scrolls.push({
1983
+ top: options.top ?? 0,
1984
+ focusLanded: document.activeElement === comment,
1985
+ });
1986
+ },
1987
+ });
1988
+ vi.spyOn(body, 'getBoundingClientRect').mockReturnValue(
1989
+ rect({top: 100, bottom: layoutBottom}),
1990
+ );
1991
+ vi.spyOn(title, 'getBoundingClientRect').mockReturnValue(
1992
+ rect({top: 150, bottom: 190}),
1993
+ );
1994
+ vi.spyOn(comment, 'getBoundingClientRect').mockImplementation(() =>
1995
+ rect({top: 700 - body.scrollTop, bottom: 740 - body.scrollTop}),
1996
+ );
1997
+
1998
+ title.focus();
1999
+ const inset = body.style.getPropertyValue('--_sheet-keyboard-inset');
2000
+ expect(inset).toBe(`${layoutBottom - (500 - 48)}px`);
2001
+
2002
+ viewport.offsetTop = layoutBottom - 500;
2003
+ void act(() => viewport.dispatchEvent(new Event('resize')));
2004
+ expect(body.style.getPropertyValue('--_sheet-keyboard-inset')).toBe(
2005
+ inset,
2006
+ );
2007
+
2008
+ // And the next browser-driven transition is still claimed and revealed,
2009
+ // by a distance measured against the unshifted keyboard boundary.
2010
+ scrolls.length = 0;
2011
+ fireEvent.blur(title, {relatedTarget: comment});
2012
+
2013
+ expect(document.activeElement).toBe(comment);
2014
+ expect(scrolls[0]).toEqual({
2015
+ top: 740 - (500 - 48),
2016
+ focusLanded: true,
2017
+ });
2018
+ });
2019
+ });
2020
+
2021
+ describe('focus restore', () => {
2022
+ function Harness() {
2023
+ const [isOpen, setIsOpen] = useState(false);
2024
+ return (
2025
+ <>
2026
+ <button type="button" onClick={() => setIsOpen(true)}>
2027
+ Open sheet
2028
+ </button>
2029
+ <BottomSheet isOpen={isOpen} onOpenChange={setIsOpen} label="Filters">
2030
+ <button type="button" onClick={() => setIsOpen(false)}>
2031
+ Done
2032
+ </button>
2033
+ </BottomSheet>
2034
+ </>
2035
+ );
2036
+ }
2037
+
2038
+ it('restores focus to the opener after close', () => {
2039
+ render(<Harness />);
2040
+ const opener = screen.getByRole('button', {name: 'Open sheet'});
2041
+ opener.focus();
2042
+ fireEvent.click(opener);
2043
+ fireEvent.click(screen.getByRole('button', {name: 'Done'}));
2044
+ finishSheetExit();
2045
+
2046
+ expect(document.activeElement).toBe(opener);
2047
+ });
2048
+ });
2049
+
2050
+ describe('initial focus', () => {
2051
+ it('focuses the sheet panel on open, not the first control', () => {
2052
+ render(
2053
+ <BottomSheet isOpen onOpenChange={() => {}} label="Filters">
2054
+ <button type="button">First action</button>
2055
+ </BottomSheet>,
2056
+ );
2057
+ const panel = getSheet();
2058
+ expect(document.activeElement).toBe(panel);
2059
+ expect(document.activeElement).not.toBe(
2060
+ screen.getByRole('button', {name: 'First action'}),
2061
+ );
2062
+ });
2063
+
2064
+ it('honors a descendant with data-autofocus', () => {
2065
+ render(
2066
+ <BottomSheet isOpen onOpenChange={() => {}} label="Filters">
2067
+ <input data-autofocus aria-label="Search" />
2068
+ </BottomSheet>,
2069
+ );
2070
+ expect(document.activeElement).toBe(
2071
+ screen.getByRole('textbox', {name: 'Search'}),
2072
+ );
2073
+ });
2074
+ });
2075
+
2076
+ describe('accessible name', () => {
2077
+ it('warns in development when label is empty', () => {
2078
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
2079
+ render(
2080
+ <BottomSheet isOpen onOpenChange={() => {}} label="">
2081
+ Content
2082
+ </BottomSheet>,
2083
+ );
2084
+ expect(
2085
+ warn.mock.calls.some(args => String(args[0]).includes('BottomSheet')),
2086
+ ).toBe(true);
2087
+ warn.mockRestore();
2088
+ });
2089
+ });
2090
+
2091
+ describe('reduced motion', () => {
2092
+ it('opens without throwing when prefers-reduced-motion is set', () => {
2093
+ vi.stubGlobal(
2094
+ 'matchMedia',
2095
+ vi.fn().mockReturnValue({
2096
+ matches: true,
2097
+ media: '(prefers-reduced-motion: reduce)',
2098
+ onchange: null,
2099
+ addEventListener: vi.fn(),
2100
+ removeEventListener: vi.fn(),
2101
+ addListener: vi.fn(),
2102
+ removeListener: vi.fn(),
2103
+ dispatchEvent: vi.fn(),
2104
+ }),
2105
+ );
2106
+ render(
2107
+ <BottomSheet isOpen onOpenChange={() => {}} label="Filters">
2108
+ Content
2109
+ </BottomSheet>,
2110
+ );
2111
+ expect(screen.getByRole('dialog')).toBeInTheDocument();
2112
+ });
2113
+ });
2114
+ });