@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,30 @@
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';
1
+ import { Component, signal, viewChild } from '@angular/core';
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 {
20
+ type LynxMainThread,
21
+ lynxMainThread,
22
+ } from '../../../test-utils/lynx-main-thread';
7
23
  import {
24
+ type AlertDialogFooterOrientation,
25
+ type AlertDialogVariant,
8
26
  UiAlertDialog,
27
+ UiAlertDialogAction,
9
28
  UiAlertDialogDescription,
10
29
  UiAlertDialogFooter,
11
30
  UiAlertDialogHeader,
@@ -22,167 +41,759 @@ import {
22
41
  LYNX_ELEMENTS,
23
42
  ],
24
43
  template: `
25
- <ui-alert-dialog [(open)]="open">
26
- <ui-alert-dialog-header>
27
- <ui-alert-dialog-title>Sure?</ui-alert-dialog-title>
28
- <ui-alert-dialog-description>Cannot undo</ui-alert-dialog-description>
29
- </ui-alert-dialog-header>
30
- <ui-alert-dialog-footer><text>Buttons</text></ui-alert-dialog-footer>
31
- </ui-alert-dialog>
44
+ @if (shown()) {
45
+ <ui-alert-dialog
46
+ [(open)]="open"
47
+ class="max-w-sm"
48
+ (closed)="closes.set(closes() + 1)"
49
+ >
50
+ <ui-alert-dialog-header class="gap-2">
51
+ <ui-alert-dialog-title class="text-xl">Sure?</ui-alert-dialog-title>
52
+ <ui-alert-dialog-description class="italic">
53
+ Cannot undo
54
+ </ui-alert-dialog-description>
55
+ </ui-alert-dialog-header>
56
+ <ui-alert-dialog-footer class="justify-start">
57
+ <text>Buttons</text>
58
+ </ui-alert-dialog-footer>
59
+ </ui-alert-dialog>
60
+ }
32
61
  `,
33
62
  })
34
63
  class AlertDialogHost {
35
- open = true;
64
+ readonly open = signal(false);
65
+ // Takes the alert dialog out of the tree, as an `@if` or a route change
66
+ // would.
67
+ readonly shown = signal(true);
68
+ readonly closes = signal(0);
69
+ readonly dialog = viewChild.required(UiAlertDialog);
36
70
  }
37
71
 
