@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,11 +1,29 @@
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';
18
+ import { fireLynxEvent } from '../../../test-utils/fire-lynx-event';
19
+ import { UiGlass } from '../glass/glass';
20
+ import {
21
+ type LynxMainThread,
22
+ lynxMainThread,
23
+ } from '../../../test-utils/lynx-main-thread';
7
24
  import {
8
25
  UiActionSheet,
26
+ UiActionSheetAnchor,
9
27
  UiActionSheetCancel,
10
28
  UiActionSheetItem,
11
29
  UiActionSheetTitle,
@@ -20,239 +38,675 @@ import {
20
38
  LYNX_ELEMENTS,
21
39
  ],
22
40
  template: `
23
- <ui-action-sheet [(open)]="open">
24
- <ui-action-sheet-title>Pick</ui-action-sheet-title>
25
- <ui-action-sheet-item>Option</ui-action-sheet-item>
26
- <ui-action-sheet-cancel>Cancel</ui-action-sheet-cancel>
41
+ <ui-action-sheet [(open)]="open" class="pb-8" (closed)="closed()">
42
+ <ui-action-sheet-title class="py-4">Pick</ui-action-sheet-title>
43
+ <ui-action-sheet-item (pressed)="shared()">Share</ui-action-sheet-item>
44
+ <ui-action-sheet-item
45
+ variant="destructive"
46
+ class="bg-muted"
47
+ (pressed)="deleted()"
48
+ >
49
+ Delete
50
+ </ui-action-sheet-item>
51
+ <ui-action-sheet-cancel class="mt-2" />
27
52
  </ui-action-sheet>
28
53
  `,
29
54
  })
30
55
  class ActionSheetHost {
31
- open = true;
56
+ readonly open = signal(false);
57
+ readonly closed = vi.fn();
58
+ readonly shared = vi.fn();
59
+ readonly deleted = vi.fn();
32
60
  }
33
61
 
