@blotch/dolan 22.2.3 → 22.2.5

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 (227) hide show
  1. package/dist/commands/add.js +9 -1
  2. package/dist/commands/add.js.map +1 -1
  3. package/dist/commands/add.test.js +38 -3
  4. package/dist/commands/add.test.js.map +1 -1
  5. package/dist/commands/doctor.js +258 -21
  6. package/dist/commands/doctor.js.map +1 -1
  7. package/dist/commands/doctor.test.js +425 -11
  8. package/dist/commands/doctor.test.js.map +1 -1
  9. package/dist/commands/init.d.ts +2 -2
  10. package/dist/commands/init.js +38 -18
  11. package/dist/commands/init.js.map +1 -1
  12. package/dist/commands/init.test.js +105 -2
  13. package/dist/commands/init.test.js.map +1 -1
  14. package/dist/commands/theme.js +1 -1
  15. package/dist/commands/theme.js.map +1 -1
  16. package/dist/commands/update.d.ts +7 -0
  17. package/dist/commands/update.js +194 -50
  18. package/dist/commands/update.js.map +1 -1
  19. package/dist/commands/update.test.js +570 -3
  20. package/dist/commands/update.test.js.map +1 -1
  21. package/dist/lockfile.d.ts +17 -0
  22. package/dist/lockfile.js +56 -1
  23. package/dist/lockfile.js.map +1 -1
  24. package/dist/lockfile.test.d.ts +1 -0
  25. package/dist/lockfile.test.js +65 -0
  26. package/dist/lockfile.test.js.map +1 -0
  27. package/dist/registry.d.ts +6 -0
  28. package/dist/registry.js +119 -11
  29. package/dist/registry.js.map +1 -1
  30. package/dist/registry.test.js +61 -6
  31. package/dist/registry.test.js.map +1 -1
  32. package/dist/test-utils.d.ts +27 -0
  33. package/dist/test-utils.js +45 -1
  34. package/dist/test-utils.js.map +1 -1
  35. package/dist/testing/fire-lynx-event.d.ts +14 -0
  36. package/dist/testing/fire-lynx-event.js +19 -0
  37. package/dist/testing/fire-lynx-event.js.map +1 -0
  38. package/dist/testing/fire-lynx-event.test.d.ts +1 -0
  39. package/dist/testing/fire-lynx-event.test.js +44 -0
  40. package/dist/testing/fire-lynx-event.test.js.map +1 -0
  41. package/dist/testing/lynx-main-thread.d.ts +22 -0
  42. package/dist/testing/lynx-main-thread.js +11 -0
  43. package/dist/testing/lynx-main-thread.js.map +1 -0
  44. package/dist/testing/lynx-main-thread.test.d.ts +1 -0
  45. package/dist/testing/lynx-main-thread.test.js +24 -0
  46. package/dist/testing/lynx-main-thread.test.js.map +1 -0
  47. package/dist/testing/set-input-signal.d.ts +5 -4
  48. package/dist/testing/set-input-signal.js +5 -4
  49. package/dist/testing/set-input-signal.js.map +1 -1
  50. package/dist/ui/boolean-inputs.test.ts +76 -0
  51. package/dist/ui/components/accordion/accordion.test.ts +158 -144
  52. package/dist/ui/components/accordion/accordion.ts +19 -14
  53. package/dist/ui/components/action-sheet/action-sheet.test.ts +637 -183
  54. package/dist/ui/components/action-sheet/action-sheet.ts +339 -55
  55. package/dist/ui/components/action-sheet/index.ts +1 -0
  56. package/dist/ui/components/alert/alert.test.ts +67 -19
  57. package/dist/ui/components/alert/alert.ts +14 -16
  58. package/dist/ui/components/alert-dialog/alert-dialog.test.ts +740 -129
  59. package/dist/ui/components/alert-dialog/alert-dialog.ts +672 -58
  60. package/dist/ui/components/alert-dialog/index.ts +5 -0
  61. package/dist/ui/components/area-chart/area-chart.test.ts +81 -1
  62. package/dist/ui/components/area-chart/area-chart.ts +3 -2
  63. package/dist/ui/components/avatar/avatar.test.ts +89 -45
  64. package/dist/ui/components/badge/badge.test.ts +57 -23
  65. package/dist/ui/components/badge/badge.ts +14 -15
  66. package/dist/ui/components/bar-chart/bar-chart.test.ts +82 -1
  67. package/dist/ui/components/bottom-sheet/bottom-sheet.test.ts +1314 -273
  68. package/dist/ui/components/bottom-sheet/bottom-sheet.ts +1046 -119
  69. package/dist/ui/components/bottom-sheet/index.ts +7 -1
  70. package/dist/ui/components/button/button.test.ts +646 -2
  71. package/dist/ui/components/button/button.ts +443 -64
  72. package/dist/ui/components/button/index.ts +2 -0
  73. package/dist/ui/components/button-group/button-group.test.ts +528 -2
  74. package/dist/ui/components/button-group/button-group.ts +428 -70
  75. package/dist/ui/components/button-group/index.ts +3 -0
  76. package/dist/ui/components/candlestick-chart/candlestick-chart.test.ts +82 -1
  77. package/dist/ui/components/card/card.test.ts +97 -61
  78. package/dist/ui/components/card/card.ts +2 -1
  79. package/dist/ui/components/chart/chart-presets.test.ts +197 -1
  80. package/dist/ui/components/chart/chart-presets.ts +14 -7
  81. package/dist/ui/components/chart/chart-theme.ts +12 -0
  82. package/dist/ui/components/chart/chart.test.ts +1031 -0
  83. package/dist/ui/components/chart/chart.ts +66 -46
  84. package/dist/ui/components/checkbox/checkbox.test.ts +124 -42
  85. package/dist/ui/components/checkbox/checkbox.ts +25 -24
  86. package/dist/ui/components/collapsible/collapsible.test.ts +158 -81
  87. package/dist/ui/components/collapsible/collapsible.ts +21 -15
  88. package/dist/ui/components/context-menu/context-menu.test.ts +224 -0
  89. package/dist/ui/components/context-menu/context-menu.ts +110 -0
  90. package/dist/ui/components/context-menu/index.ts +1 -0
  91. package/dist/ui/components/dialog/dialog.test.ts +210 -152
  92. package/dist/ui/components/dialog/dialog.ts +35 -29
  93. package/dist/ui/components/dock/dock-scroll.test.ts +130 -0
  94. package/dist/ui/components/dock/dock-scroll.ts +142 -0
  95. package/dist/ui/components/dock/dock-search.test.ts +594 -0
  96. package/dist/ui/components/dock/dock.test.ts +752 -0
  97. package/dist/ui/components/dock/dock.ts +1438 -0
  98. package/dist/ui/components/dock/index.ts +2 -0
  99. package/dist/ui/components/dynamic-color/dynamic-color.test.ts +144 -0
  100. package/dist/ui/components/dynamic-color/dynamic-color.ts +146 -0
  101. package/dist/ui/components/dynamic-color/index.ts +5 -0
  102. package/dist/ui/components/empty-state/empty-state.test.ts +28 -39
  103. package/dist/ui/components/glass/concentric-radius.test.ts +13 -0
  104. package/dist/ui/components/glass/concentric-radius.ts +17 -0
  105. package/dist/ui/components/glass/glass-design.test.ts +78 -0
  106. package/dist/ui/components/glass/glass-design.ts +119 -0
  107. package/dist/ui/components/glass/glass.test.ts +376 -0
  108. package/dist/ui/components/glass/glass.ts +430 -0
  109. package/dist/ui/components/glass/index.ts +19 -0
  110. package/dist/ui/components/icon/icon-theme.test.ts +64 -0
  111. package/dist/ui/components/icon/icon-theme.ts +70 -16
  112. package/dist/ui/components/icon/icon.test.ts +52 -41
  113. package/dist/ui/components/input/input.test.ts +178 -101
  114. package/dist/ui/components/input/input.ts +17 -13
  115. package/dist/ui/components/label/label.test.ts +11 -24
  116. package/dist/ui/components/label/label.ts +8 -2
  117. package/dist/ui/components/line-chart/line-chart.test.ts +76 -1
  118. package/dist/ui/components/line-chart/line-chart.ts +3 -2
  119. package/dist/ui/components/list/list.test.ts +80 -49
  120. package/dist/ui/components/list/list.ts +5 -4
  121. package/dist/ui/components/menu/index.ts +25 -0
  122. package/dist/ui/components/menu/menu-placement.test.ts +153 -0
  123. package/dist/ui/components/menu/menu-placement.ts +159 -0
  124. package/dist/ui/components/menu/menu-popup.test.ts +636 -0
  125. package/dist/ui/components/menu/menu-popup.ts +565 -0
  126. package/dist/ui/components/menu/menu.test.ts +523 -0
  127. package/dist/ui/components/menu/menu.ts +455 -0
  128. package/dist/ui/components/nav-drawer/nav-drawer.test.ts +425 -260
  129. package/dist/ui/components/nav-drawer/nav-drawer.ts +75 -58
  130. package/dist/ui/components/navigation-bar/index.ts +1 -0
  131. package/dist/ui/components/navigation-bar/navigation-bar.test.ts +425 -0
  132. package/dist/ui/components/navigation-bar/navigation-bar.ts +411 -0
  133. package/dist/ui/components/pagination/pagination.test.ts +136 -86
  134. package/dist/ui/components/popover/index.ts +9 -0
  135. package/dist/ui/components/popover/popover-panel.test.ts +735 -0
  136. package/dist/ui/components/popover/popover-panel.ts +603 -0
  137. package/dist/ui/components/popover/popover.test.ts +286 -0
  138. package/dist/ui/components/popover/popover.ts +207 -0
  139. package/dist/ui/components/progress/progress.test.ts +102 -46
  140. package/dist/ui/components/progress/progress.ts +54 -55
  141. package/dist/ui/components/radio-group/radio-group.test.ts +128 -83
  142. package/dist/ui/components/radio-group/radio-group.ts +26 -25
  143. package/dist/ui/components/scatter-chart/scatter-chart.test.ts +74 -1
  144. package/dist/ui/components/scroll-area/scroll-area.test.ts +72 -36
  145. package/dist/ui/components/scroll-area/scroll-area.ts +4 -3
  146. package/dist/ui/components/search-field/index.ts +15 -0
  147. package/dist/ui/components/search-field/keyboard.test.ts +147 -0
  148. package/dist/ui/components/search-field/keyboard.ts +123 -0
  149. package/dist/ui/components/search-field/search-field.test.ts +549 -0
  150. package/dist/ui/components/search-field/search-field.ts +628 -0
  151. package/dist/ui/components/segmented-control/index.ts +8 -0
  152. package/dist/ui/components/segmented-control/segmented-control.test.ts +861 -0
  153. package/dist/ui/components/segmented-control/segmented-control.ts +884 -0
  154. package/dist/ui/components/select/select.test.ts +138 -78
  155. package/dist/ui/components/select/select.ts +19 -12
  156. package/dist/ui/components/separator/separator.test.ts +18 -17
  157. package/dist/ui/components/sheet/sheet.test.ts +151 -11
  158. package/dist/ui/components/sheet/sheet.ts +68 -5
  159. package/dist/ui/components/skeleton/skeleton.test.ts +24 -7
  160. package/dist/ui/components/skeleton/skeleton.ts +10 -16
  161. package/dist/ui/components/slider/index.ts +1 -0
  162. package/dist/ui/components/slider/slider.test.ts +695 -0
  163. package/dist/ui/components/slider/slider.ts +901 -0
  164. package/dist/ui/components/spinner/spinner.test.ts +64 -46
  165. package/dist/ui/components/spinner/spinner.ts +16 -19
  166. package/dist/ui/components/switch/index.ts +1 -1
  167. package/dist/ui/components/switch/switch.test.ts +534 -48
  168. package/dist/ui/components/switch/switch.ts +613 -48
  169. package/dist/ui/components/tab-bar/index.ts +1 -0
  170. package/dist/ui/components/tab-bar/tab-bar.test.ts +280 -0
  171. package/dist/ui/components/tab-bar/tab-bar.ts +230 -0
  172. package/dist/ui/components/tabs/index.ts +7 -1
  173. package/dist/ui/components/tabs/tabs.test.ts +484 -150
  174. package/dist/ui/components/tabs/tabs.ts +344 -149
  175. package/dist/ui/components/textarea/textarea.test.ts +206 -100
  176. package/dist/ui/components/textarea/textarea.ts +16 -13
  177. package/dist/ui/components/toast/toast.test.ts +434 -350
  178. package/dist/ui/components/toast/toast.ts +42 -51
  179. package/dist/ui/components/toggle/index.ts +1 -0
  180. package/dist/ui/components/toggle/toggle.test.ts +559 -146
  181. package/dist/ui/components/toggle/toggle.ts +241 -26
  182. package/dist/ui/components/toolbar/bar-scroll.test.ts +310 -0
  183. package/dist/ui/components/toolbar/bar-scroll.ts +372 -0
  184. package/dist/ui/components/toolbar/index.ts +12 -0
  185. package/dist/ui/components/toolbar/toolbar.test.ts +438 -0
  186. package/dist/ui/components/toolbar/toolbar.ts +535 -0
  187. package/dist/ui/theme/chart-colors.test.ts +8 -3
  188. package/dist/ui/theme/dark.css +38 -2
  189. package/dist/ui/theme/default.css +55 -7
  190. package/dist/ui/theme/icon-colors.test.ts +8 -3
  191. package/dist/ui/theme/rgb-channels.test.ts +84 -0
  192. package/dist/ui/theme/tailwind-build.test.ts +114 -0
  193. package/dist/ui/theme/tailwind-css.test.ts +83 -0
  194. package/dist/ui/theme/tailwind-plugin.test.ts +42 -2
  195. package/dist/ui/theme/tailwind-plugin.ts +93 -29
  196. package/dist/ui/theme/tailwind.css +113 -0
  197. package/dist/ui/utils/animate.test.ts +13 -1
  198. package/dist/utils/analyze.d.ts +19 -0
  199. package/dist/utils/analyze.js +16 -0
  200. package/dist/utils/analyze.js.map +1 -1
  201. package/dist/utils/analyze.test.js +14 -1
  202. package/dist/utils/analyze.test.js.map +1 -1
  203. package/dist/utils/installed.d.ts +25 -1
  204. package/dist/utils/installed.js +33 -0
  205. package/dist/utils/installed.js.map +1 -1
  206. package/dist/utils/installed.test.js +55 -1
  207. package/dist/utils/installed.test.js.map +1 -1
  208. package/dist/utils/resolve-paths.d.ts +4 -4
  209. package/dist/utils/resolve-paths.js +5 -7
  210. package/dist/utils/resolve-paths.js.map +1 -1
  211. package/dist/utils/resolve-paths.test.js +9 -5
  212. package/dist/utils/resolve-paths.test.js.map +1 -1
  213. package/dist/utils/tailwind.d.ts +45 -0
  214. package/dist/utils/tailwind.js +90 -0
  215. package/dist/utils/tailwind.js.map +1 -0
  216. package/dist/utils/tailwind.test.d.ts +1 -0
  217. package/dist/utils/tailwind.test.js +134 -0
  218. package/dist/utils/tailwind.test.js.map +1 -0
  219. package/package.json +9 -1
  220. package/dist/ui/components/area-chart/area-chart-render.test.ts +0 -63
  221. package/dist/ui/components/bar-chart/bar-chart-render.test.ts +0 -58
  222. package/dist/ui/components/button/button-render.test.ts +0 -132
  223. package/dist/ui/components/button-group/button-group-render.test.ts +0 -146
  224. package/dist/ui/components/candlestick-chart/candlestick-chart-render.test.ts +0 -59
  225. package/dist/ui/components/chart/chart-render.test.ts +0 -730
  226. package/dist/ui/components/line-chart/line-chart-render.test.ts +0 -83
  227. package/dist/ui/components/scatter-chart/scatter-chart-render.test.ts +0 -64