72
+ const renderAlertDialog = async () => {
73
+ const result = await render(AlertDialogHost);
74
+ const panel = () =>
75
+ result.container.querySelector('[accessibility-exclusive-focus]')!;
76
+ return {
77
+ ...result,
78
+ host: result.componentRef.instance as AlertDialogHost,
79
+ overlay: () => result.container.querySelector('overlay')!,
80
+ panel,
81
+ backdrop: () => panel().parentElement!,
82
+ };
83
+ };
84
+
85
+ /**
86
+ * Waits for the alert dialog's open or close sequence, which runs on timers, then
87
+ * renders what it changed.
88
+ */
89
+ const after = async (ms: number) => {
90
+ await new Promise((resolve) => setTimeout(resolve, ms));
91
+ await waitForUpdate();
92
+ };
93
+
94
+ /**
95
+ * Long enough for the open sequence, including the panel's 30ms stagger. The
96
+ * sequence is chained timers, and on Windows each can fire up to a clock
97
+ * tick (about 16ms) late, so 50ms was too short on CI's Windows runner.
98
+ */
99
+ const OPENED = 150;
100
+ /** Long enough for the close animation and the hide after it, as above. */
101
+ const CLOSED = DURATION.normal + 150;
102
+
103
+ /** `__ElementAnimate`'s first argument for starting an animation. */
104
+ const START = 0;
105
+ /** `__ElementAnimate`'s first argument for canceling an animation. */
106
+ const CANCEL = 3;
107
+
108
+ /** Accessibility settings a test can change, as the host app would. */
109
+ const settings = {
110
+ reduceMotion: signal(false),
111
+ reduceTransparency: signal(false),
112
+ increaseContrast: signal(false),
113
+ };
114
+
115
+ // Watched per test: the testing library resets the main thread after each.
116
+ let animate: MockInstance<LynxMainThread['__ElementAnimate']>;
117
+ beforeEach(() => {
118
+ animate = vi.spyOn(lynxMainThread(), '__ElementAnimate');
119
+ });
120
+ afterEach(() => {
121
+ animate.mockRestore();
122
+ vi.unstubAllGlobals();
123
+ settings.reduceMotion.set(false);
124
+ });
125
+
38
126
  describe('UiAlertDialog', () => {
39
- it('stops its close timers when it is destroyed while closing', async () => {
127
+ it('renders each part with its projected content', async () => {
128
+ const { getByText } = await renderAlertDialog();
129
+ expect(getByText('Sure?')).toBeTruthy();
130
+ expect(getByText('Cannot undo')).toBeTruthy();
131
+ expect(getByText('Buttons')).toBeTruthy();
132
+ });
133
+
134
+ it("merges each part's class with its own, the given class winning", async () => {
135
+ const { container, getByText, panel } = await renderAlertDialog();
136
+ expect(panel().classList).toContain('bg-background');
137
+ expect(panel().classList).toContain('max-w-sm');
138
+ expect(getByText('Sure?').classList).toContain('font-semibold');
139
+ expect(getByText('Sure?').classList).toContain('text-xl');
140
+ expect(getByText('Cannot undo').classList).toContain('text-sm');
141
+ expect(getByText('Cannot undo').classList).toContain('italic');
142
+ // Each part's own view, not its host element, which keeps the bare class.
143
+ const header = container.querySelector('.flex-col.gap-2');
144
+ expect(header?.classList).not.toContain('gap-1.5');
145
+ const footer = container.querySelector('.pt-4');
146
+ expect(footer?.classList).toContain('justify-start');
147
+ expect(footer?.classList).not.toContain('justify-end');
148
+ });
149
+
150
+ it('stays hidden until it is opened', async () => {
151
+ const { host, overlay, panel } = await renderAlertDialog();
152
+ await after(CLOSED);
153
+ expect(overlay().getAttribute('style')).toContain('display: none');
154
+ expect(panel().getAttribute('accessibility-exclusive-focus')).toBe('false');
155
+ expect(animate).not.toHaveBeenCalled();
156
+ // Starting closed isn't closing: `closed` waits for a real close.
157
+ expect(host.closes()).toBe(0);
158
+ });
159
+
160
+ it('shows the overlay and animates the backdrop, then the panel, in', async () => {
161
+ const { host, overlay, backdrop, panel } = await renderAlertDialog();
162
+ host.open.set(true);
163
+ await after(OPENED);
164
+ expect(overlay().getAttribute('style')).not.toContain('display: none');
165
+ expect(animate).toHaveBeenCalledWith(backdrop(), [
166
+ START,
167
+ expect.any(String),
168
+ [{ opacity: 0 }, { opacity: 1 }],
169
+ expect.objectContaining({ duration: DURATION.normal }),
170
+ ]);
171
+ expect(animate).toHaveBeenCalledWith(panel(), [
172
+ START,
173
+ expect.any(String),
174
+ [
175
+ { transform: 'scale(0.9) translateY(10px)', opacity: 0 },
176
+ { transform: 'scale(1) translateY(0px)', opacity: 1 },
177
+ ],
178
+ expect.objectContaining({ duration: DURATION.slow }),
179
+ ]);
180
+ });
181
+
182
+ it('moves a screen reader into the panel and keeps it there while open', async () => {
183
+ const { host, panel } = await renderAlertDialog();
184
+ const invoke = vi.spyOn(host.dialog().panelRef().nativeElement, 'invoke');
185
+ host.open.set(true);
186
+ await after(OPENED);
187
+ expect(invoke).toHaveBeenCalledWith('requestAccessibilityFocus');
188
+ expect(panel().getAttribute('accessibility-exclusive-focus')).toBe('true');
189
+ });
190
+
191
+ it('animates out, then hides and emits closed', async () => {
192
+ const { host, overlay, backdrop, panel } = await renderAlertDialog();
193
+ host.open.set(true);
194
+ await after(OPENED);
195
+ animate.mockClear();
196
+
197
+ host.open.set(false);
198
+ await after(CLOSED);
199
+ expect(animate).toHaveBeenCalledWith(backdrop(), [
200
+ START,
201
+ expect.any(String),
202
+ [{ opacity: 1 }, { opacity: 0 }],
203
+ expect.any(Object),
204
+ ]);
205
+ expect(animate).toHaveBeenCalledWith(panel(), [
206
+ START,
207
+ expect.any(String),
208
+ [
209
+ { transform: 'scale(1) translateY(0px)', opacity: 1 },
210
+ { transform: 'scale(0.95) translateY(10px)', opacity: 0 },
211
+ ],
212
+ expect.any(Object),
213
+ ]);
214
+ expect(overlay().getAttribute('style')).toContain('display: none');
215
+ expect(panel().getAttribute('accessibility-exclusive-focus')).toBe('false');
216
+ expect(host.closes()).toBe(1);
217
+ });
218
+
219
+ it('cancels the previous animations when it opens again', async () => {
220
+ const { host, backdrop, panel } = await renderAlertDialog();
221
+ host.open.set(true);
222
+ await after(OPENED);
223
+ host.open.set(false);
224
+ await after(CLOSED);
225
+ animate.mockClear();
226
+
227
+ host.open.set(true);
228
+ await after(OPENED);
229
+ // The close animations hold their last frame (fill: forwards) until
230
+ // they're canceled, which would hide the panel being opened.
231
+ expect(animate).toHaveBeenCalledWith(backdrop(), [
232
+ CANCEL,
233
+ expect.any(String),
234
+ ]);
235
+ expect(animate).toHaveBeenCalledWith(panel(), [CANCEL, expect.any(String)]);
236
+ });
237
+
238
+ it('stays open when the backdrop or the panel is tapped', async () => {
239
+ const { host, backdrop, panel } = await renderAlertDialog();
240
+ host.open.set(true);
241
+ await after(OPENED);
242
+ // Unlike a dialog, an alert dialog waits for an answer from its actions.
243
+ fireEvent.tap(backdrop());
244
+ // The panel catches its taps, so they don't reach the backdrop either.
245
+ fireEvent.tap(panel(), { eventType: 'catchEvent' });
246
+ await waitForUpdate();
247
+ expect(host.open()).toBe(true);
248
+ });
249
+
250
+ it('only fades its panel under Reduce Motion', async () => {
251
+ const result = await render(AlertDialogHost, {
252
+ providers: [{ provide: LynxAccessibilitySettings, useValue: settings }],
253
+ });
254
+ const host = result.componentRef.instance as AlertDialogHost;
255
+ const panel = result.container.querySelector(
256
+ '[accessibility-exclusive-focus]',
257
+ )!;
258
+ settings.reduceMotion.set(true);
259
+ host.open.set(true);
260
+ await after(OPENED);
261
+ expect(animate).toHaveBeenCalledWith(panel, [
262
+ START,
263
+ expect.any(String),
264
+ [{ opacity: 0 }, { opacity: 1 }],
265
+ expect.any(Object),
266
+ ]);
267
+
268
+ host.open.set(false);
269
+ await after(CLOSED);
270
+ expect(animate).toHaveBeenCalledWith(panel, [
271
+ START,
272
+ expect.any(String),
273
+ [{ opacity: 1 }, { opacity: 0 }],
274
+ expect.any(Object),
275
+ ]);
276
+ });
277
+
278
+ it('stops its close sequence when it is destroyed while closing', async () => {
40
279
  const warn = vi.spyOn(console, 'warn');
41
- const { componentRef, unmount } = await render(UiAlertDialog);
42
- const inst = componentRef.instance as UiAlertDialog;
43
- inst.open.set(true);
44
- for (let i = 0; i < 5; i++) await waitForUpdate();
45
- inst.open.set(false);
280
+ const { host } = await renderAlertDialog();
281
+ host.open.set(true);
282
+ await after(OPENED);
283
+ host.open.set(false);
284
+ await after(0);
285
+ // Destroyed during the close animation. Its close timer
286
+ // (DURATION.normal + 20) used to fire anyway and emit `closed` from the
287
+ // destroyed output, which Angular warns about in dev builds.
288
+ host.shown.set(false);
46
289
  await waitForUpdate();
47
- // Destroyed during the close animation, as an `@if` around the alert dialog
48
- // would do. Its close timer (DURATION.normal + 20) used to fire anyway
49
- // and emit `closed` from the destroyed output.
50
- unmount();
51
- await new Promise((r) => setTimeout(r, DURATION.normal + 50));
290
+ await after(CLOSED);
52
291
  const warnings = warn.mock.calls.map(([message]) => String(message));
53
292
  warn.mockRestore();
54
293
  expect(warnings.filter((message) => message.includes('NG0953'))).toEqual(
55
294
  [],
56
295
  );
296
+ expect(host.closes()).toBe(0);
57
297
  });
298
+ });
58
299
 
59
- it('moves a screen reader into the panel and keeps it there while open', async () => {
60
- const { componentRef, container } = await render(UiAlertDialog);
61
- const inst = componentRef.instance as UiAlertDialog;
62
- const panel = container.querySelector(
63
- '[accessibility-exclusive-focus]',
64
- ) as Element & { invoke?: unknown };
65
- expect(panel.getAttribute('accessibility-exclusive-focus')).toBe('false');
66
- const invoke = vi.fn();
67
- panel.invoke = invoke;
68
- (inst as unknown as { backdropRef: unknown }).backdropRef = signal({
69
- nativeElement: panel,
70
- });
71
- (inst as unknown as { panelRef: unknown }).panelRef = signal({
72
- nativeElement: panel,
73
- });
74
- inst.open.set(true);
75
- for (let i = 0; i < 5; i++) await waitForUpdate();
76
- expect(invoke).toHaveBeenCalledWith('requestAccessibilityFocus');
77
- expect(panel.getAttribute('accessibility-exclusive-focus')).toBe('true');
78
- });
300
+ @Component({
301
+ imports: [
302
+ UiAlertDialog,
303
+ UiAlertDialogHeader,
304
+ UiAlertDialogTitle,
305
+ UiAlertDialogDescription,
306
+ UiAlertDialogFooter,
307
+ UiAlertDialogAction,
308
+ LYNX_ELEMENTS,
309
+ ],
310
+ template: `
311
+ <ui-alert-dialog
312
+ [variant]="variant()"
313
+ [(open)]="open"
314
+ class="tall"
315
+ (closed)="closes.set(closes() + 1)"
316
+ >
317
+ <ui-alert-dialog-header>
318
+ <ui-alert-dialog-title>Delete Photo?</ui-alert-dialog-title>
319
+ <ui-alert-dialog-description>
320
+ It's deleted from all your devices.
321
+ </ui-alert-dialog-description>
322
+ </ui-alert-dialog-header>
323
+ <ui-alert-dialog-footer [orientation]="orientation()">
324
+ <ui-alert-dialog-action
325
+ variant="cancel"
326
+ (pressed)="choices.push('cancel')"
327
+ >
328
+ Cancel
329
+ </ui-alert-dialog-action>
330
+ @if (withSave()) {
331
+ <ui-alert-dialog-action
332
+ [preferred]="savePreferred()"
333
+ (pressed)="choices.push('save')"
334
+ >
335
+ Save
336
+ </ui-alert-dialog-action>
337
+ }
338
+ <ui-alert-dialog-action
339
+ variant="destructive"
340
+ class="wide"
341
+ [preferred]="deletePreferred()"
342
+ [accessibilityLabel]="deleteLabel()"
343
+ (pressed)="choices.push('delete')"
344
+ >
345
+ Delete
346
+ </ui-alert-dialog-action>
347
+ </ui-alert-dialog-footer>
348
+ </ui-alert-dialog>
349
+ `,
350
+ })
351
+ class GlassAlertHost {
352
+ readonly variant = signal<AlertDialogVariant>('glass');
353
+ readonly open = signal(false);
354
+ readonly orientation = signal<AlertDialogFooterOrientation>('auto');
355
+ readonly withSave = signal(false);
356
+ readonly savePreferred = signal(false);
357
+ readonly deletePreferred = signal(false);
358
+ readonly deleteLabel = signal<string | undefined>(undefined);
359
+ readonly closes = signal(0);
360
+ readonly choices: string[] = [];
361
+ }
79
362
 
80
- it('renders header, title, description, and footer', async () => {
81
- const { getByText } = await render(AlertDialogHost);
82
- expect(getByText('Sure?')).toBeTruthy();
83
- expect(getByText('Cannot undo')).toBeTruthy();
84
- expect(getByText('Buttons')).toBeTruthy();
85
- });
363
+ const IOS_26 = { platform: 'iOS', osVersion: '26.0' };
364
+ const IOS_18 = { platform: 'iOS', osVersion: '18.6' };
86
365
 
87
- it('panel tap is a no-op (catchtap stops bubbling)', async () => {
88
- const { componentRef } = await render(UiAlertDialog);
89
- const inst = componentRef.instance as UiAlertDialog;
90
- expect(() =>
91
- (inst as unknown as { onPanelTap: () => void }).onPanelTap(),
92
- ).not.toThrow();
366
+ /**
367
+ * Renders the glass alert on a platform, as `SystemInfo` reports it, or with
368
+ * none, which is Material's design.
369
+ */
370
+ const renderGlassAlert = async (
371
+ platform: { platform: string; osVersion: string } | null = IOS_26,
372
+ ) => {
373
+ vi.stubGlobal('SystemInfo', platform ?? undefined);
374
+ const result = await render(GlassAlertHost, {
375
+ providers: [{ provide: LynxAccessibilitySettings, useValue: settings }],
93
376
  });
377
+ const host = result.componentRef.instance as GlassAlertHost;
378
+ const panel = () =>
379
+ result.container.querySelector('[accessibility-exclusive-focus]')!;
380
+ const shell = () => panel().parentElement!;
381
+ /**
382
+ * An action's own element, from its label.
383
+ */
384
+ const action = (label: string) => result.getByText(label).parentElement!;
385
+ const update = async (change: () => void) => {
386
+ change();
387
+ await waitForUpdate();
388
+ };
389
+ return {
390
+ ...result,
391
+ host,
392
+ panel,
393
+ shell,
394
+ dim: () => shell().previousElementSibling!,
395
+ action,
396
+ footer: () => action('Cancel').parentElement!,
397
+ style: (element: Element) => element.getAttribute('style') ?? '',
398
+ update,
399
+ };
400
+ };
94
401
 
95
- it('open effect: showing then hiding runs the animate-in/out lifecycles', async () => {
96
- // Monkey-patch backdrop/panel refs so #animateIn / #animateOut execute in
97
- // full instead of bailing on !backdrop || !panel.
98
- const { componentRef, container } = await render(UiAlertDialog);
99
- const inst = componentRef.instance as UiAlertDialog;
100
- const view = container.querySelector('view') as Element;
101
- (inst as unknown as { backdropRef: unknown }).backdropRef = signal({
102
- nativeElement: view,
402
+ describe('UiAlertDialog with variant="glass"', () => {
403
+ describe('on iOS 26 and later', () => {
404
+ it('draws a Liquid Glass panel with concentric corners', async () => {
405
+ const { panel, style } = await renderGlassAlert();
406
+ expect(panel().tagName.toLowerCase()).toBe('blur-view');
407
+ expect(panel().getAttribute('blur-effect')).toBe('glass');
408
+ // 40 = the 24px capsule buttons' radius + their 16px inset.
409
+ expect(style(panel())).toContain('border-radius: 40px');
410
+ expect(panel().classList).toContain('max-w-[320px]');
411
+ expect(panel().classList).toContain('pt-[22px]');
412
+ // The dialog's class still goes on the panel.
413
+ expect(panel().classList).toContain('tall');
103
414
  });
104
- (inst as unknown as { panelRef: unknown }).panelRef = signal({
105
- nativeElement: view,
415
+
416
+ it('sets the title and message leading, in the glass colors', async () => {
417
+ const { getByText, container } = await renderGlassAlert();
418
+ expect(getByText('Delete Photo?').classList).toContain('text-[17px]');
419
+ expect(getByText('Delete Photo?').classList).toContain(
420
+ 'text-glass-foreground',
421
+ );
422
+ expect(getByText('Delete Photo?').classList).not.toContain('text-center');
423
+ expect(
424
+ getByText("It's deleted from all your devices.").classList,
425
+ ).toContain('text-glass-muted-foreground');
426
+ expect(container.querySelector('.px-\\[14px\\]')).toBeTruthy();
106
427
  });
107
428
 
108
- inst.open.set(true);
109
- await waitForUpdate();
110
- await waitForUpdate();
111
- await waitForUpdate();
429
+ it('puts two actions side by side on capsules, cancel first', async () => {
430
+ const { action, footer, style } = await renderGlassAlert();
431
+ expect(footer().classList).toContain('flex-row');
432
+ expect(action('Cancel').classList).toContain('rounded-full');
433
+ expect(action('Cancel').classList).toContain('flex-1');
434
+ expect(action('Cancel').classList).toContain('h-[48px]');
435
+ expect(style(action('Cancel'))).toContain('order: 0');
436
+ expect(style(action('Delete'))).toContain('order: 1');
437
+ // The capsule's fill: the glass's foreground, faintly.
438
+ const fill = action('Cancel').firstElementChild!;
439
+ expect(fill.classList).toContain('bg-glass-foreground');
440
+ expect(style(fill)).toContain('opacity: 0.12');
441
+ expect(action('Delete').lastElementChild!.classList).toContain(
442
+ 'text-destructive',
443
+ );
444
+ });
112
445
 
113
- inst.open.set(false);
114
- await new Promise((r) => setTimeout(r, 320));
115
- await waitForUpdate();
116
- expect(inst.open()).toBe(false);
117
- });
446
+ it('stacks three actions, cancel at the bottom wherever it is written', async () => {
447
+ const { host, action, footer, style, update } = await renderGlassAlert();
448
+ await update(() => host.withSave.set(true));
449
+ expect(footer().classList).toContain('flex-col');
450
+ expect(action('Cancel').classList).not.toContain('flex-1');
451
+ expect(style(action('Save'))).toContain('order: 0');
452
+ expect(style(action('Delete'))).toContain('order: 1');
453
+ expect(style(action('Cancel'))).toContain('order: 2');
454
+ });
118
455
 
119
- it('open effect bails when backdrop/panel refs are undefined', async () => {
120
- // Covers the early-return branches in #animateIn / #animateOut.
121
- const { componentRef } = await render(UiAlertDialog);
122
- const inst = componentRef.instance as UiAlertDialog;
123
- const closed = vi.fn();
124
- inst.closed.subscribe(closed);
125
- inst.open.set(true);
126
- await waitForUpdate();
127
- await waitForUpdate();
128
- inst.open.set(false);
129
- await waitForUpdate();
130
- // Wait out the close sequence, so #animateOut runs (and bails) while the
131
- // component is still alive. Destroying it clears the sequence's timers.
132
- await new Promise((r) => setTimeout(r, DURATION.normal + 50));
133
- expect(inst.open()).toBe(false);
134
- expect(closed).toHaveBeenCalledOnce();
135
- });
456
+ it('lays its actions out as the footer says', async () => {
457
+ const { host, footer, update } = await renderGlassAlert();
458
+ await update(() => host.orientation.set('vertical'));
459
+ expect(footer().classList).toContain('flex-col');
460
+ await update(() => {
461
+ host.withSave.set(true);
462
+ host.orientation.set('horizontal');
463
+ });
464
+ expect(footer().classList).toContain('flex-row');
465
+ });
136
466
 
137
- it('overlayStyle switches between visible and hidden variants', async () => {
138
- const { componentRef, container } = await render(UiAlertDialog);
139
- const inst = componentRef.instance as UiAlertDialog;
140
- let overlay = container.querySelector('overlay');
141
- expect(overlay?.getAttribute('style')).toContain('display: none');
467
+ it('fills the preferred action with the tint, or the destructive color', async () => {
468
+ const { host, action, update } = await renderGlassAlert();
469
+ await update(() => {
470
+ host.withSave.set(true);
471
+ host.savePreferred.set(true);
472
+ });
473
+ expect(action('Save').classList).toContain('bg-glass-tint');
474
+ expect(action('Save').lastElementChild!.classList).toContain(
475
+ 'text-glass-tint-foreground',
476
+ );
477
+ expect(action('Save').lastElementChild!.classList).toContain(
478
+ 'font-semibold',
479
+ );
480
+ // Its highlight is the tint's foreground, shown only under the finger.
481
+ expect(action('Save').firstElementChild!.classList).toContain(
482
+ 'bg-glass-tint-foreground',
483
+ );
142
484
 
143
- const view = container.querySelector('view') as Element;
144
- (inst as unknown as { backdropRef: unknown }).backdropRef = signal({
145
- nativeElement: view,
485
+ await update(() => host.deletePreferred.set(true));
486
+ expect(action('Delete').classList).toContain('bg-destructive');
487
+ expect(action('Delete').lastElementChild!.classList).toContain(
488
+ 'text-destructive-foreground',
489
+ );
146
490
  });
147
- (inst as unknown as { panelRef: unknown }).panelRef = signal({
148
- nativeElement: view,
491
+
492
+ it('deepens a capsule while it is pressed, by touch or by mouse', async () => {
493
+ const { host, action, style, update } = await renderGlassAlert();
494
+ const fill = () => action('Cancel').firstElementChild!;
495
+ await update(() => fireEvent.touchstart(action('Cancel')));
496
+ expect(style(fill())).toContain('opacity: 0.2');
497
+ await update(() => fireEvent.touchend(action('Cancel')));
498
+ expect(style(fill())).toContain('opacity: 0.12');
499
+ await update(() => fireLynxEvent(action('Cancel'), 'mousedown'));
500
+ expect(style(fill())).toContain('opacity: 0.2');
501
+ await update(() => fireLynxEvent(action('Cancel'), 'mouseleave'));
502
+ expect(style(fill())).toContain('opacity: 0.12');
503
+ await update(() => fireLynxEvent(action('Cancel'), 'mousedown'));
504
+ await update(() => fireLynxEvent(action('Cancel'), 'mouseup'));
505
+ expect(style(fill())).toContain('opacity: 0.12');
506
+ await update(() => fireEvent.touchstart(action('Cancel')));
507
+ await update(() => fireEvent.touchcancel(action('Cancel')));
508
+ expect(style(fill())).toContain('opacity: 0.12');
509
+
510
+ // A preferred capsule lightens under the finger instead.
511
+ await update(() => host.deletePreferred.set(true));
512
+ const highlight = () => action('Delete').firstElementChild!;
513
+ expect(style(highlight())).toContain('opacity: 0');
514
+ await update(() => fireEvent.touchstart(action('Delete')));
515
+ expect(style(highlight())).toContain('opacity: 0.1');
516
+ });
517
+
518
+ it('scales its glass in from a little larger as it fades, and fades it out', async () => {
519
+ const { host, shell, dim, update } = await renderGlassAlert();
520
+ await update(() => host.open.set(true));
521
+ await after(OPENED);
522
+ expect(animate).toHaveBeenCalledWith(dim(), [
523
+ START,
524
+ expect.any(String),
525
+ [{ opacity: 0 }, { opacity: 1 }],
526
+ expect.any(Object),
527
+ ]);
528
+ // The view the glass is in, never the glass: on iOS a blur view loses
529
+ // its material at less than full opacity.
530
+ expect(animate).toHaveBeenCalledWith(shell(), [
531
+ START,
532
+ expect.any(String),
533
+ [
534
+ { transform: 'scale(1.15) translateY(0px)', opacity: 0 },
535
+ { transform: 'scale(1) translateY(0px)', opacity: 1 },
536
+ ],
537
+ expect.objectContaining({ duration: DURATION.slow }),
538
+ ]);
539
+ animate.mockClear();
540
+
541
+ await update(() => host.open.set(false));
542
+ await after(CLOSED);
543
+ expect(animate).toHaveBeenCalledWith(shell(), [
544
+ START,
545
+ expect.any(String),
546
+ [
547
+ { transform: 'scale(1) translateY(0px)', opacity: 1 },
548
+ { transform: 'scale(1) translateY(0px)', opacity: 0 },
549
+ ],
550
+ expect.any(Object),
551
+ ]);
552
+ expect(animate).toHaveBeenCalledWith(dim(), [
553
+ START,
554
+ expect.any(String),
555
+ [{ opacity: 1 }, { opacity: 0 }],
556
+ expect.any(Object),
557
+ ]);
558
+ expect(host.closes()).toBe(1);
559
+ });
560
+
561
+ it('only fades under Reduce Motion', async () => {
562
+ const { host, shell, update } = await renderGlassAlert();
563
+ settings.reduceMotion.set(true);
564
+ await update(() => host.open.set(true));
565
+ await after(OPENED);
566
+ expect(animate).toHaveBeenCalledWith(shell(), [
567
+ START,
568
+ expect.any(String),
569
+ [{ opacity: 0 }, { opacity: 1 }],
570
+ expect.any(Object),
571
+ ]);
572
+ await update(() => host.open.set(false));
573
+ await after(CLOSED);
574
+ expect(animate).toHaveBeenCalledWith(shell(), [
575
+ START,
576
+ expect.any(String),
577
+ [{ opacity: 1 }, { opacity: 0 }],
578
+ expect.any(Object),
579
+ ]);
580
+ });
581
+
582
+ it('stays open when the dimmed screen or the panel is tapped', async () => {
583
+ const { host, dim, panel, update } = await renderGlassAlert();
584
+ await update(() => host.open.set(true));
585
+ await after(OPENED);
586
+ await update(() => fireEvent.tap(dim()));
587
+ await update(() => fireEvent.tap(panel(), { eventType: 'catchEvent' }));
588
+ expect(host.open()).toBe(true);
589
+ });
590
+
591
+ it('dims the screen at 20%, with no blur', async () => {
592
+ const { dim } = await renderGlassAlert();
593
+ expect(dim().classList).toContain('bg-black/20');
594
+ expect(dim().tagName.toLowerCase()).toBe('view');
149
595
  });
150
- inst.open.set(true);
151
- for (let i = 0; i < 10; i++) await waitForUpdate();
152
- overlay = container.querySelector('overlay');
153
- expect(overlay?.getAttribute('style')).not.toContain('display: none');
154
596
  });
155
597
 
156
- it('accepts a user class on the panel', async () => {
157
- const { componentRef, container } = await render(UiAlertDialog);
158
- const inst = componentRef.instance as UiAlertDialog;
159
- setInputSignal(inst.userClass, 'custom-panel');
160
- await waitForUpdate();
161
- const views = Array.from(container.querySelectorAll('view'));
162
- expect(
163
- views.some((el) => el.getAttribute('class')?.includes('custom-panel')),
164
- ).toBe(true);
598
+ describe('on iOS 15 to 25', () => {
599
+ it("draws that era's frosted alert, 270pt wide with 14pt corners", async () => {
600
+ const { panel, style, getByText } = await renderGlassAlert(IOS_18);
601
+ expect(panel().getAttribute('blur-effect')).toBe('extra-light');
602
+ expect(style(panel())).toContain('border-radius: 14px');
603
+ expect(panel().classList).toContain('w-[270px]');
604
+ expect(getByText('Delete Photo?').classList).toContain('text-center');
605
+ expect(
606
+ getByText("It's deleted from all your devices.").classList,
607
+ ).toContain('text-[13px]');
608
+ });
609
+
610
+ it('parts its buttons with hairlines', async () => {
611
+ const { host, action, footer, update } = await renderGlassAlert(IOS_18);
612
+ // The footer's own line over the buttons.
613
+ expect(footer().firstElementChild!.classList).toContain('h-[0.5px]');
614
+ // Beside the second button in a row, but not the first.
615
+ expect(action('Cancel').querySelector('.w-\\[0\\.5px\\]')).toBeNull();
616
+ expect(action('Delete').querySelector('.w-\\[0\\.5px\\]')).toBeTruthy();
617
+ expect(action('Delete').classList).toContain('h-[44px]');
618
+ expect(action('Delete').classList).toContain('flex-1');
619
+
620
+ // Above every button but the first in a stack.
621
+ await update(() => host.withSave.set(true));
622
+ expect(action('Save').querySelector('.h-\\[0\\.5px\\]')).toBeNull();
623
+ expect(action('Delete').querySelector('.h-\\[0\\.5px\\]')).toBeTruthy();
624
+ expect(action('Cancel').querySelector('.h-\\[0\\.5px\\]')).toBeTruthy();
625
+ expect(action('Cancel').classList).not.toContain('flex-1');
626
+ });
627
+
628
+ it('sets the preferred action in bold, or else the cancel', async () => {
629
+ const { host, getByText, update } = await renderGlassAlert(IOS_18);
630
+ await update(() => host.withSave.set(true));
631
+ expect(getByText('Cancel').classList).toContain('font-semibold');
632
+ expect(getByText('Cancel').classList).toContain('text-glass-tint');
633
+ expect(getByText('Save').classList).not.toContain('font-semibold');
634
+ expect(getByText('Delete').classList).toContain('text-destructive');
635
+
636
+ await update(() => host.savePreferred.set(true));
637
+ expect(getByText('Save').classList).toContain('font-semibold');
638
+ expect(getByText('Cancel').classList).not.toContain('font-semibold');
639
+ });
640
+
641
+ it('lights a button up while it is pressed', async () => {
642
+ const { action, style, update } = await renderGlassAlert(IOS_18);
643
+ const highlight = () => action('Delete').firstElementChild!;
644
+ expect(style(highlight())).toContain('opacity: 0');
645
+ await update(() => fireEvent.touchstart(action('Delete')));
646
+ expect(style(highlight())).toContain('opacity: 0.1');
647
+ });
165
648
  });
166
- });
167
649
 
168
- describe('Alert dialog sub-components', () => {
169
- it('UiAlertDialogHeader renders projected content', async () => {
170
- const { container } = await render(UiAlertDialogHeader);
171
- expect(container.querySelector('view')).toBeTruthy();
650
+ describe('on Android', () => {
651
+ it("draws Material 3's basic dialog", async () => {
652
+ const { panel, style, getByText, dim, footer } =
653
+ await renderGlassAlert(null);
654
+ expect(style(panel())).toContain('border-radius: 28px');
655
+ expect(panel().classList).toContain('bg-secondary');
656
+ expect(panel().classList).toContain('p-[24px]');
657
+ expect(panel().classList).toContain('min-w-[280px]');
658
+ expect(getByText('Delete Photo?').classList).toContain('text-[24px]');
659
+ expect(
660
+ getByText("It's deleted from all your devices.").classList,
661
+ ).toContain('text-[14px]');
662
+ expect(dim().classList).toContain('bg-black/[0.32]');
663
+ expect(footer().classList).toContain('justify-end');
664
+ });
665
+
666
+ it('keeps its text buttons in a row at the end, cancel first', async () => {
667
+ const { host, action, footer, getByText, style, update } =
668
+ await renderGlassAlert(null);
669
+ await update(() => host.withSave.set(true));
670
+ expect(footer().classList).toContain('flex-row');
671
+ expect(style(action('Cancel'))).toContain('order: 0');
672
+ expect(action('Save').classList).toContain('h-[40px]');
673
+ expect(getByText('Save').classList).toContain('text-primary');
674
+ expect(getByText('Delete').classList).toContain('text-destructive');
675
+ // The state layer takes the label's color.
676
+ expect(action('Save').firstElementChild!.classList).toContain(
677
+ 'bg-primary',
678
+ );
679
+ expect(action('Delete').firstElementChild!.classList).toContain(
680
+ 'bg-destructive',
681
+ );
682
+ });
683
+
684
+ it('grows in and shrinks back as it fades', async () => {
685
+ const { host, shell, update } = await renderGlassAlert(null);
686
+ await update(() => host.open.set(true));
687
+ await after(OPENED);
688
+ expect(animate).toHaveBeenCalledWith(shell(), [
689
+ START,
690
+ expect.any(String),
691
+ [
692
+ { transform: 'scale(0.9) translateY(0px)', opacity: 0 },
693
+ { transform: 'scale(1) translateY(0px)', opacity: 1 },
694
+ ],
695
+ expect.any(Object),
696
+ ]);
697
+ await update(() => host.open.set(false));
698
+ await after(CLOSED);
699
+ expect(animate).toHaveBeenCalledWith(shell(), [
700
+ START,
701
+ expect.any(String),
702
+ [
703
+ { transform: 'scale(1) translateY(0px)', opacity: 1 },
704
+ { transform: 'scale(0.95) translateY(0px)', opacity: 0 },
705
+ ],
706
+ expect.any(Object),
707
+ ]);
708
+ });
172
709
  });
173
710
 
174
- it('UiAlertDialogTitle renders projected content', async () => {
175
- const { container } = await render(UiAlertDialogTitle);
176
- expect(container.querySelector('text')).toBeTruthy();
711
+ it('emits the chosen action and closes, once', async () => {
712
+ const { host, getByRole, action, update } = await renderGlassAlert();
713
+ await update(() => host.open.set(true));
714
+ await after(OPENED);
715
+ expect(getByRole('button', { name: 'Delete' })).toBeTruthy();
716
+ await update(() => fireEvent.tap(action('Delete')));
717
+ // A second tap while it closes does nothing.
718
+ await update(() => fireEvent.tap(action('Cancel')));
719
+ expect(host.choices).toEqual(['delete']);
720
+ expect(host.open()).toBe(false);
721
+ await after(CLOSED);
722
+ expect(host.closes()).toBe(1);
177
723
  });
178
724
 
179
- it('UiAlertDialogDescription renders projected content', async () => {
180
- const { container } = await render(UiAlertDialogDescription);
181
- expect(container.querySelector('text')).toBeTruthy();
725
+ it('makes an action one element that says its accessibility label', async () => {
726
+ const { host, getByRole, action, update } = await renderGlassAlert();
727
+ // Open: a screen reader reaches nothing in the hidden overlay.
728
+ await update(() => {
729
+ host.open.set(true);
730
+ host.deleteLabel.set('Delete the photo');
731
+ });
732
+ await after(OPENED);
733
+ expect(getByRole('button', { name: 'Delete the photo' })).toBe(
734
+ action('Delete'),
735
+ );
736
+ expect(
737
+ action('Delete').lastElementChild!.getAttribute('accessibility-traits'),
738
+ ).toBeNull();
182
739
  });
183
740
 
184
- it('UiAlertDialogFooter renders projected content', async () => {
185
- const { container } = await render(UiAlertDialogFooter);
186
- expect(container.querySelector('view')).toBeTruthy();
741
+ it('draws its own panel again with variant="default", and its actions as the default buttons', async () => {
742
+ const { host, panel, action, getByText, style, update } =
743
+ await renderGlassAlert();
744
+ await update(() => host.variant.set('default'));
745
+ expect(panel().tagName.toLowerCase()).toBe('view');
746
+ expect(panel().classList).toContain('bg-background');
747
+ expect(action('Cancel').classList).toContain('border-border');
748
+ expect(action('Delete').classList).toContain('bg-destructive');
749
+ // The given class wins over the action's own.
750
+ expect(action('Delete').classList).toContain('wide');
751
+ expect(getByText('Cancel').classList).toContain('text-foreground');
752
+ expect(getByText('Delete').classList).toContain(
753
+ 'text-destructive-foreground',
754
+ );
755
+ // Written order, no fill or hairline.
756
+ expect(style(action('Cancel'))).not.toContain('order');
757
+ expect(action('Cancel').querySelector('.absolute')).toBeNull();
758
+
759
+ await update(() => host.withSave.set(true));
760
+ expect(action('Save').classList).toContain('bg-primary');
761
+ expect(getByText('Save').classList).toContain('text-primary-foreground');
762
+
763
+ // A pressed button dims.
764
+ await update(() => fireEvent.touchstart(action('Save')));
765
+ expect(style(action('Save'))).toContain('opacity: 0.8');
766
+ await update(() => fireEvent.touchend(action('Save')));
767
+ expect(style(action('Save'))).not.toContain('opacity');
187
768
  });
188
769
  });
770
+
771
+ @Component({
772
+ imports: [
773
+ UiAlertDialogHeader,
774
+ UiAlertDialogTitle,
775
+ UiAlertDialogDescription,
776
+ UiAlertDialogFooter,
777
+ LYNX_ELEMENTS,
778
+ ],
779
+ template: `
780
+ <ui-alert-dialog-header>
781
+ <ui-alert-dialog-title>Alone</ui-alert-dialog-title>
782
+ <ui-alert-dialog-description
783
+ >Outside a dialog</ui-alert-dialog-description
784
+ >
785
+ </ui-alert-dialog-header>
786
+ <ui-alert-dialog-footer><text>Footer</text></ui-alert-dialog-footer>
787
+ `,
788
+ })
789
+ class LoosePartsHost {}
790
+
791
+ it("draws an alert dialog's parts in the default look outside one", async () => {
792
+ const { getByText } = await render(LoosePartsHost);
793
+ expect(getByText('Alone').classList).toContain('text-lg');
794
+ expect(getByText('Outside a dialog').classList).toContain('text-sm');
795
+ expect(getByText('Footer').parentElement!.classList).toContain('pt-4');
796
+ expect(getByText('Alone').parentElement!.parentElement!.classList).toContain(
797
+ 'gap-1.5',
798
+ );
799
+ });