62
+ /**
63
+ * A cancel button given its own label.
64
+ */
65
+ @Component({
66
+ imports: [UiActionSheet, UiActionSheetCancel],
67
+ template: `
68
+ <ui-action-sheet>
69
+ <ui-action-sheet-cancel label="Dismiss" />
70
+ </ui-action-sheet>
71
+ `,
72
+ })
73
+ class CancelLabelHost {}
74
+
75
+ /**
76
+ * An action sheet opened from the button that presents it.
77
+ */
78
+ @Component({
79
+ imports: [
80
+ UiActionSheet,
81
+ UiActionSheetAnchor,
82
+ UiActionSheetTitle,
83
+ UiActionSheetItem,
84
+ UiActionSheetCancel,
85
+ UiGlass,
86
+ LYNX_ELEMENTS,
87
+ ],
88
+ template: `
89
+ @if (glassAnchor()) {
90
+ <blur-view
91
+ ui-glass
92
+ class="share"
93
+ [uiActionSheetAnchor]="sheet"
94
+ (bindtap)="open.set(true)"
95
+ >
96
+ <text>Share</text>
97
+ </blur-view>
98
+ } @else if (anchored()) {
99
+ <view
100
+ class="share"
101
+ [uiActionSheetAnchor]="sheet"
102
+ (bindtap)="open.set(true)"
103
+ >
104
+ <text>Share</text>
105
+ </view>
106
+ }
107
+ <ui-action-sheet #sheet [(open)]="open" (closed)="closed()">
108
+ <ui-action-sheet-title>Delete this draft?</ui-action-sheet-title>
109
+ <ui-action-sheet-item (pressed)="saved()"
110
+ >Save Draft</ui-action-sheet-item
111
+ >
112
+ <ui-action-sheet-item variant="destructive" (pressed)="deleted()">
113
+ Delete Draft
114
+ </ui-action-sheet-item>
115
+ <ui-action-sheet-cancel />
116
+ </ui-action-sheet>
117
+ `,
118
+ })
119
+ class AnchoredHost {
120
+ readonly open = signal(false);
121
+ readonly anchored = signal(true);
122
+ readonly glassAnchor = signal(false);
123
+ readonly closed = vi.fn();
124
+ readonly saved = vi.fn();
125
+ readonly deleted = vi.fn();
126
+ }
127
+
128
+ /**
129
+ * A title on its own, outside any action sheet.
130
+ */
131
+ @Component({
132
+ imports: [UiActionSheetTitle],
133
+ template: `<ui-action-sheet-title>Alone</ui-action-sheet-title>`,
134
+ })
135
+ class LoneTitleHost {}
136
+
137
+ /**
138
+ * The Lynx function an element's `invoke()` calls to run one of its UI
139
+ * methods, such as moving a screen reader's focus. The testing library
140
+ * defines it on the global object.
141
+ */
142
+ type InvokeUIMethod = {
143
+ __InvokeUIMethod: (
144
+ element: Element,
145
+ method: string,
146
+ params: Record<string, unknown>,
147
+ callback: (result: { code: number }) => void,
148
+ ) => void;
149
+ };
150
+
151
+ /** `__ElementAnimate`'s first argument for starting an animation. */
152
+ const START = 0;
153
+
154
+ const renderSheet = async () => {
155
+ const result = await render(ActionSheetHost);
156
+ const { container } = result;
157
+ return {
158
+ ...result,
159
+ host: result.componentRef.instance as ActionSheetHost,
160
+ overlay: container.querySelector('overlay')!,
161
+ backdrop: container.querySelector('overlay > view')!,
162
+ panel: container.querySelector('[accessibility-exclusive-focus]')!,
163
+ // A button's pressable tile: the view around its label.
164
+ button: (label: string) => result.getByText(label).parentElement!,
165
+ };
166
+ };
167
+
168
+ /**
169
+ * Opens the action sheet and waits out its open sequence: the overlay shows
170
+ * on one tick, and the panel slides in on the next.
171
+ */
172
+ const openSheet = async (host: ActionSheetHost) => {
173
+ host.open.set(true);
174
+ for (let i = 0; i < 3; i++) await waitForUpdate();
175
+ };
176
+
177
+ /**
178
+ * Waits for the close sequence to finish: the panel slides out, then the
179
+ * overlay hides and `closed` fires. Polled rather than timed, since the
180
+ * sequence only starts on the update after `open` changes.
181
+ */
182
+ const waitForClose = async (host: ActionSheetHost) => {
183
+ await vi.waitFor(() => expect(host.closed).toHaveBeenCalled());
184
+ await waitForUpdate();
185
+ };
186
+
187
+ /**
188
+ * The scale a button's inline style holds, if any.
189
+ */
190
+ const scaleOf = (element: Element) =>
191
+ (element as HTMLElement).style.getPropertyValue('transform');
192
+
193
+ /**
194
+ * Presses a button and lets go every way Lynx reports it, checking that it
195
+ * scales down while pressed and back up once released.
196
+ */
197
+ const expectPressFeedback = (button: Element) => {
198
+ fireEvent.touchstart(button);
199
+ expect(scaleOf(button)).toBe('scale(0.985)');
200
+ fireEvent.touchend(button);
201
+ expect(scaleOf(button)).toBe('scale(1)');
202
+
203
+ // A scroll that takes the touch over cancels it: the button scales back.
204
+ fireEvent.touchstart(button);
205
+ fireEvent.touchcancel(button);
206
+ expect(scaleOf(button)).toBe('scale(1)');
207
+
208
+ // On web, the mouse drives the same feedback.
209
+ fireEvent.mousedown(button);
210
+ expect(scaleOf(button)).toBe('scale(0.985)');
211
+ fireEvent.mouseup(button);
212
+ expect(scaleOf(button)).toBe('scale(1)');
213
+
214
+ fireEvent.mousedown(button);
215
+ fireLynxEvent(button, 'mouseleave');
216
+ expect(scaleOf(button)).toBe('scale(1)');
217
+ };
218
+
34
219
  describe('UiActionSheet', () => {
220
+ // Watched per test: the testing library resets the main thread after each.
221
+ let animate: MockInstance<LynxMainThread['__ElementAnimate']>;
222
+ beforeEach(() => {
223
+ animate = vi.spyOn(lynxMainThread(), '__ElementAnimate');
224
+ });
225
+ afterEach(() => animate.mockRestore());
226
+
227
+ it('stays hidden until opened, then shows its title and buttons', async () => {
228
+ const { host, overlay, getByText } = await renderSheet();
229
+ expect(overlay.getAttribute('style')).toContain('display: none');
230
+ expect(overlay.getAttribute('visible')).toBe('false');
231
+
232
+ await openSheet(host);
233
+ expect(overlay.getAttribute('style')).not.toContain('display: none');
234
+ expect(overlay.getAttribute('visible')).toBe('true');
235
+ expect(getByText('Pick')).toBeTruthy();
236
+ expect(getByText('Share')).toBeTruthy();
237
+ expect(getByText('Delete')).toBeTruthy();
238
+ expect(getByText('Cancel')).toBeTruthy();
239
+ });
240
+
241
+ it("merges the class into the panel's own", async () => {
242
+ const { panel } = await renderSheet();
243
+ expect(panel.classList).toContain('pb-8');
244
+ expect(panel.classList).not.toContain('pb-6');
245
+ expect(panel.classList).toContain('px-4');
246
+ });
247
+
248
+ it('fades the backdrop in and slides the panel up when opened', async () => {
249
+ const { host, backdrop, panel } = await renderSheet();
250
+ await openSheet(host);
251
+ expect(animate).toHaveBeenCalledWith(backdrop, [
252
+ START,
253
+ expect.any(String),
254
+ [{ opacity: 0 }, { opacity: 1 }],
255
+ expect.objectContaining({ duration: DURATION.normal }),
256
+ ]);
257
+ expect(animate).toHaveBeenCalledWith(panel, [
258
+ START,
259
+ expect.any(String),
260
+ [
261
+ { transform: 'translateY(100%)', opacity: 0.8 },
262
+ { transform: 'translateY(0%)', opacity: 1 },
263
+ ],
264
+ expect.objectContaining({ duration: DURATION.slow }),
265
+ ]);
266
+ });
267
+
268
+ it('moves a screen reader into the panel and keeps it there while open', async () => {
269
+ const invoke = vi.spyOn(
270
+ globalThis as unknown as InvokeUIMethod,
271
+ '__InvokeUIMethod',
272
+ );
273
+ const { host, panel } = await renderSheet();
274
+ expect(panel.getAttribute('accessibility-exclusive-focus')).toBe('false');
275
+
276
+ await openSheet(host);
277
+ expect(invoke).toHaveBeenCalledWith(
278
+ panel,
279
+ 'requestAccessibilityFocus',
280
+ expect.anything(),
281
+ expect.any(Function),
282
+ );
283
+ expect(panel.getAttribute('accessibility-exclusive-focus')).toBe('true');
284
+ invoke.mockRestore();
285
+ });
286
+
287
+ it('closes when the backdrop is tapped, sliding the panel down', async () => {
288
+ const { host, overlay, backdrop, panel } = await renderSheet();
289
+ await openSheet(host);
290
+
291
+ fireEvent.tap(backdrop);
292
+ expect(host.open()).toBe(false);
293
+ await waitForClose(host);
294
+ expect(animate).toHaveBeenCalledWith(backdrop, [
295
+ START,
296
+ expect.any(String),
297
+ [{ opacity: 1 }, { opacity: 0 }],
298
+ expect.objectContaining({ duration: DURATION.normal }),
299
+ ]);
300
+ expect(animate).toHaveBeenCalledWith(panel, [
301
+ START,
302
+ expect.any(String),
303
+ [
304
+ { transform: 'translateY(0%)', opacity: 1 },
305
+ { transform: 'translateY(100%)', opacity: 0.8 },
306
+ ],
307
+ expect.objectContaining({ duration: DURATION.normal }),
308
+ ]);
309
+ // Once the slide is over, the overlay hides and `closed` fires, only for
310
+ // this close: an action sheet that starts closed hasn't closed before.
311
+ expect(overlay.getAttribute('style')).toContain('display: none');
312
+ expect(host.closed).toHaveBeenCalledOnce();
313
+ });
314
+
315
+ it("closes on Android's back button", async () => {
316
+ const { host, overlay } = await renderSheet();
317
+ await openSheet(host);
318
+
319
+ fireLynxEvent(overlay, 'requestclose');
320
+ expect(host.open()).toBe(false);
321
+ });
322
+
323
+ it('stays open when the panel is tapped', async () => {
324
+ const { host, panel } = await renderSheet();
325
+ await openSheet(host);
326
+ // The panel catches its taps, so they never reach the backdrop.
327
+ fireEvent.tap(panel, { eventType: 'catchEvent' });
328
+ expect(host.open()).toBe(true);
329
+ });
330
+
35
331
  it('stops its close timers when it is destroyed while closing', async () => {
36
332
  const warn = vi.spyOn(console, 'warn');
37
- const { componentRef, unmount } = await render(UiActionSheet);
38
- const inst = componentRef.instance as UiActionSheet;
39
- inst.open.set(true);
40
- for (let i = 0; i < 5; i++) await waitForUpdate();
41
- inst.open.set(false);
333
+ const { host, unmount } = await renderSheet();
334
+ await openSheet(host);
335
+ host.open.set(false);
42
336
  await waitForUpdate();
43
- // Destroyed during the close animation, as an `@if` around the action sheet
44
- // would do. Its close timer (DURATION.normal + 20) used to fire anyway
45
- // and emit `closed` from the destroyed output.
337
+ // Destroyed during the close animation, as an `@if` around the action
338
+ // sheet would do. Its close timer (DURATION.normal + 20) used to fire
339
+ // anyway and emit `closed` from the destroyed output.
46
340
  unmount();
47
- await new Promise((r) => setTimeout(r, DURATION.normal + 50));
341
+ await new Promise((resolve) => setTimeout(resolve, DURATION.normal + 50));
48
342
  const warnings = warn.mock.calls.map(([message]) => String(message));
49
343
  warn.mockRestore();
50
344
  expect(warnings.filter((message) => message.includes('NG0953'))).toEqual(
51
345
  [],
52
346
  );
347
+ expect(host.closed).not.toHaveBeenCalled();
53
348
  });
349
+ });
54
350
 