@@ -1,345 +1,1386 @@
1
1
  import { Component, signal } from '@angular/core';
2
- import { render, waitForUpdate } from '@blotch/angular-lynx-testing-library';
3
- import { LYNX_ELEMENTS } from '@blotch/angular-lynx';
4
- import { describe, expect, it, vi } from 'vitest';
2
+ import {
3
+ fireEvent,
4
+ render,
5
+ waitForUpdate,
6
+ } from '@blotch/angular-lynx-testing-library';
7
+ import { LYNX_ELEMENTS, LynxAccessibilitySettings } from '@blotch/angular-lynx';
8
+ import {
9
+ type MockInstance,
10
+ afterEach,
11
+ beforeEach,
12
+ describe,
13
+ expect,
14
+ it,
15
+ vi,
16
+ } from 'vitest';
5
17
  import { DURATION } from '../../utils/animate';
6
- import { setInputSignal } from '../../../test-utils/set-input-signal';
7
- import { UiBottomSheet } from './bottom-sheet';
18
+ import { fireLynxEvent } from '../../../test-utils/fire-lynx-event';
19
+ import {
20
+ type LynxMainThread,
21
+ lynxMainThread,
22
+ } from '../../../test-utils/lynx-main-thread';
23
+ import { UiGlass } from '../glass/glass';
24
+ import {
25
+ type BottomSheetVariant,
26
+ type SheetDetent,
27
+ UiBottomSheet,
28
+ UiSheetSource,
29
+ } from './bottom-sheet';
8
30
 
9
31
  @Component({
10
32
  imports: [UiBottomSheet, LYNX_ELEMENTS],
11
- template: `<ui-bottom-sheet [(open)]="open"
12
- ><text>Panel</text></ui-bottom-sheet
13
- >`,
33
+ template: `
34
+ <ui-bottom-sheet [(open)]="open" class="max-h-96" (closed)="closed()">
35
+ <text>Panel</text>
36
+ </ui-bottom-sheet>
37
+ `,
14
38
  })
15
39
  class BottomSheetHost {
16
- open = true;
40
+ readonly open = signal(false);
41
+ readonly closed = vi.fn();
17
42
  }
18
43
 
44
+ /**
45
+ * The Lynx function an element's `invoke()` calls to run one of its UI
46
+ * methods, such as moving a screen reader's focus. The testing library
47
+ * defines it on the global object.
48
+ */
49
+ type InvokeUIMethod = {
50
+ __InvokeUIMethod: (
51
+ element: Element,
52
+ method: string,
53
+ params: Record<string, unknown>,
54
+ callback: (result: { code: number }) => void,
55
+ ) => void;
56
+ };
57
+
58
+ /** `__ElementAnimate`'s first argument for starting an animation. */
59
+ const START = 0;
60
+ /** `__ElementAnimate`'s first argument for canceling an animation. */
61
+ const CANCEL = 3;
62
+
63
+ const renderSheet = async () => {
64
+ const result = await render(BottomSheetHost);
65
+ const { container } = result;
66
+ return {
67
+ ...result,
68
+ host: result.componentRef.instance as BottomSheetHost,
69
+ overlay: container.querySelector('overlay')!,
70
+ backdrop: container.querySelector('overlay > view')!,
71
+ panel: container.querySelector('.rounded-t-2xl')!,
72
+ // The strip around the grabber pill, which takes the drag.
73
+ handle: container.querySelector('.bg-muted')!.parentElement!,
74
+ };
75
+ };
76
+
77
+ /**
78
+ * Opens the sheet and waits out its open sequence: the overlay shows on one
79
+ * tick, and the panel slides in on the next.
80
+ */
81
+ const openSheet = async (host: BottomSheetHost) => {
82
+ host.open.set(true);
83
+ for (let i = 0; i < 3; i++) await waitForUpdate();
84
+ };
85
+
86
+ /**
87
+ * Waits for the close sequence to finish: the panel slides out, then the
88
+ * overlay hides and `closed` fires. Polled rather than timed, since the
89
+ * sequence only starts on the update after `open` changes.
90
+ */
91
+ const waitForClose = async (host: BottomSheetHost) => {
92
+ await vi.waitFor(() => expect(host.closed).toHaveBeenCalled());
93
+ await waitForUpdate();
94
+ };
95
+
96
+ /**
97
+ * The panel's live drag offset, from its inline transform.
98
+ */
99
+ const transformOf = (panel: Element) =>
100
+ (panel as HTMLElement).style.getPropertyValue('transform');
101
+
102
+ /**
103
+ * The touch fields a handle's touch event carries.
104
+ */
105
+ const touchAt = (clientY: number) => ({
106
+ eventType: 'catchEvent',
107
+ touches: [{ clientY }],
108
+ });
109
+
19
110
  describe('UiBottomSheet', () => {
111
+ // Watched per test: the testing library resets the main thread after each.
112
+ let animate: MockInstance<LynxMainThread['__ElementAnimate']>;
113
+ beforeEach(() => {
114
+ animate = vi.spyOn(lynxMainThread(), '__ElementAnimate');
115
+ });
116
+ afterEach(() => animate.mockRestore());
117
+
118
+ it('stays hidden until opened, then shows the panel and its content', async () => {
119
+ const { host, overlay, getByText } = await renderSheet();
120
+ expect(overlay.getAttribute('style')).toContain('display: none');
121
+ expect(overlay.getAttribute('visible')).toBe('false');
122
+
123
+ await openSheet(host);
124
+ expect(overlay.getAttribute('style')).not.toContain('display: none');
125
+ expect(overlay.getAttribute('visible')).toBe('true');
126
+ expect(getByText('Panel')).toBeTruthy();
127
+ });
128
+
129
+ it("adds the class to the panel's own", async () => {
130
+ const { panel } = await renderSheet();
131
+ expect(panel.classList).toContain('max-h-96');
132
+ expect(panel.classList).toContain('bg-background');
133
+ });
134
+
135
+ it('fades the backdrop in and slides the panel up when opened', async () => {
136
+ const { host, backdrop, panel } = await renderSheet();
137
+ await openSheet(host);
138
+ expect(animate).toHaveBeenCalledWith(backdrop, [
139
+ START,
140
+ expect.any(String),
141
+ [{ opacity: 0 }, { opacity: 1 }],
142
+ expect.objectContaining({ duration: DURATION.normal }),
143
+ ]);
144
+ expect(animate).toHaveBeenCalledWith(panel, [
145
+ START,
146
+ expect.any(String),
147
+ [
148
+ { transform: 'translateY(100%)', opacity: 0.8 },
149
+ { transform: 'translateY(0%)', opacity: 1 },
150
+ ],
151
+ expect.objectContaining({ duration: DURATION.slow }),
152
+ ]);
153
+ });
154
+
155
+ it('moves a screen reader into the panel and keeps it there while open', async () => {
156
+ const invoke = vi.spyOn(
157
+ globalThis as unknown as InvokeUIMethod,
158
+ '__InvokeUIMethod',
159
+ );
160
+ const { host, panel } = await renderSheet();
161
+ expect(panel.getAttribute('accessibility-exclusive-focus')).toBe('false');
162
+
163
+ await openSheet(host);
164
+ expect(invoke).toHaveBeenCalledWith(
165
+ panel,
166
+ 'requestAccessibilityFocus',
167
+ expect.anything(),
168
+ expect.any(Function),
169
+ );
170
+ expect(panel.getAttribute('accessibility-exclusive-focus')).toBe('true');
171
+ invoke.mockRestore();
172
+ });
173
+
174
+ it('closes when the backdrop is tapped, sliding the panel down', async () => {
175
+ const { host, overlay, backdrop, panel } = await renderSheet();
176
+ await openSheet(host);
177
+
178
+ fireEvent.tap(backdrop);
179
+ expect(host.open()).toBe(false);
180
+ await waitForClose(host);
181
+ expect(animate).toHaveBeenCalledWith(backdrop, [
182
+ START,
183
+ expect.any(String),
184
+ [{ opacity: 1 }, { opacity: 0 }],
185
+ expect.objectContaining({ duration: DURATION.normal }),
186
+ ]);
187
+ expect(animate).toHaveBeenCalledWith(panel, [
188
+ START,
189
+ expect.any(String),
190
+ [
191
+ { transform: 'translateY(0%)', opacity: 1 },
192
+ { transform: 'translateY(100%)', opacity: 0.8 },
193
+ ],
194
+ expect.objectContaining({ duration: DURATION.normal }),
195
+ ]);
196
+ // Once the slide is over, the overlay hides and `closed` fires, only for
197
+ // this close: a sheet that starts closed hasn't closed before.
198
+ expect(overlay.getAttribute('style')).toContain('display: none');
199
+ expect(host.closed).toHaveBeenCalledOnce();
200
+ });
201
+
202
+ it("closes on Android's back button", async () => {
203
+ const { host, overlay } = await renderSheet();
204
+ await openSheet(host);
205
+ // While an overlay shows, it takes the press and fires requestclose.
206
+ fireLynxEvent(overlay, 'requestclose');
207
+ expect(host.open()).toBe(false);
208
+ await waitForClose(host);
209
+ expect(host.closed).toHaveBeenCalledOnce();
210
+ });
211
+
212
+ it('stays open when the panel is tapped', async () => {
213
+ const { host, panel } = await renderSheet();
214
+ await openSheet(host);
215
+ // The panel catches its taps, so they never reach the backdrop.
216
+ fireEvent.tap(panel, { eventType: 'catchEvent' });
217
+ expect(host.open()).toBe(true);
218
+ });
219
+
20
220
  it('stops its close timers when it is destroyed while closing', async () => {
21
221
  const warn = vi.spyOn(console, 'warn');
22
- const { componentRef, unmount } = await render(UiBottomSheet);
23
- const inst = componentRef.instance as UiBottomSheet;
24
- inst.open.set(true);
25
- for (let i = 0; i < 5; i++) await waitForUpdate();
26
- inst.open.set(false);
222
+ const { host, unmount } = await renderSheet();
223
+ await openSheet(host);
224
+ host.open.set(false);
27
225
  await waitForUpdate();
28
226
  // Destroyed during the close animation, as an `@if` around the sheet
29
227
  // would do. Its close timer (DURATION.normal + 20) used to fire anyway
30
228
  // and emit `closed` from the destroyed output.
31
229
  unmount();
32
- await new Promise((r) => setTimeout(r, DURATION.normal + 50));
230
+ await new Promise((resolve) => setTimeout(resolve, DURATION.normal + 50));
33
231
  const warnings = warn.mock.calls.map(([message]) => String(message));
34
232
  warn.mockRestore();
35
233
  expect(warnings.filter((message) => message.includes('NG0953'))).toEqual(
36
234
  [],
37
235
  );
236
+ expect(host.closed).not.toHaveBeenCalled();
38
237
  });
39
238
 
40
- it('moves a screen reader into the panel and keeps it there while open', async () => {
41
- const { componentRef, container } = await render(UiBottomSheet);
42
- const inst = componentRef.instance as UiBottomSheet;
43
- const panel = container.querySelector(
44
- '[accessibility-exclusive-focus]',
45
- ) as Element & { invoke?: unknown };
46
- expect(panel.getAttribute('accessibility-exclusive-focus')).toBe('false');
47
- const invoke = vi.fn();
48
- panel.invoke = invoke;
49
- (inst as unknown as { backdropRef: unknown }).backdropRef = signal({
50
- nativeElement: panel,
239
+ describe('dragged by its handle', () => {
240
+ it('follows the finger down, lightening the backdrop as it goes', async () => {
241
+ const { host, backdrop, panel, handle } = await renderSheet();
242
+ await openSheet(host);
243
+ animate.mockClear();
244
+
245
+ fireEvent.touchstart(handle, touchAt(100));
246
+ // The open slide holds the panel in place until it's canceled.
247
+ expect(animate).toHaveBeenCalledWith(panel, [CANCEL, expect.any(String)]);
248
+ fireEvent.touchmove(handle, touchAt(200));
249
+ await waitForUpdate();
250
+ expect(transformOf(panel)).toBe('translateY(100px)');
251
+ // 100px of the 500px over which the backdrop clears: 4/5 of its dim.
252
+ expect(backdrop.getAttribute('style')).toContain('rgba(0, 0, 0, 0.4)');
51
253
  });
52
- (inst as unknown as { panelRef: unknown }).panelRef = signal({
53
- nativeElement: panel,
254
+
255
+ it('closes when released past the threshold, sliding on from the finger', async () => {
256
+ const { host, panel, handle } = await renderSheet();
257
+ await openSheet(host);
258
+
259
+ fireEvent.touchstart(handle, touchAt(100));
260
+ fireEvent.touchmove(handle, touchAt(300));
261
+ fireEvent.touchend(handle, { eventType: 'catchEvent' });
262
+ expect(host.open()).toBe(false);
263
+ await waitForClose(host);
264
+ expect(animate).toHaveBeenCalledWith(panel, [
265
+ START,
266
+ expect.any(String),
267
+ [
268
+ { transform: 'translateY(200px)', opacity: 1 },
269
+ { transform: 'translateY(600px)', opacity: 0.8 },
270
+ ],
271
+ expect.objectContaining({ duration: DURATION.normal }),
272
+ ]);
273
+ expect(host.closed).toHaveBeenCalledOnce();
274
+ // Hidden, it waits below the screen, and opens again back in place.
275
+ expect(transformOf(panel)).toBe('translateY(110%)');
276
+ await openSheet(host);
277
+ expect(transformOf(panel)).toBe('translateY(0px)');
278
+ });
279
+
280
+ it('springs back when released or canceled short of the threshold', async () => {
281
+ const { host, panel, handle } = await renderSheet();
282
+ await openSheet(host);
283
+
284
+ fireEvent.touchstart(handle, touchAt(100));
285
+ fireEvent.touchmove(handle, touchAt(130));
286
+ fireEvent.touchend(handle, { eventType: 'catchEvent' });
287
+ await waitForUpdate();
288
+ expect(host.open()).toBe(true);
289
+ expect(animate).toHaveBeenCalledWith(panel, [
290
+ START,
291
+ expect.any(String),
292
+ [{ transform: 'translateY(30px)' }, { transform: 'translateY(0px)' }],
293
+ expect.objectContaining({ duration: DURATION.fast }),
294
+ ]);
295
+ expect(transformOf(panel)).toBe('translateY(0px)');
296
+
297
+ // A scroll that takes the touch over cancels it the same way.
298
+ fireEvent.touchstart(handle, touchAt(100));
299
+ fireEvent.touchmove(handle, touchAt(150));
300
+ fireEvent.touchcancel(handle, { eventType: 'catchEvent' });
301
+ await waitForUpdate();
302
+ expect(host.open()).toBe(true);
303
+ expect(transformOf(panel)).toBe('translateY(0px)');
304
+ });
305
+
306
+ it("doesn't move up past where it's docked", async () => {
307
+ const { host, panel, handle } = await renderSheet();
308
+ await openSheet(host);
309
+ fireEvent.touchstart(handle, touchAt(100));
310
+ fireEvent.touchmove(handle, touchAt(40));
311
+ await waitForUpdate();
312
+ expect(transformOf(panel)).toBe('translateY(0px)');
313
+ });
314
+
315
+ it('ignores touches without a position, and moves outside a drag', async () => {
316
+ const { host, panel, handle } = await renderSheet();
317
+ await openSheet(host);
318
+
319
+ // A move or release with no drag under way does nothing.
320
+ fireEvent.touchmove(handle, touchAt(300));
321
+ fireEvent.touchend(handle, { eventType: 'catchEvent' });
322
+ // Nor does a touch whose position is missing.
323
+ fireEvent.touchstart(handle, { eventType: 'catchEvent' });
324
+ fireEvent.touchmove(handle, touchAt(300));
325
+ fireEvent.touchstart(handle, { eventType: 'catchEvent', touches: [] });
326
+ fireEvent.touchmove(handle, touchAt(300));
327
+ await waitForUpdate();
328
+ expect(transformOf(panel)).toBe('translateY(0px)');
329
+
330
+ fireEvent.touchstart(handle, touchAt(100));
331
+ fireEvent.touchmove(handle, { eventType: 'catchEvent', touches: [] });
332
+ await waitForUpdate();
333
+ expect(transformOf(panel)).toBe('translateY(0px)');
334
+ expect(host.open()).toBe(true);
54
335
  });
55
- inst.open.set(true);
56
- for (let i = 0; i < 5; i++) await waitForUpdate();
57
- expect(invoke).toHaveBeenCalledWith('requestAccessibilityFocus');
58
- expect(panel.getAttribute('accessibility-exclusive-focus')).toBe('true');
59
336
  });
60
337
 
61
- const patchRefs = (inst: UiBottomSheet, view: Element) => {
62
- (inst as unknown as { backdropRef: unknown }).backdropRef = signal({
63
- nativeElement: view,
338
+ describe('dragged by mouse', () => {
339
+ /**
340
+ * Presses the handle, which catches its own mousedown.
341
+ */
342
+ const press = (handle: Element, y: number) =>
343
+ fireEvent.mousedown(handle, { eventType: 'catchEvent', y });
344
+ /**
345
+ * Moves the mouse, which the backdrop sees anywhere in the sheet.
346
+ */
347
+ const move = (backdrop: Element, y: number, buttons = 1) =>
348
+ fireEvent.mousemove(backdrop, { y, buttons });
349
+
350
+ it('closes when released past the threshold', async () => {
351
+ const { host, backdrop, panel, handle } = await renderSheet();
352
+ await openSheet(host);
353
+
354
+ press(handle, 100);
355
+ move(backdrop, 300);
356
+ await waitForUpdate();
357
+ expect(transformOf(panel)).toBe('translateY(200px)');
358
+ fireEvent.mouseup(backdrop);
359
+ expect(host.open()).toBe(false);
360
+ });
361
+
362
+ it('springs back when released short of the threshold', async () => {
363
+ const { host, backdrop, panel, handle } = await renderSheet();
364
+ await openSheet(host);
365
+
366
+ press(handle, 100);
367
+ move(backdrop, 130);
368
+ fireEvent.mouseup(backdrop);
369
+ await waitForUpdate();
370
+ expect(host.open()).toBe(true);
371
+ expect(transformOf(panel)).toBe('translateY(0px)');
372
+ });
373
+
374
+ it('ignores the backdrop click that follows a moved drag', async () => {
375
+ const { host, backdrop, handle } = await renderSheet();
376
+ await openSheet(host);
377
+
378
+ // A drag released above the panel: the browser's click lands on the
379
+ // backdrop, and must not close the sheet that just snapped back.
380
+ press(handle, 100);
381
+ move(backdrop, 40);
382
+ fireEvent.mouseup(backdrop);
383
+ fireEvent.tap(backdrop);
384
+ expect(host.open()).toBe(true);
385
+
386
+ // Only that one click is ignored: the next backdrop tap closes it.
387
+ fireEvent.tap(backdrop);
388
+ expect(host.open()).toBe(false);
389
+ });
390
+
391
+ it('a new press clears an ignore no click consumed', async () => {
392
+ const { host, backdrop, handle } = await renderSheet();
393
+ await openSheet(host);
394
+
395
+ // Released over the panel, so its catchtap ate the click.
396
+ press(handle, 100);
397
+ move(backdrop, 120);
398
+ fireEvent.mouseup(backdrop);
399
+ // A real backdrop click starts with a mousedown on the backdrop.
400
+ fireEvent.mousedown(backdrop);
401
+ fireEvent.tap(backdrop);
402
+ expect(host.open()).toBe(false);
64
403
  });
65
- (inst as unknown as { panelRef: unknown }).panelRef = signal({
66
- nativeElement: view,
404
+
405
+ it('a press without a move, or a touch drag, leaves the backdrop tap alone', async () => {
406
+ const { host, backdrop, handle } = await renderSheet();
407
+ await openSheet(host);
408
+
409
+ press(handle, 100);
410
+ fireEvent.mouseup(backdrop);
411
+ // A moved touch is never followed by a tap, so nothing is ignored.
412
+ fireEvent.touchstart(handle, touchAt(100));
413
+ fireEvent.touchmove(handle, touchAt(120));
414
+ fireEvent.touchend(handle, { eventType: 'catchEvent' });
415
+ fireEvent.tap(backdrop);
416
+ expect(host.open()).toBe(false);
67
417
  });
418
+
419
+ it('ends a drag whose release it never saw', async () => {
420
+ const { host, backdrop, handle } = await renderSheet();
421
+ await openSheet(host);
422
+
423
+ press(handle, 100);
424
+ move(backdrop, 300);
425
+ // The button came up outside the window; the cursor is back without it.
426
+ move(backdrop, 120, 0);
427
+ // Released 200px down, past the threshold, so it closes.
428
+ expect(host.open()).toBe(false);
429
+ });
430
+
431
+ it('a move outside a drag leaves the sheet in place', async () => {
432
+ const { host, backdrop, panel } = await renderSheet();
433
+ await openSheet(host);
434
+ // The cursor passing over an open sheet with a button held elsewhere.
435
+ move(backdrop, 300);
436
+ await waitForUpdate();
437
+ expect(transformOf(panel)).toBe('translateY(0px)');
438
+ });
439
+ });
440
+ });
441
+
442
+ @Component({
443
+ imports: [UiBottomSheet, LYNX_ELEMENTS],
444
+ template: `
445
+ <ui-bottom-sheet
446
+ [(open)]="open"
447
+ [variant]="variant()"
448
+ [detents]="detents()"
449
+ [(detent)]="detent"
450
+ (closed)="closed()"
451
+ >
452
+ <text>Panel</text>
453
+ </ui-bottom-sheet>
454
+ `,
455
+ })
456
+ class DetentHost {
457
+ readonly open = signal(false);
458
+ readonly variant = signal<BottomSheetVariant>('glass');
459
+ readonly detents = signal<readonly SheetDetent[]>(['medium', 'large']);
460
+ readonly detent = signal<SheetDetent | undefined>(undefined);
461
+ readonly closed = vi.fn();
462
+ }
463
+
464
+ /** Accessibility settings a test can change, as the host app would. */
465
+ const settings = {
466
+ reduceMotion: signal(false),
467
+ reduceTransparency: signal(false),
468
+ increaseContrast: signal(false),
469
+ };
470
+
471
+ const IOS_26 = { platform: 'iOS', osVersion: '26.0' };
472
+ const IOS_18 = { platform: 'iOS', osVersion: '18.5' };
473
+ const ANDROID = { platform: 'Android', osVersion: '15' };
474
+
475
+ /** The phone the tests lay the sheet out on: an iPhone 18 Pro. */
476
+ const SCREEN = 874;
477
+ /** The screen below its status bar, which the large detent fills. */
478
+ const LARGE = 812;
479
+ /** Half the screen, the medium detent. */
480
+ const MEDIUM = SCREEN / 2;
481
+ /** The panel's height when it fits its content. */
482
+ const FIT = 300;
483
+
484
+ /**
485
+ * Renders the host on a platform, as `SystemInfo` reports it, with the
486
+ * sheet's elements found the same way in every look.
487
+ */
488
+ const renderDetents = async (
489
+ platform: { platform: string; osVersion: string } = IOS_26,
490
+ setUp: (host: DetentHost) => void = () => {},
491
+ ) => {
492
+ vi.stubGlobal('SystemInfo', platform);
493
+ const result = await render(DetentHost, {
494
+ providers: [{ provide: LynxAccessibilitySettings, useValue: settings }],
495
+ });
496
+ const host = result.componentRef.instance as DetentHost;
497
+ setUp(host);
498
+ await waitForUpdate();
499
+ const backdrop = result.container.querySelector('overlay > view')!;
500
+ const panel = backdrop.children[1] as HTMLElement;
501
+ // The grabber is the panel's one capsule: the glass is a rounded shape.
502
+ const grabber = panel.querySelector('.rounded-full')!;
503
+ return {
504
+ ...result,
505
+ host,
506
+ overlay: result.container.querySelector('overlay')!,
507
+ backdrop,
508
+ large: backdrop.children[0],
509
+ panel,
510
+ grabber,
511
+ handle: grabber.parentElement!,
512
+ glass: () => panel.querySelector('blur-view'),
513
+ opaque: () => panel.querySelector('.bg-background.absolute') as HTMLElement,
68
514
  };
515
+ };
69
516
 
70
- it('renders the panel content when open', async () => {
71
- const { getByText } = await render(BottomSheetHost);
72
- expect(getByText('Panel')).toBeTruthy();
517
+ type Rendered = Awaited<ReturnType<typeof renderDetents>>;
518
+
519
+ /**
520
+ * Lays the screen out as Lynx reports it after the first frame: the screen,
521
+ * the part below the status bar, and the panel at its content's height.
522
+ */
523
+ const layOut = async (sheet: Rendered, panel = FIT) => {
524
+ fireEvent.layoutchange(sheet.backdrop, { detail: { height: SCREEN } });
525
+ fireEvent.layoutchange(sheet.large, { detail: { height: LARGE } });
526
+ fireEvent.layoutchange(sheet.panel, { detail: { height: panel } });
527
+ await waitForUpdate();
528
+ };
529
+
530
+ const style = (element: Element, property: string) =>
531
+ (element as HTMLElement).style.getPropertyValue(property);
532
+
533
+ /**
534
+ * Waits for a settle at a detent to finish.
535
+ */
536
+ const settled = async () => {
537
+ await new Promise((resolve) => setTimeout(resolve, DURATION.slow + 40));
538
+ await waitForUpdate();
539
+ };
540
+
541
+ /**
542
+ * Drags the handle from `from` to `to`, a screen y each, and lets go.
543
+ */
544
+ const drag = async (sheet: Rendered, from: number, to: number) => {
545
+ fireEvent.touchstart(sheet.handle, touchAt(from));
546
+ fireEvent.touchmove(sheet.handle, touchAt(to));
547
+ await waitForUpdate();
548
+ fireEvent.touchend(sheet.handle, { eventType: 'catchEvent' });
549
+ await waitForUpdate();
550
+ };
551
+
552
+ const tapGrabber = async (sheet: Rendered) => {
553
+ fireEvent.tap(sheet.handle, { eventType: 'catchEvent' });
554
+ await waitForUpdate();
555
+ };
556
+
557
+ describe('UiBottomSheet detents and glass variant', () => {
558
+ let animate: MockInstance<LynxMainThread['__ElementAnimate']>;
559
+ beforeEach(() => {
560
+ animate = vi.spyOn(lynxMainThread(), '__ElementAnimate');
561
+ });
562
+ afterEach(() => {
563
+ animate.mockRestore();
564
+ vi.unstubAllGlobals();
565
+ settings.reduceMotion.set(false);
73
566
  });
74
567
 
75
- it('backdrop tap closes the sheet', async () => {
76
- const { componentRef } = await render(UiBottomSheet);
77
- const inst = componentRef.instance as UiBottomSheet;
78
- inst.open.set(true);
79
- await waitForUpdate();
80
- (inst as unknown as { onBackdropTap: () => void }).onBackdropTap();
81
- expect(inst.open()).toBe(false);
568
+ describe('on iOS 26 (Liquid Glass)', () => {
569
+ it('waits below the screen, clear, until it opens', async () => {
570
+ const sheet = await renderDetents();
571
+ expect(style(sheet.panel, 'transform')).toBe('translateY(110%)');
572
+ expect(sheet.backdrop.getAttribute('style')).toContain(
573
+ 'rgba(0, 0, 0, 0)',
574
+ );
575
+ });
576
+
577
+ it('opens at its first detent as inset glass, with concentric corners', async () => {
578
+ const sheet = await renderDetents();
579
+ await layOut(sheet);
580
+ await openSheet(sheet.host);
581
+
582
+ expect(sheet.host.detent()).toBe('medium');
583
+ // Inset 8px from the sides and bottom; its top halfway up the screen.
584
+ expect(style(sheet.panel, 'bottom')).toBe('8px');
585
+ expect(style(sheet.panel, 'left')).toBe('8px');
586
+ expect(style(sheet.panel, 'right')).toBe('8px');
587
+ expect(style(sheet.panel, 'height')).toBe(`${MEDIUM - 8}px`);
588
+ // The display's 62px corners, less the inset.
589
+ expect(sheet.glass()?.getAttribute('blur-effect')).toBe('glass');
590
+ expect(style(sheet.glass()!, 'border-radius')).toBe('54px');
591
+ expect(style(sheet.opaque(), 'opacity')).toBe('0');
592
+ expect(sheet.backdrop.getAttribute('style')).toContain(
593
+ 'rgba(0, 0, 0, 0.2)',
594
+ );
595
+ expect(sheet.grabber.classList).toContain('w-[60px]');
596
+ // Glass slides up without fading.
597
+ expect(animate).toHaveBeenCalledWith(sheet.panel, [
598
+ START,
599
+ expect.any(String),
600
+ [{ transform: 'translateY(110%)' }, { transform: 'translateY(0%)' }],
601
+ expect.objectContaining({ duration: DURATION.slow }),
602
+ ]);
603
+ });
604
+
605
+ it('turns opaque and meets the edges at full height', async () => {
606
+ const sheet = await renderDetents();
607
+ await layOut(sheet);
608
+ await openSheet(sheet.host);
609
+
610
+ sheet.host.detent.set('large');
611
+ await waitForUpdate();
612
+ // The frame transitions there, and the opaque layer fades in.
613
+ expect(style(sheet.panel, 'transition')).toContain('height');
614
+ expect(style(sheet.panel, 'height')).toBe(`${LARGE}px`);
615
+ expect(style(sheet.panel, 'left')).toBe('0px');
616
+ expect(style(sheet.panel, 'bottom')).toBe('0px');
617
+ expect(style(sheet.opaque(), 'opacity')).toBe('1');
618
+ expect(style(sheet.opaque(), 'transition')).toContain('opacity');
619
+ expect(style(sheet.opaque(), 'border-bottom-left-radius')).toBe('55px');
620
+
621
+ await settled();
622
+ // At rest, its bottom corners meet the screen's edge square.
623
+ expect(style(sheet.panel, 'transition')).toBe('');
624
+ expect(style(sheet.opaque(), 'border-top-left-radius')).toBe('55px');
625
+ expect(style(sheet.opaque(), 'border-bottom-left-radius')).toBe('0px');
626
+ expect(style(sheet.opaque(), 'transition')).toBe('none');
627
+ expect(sheet.host.detent()).toBe('large');
628
+ });
629
+
630
+ it('follows a drag between detents, less inset and more opaque as it grows', async () => {
631
+ const sheet = await renderDetents();
632
+ await layOut(sheet);
633
+ await openSheet(sheet.host);
634
+
635
+ // Halfway from medium to large.
636
+ const halfway = (LARGE - MEDIUM) / 2;
637
+ fireEvent.touchstart(sheet.handle, touchAt(MEDIUM));
638
+ fireEvent.touchmove(sheet.handle, touchAt(MEDIUM - halfway));
639
+ await waitForUpdate();
640
+ expect(style(sheet.panel, 'left')).toBe('4px');
641
+ expect(style(sheet.panel, 'height')).toBe(`${MEDIUM + halfway - 4}px`);
642
+ expect(style(sheet.opaque(), 'opacity')).toBe('0.5');
643
+ expect(style(sheet.panel, 'transition')).toBe('');
644
+ // It goes no higher than the large detent.
645
+ fireEvent.touchmove(sheet.handle, touchAt(0));
646
+ await waitForUpdate();
647
+ expect(style(sheet.panel, 'height')).toBe(`${LARGE}px`);
648
+
649
+ // Let go past the threshold: on to the large detent.
650
+ fireEvent.touchend(sheet.handle, { eventType: 'catchEvent' });
651
+ await waitForUpdate();
652
+ expect(sheet.host.detent()).toBe('large');
653
+ });
654
+
655
+ it('settles back at its detent after a short drag', async () => {
656
+ const sheet = await renderDetents();
657
+ await layOut(sheet);
658
+ await openSheet(sheet.host);
659
+
660
+ await drag(sheet, MEDIUM, MEDIUM - 30);
661
+ expect(sheet.host.detent()).toBe('medium');
662
+ expect(style(sheet.panel, 'transition')).toContain('height');
663
+ expect(style(sheet.panel, 'height')).toBe(`${MEDIUM - 8}px`);
664
+ });
665
+
666
+ it('opens at the detent the app sets, and drags down to the next', async () => {
667
+ const sheet = await renderDetents(IOS_26, (host) =>
668
+ host.detent.set('large'),
669
+ );
670
+ await layOut(sheet);
671
+ await openSheet(sheet.host);
672
+ expect(style(sheet.panel, 'height')).toBe(`${LARGE}px`);
673
+
674
+ await drag(sheet, 100, 400);
675
+ expect(sheet.host.detent()).toBe('medium');
676
+ await settled();
677
+ expect(style(sheet.panel, 'height')).toBe(`${MEDIUM - 8}px`);
678
+ });
679
+
680
+ it('closes when dragged down past its shortest detent, sliding on from the finger', async () => {
681
+ const sheet = await renderDetents();
682
+ await layOut(sheet);
683
+ await openSheet(sheet.host);
684
+
685
+ await drag(sheet, MEDIUM, MEDIUM + 150);
686
+ expect(sheet.host.open()).toBe(false);
687
+ await vi.waitFor(() => expect(sheet.host.closed).toHaveBeenCalled());
688
+ // Past the screen's height, and without fading the glass.
689
+ expect(animate).toHaveBeenCalledWith(sheet.panel, [
690
+ START,
691
+ expect.any(String),
692
+ [
693
+ { transform: 'translateY(150px)' },
694
+ { transform: `translateY(${SCREEN}px)` },
695
+ ],
696
+ expect.objectContaining({ duration: DURATION.normal }),
697
+ ]);
698
+ // Closed, it has no detent, and opens again at its first.
699
+ await waitForUpdate();
700
+ expect(sheet.host.detent()).toBeUndefined();
701
+ await openSheet(sheet.host);
702
+ expect(sheet.host.detent()).toBe('medium');
703
+ });
704
+
705
+ it('springs back up to its shortest detent short of closing', async () => {
706
+ const sheet = await renderDetents();
707
+ await layOut(sheet);
708
+ await openSheet(sheet.host);
709
+ animate.mockClear();
710
+
711
+ await drag(sheet, MEDIUM, MEDIUM + 40);
712
+ expect(sheet.host.open()).toBe(true);
713
+ expect(animate).toHaveBeenCalledWith(sheet.panel, [
714
+ START,
715
+ expect.any(String),
716
+ [{ transform: 'translateY(40px)' }, { transform: 'translateY(0px)' }],
717
+ expect.objectContaining({ duration: DURATION.fast }),
718
+ ]);
719
+ expect(sheet.host.detent()).toBe('medium');
720
+ });
721
+
722
+ it('slides down without fading when closed at rest', async () => {
723
+ const sheet = await renderDetents();
724
+ await layOut(sheet);
725
+ await openSheet(sheet.host);
726
+
727
+ fireEvent.tap(sheet.backdrop);
728
+ await vi.waitFor(() => expect(sheet.host.closed).toHaveBeenCalled());
729
+ expect(animate).toHaveBeenCalledWith(sheet.panel, [
730
+ START,
731
+ expect.any(String),
732
+ [{ transform: 'translateY(0%)' }, { transform: 'translateY(110%)' }],
733
+ expect.objectContaining({ duration: DURATION.normal }),
734
+ ]);
735
+ });
736
+
737
+ it('moves to the next detent when the grabber is tapped, and back from the last', async () => {
738
+ const sheet = await renderDetents();
739
+ await layOut(sheet);
740
+ await openSheet(sheet.host);
741
+ // A button that says what it is, for VoiceOver and TalkBack.
742
+ expect(sheet.handle.getAttribute('accessibility-traits')).toBe('button');
743
+ expect(sheet.handle.getAttribute('accessibility-label')).toBe(
744
+ 'Sheet grabber',
745
+ );
746
+
747
+ await tapGrabber(sheet);
748
+ expect(sheet.host.detent()).toBe('large');
749
+ await tapGrabber(sheet);
750
+ expect(sheet.host.detent()).toBe('medium');
751
+ });
752
+
753
+ it("doesn't take the click that follows a mouse drag released on the grabber", async () => {
754
+ const sheet = await renderDetents();
755
+ await layOut(sheet);
756
+ await openSheet(sheet.host);
757
+
758
+ fireEvent.mousedown(sheet.handle, { eventType: 'catchEvent', y: MEDIUM });
759
+ fireEvent.mousemove(sheet.backdrop, { y: MEDIUM - 20, buttons: 1 });
760
+ fireEvent.mouseup(sheet.backdrop);
761
+ await tapGrabber(sheet);
762
+ expect(sheet.host.detent()).toBe('medium');
763
+ // Only that one: the next tap moves it.
764
+ await tapGrabber(sheet);
765
+ expect(sheet.host.detent()).toBe('large');
766
+ });
767
+
768
+ it('ignores a detent it was not given, and one set during a drag', async () => {
769
+ const sheet = await renderDetents();
770
+ await layOut(sheet);
771
+ await openSheet(sheet.host);
772
+
773
+ sheet.host.detent.set('fit');
774
+ await waitForUpdate();
775
+ expect(style(sheet.panel, 'height')).toBe(`${MEDIUM - 8}px`);
776
+
777
+ fireEvent.touchstart(sheet.handle, touchAt(MEDIUM));
778
+ sheet.host.detent.set('large');
779
+ await waitForUpdate();
780
+ expect(style(sheet.panel, 'transition')).toBe('');
781
+ expect(style(sheet.panel, 'height')).toBe(`${MEDIUM - 8}px`);
782
+ });
783
+
784
+ it("keeps a drag that interrupts a settle going when the settle's time is up", async () => {
785
+ const sheet = await renderDetents();
786
+ await layOut(sheet);
787
+ await openSheet(sheet.host);
788
+
789
+ await tapGrabber(sheet);
790
+ fireEvent.touchstart(sheet.handle, touchAt(100));
791
+ await settled();
792
+ // Still following the finger, halfway back down to medium: no
793
+ // transition came back.
794
+ const halfway = (LARGE - MEDIUM) / 2;
795
+ fireEvent.touchmove(sheet.handle, touchAt(100 + halfway));
796
+ await waitForUpdate();
797
+ expect(style(sheet.panel, 'transition')).toBe('');
798
+ expect(style(sheet.panel, 'height')).toBe(`${LARGE - halfway - 4}px`);
799
+ });
800
+
801
+ it('fits its content at a fit detent, and transitions from it', async () => {
802
+ const sheet = await renderDetents(IOS_26, (host) =>
803
+ host.detents.set(['fit', 'large']),
804
+ );
805
+ await layOut(sheet);
806
+ await openSheet(sheet.host);
807
+ expect(sheet.host.detent()).toBe('fit');
808
+ expect(style(sheet.panel, 'height')).toBe('');
809
+
810
+ // A transition can't start from the content's height, so the sheet
811
+ // pins it for a frame first.
812
+ await tapGrabber(sheet);
813
+ expect(style(sheet.panel, 'height')).toBe(`${FIT}px`);
814
+ await vi.waitFor(() =>
815
+ expect(style(sheet.panel, 'height')).toBe(`${LARGE}px`),
816
+ );
817
+ await settled();
818
+ // A layout at the large detent's height isn't its content's.
819
+ fireEvent.layoutchange(sheet.panel, { detail: { height: LARGE } });
820
+
821
+ await tapGrabber(sheet);
822
+ expect(style(sheet.panel, 'height')).toBe(`${FIT}px`);
823
+ await settled();
824
+ expect(style(sheet.panel, 'height')).toBe('');
825
+ });
826
+
827
+ it('settles at the nearest detent a long drag passes, past the ones between', async () => {
828
+ const sheet = await renderDetents(IOS_26, (host) =>
829
+ host.detents.set(['fit', 'medium', 'large']),
830
+ );
831
+ await layOut(sheet);
832
+ await openSheet(sheet.host);
833
+
834
+ // From its content's height to just short of the large detent.
835
+ await drag(sheet, SCREEN - FIT, 100);
836
+ expect(sheet.host.detent()).toBe('large');
837
+ });
838
+
839
+ it('lets a drag take over from the pinned content height', async () => {
840
+ const sheet = await renderDetents(IOS_26, (host) =>
841
+ host.detents.set(['fit', 'large']),
842
+ );
843
+ await layOut(sheet);
844
+ await openSheet(sheet.host);
845
+
846
+ await tapGrabber(sheet);
847
+ fireEvent.touchstart(sheet.handle, touchAt(500));
848
+ await new Promise((resolve) => setTimeout(resolve, 40));
849
+ await waitForUpdate();
850
+ expect(style(sheet.panel, 'transition')).toBe('');
851
+ expect(style(sheet.panel, 'height')).toBe(`${FIT}px`);
852
+ });
853
+
854
+ it('waits for the screen to be laid out before opening at a detent', async () => {
855
+ const sheet = await renderDetents();
856
+ sheet.host.open.set(true);
857
+ for (let i = 0; i < 3; i++) await waitForUpdate();
858
+ expect(style(sheet.panel, 'transform')).toBe('translateY(110%)');
859
+
860
+ await layOut(sheet);
861
+ await vi.waitFor(() =>
862
+ expect(style(sheet.panel, 'height')).toBe(`${MEDIUM - 8}px`),
863
+ );
864
+ expect(style(sheet.panel, 'transform')).toBe('translateY(0px)');
865
+ });
866
+
867
+ it('opens at its content height if the screen is never laid out', async () => {
868
+ const sheet = await renderDetents();
869
+ sheet.host.open.set(true);
870
+ await vi.waitFor(() =>
871
+ expect(style(sheet.panel, 'transform')).toBe('translateY(0px)'),
872
+ );
873
+ expect(style(sheet.panel, 'height')).toBe('');
874
+ // Partial until it knows the screen.
875
+ expect(style(sheet.panel, 'left')).toBe('8px');
876
+ });
877
+
878
+ it('treats no detents as fitting its content', async () => {
879
+ const sheet = await renderDetents(IOS_26, (host) => host.detents.set([]));
880
+ await openSheet(sheet.host);
881
+ expect(sheet.host.detent()).toBe('fit');
882
+ expect(sheet.handle.hasAttribute('accessibility-label')).toBe(false);
883
+ // Not resizable: a tap on the grabber does nothing.
884
+ await tapGrabber(sheet);
885
+ expect(sheet.host.detent()).toBe('fit');
886
+ });
887
+
888
+ it('fills the screen at a fit detent whose content is taller than it', async () => {
889
+ const sheet = await renderDetents(IOS_26, (host) =>
890
+ host.detents.set(['fit']),
891
+ );
892
+ await layOut(sheet, 900);
893
+ await openSheet(sheet.host);
894
+ expect(style(sheet.opaque(), 'opacity')).toBe('1');
895
+ });
896
+
897
+ it('under Reduce Motion, appears and moves between detents at once', async () => {
898
+ settings.reduceMotion.set(true);
899
+ const sheet = await renderDetents();
900
+ await layOut(sheet);
901
+ await openSheet(sheet.host);
902
+ const animated = () =>
903
+ animate.mock.calls.filter(
904
+ ([element, [kind]]) => element === sheet.panel && kind === START,
905
+ );
906
+ // It fades in with the backdrop, and doesn't slide.
907
+ expect(animated()).toEqual([]);
908
+
909
+ await tapGrabber(sheet);
910
+ expect(style(sheet.panel, 'transition')).toBe('');
911
+ expect(style(sheet.panel, 'height')).toBe(`${LARGE}px`);
912
+
913
+ // Straight back after a short drag down, and away without sliding.
914
+ await tapGrabber(sheet);
915
+ await drag(sheet, MEDIUM, MEDIUM + 40);
916
+ expect(style(sheet.panel, 'transform')).toBe('translateY(0px)');
917
+ fireEvent.tap(sheet.backdrop);
918
+ await vi.waitFor(() => expect(sheet.host.closed).toHaveBeenCalled());
919
+ expect(animated()).toEqual([]);
920
+ });
82
921
  });
83
922
 
84
- it('panel tap is a no-op', async () => {
85
- const { componentRef } = await render(UiBottomSheet);
86
- const inst = componentRef.instance as UiBottomSheet;
87
- expect(() =>
88
- (inst as unknown as { onPanelTap: () => void }).onPanelTap(),
89
- ).not.toThrow();
923
+ describe('on iOS 15 to 25', () => {
924
+ it("draws the opaque sheet of its time, which doesn't inset", async () => {
925
+ const sheet = await renderDetents(IOS_18);
926
+ await layOut(sheet);
927
+ await openSheet(sheet.host);
928
+
929
+ expect(sheet.glass()).toBeNull();
930
+ expect(sheet.panel.classList).toContain('rounded-t-[10px]');
931
+ expect(sheet.panel.classList).toContain('bg-background');
932
+ expect(style(sheet.panel, 'left')).toBe('0px');
933
+ expect(style(sheet.panel, 'height')).toBe(`${MEDIUM}px`);
934
+ expect(sheet.grabber.classList).toContain('w-[36px]');
935
+ expect(sheet.backdrop.getAttribute('style')).toContain(
936
+ 'rgba(0, 0, 0, 0.2)',
937
+ );
938
+ // An opaque sheet slides up as the default does.
939
+ expect(animate).toHaveBeenCalledWith(sheet.panel, [
940
+ START,
941
+ expect.any(String),
942
+ [
943
+ { transform: 'translateY(100%)', opacity: 0.8 },
944
+ { transform: 'translateY(0%)', opacity: 1 },
945
+ ],
946
+ expect.objectContaining({ duration: DURATION.slow }),
947
+ ]);
948
+ });
949
+
950
+ it('fades in and out with the backdrop under Reduce Motion', async () => {
951
+ settings.reduceMotion.set(true);
952
+ const sheet = await renderDetents(IOS_18);
953
+ await layOut(sheet);
954
+ await openSheet(sheet.host);
955
+ fireEvent.tap(sheet.backdrop);
956
+ await vi.waitFor(() => expect(sheet.host.closed).toHaveBeenCalled());
957
+
958
+ const panelStarts = animate.mock.calls.filter(
959
+ ([element, [kind]]) => element === sheet.panel && kind === START,
960
+ );
961
+ expect(panelStarts).toEqual([]);
962
+ expect(animate).toHaveBeenCalledWith(sheet.backdrop, [
963
+ START,
964
+ expect.any(String),
965
+ [{ opacity: 1 }, { opacity: 0 }],
966
+ expect.objectContaining({ duration: DURATION.normal }),
967
+ ]);
968
+ });
90
969
  });
91
970
 
92
- it('open effect runs the animate-in and animate-out lifecycles', async () => {
93
- const { componentRef, container } = await render(UiBottomSheet);
94
- const inst = componentRef.instance as UiBottomSheet;
95
- const view = container.querySelector('view') as Element;
96
- patchRefs(inst, view);
971
+ describe('on Android (Material 3)', () => {
972
+ it('draws a Material 3 bottom sheet', async () => {
973
+ const sheet = await renderDetents(ANDROID);
974
+ await layOut(sheet);
975
+ await openSheet(sheet.host);
97
976
 
98
- inst.open.set(true);
99
- await waitForUpdate();
100
- await waitForUpdate();
101
- await waitForUpdate();
977
+ expect(sheet.glass()).toBeNull();
978
+ expect(sheet.panel.classList).toContain('rounded-t-[28px]');
979
+ expect(sheet.panel.classList).toContain('bg-secondary');
980
+ expect(style(sheet.panel, 'box-shadow')).not.toBe('');
981
+ expect(sheet.handle.classList).toContain('py-[22px]');
982
+ expect(sheet.grabber.classList).toContain('w-[32px]');
983
+ expect(sheet.backdrop.getAttribute('style')).toContain(
984
+ 'rgba(0, 0, 0, 0.32)',
985
+ );
986
+ });
102
987
 
103
- inst.open.set(false);
104
- await new Promise((r) => setTimeout(r, 320));
105
- await waitForUpdate();
106
- expect(inst.open()).toBe(false);
988
+ it('slides on from the finger, fading, when dragged closed', async () => {
989
+ const sheet = await renderDetents(ANDROID);
990
+ await layOut(sheet);
991
+ await openSheet(sheet.host);
992
+
993
+ await drag(sheet, MEDIUM, MEDIUM + 200);
994
+ await vi.waitFor(() => expect(sheet.host.closed).toHaveBeenCalled());
995
+ expect(animate).toHaveBeenCalledWith(sheet.panel, [
996
+ START,
997
+ expect.any(String),
998
+ [
999
+ { transform: 'translateY(200px)', opacity: 1 },
1000
+ { transform: `translateY(${SCREEN}px)`, opacity: 0.8 },
1001
+ ],
1002
+ expect.objectContaining({ duration: DURATION.normal }),
1003
+ ]);
1004
+ });
107
1005
  });
108
1006
 
109
- it('open effect bails when backdrop/panel refs are undefined', async () => {
110
- const { componentRef } = await render(UiBottomSheet);
111
- const inst = componentRef.instance as UiBottomSheet;
112
- const closed = vi.fn();
113
- inst.closed.subscribe(closed);
114
- inst.open.set(true);
115
- await waitForUpdate();
116
- await waitForUpdate();
117
- inst.open.set(false);
118
- await waitForUpdate();
119
- // Wait out the close sequence, so #animateOut runs (and bails) while the
120
- // component is still alive. Destroying it clears the sequence's timers.
121
- await new Promise((r) => setTimeout(r, DURATION.normal + 50));
122
- expect(inst.open()).toBe(false);
123
- expect(closed).toHaveBeenCalledOnce();
124
- });
125
-
126
- it('drag beyond the dismiss threshold closes the sheet', async () => {
127
- const { componentRef, container } = await render(UiBottomSheet);
128
- const inst = componentRef.instance as UiBottomSheet;
129
- const view = container.querySelector('view') as Element;
130
- patchRefs(inst, view);
131
- const animate = vi.spyOn(
132
- view as Element & { animate: () => unknown },
133
- 'animate',
1007
+ it('keeps the default look with detents', async () => {
1008
+ const sheet = await renderDetents(IOS_26, (host) =>
1009
+ host.variant.set('default'),
134
1010
  );
135
- inst.open.set(true);
136
- await waitForUpdate();
1011
+ await layOut(sheet);
1012
+ await openSheet(sheet.host);
1013
+ expect(sheet.glass()).toBeNull();
1014
+ expect(sheet.panel.classList).toContain('rounded-t-2xl');
1015
+ expect(style(sheet.panel, 'height')).toBe(`${MEDIUM}px`);
1016
+ expect(sheet.backdrop.getAttribute('style')).toContain(
1017
+ 'rgba(0, 0, 0, 0.5)',
1018
+ );
1019
+ });
1020
+ });
137
1021
 
138
- const cast = inst as unknown as {
139
- onHandleTouchStart: (e: { touches: { clientY: number }[] }) => void;
140
- onHandleTouchMove: (e: { touches: { clientY: number }[] }) => void;
141
- onDragEnd: () => void;
142
- };
143
- cast.onHandleTouchStart({ touches: [{ clientY: 100 }] });
144
- cast.onHandleTouchMove({ touches: [{ clientY: 300 }] });
145
- cast.onDragEnd();
146
- await waitForUpdate();
147
- expect(inst.open()).toBe(false);
148
- // The exit slide starts the next tick, from where the finger let go
149
- // rather than from the docked position.
150
- await new Promise((r) => setTimeout(r, 0));
151
- expect(
152
- animate.mock.calls.some(
153
- ([keyframes]) =>
154
- (keyframes as Keyframe[])[0]?.transform === 'translateY(200px)',
155
- ),
156
- ).toBe(true);
157
- });
158
-
159
- it('drag below the dismiss threshold snaps back to open', async () => {
160
- const { componentRef, container } = await render(UiBottomSheet);
161
- const inst = componentRef.instance as UiBottomSheet;
162
- const view = container.querySelector('view') as Element;
163
- patchRefs(inst, view);
164
- inst.open.set(true);
165
- await waitForUpdate();
1022
+ /**
1023
+ * A glass sheet presented by a control marked `uiSheetSource`: a glass
1024
+ * button, or one that isn't glass.
1025
+ */
1026
+ @Component({
1027
+ imports: [UiBottomSheet, UiSheetSource, UiGlass, LYNX_ELEMENTS],
1028
+ template: `
1029
+ @if (glassSource()) {
1030
+ <blur-view ui-glass class="source" [uiSheetSource]="sheet">
1031
+ <text>Add</text>
1032
+ </blur-view>
1033
+ } @else if (withSource()) {
1034
+ <view class="source" [uiSheetSource]="sheet"><text>Add</text></view>
1035
+ }
1036
+ <ui-bottom-sheet
1037
+ #sheet
1038
+ [(open)]="open"
1039
+ [variant]="variant()"
1040
+ [detents]="detents()"
1041
+ (closed)="closed()"
1042
+ >
1043
+ <text>Panel</text>
1044
+ </ui-bottom-sheet>
1045
+ `,
1046
+ })
1047
+ class SourceHost {
1048
+ readonly open = signal(false);
1049
+ readonly variant = signal<BottomSheetVariant>('glass');
1050
+ readonly detents = signal<readonly SheetDetent[]>(['medium']);
1051
+ readonly glassSource = signal(true);
1052
+ readonly withSource = signal(true);
1053
+ readonly closed = vi.fn();
1054
+ }
166
1055
 
167
- const cast = inst as unknown as {
168
- onHandleTouchStart: (e: { touches: { clientY: number }[] }) => void;
169
- onHandleTouchMove: (e: { touches: { clientY: number }[] }) => void;
170
- onDragEnd: () => void;
171
- };
172
- cast.onHandleTouchStart({ touches: [{ clientY: 100 }] });
173
- cast.onHandleTouchMove({ touches: [{ clientY: 130 }] });
174
- cast.onDragEnd();
175
- await waitForUpdate();
176
- expect(inst.open()).toBe(true);
177
- });
178
-
179
- it('touch handlers guard against missing touches and non-active drags', async () => {
180
- const { componentRef } = await render(UiBottomSheet);
181
- const inst = componentRef.instance as UiBottomSheet;
182
- const cast = inst as unknown as {
183
- onHandleTouchStart: (e: { touches?: { clientY: number }[] }) => void;
184
- onHandleTouchMove: (e: { touches?: { clientY: number }[] }) => void;
185
- onDragEnd: () => void;
186
- };
187
- // touchmove without prior touchstart is a no-op (guards `#isDragging`).
188
- cast.onHandleTouchMove({ touches: [{ clientY: 200 }] });
189
- // touchstart / touchmove without touches entries is a no-op.
190
- cast.onHandleTouchStart({ touches: [] });
191
- cast.onHandleTouchStart({ touches: [{ clientY: 100 }] });
192
- cast.onHandleTouchMove({ touches: [] });
193
- // touchend without an active drag is also a no-op.
194
- cast.onDragEnd();
195
- cast.onDragEnd();
196
- });
197
-
198
- type MouseDrag = {
199
- onHandleMouseDown: (e: { y: number }) => void;
200
- onMouseDragMove: (e: { y: number; buttons: number }) => void;
201
- onDragEnd: () => void;
202
- onBackdropMouseDown: () => void;
203
- onBackdropTap: () => void;
1056
+ /** The phone's width, beside SCREEN. */
1057
+ const WIDTH = 402;
1058
+ /** Where the control sits: a 44px circle near the top right. */
1059
+ const SOURCE = { left: 340, top: 60, width: 44, height: 44 };
1060
+ /** Where the sheet starts from and ends at: the control, as its style puts it. */
1061
+ const SOURCE_FRAME = {
1062
+ left: '340px',
1063
+ right: `${WIDTH - 340 - 44}px`,
1064
+ bottom: `${SCREEN - 60 - 44}px`,
1065
+ height: '44px',
1066
+ };
1067
+
1068
+ /**
1069
+ * Renders the host on a platform, measuring the screen as
1070
+ * `boundingClientRect` would, and the control once the test lets the answer
1071
+ * through (see measureSource), or failing to measure it.
1072
+ */
1073
+ const renderSource = async (
1074
+ platform: { platform: string; osVersion: string } = IOS_26,
1075
+ { measurable = true } = {},
1076
+ ) => {
1077
+ vi.stubGlobal('SystemInfo', platform);
1078
+ const answers: (() => void)[] = [];
1079
+ vi.spyOn(
1080
+ globalThis as unknown as InvokeUIMethod,
1081
+ '__InvokeUIMethod',
1082
+ ).mockImplementation((element, method, _params, callback) => {
1083
+ if (method !== 'boundingClientRect') {
1084
+ callback({ code: 0 });
1085
+ return;
1086
+ }
1087
+ const answer = callback as (result: {
1088
+ code: number;
1089
+ data?: unknown;
1090
+ }) => void;
1091
+ if (!element.classList.contains('source')) {
1092
+ answer({
1093
+ code: 0,
1094
+ data: { left: 0, top: 0, width: WIDTH, height: SCREEN },
1095
+ });
1096
+ } else if (measurable) {
1097
+ answers.push(() => answer({ code: 0, data: SOURCE }));
1098
+ } else {
1099
+ answers.push(() => answer({ code: 1 }));
1100
+ }
1101
+ });
1102
+ const result = await render(SourceHost, {
1103
+ providers: [{ provide: LynxAccessibilitySettings, useValue: settings }],
1104
+ });
1105
+ const host = result.componentRef.instance as SourceHost;
1106
+ const backdrop = result.container.querySelector('overlay > view')!;
1107
+ const panel = backdrop.children[1] as HTMLElement;
1108
+ fireEvent.layoutchange(backdrop, {
1109
+ detail: { height: SCREEN, width: WIDTH },
1110
+ });
1111
+ fireEvent.layoutchange(backdrop.children[0], { detail: { height: LARGE } });
1112
+ fireEvent.layoutchange(panel, { detail: { height: FIT } });
1113
+ await waitForUpdate();
1114
+ return {
1115
+ ...result,
1116
+ host,
1117
+ overlay: result.container.querySelector('overlay')!,
1118
+ backdrop,
1119
+ panel,
1120
+ source: () => result.container.querySelector('.source') as HTMLElement,
1121
+ glass: () => panel.querySelector('blur-view')!,
1122
+ // The handle and the content, which fade as the sheet grows.
1123
+ content: () => panel.querySelector('.grow') as HTMLElement,
1124
+ /**
1125
+ * Waits until the sheet measures its control, and answers. The sheet
1126
+ * then shows at the control's frame for a frame, which the test sees
1127
+ * right after the update.
1128
+ */
1129
+ measureSource: async () => {
1130
+ await vi.waitFor(() => expect(answers.length).toBeGreaterThan(0));
1131
+ for (const answer of answers.splice(0)) answer();
1132
+ await waitForUpdate();
1133
+ },
204
1134
  };
1135
+ };
205
1136
 
206
- it('mouse drag beyond the dismiss threshold closes the sheet', async () => {
207
- const { componentRef, container } = await render(UiBottomSheet);
208
- const inst = componentRef.instance as UiBottomSheet;
209
- patchRefs(inst, container.querySelector('view') as Element);
210
- inst.open.set(true);
211
- await waitForUpdate();
1137
+ type SourceRendered = Awaited<ReturnType<typeof renderSource>>;
212
1138
 
213
- const cast = inst as unknown as MouseDrag;
214
- cast.onHandleMouseDown({ y: 100 });
215
- cast.onMouseDragMove({ y: 300, buttons: 1 });
216
- cast.onDragEnd();
217
- await waitForUpdate();
218
- expect(inst.open()).toBe(false);
1139
+ /**
1140
+ * The panel's frame, from its inline style.
1141
+ */
1142
+ const frameOf = (panel: Element) => ({
1143
+ left: style(panel, 'left'),
1144
+ right: style(panel, 'right'),
1145
+ bottom: style(panel, 'bottom'),
1146
+ height: style(panel, 'height'),
1147
+ });
1148
+
1149
+ /**
1150
+ * Answers the sheet's measuring of its control, and checks it then shows at
1151
+ * the control's frame, to grow out of it or as it shrinks into it.
1152
+ */
1153
+ const atSource = async (sheet: SourceRendered) => {
1154
+ await sheet.measureSource();
1155
+ expect(frameOf(sheet.panel)).toEqual(SOURCE_FRAME);
1156
+ };
1157
+
1158
+ /** The frame of the sheet at rest at its medium detent. */
1159
+ const MEDIUM_FRAME = {
1160
+ left: '8px',
1161
+ right: '8px',
1162
+ bottom: '8px',
1163
+ height: `${MEDIUM - 8}px`,
1164
+ };
1165
+
1166
+ /**
1167
+ * Waits for a sheet growing out of its control to come to rest: the grow
1168
+ * takes longer than a settle between detents.
1169
+ */
1170
+ const grown = async () => {
1171
+ await new Promise((resolve) => setTimeout(resolve, DURATION.slow + 140));
1172
+ await waitForUpdate();
1173
+ };
1174
+
1175
+ describe('UiBottomSheet grown out of its control', () => {
1176
+ let animate: MockInstance<LynxMainThread['__ElementAnimate']>;
1177
+ beforeEach(() => {
1178
+ animate = vi.spyOn(lynxMainThread(), '__ElementAnimate');
1179
+ // The renderer warns that the components aren't Lynx elements.
1180
+ vi.spyOn(console, 'warn').mockImplementation(() => {});
1181
+ });
1182
+ afterEach(() => {
1183
+ vi.restoreAllMocks();
1184
+ vi.unstubAllGlobals();
1185
+ settings.reduceMotion.set(false);
219
1186
  });
220
1187
 
221
- it('mouse drag below the dismiss threshold snaps back to open', async () => {
222
- const { componentRef, container } = await render(UiBottomSheet);
223
- const inst = componentRef.instance as UiBottomSheet;
224
- patchRefs(inst, container.querySelector('view') as Element);
225
- inst.open.set(true);
226
- await waitForUpdate();
1188
+ it('grows out of the control that presents it, which hides meanwhile', async () => {
1189
+ const sheet = await renderSource();
1190
+ // The control and the sheet's glass share an id, for the shell to pair
1191
+ // them; the sheet's glass is hidden until the control hides.
1192
+ const id = sheet.source().getAttribute('glass-effect-id');
1193
+ expect(id).toMatch(/^ui-sheet-\d+$/);
1194
+ expect(sheet.glass().getAttribute('glass-effect-id')).toBe(id);
1195
+ expect(style(sheet.glass(), 'visibility')).toBe('hidden');
1196
+ expect(style(sheet.source(), 'visibility')).toBe('');
227
1197
 
228
- const cast = inst as unknown as MouseDrag;
229
- cast.onHandleMouseDown({ y: 100 });
230
- cast.onMouseDragMove({ y: 130, buttons: 1 });
231
- cast.onDragEnd();
232
- await waitForUpdate();
233
- expect(inst.open()).toBe(true);
1198
+ sheet.host.open.set(true);
1199
+ await atSource(sheet);
1200
+ expect(style(sheet.source(), 'visibility')).toBe('hidden');
1201
+ expect(style(sheet.glass(), 'visibility')).toBe('');
1202
+ expect(style(sheet.content(), 'opacity')).toBe('0');
1203
+ expect(style(sheet.panel, 'overflow')).toBe('hidden');
1204
+ // The backdrop dims through its color rather than fading in, which
1205
+ // would fade the glass in it too.
1206
+ expect(sheet.backdrop.getAttribute('style')).toContain(
1207
+ 'rgba(0, 0, 0, 0.2)',
1208
+ );
1209
+ expect(style(sheet.backdrop, 'transition')).toContain('background-color');
1210
+
1211
+ // On to its detent, on the sheet's curve, its content fading in.
1212
+ await vi.waitFor(() => expect(style(sheet.panel, 'left')).toBe('8px'));
1213
+ expect(frameOf(sheet.panel)).toEqual(MEDIUM_FRAME);
1214
+ expect(style(sheet.panel, 'transition')).toContain(
1215
+ `height ${DURATION.slow + 100}ms cubic-bezier(0.32, 0.72, 0, 1)`,
1216
+ );
1217
+ expect(style(sheet.content(), 'opacity')).toBe('1');
1218
+ // It grows rather than slides, and nothing fades.
1219
+ expect(animate).not.toHaveBeenCalledWith(sheet.panel, expect.anything());
1220
+ expect(animate).not.toHaveBeenCalledWith(sheet.backdrop, expect.anything());
1221
+
1222
+ await grown();
1223
+ expect(style(sheet.panel, 'transition')).toBe('');
1224
+ expect(style(sheet.panel, 'overflow')).toBe('');
234
1225
  });
235
1226
 
236
- it('ignores the backdrop click that follows a moved mouse drag', async () => {
237
- const { componentRef } = await render(UiBottomSheet);
238
- const inst = componentRef.instance as UiBottomSheet;
239
- inst.open.set(true);
1227
+ it('shrinks back into its control as it closes, which shows again', async () => {
1228
+ const sheet = await renderSource();
1229
+ sheet.host.open.set(true);
1230
+ await atSource(sheet);
1231
+ await grown();
1232
+
1233
+ sheet.host.open.set(false);
1234
+ await sheet.measureSource();
1235
+ // Its height pinned for a frame, then into the control.
1236
+ await vi.waitFor(() => expect(frameOf(sheet.panel)).toEqual(SOURCE_FRAME));
1237
+ expect(style(sheet.panel, 'transition')).toContain(
1238
+ `height ${DURATION.normal}ms`,
1239
+ );
1240
+ expect(style(sheet.content(), 'opacity')).toBe('0');
1241
+ expect(style(sheet.source(), 'visibility')).toBe('hidden');
1242
+ expect(sheet.backdrop.getAttribute('style')).toContain('rgba(0, 0, 0, 0)');
1243
+
1244
+ // The control shows in the update that hides the sheet's glass.
1245
+ await vi.waitFor(() => expect(sheet.host.closed).toHaveBeenCalled());
240
1246
  await waitForUpdate();
1247
+ expect(sheet.overlay.getAttribute('visible')).toBe('false');
1248
+ expect(style(sheet.source(), 'visibility')).toBe('');
1249
+ expect(style(sheet.glass(), 'visibility')).toBe('hidden');
1250
+ });
241
1251
 
242
- const cast = inst as unknown as MouseDrag;
243
- // A drag released above the panel: the browser's click lands on the
244
- // backdrop, and must not close the sheet that just snapped back.
245
- cast.onHandleMouseDown({ y: 100 });
246
- cast.onMouseDragMove({ y: 40, buttons: 1 });
247
- cast.onDragEnd();
248
- cast.onBackdropTap();
249
- expect(inst.open()).toBe(true);
1252
+ it("slides up and down when its control can't be measured", async () => {
1253
+ const sheet = await renderSource(IOS_26, { measurable: false });
1254
+ sheet.host.open.set(true);
1255
+ await sheet.measureSource();
1256
+ await vi.waitFor(() =>
1257
+ expect(animate).toHaveBeenCalledWith(sheet.panel, [
1258
+ START,
1259
+ expect.any(String),
1260
+ [{ transform: 'translateY(110%)' }, { transform: 'translateY(0%)' }],
1261
+ expect.anything(),
1262
+ ]),
1263
+ );
1264
+ expect(style(sheet.panel, 'left')).toBe('8px');
250
1265
 
251
- // Only that one click is ignored: the next backdrop tap closes it.
252
- cast.onBackdropTap();
253
- expect(inst.open()).toBe(false);
1266
+ sheet.host.open.set(false);
1267
+ await sheet.measureSource();
1268
+ await vi.waitFor(() =>
1269
+ expect(animate).toHaveBeenCalledWith(sheet.panel, [
1270
+ START,
1271
+ expect.any(String),
1272
+ [{ transform: 'translateY(0%)' }, { transform: 'translateY(110%)' }],
1273
+ expect.anything(),
1274
+ ]),
1275
+ );
1276
+ await vi.waitFor(() => expect(sheet.host.closed).toHaveBeenCalled());
254
1277
  });
255
1278
 
256
- it('a new press clears an ignore no click consumed', async () => {
257
- const { componentRef } = await render(UiBottomSheet);
258
- const inst = componentRef.instance as UiBottomSheet;
259
- inst.open.set(true);
1279
+ it('goes on down from the finger when dragged closed', async () => {
1280
+ const sheet = await renderSource();
1281
+ sheet.host.open.set(true);
1282
+ await atSource(sheet);
1283
+ await grown();
1284
+
1285
+ const handle = sheet.panel.querySelector('.rounded-full')!.parentElement!;
1286
+ fireEvent.touchstart(handle, touchAt(MEDIUM));
1287
+ fireEvent.touchmove(handle, touchAt(MEDIUM + 200));
260
1288
  await waitForUpdate();
1289
+ fireEvent.touchend(handle, { eventType: 'catchEvent' });
1290
+ await vi.waitFor(() => expect(sheet.host.closed).toHaveBeenCalled());
1291
+ expect(animate).toHaveBeenCalledWith(sheet.panel, [
1292
+ START,
1293
+ expect.any(String),
1294
+ [
1295
+ { transform: 'translateY(200px)' },
1296
+ { transform: `translateY(${SCREEN}px)` },
1297
+ ],
1298
+ expect.anything(),
1299
+ ]);
1300
+ await waitForUpdate();
1301
+ expect(style(sheet.source(), 'visibility')).toBe('');
1302
+ });
261
1303
 
262
- const cast = inst as unknown as MouseDrag;
263
- // Released over the panel, so its catchtap ate the click.
264
- cast.onHandleMouseDown({ y: 100 });
265
- cast.onMouseDragMove({ y: 120, buttons: 1 });
266
- cast.onDragEnd();
267
- // A real backdrop click starts with a mousedown on the backdrop.
268
- cast.onBackdropMouseDown();
269
- cast.onBackdropTap();
270
- expect(inst.open()).toBe(false);
271
- });
272
-
273
- it('a mouse press without a move, or a touch drag, leaves the backdrop tap alone', async () => {
274
- const { componentRef } = await render(UiBottomSheet);
275
- const inst = componentRef.instance as UiBottomSheet;
276
- inst.open.set(true);
1304
+ it('lets a drag that begins as it grows have it', async () => {
1305
+ const sheet = await renderSource();
1306
+ sheet.host.detents.set(['medium', 'large']);
1307
+ sheet.host.open.set(true);
1308
+ await atSource(sheet);
1309
+ await vi.waitFor(() => expect(style(sheet.panel, 'left')).toBe('8px'));
1310
+
1311
+ const handle = sheet.panel.querySelector('.rounded-full')!.parentElement!;
1312
+ fireEvent.touchstart(handle, touchAt(MEDIUM));
1313
+ fireEvent.touchmove(handle, touchAt(MEDIUM - 100));
277
1314
  await waitForUpdate();
1315
+ const dragged = style(sheet.panel, 'height');
1316
+ expect(dragged).not.toBe(MEDIUM_FRAME.height);
1317
+ await grown();
1318
+ // Still where the finger has it, not back at the detent.
1319
+ expect(style(sheet.panel, 'height')).toBe(dragged);
1320
+ });
278
1321
 
279
- const cast = inst as unknown as MouseDrag & {
280
- onHandleTouchStart: (e: { touches: { clientY: number }[] }) => void;
281
- onHandleTouchMove: (e: { touches: { clientY: number }[] }) => void;
282
- };
283
- cast.onHandleMouseDown({ y: 100 });
284
- cast.onDragEnd();
285
- // A moved touch is never followed by a tap, so nothing is ignored.
286
- cast.onHandleTouchStart({ touches: [{ clientY: 100 }] });
287
- cast.onHandleTouchMove({ touches: [{ clientY: 120 }] });
288
- cast.onDragEnd();
289
- cast.onBackdropTap();
290
- expect(inst.open()).toBe(false);
291
- });
292
-
293
- it('ends a mouse drag whose release it never saw', async () => {
294
- const { componentRef, container } = await render(UiBottomSheet);
295
- const inst = componentRef.instance as UiBottomSheet;
296
- patchRefs(inst, container.querySelector('view') as Element);
297
- inst.open.set(true);
1322
+ it('stops growing when it closes first, and shrinking when it opens again', async () => {
1323
+ const sheet = await renderSource();
1324
+ // Closed while it measures its control: it never shows.
1325
+ sheet.host.open.set(true);
1326
+ await vi.waitFor(() =>
1327
+ expect(sheet.overlay.getAttribute('visible')).toBe('true'),
1328
+ );
1329
+ sheet.host.open.set(false);
1330
+ await waitForUpdate();
1331
+ await sheet.measureSource();
1332
+ await vi.waitFor(() => expect(sheet.host.closed).toHaveBeenCalled());
298
1333
  await waitForUpdate();
1334
+ expect(style(sheet.source(), 'visibility')).toBe('');
299
1335
 
300
- const cast = inst as unknown as MouseDrag;
301
- cast.onHandleMouseDown({ y: 100 });
302
- cast.onMouseDragMove({ y: 300, buttons: 1 });
303
- // The button came up outside the window; the cursor is back without it.
304
- cast.onMouseDragMove({ y: 120, buttons: 0 });
1336
+ // Opened again while it measures its control to shrink into it: it
1337
+ // grows again instead.
1338
+ sheet.host.open.set(true);
1339
+ await atSource(sheet);
1340
+ await grown();
1341
+ sheet.host.open.set(false);
1342
+ await waitForUpdate();
1343
+ sheet.host.open.set(true);
305
1344
  await waitForUpdate();
306
- // Released 200px down, past the threshold, so it closes.
307
- expect(inst.open()).toBe(false);
1345
+ await sheet.measureSource();
1346
+ await sheet.measureSource();
1347
+ await grown();
1348
+ expect(sheet.overlay.getAttribute('visible')).toBe('true');
1349
+ expect(frameOf(sheet.panel)).toEqual(MEDIUM_FRAME);
308
1350
  });
309
1351
 
310
- it('a mouse move outside a drag leaves the sheet in place', async () => {
311
- const { componentRef } = await render(UiBottomSheet);
312
- const inst = componentRef.instance as UiBottomSheet;
313
- // The cursor moving over an open sheet with no button pressed.
314
- (inst as unknown as MouseDrag).onMouseDragMove({ y: 300, buttons: 1 });
315
- expect((inst as unknown as { dragOffset: () => number }).dragOffset()).toBe(
316
- 0,
317
- );
1352
+ it('appears as without one under Reduce Motion', async () => {
1353
+ settings.reduceMotion.set(true);
1354
+ const sheet = await renderSource();
1355
+ await openSheet(sheet.host);
1356
+ expect(style(sheet.source(), 'visibility')).toBe('');
1357
+ expect(sheet.content().getAttribute('style') ?? '').toBe('');
1358
+ });
1359
+
1360
+ it('slides as without one before iOS 26', async () => {
1361
+ const sheet = await renderSource(IOS_18);
1362
+ await openSheet(sheet.host);
1363
+ expect(style(sheet.source(), 'visibility')).toBe('');
318
1364
  });
319
1365
 
320
- it('applies a user class on the panel', async () => {
321
- const { componentRef, container } = await render(UiBottomSheet);
322
- const inst = componentRef.instance as UiBottomSheet;
323
- setInputSignal(inst.userClass, 'custom-panel');
1366
+ it("leaves a control that isn't glass unpaired, and lets go of one that goes", async () => {
1367
+ const sheet = await renderSource();
1368
+ sheet.host.glassSource.set(false);
324
1369
  await waitForUpdate();
325
- expect(
326
- Array.from(container.querySelectorAll('view')).some((el) =>
327
- el.getAttribute('class')?.includes('custom-panel'),
328
- ),
329
- ).toBe(true);
330
- });
331
-
332
- it('overlayStyle switches between visible and hidden variants', async () => {
333
- const { componentRef, container } = await render(UiBottomSheet);
334
- const inst = componentRef.instance as UiBottomSheet;
335
- let overlay = container.querySelector('overlay');
336
- expect(overlay?.getAttribute('style')).toContain('display: none');
337
-
338
- const view = container.querySelector('view') as Element;
339
- patchRefs(inst, view);
340
- inst.open.set(true);
341
- for (let i = 0; i < 10; i++) await waitForUpdate();
342
- overlay = container.querySelector('overlay');
343
- expect(overlay?.getAttribute('style')).not.toContain('display: none');
1370
+ expect(sheet.source().tagName.toLowerCase()).toBe('view');
1371
+ expect(sheet.source().hasAttribute('glass-effect-id')).toBe(false);
1372
+
1373
+ // Without a control, the glass sheet slides up again.
1374
+ sheet.host.withSource.set(false);
1375
+ await waitForUpdate();
1376
+ await openSheet(sheet.host);
1377
+ await vi.waitFor(() =>
1378
+ expect(animate).toHaveBeenCalledWith(sheet.panel, [
1379
+ START,
1380
+ expect.any(String),
1381
+ [{ transform: 'translateY(110%)' }, { transform: 'translateY(0%)' }],
1382
+ expect.anything(),
1383
+ ]),
1384
+ );
344
1385
  });
345
1386
  });