55
- it('moves a screen reader into the panel and keeps it there while open', async () => {
56
- const { componentRef, container } = await render(UiActionSheet);
57
- const inst = componentRef.instance as UiActionSheet;
58
- const panel = container.querySelector(
59
- '[accessibility-exclusive-focus]',
60
- ) as Element & { invoke?: unknown };
61
- expect(panel.getAttribute('accessibility-exclusive-focus')).toBe('false');
62
- const invoke = vi.fn();
63
- panel.invoke = invoke;
64
- (inst as unknown as { backdropRef: unknown }).backdropRef = signal({
65
- nativeElement: panel,
66
- });
67
- (inst as unknown as { panelRef: unknown }).panelRef = signal({
68
- nativeElement: panel,
69
- });
70
- inst.open.set(true);
71
- for (let i = 0; i < 5; i++) await waitForUpdate();
72
- expect(invoke).toHaveBeenCalledWith('requestAccessibilityFocus');
73
- expect(panel.getAttribute('accessibility-exclusive-focus')).toBe('true');
351
+ describe('UiActionSheetTitle', () => {
352
+ it('shows its text muted, with the class on its container', async () => {
353
+ const { getByText } = await renderSheet();
354
+ const title = getByText('Pick');
355
+ expect(title.classList).toContain('text-muted-foreground');
356
+ expect(title.parentElement?.classList).toContain('py-4');
357
+ expect(title.parentElement?.classList).not.toContain('py-3');
74
358
  });
359
+ });
75
360
 
76
- const patchRefs = (inst: UiActionSheet, view: Element) => {
77
- (inst as unknown as { backdropRef: unknown }).backdropRef = signal({
78
- nativeElement: view,
79
- });
80
- (inst as unknown as { panelRef: unknown }).panelRef = signal({
81
- nativeElement: view,
82
- });
83
- };
361
+ describe('UiActionSheetItem', () => {
362
+ it('reports the press and closes the action sheet when tapped', async () => {
363
+ const { host, button } = await renderSheet();
364
+ await openSheet(host);
84
365
 
85
- it('renders the sheet, title, item, and cancel content', async () => {
86
- const { getByText } = await render(ActionSheetHost);
87
- expect(getByText('Pick')).toBeTruthy();
88
- expect(getByText('Option')).toBeTruthy();
89
- expect(getByText('Cancel')).toBeTruthy();
366
+ fireEvent.tap(button('Share'));
367
+ expect(host.shared).toHaveBeenCalledOnce();
368
+ expect(host.deleted).not.toHaveBeenCalled();
369
+ expect(host.open()).toBe(false);
90
370
  });
91
371
 
92
- it('backdrop tap closes the sheet', async () => {
93
- const { componentRef } = await render(UiActionSheet);
94
- const inst = componentRef.instance as UiActionSheet;
95
- inst.open.set(true);
96
- await waitForUpdate();
97
- (inst as unknown as { onBackdropTap: () => void }).onBackdropTap();
98
- expect(inst.open()).toBe(false);
372
+ it('colors its label by variant', async () => {
373
+ const { getByText } = await renderSheet();
374
+ expect(getByText('Share').classList).toContain('text-primary');
375
+ expect(getByText('Delete').classList).toContain('text-destructive');
99
376
  });
100
377
 
101
- it('close() sets open to false', async () => {
102
- const { componentRef } = await render(UiActionSheet);
103
- const inst = componentRef.instance as UiActionSheet;
104
- inst.open.set(true);
105
- inst.close();
106
- expect(inst.open()).toBe(false);
378
+ it("merges the class into its tile's own", async () => {
379
+ const { button } = await renderSheet();
380
+ expect(button('Delete').classList).toContain('bg-muted');
381
+ expect(button('Delete').classList).toContain('border-t');
107
382
  });
108
383
 
109
- it('panel tap is a no-op', async () => {
110
- const { componentRef } = await render(UiActionSheet);
111
- const inst = componentRef.instance as UiActionSheet;
112
- expect(() =>
113
- (inst as unknown as { onPanelTap: () => void }).onPanelTap(),
114
- ).not.toThrow();
384
+ it('scales down while pressed, and back on release', async () => {
385
+ const { button } = await renderSheet();
386
+ expectPressFeedback(button('Share'));
115
387
  });
388
+ });
116
389
 
117
- it('open effect runs the animate-in and animate-out lifecycles', async () => {
118
- const { componentRef, container } = await render(UiActionSheet);
119
- const inst = componentRef.instance as UiActionSheet;
120
- const view = container.querySelector('view') as Element;
121
- patchRefs(inst, view);
390
+ describe('UiActionSheetCancel', () => {
391
+ it('closes the action sheet when tapped', async () => {
392
+ const { host, button } = await renderSheet();
393
+ await openSheet(host);
122
394
 
123
- inst.open.set(true);
124
- await waitForUpdate();
395
+ fireEvent.tap(button('Cancel'));
396
+ expect(host.open()).toBe(false);
397
+ });
398
+
399
+ it('shows Cancel, or the label it is given', async () => {
400
+ expect((await renderSheet()).getByText('Cancel')).toBeTruthy();
401
+ const { getByText } = await render(CancelLabelHost);
402
+ expect(getByText('Dismiss')).toBeTruthy();
403
+ });
404
+
405
+ it("merges the class into its tile's own", async () => {
406
+ const { button } = await renderSheet();
407
+ expect(button('Cancel').classList).toContain('mt-2');
408
+ expect(button('Cancel').classList).toContain('bg-card');
409
+ });
410
+
411
+ it('scales down while pressed, and back on release', async () => {
412
+ const { button } = await renderSheet();
413
+ expectPressFeedback(button('Cancel'));
414
+ });
415
+ });
416
+
417
+ /**
418
+ * The Lynx function that runs an element's UI methods, here measuring the
419
+ * anchor and the popover (`boundingClientRect`) as on a phone.
420
+ */
421
+ type MeasureUIMethod = {
422
+ __InvokeUIMethod: (
423
+ element: Element,
424
+ method: string,
425
+ params: Record<string, unknown>,
426
+ callback: (result: { code: number; data?: unknown }) => void,
427
+ ) => void;
428
+ };
429
+
430
+ /** Accessibility settings a test can change, as the host app would. */
431
+ const settings = {
432
+ reduceMotion: signal(false),
433
+ reduceTransparency: signal(false),
434
+ increaseContrast: signal(false),
435
+ };
436
+
437
+ /**
438
+ * Renders the anchored sheet on a platform (none for Material), with the
439
+ * safe area's markers a phone's screen, the popover's content 270 by 150,
440
+ * and the anchor a button near the top.
441
+ */
442
+ const renderAnchored = async (
443
+ platform: { platform: string; osVersion: string } | null = {
444
+ platform: 'iOS',
445
+ osVersion: '26.0',
446
+ },
447
+ ) => {
448
+ vi.stubGlobal('SystemInfo', platform ?? undefined);
449
+ vi.spyOn(
450
+ globalThis as unknown as MeasureUIMethod,
451
+ '__InvokeUIMethod',
452
+ ).mockImplementation((element, method, _params, callback) => {
453
+ if (method !== 'boundingClientRect') {
454
+ callback({ code: 0 });
455
+ return;
456
+ }
457
+ const style = element.getAttribute('style') ?? '';
458
+ const data = style.includes('safe-area-inset')
459
+ ? { left: 0, top: 0, width: 402, height: 840 }
460
+ : element.classList.contains('shrink-0')
461
+ ? { left: 0, top: 0, width: 270, height: 150 }
462
+ : { left: 16, top: 80, width: 80, height: 44 };
463
+ callback({ code: 0, data });
464
+ });
465
+ const result = await render(AnchoredHost, {
466
+ providers: [{ provide: LynxAccessibilitySettings, useValue: settings }],
467
+ });
468
+ const query = (selector: string) =>
469
+ result.container.querySelector(selector) as HTMLElement | null;
470
+ return {
471
+ ...result,
472
+ host: result.componentRef.instance as AnchoredHost,
473
+ overlay: () => query('overlay')!,
474
+ anchor: () => query('.share')!,
475
+ popover: () => query('blur-view[ui-glass]:not(.share)'),
476
+ content: () => query('.shrink-0')!,
477
+ // A button's pressable row: the view around its label.
478
+ button: (label: string) => result.getByText(label).parentElement!,
479
+ };
480
+ };
481
+
482
+ type Anchored = Awaited<ReturnType<typeof renderAnchored>>;
483
+
484
+ /**
485
+ * Opens the anchored sheet and waits until its popover has grown.
486
+ */
487
+ const openAnchored = async (rendered: Anchored) => {
488
+ fireEvent.tap(rendered.anchor());
489
+ await vi.waitFor(() => expect(rendered.content().style.opacity).toBe('1'));
490
+ };
491
+
492
+ /**
493
+ * Lets Lynx's timers run: the touch check waits for the next one.
494
+ */
495
+ const nextTimer = () => new Promise((resolve) => setTimeout(resolve, 5));
496
+
497
+ describe('UiActionSheet opened from its control', () => {
498
+ beforeEach(() => {
499
+ // The renderer warns that the components aren't Lynx elements.
500
+ vi.spyOn(console, 'warn').mockImplementation(() => {});
501
+ });
502
+ afterEach(() => {
503
+ vi.restoreAllMocks();
504
+ vi.unstubAllGlobals();
505
+ });
506
+
507
+ it('grows out of its control as Liquid Glass on iOS 26, leaving the screen undimmed', async () => {
508
+ const rendered = await renderAnchored();
509
+ await openAnchored(rendered);
510
+
511
+ const popover = rendered.popover()!;
512
+ expect(popover.getAttribute('blur-effect')).toBe('glass');
513
+ expect(popover.style.width).toBe('270px');
514
+ // The bottom sheet and its backdrop stay hidden.
515
+ expect(rendered.overlay().getAttribute('visible')).toBe('false');
516
+ // The control hides under the glass that grows out of it.
517
+ expect(rendered.anchor().style.visibility).toBe('hidden');
518
+ // Its title and actions, as iOS 26 lays them out, and no Cancel: a
519
+ // touch outside cancels.
520
+ expect(popover.contains(rendered.getByText('Delete this draft?'))).toBe(
521
+ true,
522
+ );
523
+ expect(rendered.getByText('Delete this draft?').classList).toContain(
524
+ 'text-glass-muted-foreground',
525
+ );
526
+ expect(rendered.getByText('Save Draft').classList).toContain(
527
+ 'text-glass-foreground',
528
+ );
529
+ expect(rendered.getByText('Delete Draft').classList).toContain(
530
+ 'text-destructive',
531
+ );
532
+ expect(rendered.button('Save Draft').classList).toContain('min-h-[44px]');
533
+ expect(rendered.button('Save Draft').classList).toContain('justify-center');
534
+ expect(popover.contains(rendered.getByText('Cancel'))).toBe(false);
535
+ });
536
+
537
+ it("takes a glass control's glass over, shown as the control hides", async () => {
538
+ const rendered = await renderAnchored();
539
+ expect(rendered.anchor().hasAttribute('glass-effect-id')).toBe(false);
540
+ rendered.host.glassAnchor.set(true);
125
541
  await waitForUpdate();
542
+ const id = rendered.anchor().getAttribute('glass-effect-id');
543
+ expect(id).toMatch(/^ui-action-sheet-\d+$/);
544
+
545
+ await openAnchored(rendered);
546
+ expect(rendered.popover()!.getAttribute('glass-effect-id')).toBe(id);
547
+ expect(rendered.anchor().style.visibility).toBe('hidden');
548
+
549
+ // A control that goes takes its glass with it.
550
+ rendered.host.open.set(false);
551
+ await vi.waitFor(() => expect(rendered.host.closed).toHaveBeenCalled());
552
+ rendered.host.glassAnchor.set(false);
126
553
  await waitForUpdate();
554
+ expect(rendered.anchor().tagName.toLowerCase()).toBe('view');
555
+ });
556
+
557
+ it('chooses an action, and closes', async () => {
558
+ const rendered = await renderAnchored();
559
+ await openAnchored(rendered);
127
560
 
128
- inst.open.set(false);
129
- await new Promise((r) => setTimeout(r, 320));
561
+ fireEvent.tap(rendered.button('Delete Draft'));
562
+ expect(rendered.host.deleted).toHaveBeenCalledTimes(1);
563
+ expect(rendered.host.open()).toBe(false);
564
+ await vi.waitFor(() => expect(rendered.host.closed).toHaveBeenCalled());
130
565
  await waitForUpdate();
131
- expect(inst.open()).toBe(false);
566
+ expect(rendered.popover()).toBeNull();
567
+ expect(rendered.anchor().style.visibility).toBe('');
568
+ expect(rendered.host.closed).toHaveBeenCalledTimes(1);
132
569
  });
133
570
 
134
- it('open effect bails when backdrop/panel refs are undefined', async () => {
135
- const { componentRef } = await render(UiActionSheet);
136
- const inst = componentRef.instance as UiActionSheet;
137
- const closed = vi.fn();
138
- inst.closed.subscribe(closed);
139
- inst.open.set(true);
571
+ it('lights a row up while it is pressed, rather than shrinking it', async () => {
572
+ const rendered = await renderAnchored();
573
+ await openAnchored(rendered);
574
+ const row = rendered.button('Save Draft');
575
+ const highlight = row.querySelector('.bg-glass-foreground') as HTMLElement;
576
+ // iOS 26's menu rows light up in a capsule, inset from the sides.
577
+ expect(highlight.className.split(' ')).toEqual(
578
+ expect.arrayContaining(['left-[10px]', 'right-[10px]', 'rounded-full']),
579
+ );
580
+
581
+ fireEvent.touchstart(row);
140
582
  await waitForUpdate();
583
+ expect(highlight.style.opacity).toBe('0.08');
584
+ expect(row.style.transform).toBe('');
585
+ fireEvent.touchend(row);
141
586
  await waitForUpdate();
142
- inst.open.set(false);
587
+ expect(highlight.style.opacity).toBe('0');
588
+ fireEvent.mousedown(row);
143
589
  await waitForUpdate();
144
- // Wait out the close sequence, so #animateOut runs (and bails) while the
145
- // component is still alive. Destroying it clears the sequence's timers.
146
- await new Promise((r) => setTimeout(r, DURATION.normal + 50));
147
- expect(inst.open()).toBe(false);
148
- expect(closed).toHaveBeenCalledOnce();
590
+ expect(highlight.style.opacity).toBe('0.08');
591
+ fireLynxEvent(row, 'mouseleave');
592
+ await waitForUpdate();
593
+ expect(highlight.style.opacity).toBe('0');
149
594
  });
150
595
 
151
- it('applies user class on the panel', async () => {
152
- const { componentRef, container } = await render(UiActionSheet);
153
- const inst = componentRef.instance as UiActionSheet;
154
- setInputSignal(inst.userClass, 'my-panel');
596
+ it('lights a Material row up edge to edge', async () => {
597
+ const rendered = await renderAnchored(null);
598
+ await openAnchored(rendered);
599
+ const row = rendered.button('Save Draft');
600
+ const highlight = row.querySelector('.bg-glass-foreground') as HTMLElement;
601
+ expect(highlight.className.split(' ')).toEqual(
602
+ expect.arrayContaining(['left-0', 'right-0']),
603
+ );
604
+ fireEvent.touchstart(row);
155
605
  await waitForUpdate();
156
- expect(
157
- Array.from(container.querySelectorAll('view')).some((el) =>
158
- el.getAttribute('class')?.includes('my-panel'),
159
- ),
160
- ).toBe(true);
606
+ expect(highlight.style.opacity).toBe('0.1');
161
607
  });
162
- });
163
608
 
164
- describe('UiActionSheetTitle', () => {
165
- it('renders projected content', async () => {
166
- const { container } = await render(UiActionSheetTitle);
167
- expect(container.querySelector('view')).toBeTruthy();
168
- expect(container.querySelector('text')).toBeTruthy();
609
+ it('closes on a touch outside it, without counting one on its control', async () => {
610
+ const rendered = await renderAnchored();
611
+ await openAnchored(rendered);
612
+
613
+ // A touch on the control that presented it isn't outside it.
614
+ fireLynxEvent(rendered.anchor(), 'touchstart', {}, 'bindEvent');
615
+ fireLynxEvent(rendered.popover()!, 'touchstart', {}, 'global-bindEvent');
616
+ await nextTimer();
617
+ fireLynxEvent(rendered.anchor(), 'mousedown', {}, 'bindEvent');
618
+ fireLynxEvent(rendered.popover()!, 'mousedown', {}, 'global-bindEvent');
619
+ await nextTimer();
620
+ expect(rendered.host.open()).toBe(true);
621
+
622
+ fireLynxEvent(rendered.popover()!, 'touchstart', {}, 'global-bindEvent');
623
+ await nextTimer();
624
+ expect(rendered.host.open()).toBe(false);
169
625
  });
170
- });
171
626
 
172
- describe('UiActionSheetItem', () => {
173
- const renderItem = async () => {
174
- const parentResult = await render(UiActionSheet);
175
- const parent = parentResult.componentRef.instance as UiActionSheet;
176
- parent.open.set(true);
177
- await waitForUpdate();
627
+ it('is a Material 3 menu beside its control on Android', async () => {
628
+ const rendered = await renderAnchored(null);
629
+ await openAnchored(rendered);
630
+
631
+ const popover = rendered.popover()!;
632
+ expect(popover.style.width).toBe('280px');
633
+ expect(popover.classList).toContain('bg-secondary');
634
+ // Beside the control, which stays in view.
635
+ expect(rendered.anchor().style.visibility).toBe('');
636
+ expect(rendered.button('Save Draft').classList).toContain('min-h-[48px]');
637
+ expect(rendered.button('Save Draft').classList).not.toContain(
638
+ 'justify-center',
639
+ );
640
+ expect(rendered.getByText('Save Draft').classList).toContain('text-[16px]');
641
+ expect(
642
+ rendered.getByText('Delete this draft?').parentElement?.classList,
643
+ ).toContain('px-3');
644
+ });
178
645
 
179
- const itemResult = await render(UiActionSheetItem, {
180
- providers: [{ provide: UiActionSheet, useValue: parent }],
646
+ it('rises from the bottom before iOS 26, as iPhone action sheets did', async () => {
647
+ const rendered = await renderAnchored({
648
+ platform: 'iOS',
649
+ osVersion: '18.5',
181
650
  });
182
- return { parent, itemResult };
183
- };
651
+ fireEvent.tap(rendered.anchor());
652
+ for (let i = 0; i < 3; i++) await waitForUpdate();
653
+
654
+ expect(rendered.overlay().getAttribute('visible')).toBe('true');
655
+ expect(rendered.popover()).toBeNull();
656
+ expect(rendered.getByText('Save Draft').classList).toContain(
657
+ 'text-primary',
658
+ );
659
+ expect(rendered.anchor().style.visibility).toBe('');
660
+ });
184
661
 
185
- it('onTap emits `pressed` and closes the parent sheet', async () => {
186
- const { parent, itemResult } = await renderItem();
187
- const inst = itemResult.componentRef
188
- .instance as unknown as UiActionSheetItem & {
189
- onTap: () => void;
190
- };
191
- const emitted = vi.fn();
192
- inst.pressed.subscribe(emitted);
193
- inst.onTap();
194
- expect(emitted).toHaveBeenCalledTimes(1);
195
- expect(parent.open()).toBe(false);
196
- });
197
-
198
- it('press handlers run cleanly', async () => {
199
- const { itemResult } = await renderItem();
200
- const inst = itemResult.componentRef.instance as unknown as {
201
- onPressStart: () => void;
202
- onPressEnd: () => void;
203
- onPressCancel: () => void;
204
- };
205
- expect(() => inst.onPressStart()).not.toThrow();
206
- expect(() => inst.onPressEnd()).not.toThrow();
207
- expect(() => inst.onPressCancel()).not.toThrow();
208
- });
209
-
210
- it('applies destructive variant text class', async () => {
211
- const { itemResult } = await renderItem();
212
- const inst = itemResult.componentRef.instance as UiActionSheetItem;
213
- setInputSignal(inst.variant, 'destructive');
662
+ it('rises from the bottom again once its control is gone', async () => {
663
+ const rendered = await renderAnchored();
664
+ rendered.host.anchored.set(false);
214
665
  await waitForUpdate();
215
- const text = itemResult.container.querySelector('text');
216
- expect(text?.getAttribute('class')).toContain('text-destructive');
666
+
667
+ rendered.host.open.set(true);
668
+ for (let i = 0; i < 3; i++) await waitForUpdate();
669
+ expect(rendered.overlay().getAttribute('visible')).toBe('true');
670
+ expect(rendered.popover()).toBeNull();
671
+ // Its actions are back in the sheet's group, with Cancel below.
672
+ expect(rendered.button('Save Draft').classList).toContain('border-t');
673
+ expect(rendered.getByText('Cancel')).toBeTruthy();
217
674
  });
218
- });
219
675
 
220
- describe('UiActionSheetCancel', () => {
221
- const renderCancel = async () => {
222
- const parentResult = await render(UiActionSheet);
223
- const parent = parentResult.componentRef.instance as UiActionSheet;
224
- parent.open.set(true);
676
+ it("doesn't report a close when its control comes or goes while it's closed", async () => {
677
+ const rendered = await renderAnchored();
678
+ // Opened and closed from the bottom once...
679
+ rendered.host.anchored.set(false);
225
680
  await waitForUpdate();
681
+ rendered.host.open.set(true);
682
+ for (let i = 0; i < 3; i++) await waitForUpdate();
683
+ rendered.host.open.set(false);
684
+ await vi.waitFor(() => expect(rendered.host.closed).toHaveBeenCalled());
226
685
 
227
- const cancelResult = await render(UiActionSheetCancel, {
228
- providers: [{ provide: UiActionSheet, useValue: parent }],
229
- });
230
- return { parent, cancelResult };
231
- };
686
+ // ...then given its control back, closed.
687
+ rendered.host.anchored.set(true);
688
+ await waitForUpdate();
689
+ await new Promise((resolve) => setTimeout(resolve, 300));
690
+ expect(rendered.host.closed).toHaveBeenCalledTimes(1);
691
+ });
692
+ });
693
+
694
+ describe('UiActionSheet accessibility', () => {
695
+ it('reads its title as a heading and its actions as buttons', async () => {
696
+ const { getByText } = await renderSheet();
697
+ expect(getByText('Pick').getAttribute('accessibility-traits')).toBe(
698
+ 'header',
699
+ );
700
+ expect(getByText('Share').getAttribute('accessibility-traits')).toBe(
701
+ 'button',
702
+ );
703
+ expect(getByText('Cancel').getAttribute('accessibility-traits')).toBe(
704
+ 'button',
705
+ );
706
+ });
232
707
 
233
- it('renders the default cancel label', async () => {
234
- const { cancelResult } = await renderCancel();
235
- expect(cancelResult.container.textContent).toContain('Cancel');
236
- });
237
-
238
- it('onTap closes the parent sheet', async () => {
239
- const { parent, cancelResult } = await renderCancel();
240
- const inst = cancelResult.componentRef.instance as unknown as {
241
- onTap: () => void;
242
- };
243
- inst.onTap();
244
- expect(parent.open()).toBe(false);
245
- });
246
-
247
- it('press handlers run cleanly', async () => {
248
- const { cancelResult } = await renderCancel();
249
- const inst = cancelResult.componentRef.instance as unknown as {
250
- onPressStart: () => void;
251
- onPressEnd: () => void;
252
- onPressCancel: () => void;
253
- };
254
- expect(() => inst.onPressStart()).not.toThrow();
255
- expect(() => inst.onPressEnd()).not.toThrow();
256
- expect(() => inst.onPressCancel()).not.toThrow();
708
+ it('draws a title outside an action sheet as in one', async () => {
709
+ const { getByText } = await render(LoneTitleHost);
710
+ expect(getByText('Alone').classList).toContain('text-muted-foreground');
257
711
  });
258
712
  });