@blotch/dolan 22.2.2 → 22.2.4

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 (259) hide show
  1. package/dist/commands/add.d.ts +6 -2
  2. package/dist/commands/add.js +41 -11
  3. package/dist/commands/add.js.map +1 -1
  4. package/dist/commands/add.test.js +162 -9
  5. package/dist/commands/add.test.js.map +1 -1
  6. package/dist/commands/diff.d.ts +2 -1
  7. package/dist/commands/diff.js +38 -25
  8. package/dist/commands/diff.js.map +1 -1
  9. package/dist/commands/diff.test.js +72 -11
  10. package/dist/commands/diff.test.js.map +1 -1
  11. package/dist/commands/doctor.js +228 -34
  12. package/dist/commands/doctor.js.map +1 -1
  13. package/dist/commands/doctor.test.js +367 -10
  14. package/dist/commands/doctor.test.js.map +1 -1
  15. package/dist/commands/eject.js +7 -0
  16. package/dist/commands/eject.js.map +1 -1
  17. package/dist/commands/eject.test.js +39 -0
  18. package/dist/commands/eject.test.js.map +1 -1
  19. package/dist/commands/info.js +3 -2
  20. package/dist/commands/info.js.map +1 -1
  21. package/dist/commands/init.d.ts +37 -1
  22. package/dist/commands/init.js +169 -40
  23. package/dist/commands/init.js.map +1 -1
  24. package/dist/commands/init.test.js +392 -27
  25. package/dist/commands/init.test.js.map +1 -1
  26. package/dist/commands/install-packages.d.ts +6 -0
  27. package/dist/commands/install-packages.js +20 -0
  28. package/dist/commands/install-packages.js.map +1 -0
  29. package/dist/commands/install-packages.test.d.ts +1 -0
  30. package/dist/commands/install-packages.test.js +55 -0
  31. package/dist/commands/install-packages.test.js.map +1 -0
  32. package/dist/commands/list.js +21 -14
  33. package/dist/commands/list.js.map +1 -1
  34. package/dist/commands/list.test.js +62 -5
  35. package/dist/commands/list.test.js.map +1 -1
  36. package/dist/commands/outdated.js +11 -10
  37. package/dist/commands/outdated.js.map +1 -1
  38. package/dist/commands/outdated.test.js +20 -5
  39. package/dist/commands/outdated.test.js.map +1 -1
  40. package/dist/commands/remove.d.ts +4 -0
  41. package/dist/commands/remove.js +17 -4
  42. package/dist/commands/remove.js.map +1 -1
  43. package/dist/commands/remove.test.js +53 -2
  44. package/dist/commands/remove.test.js.map +1 -1
  45. package/dist/commands/theme.js +1 -1
  46. package/dist/commands/theme.js.map +1 -1
  47. package/dist/commands/update.d.ts +11 -0
  48. package/dist/commands/update.js +174 -58
  49. package/dist/commands/update.js.map +1 -1
  50. package/dist/commands/update.test.js +587 -33
  51. package/dist/commands/update.test.js.map +1 -1
  52. package/dist/index.js +7 -0
  53. package/dist/index.js.map +1 -1
  54. package/dist/lockfile.d.ts +7 -0
  55. package/dist/lockfile.js +36 -0
  56. package/dist/lockfile.js.map +1 -1
  57. package/dist/lockfile.test.d.ts +1 -0
  58. package/dist/lockfile.test.js +25 -0
  59. package/dist/lockfile.test.js.map +1 -0
  60. package/dist/registry.d.ts +14 -0
  61. package/dist/registry.js +78 -24
  62. package/dist/registry.js.map +1 -1
  63. package/dist/registry.test.js +119 -38
  64. package/dist/registry.test.js.map +1 -1
  65. package/dist/test-utils.d.ts +5 -0
  66. package/dist/test-utils.js +6 -1
  67. package/dist/test-utils.js.map +1 -1
  68. package/dist/testing/fire-lynx-event.d.ts +14 -0
  69. package/dist/testing/fire-lynx-event.js +19 -0
  70. package/dist/testing/fire-lynx-event.js.map +1 -0
  71. package/dist/testing/fire-lynx-event.test.d.ts +1 -0
  72. package/dist/testing/fire-lynx-event.test.js +44 -0
  73. package/dist/testing/fire-lynx-event.test.js.map +1 -0
  74. package/dist/testing/lynx-main-thread.d.ts +22 -0
  75. package/dist/testing/lynx-main-thread.js +11 -0
  76. package/dist/testing/lynx-main-thread.js.map +1 -0
  77. package/dist/testing/lynx-main-thread.test.d.ts +1 -0
  78. package/dist/testing/lynx-main-thread.test.js +24 -0
  79. package/dist/testing/lynx-main-thread.test.js.map +1 -0
  80. package/dist/testing/set-input-signal.d.ts +14 -0
  81. package/dist/testing/set-input-signal.js +21 -0
  82. package/dist/testing/set-input-signal.js.map +1 -0
  83. package/dist/testing/set-input-signal.test.d.ts +1 -0
  84. package/dist/testing/set-input-signal.test.js +20 -0
  85. package/dist/testing/set-input-signal.test.js.map +1 -0
  86. package/dist/ui/components/accordion/accordion.test.ts +158 -144
  87. package/dist/ui/components/accordion/accordion.ts +19 -14
  88. package/dist/ui/components/action-sheet/action-sheet.test.ts +283 -151
  89. package/dist/ui/components/action-sheet/action-sheet.ts +77 -43
  90. package/dist/ui/components/alert/alert.test.ts +67 -19
  91. package/dist/ui/components/alert/alert.ts +14 -16
  92. package/dist/ui/components/alert-dialog/alert-dialog.test.ts +208 -94
  93. package/dist/ui/components/alert-dialog/alert-dialog.ts +75 -38
  94. package/dist/ui/components/area-chart/area-chart.test.ts +134 -119
  95. package/dist/ui/components/area-chart/area-chart.ts +100 -315
  96. package/dist/ui/components/area-chart/index.ts +2 -3
  97. package/dist/ui/components/avatar/avatar.test.ts +89 -45
  98. package/dist/ui/components/badge/badge.test.ts +57 -23
  99. package/dist/ui/components/badge/badge.ts +12 -14
  100. package/dist/ui/components/bar-chart/bar-chart.test.ts +172 -45
  101. package/dist/ui/components/bar-chart/bar-chart.ts +140 -294
  102. package/dist/ui/components/bar-chart/index.ts +3 -3
  103. package/dist/ui/components/bottom-sheet/bottom-sheet.test.ts +386 -128
  104. package/dist/ui/components/bottom-sheet/bottom-sheet.ts +170 -53
  105. package/dist/ui/components/button/button.test.ts +611 -2
  106. package/dist/ui/components/button/button.ts +434 -49
  107. package/dist/ui/components/button/index.ts +2 -0
  108. package/dist/ui/components/button-group/button-group.test.ts +484 -2
  109. package/dist/ui/components/button-group/button-group.ts +380 -68
  110. package/dist/ui/components/button-group/index.ts +3 -0
  111. package/dist/ui/components/candlestick-chart/candlestick-chart.test.ts +183 -58
  112. package/dist/ui/components/candlestick-chart/candlestick-chart.ts +150 -323
  113. package/dist/ui/components/candlestick-chart/index.ts +3 -4
  114. package/dist/ui/components/card/card.test.ts +82 -62
  115. package/dist/ui/components/chart/chart-focus.test.ts +132 -0
  116. package/dist/ui/components/chart/chart-focus.ts +115 -0
  117. package/dist/ui/components/chart/chart-presets.test.ts +335 -0
  118. package/dist/ui/components/chart/chart-presets.ts +278 -0
  119. package/dist/ui/components/chart/chart-scene.test.ts +589 -0
  120. package/dist/ui/components/chart/chart-scene.ts +483 -0
  121. package/dist/ui/components/chart/chart-theme.test.ts +85 -0
  122. package/dist/ui/components/chart/chart-theme.ts +210 -0
  123. package/dist/ui/components/chart/chart-zoom.test.ts +159 -0
  124. package/dist/ui/components/chart/chart-zoom.ts +162 -0
  125. package/dist/ui/components/chart/chart.test.ts +1017 -0
  126. package/dist/ui/components/chart/chart.ts +934 -0
  127. package/dist/ui/components/chart/index.ts +24 -0
  128. package/dist/ui/components/checkbox/checkbox.test.ts +124 -42
  129. package/dist/ui/components/checkbox/checkbox.ts +23 -23
  130. package/dist/ui/components/collapsible/collapsible.test.ts +158 -81
  131. package/dist/ui/components/collapsible/collapsible.ts +19 -14
  132. package/dist/ui/components/context-menu/context-menu.test.ts +224 -0
  133. package/dist/ui/components/context-menu/context-menu.ts +110 -0
  134. package/dist/ui/components/context-menu/index.ts +1 -0
  135. package/dist/ui/components/dialog/dialog.test.ts +213 -108
  136. package/dist/ui/components/dialog/dialog.ts +72 -35
  137. package/dist/ui/components/dock/dock-scroll.test.ts +130 -0
  138. package/dist/ui/components/dock/dock-scroll.ts +133 -0
  139. package/dist/ui/components/dock/dock.test.ts +749 -0
  140. package/dist/ui/components/dock/dock.ts +960 -0
  141. package/dist/ui/components/dock/index.ts +2 -0
  142. package/dist/ui/components/empty-state/empty-state.test.ts +28 -39
  143. package/dist/ui/components/glass/concentric-radius.test.ts +13 -0
  144. package/dist/ui/components/glass/concentric-radius.ts +17 -0
  145. package/dist/ui/components/glass/glass-design.test.ts +78 -0
  146. package/dist/ui/components/glass/glass-design.ts +119 -0
  147. package/dist/ui/components/glass/glass.test.ts +338 -0
  148. package/dist/ui/components/glass/glass.ts +360 -0
  149. package/dist/ui/components/glass/index.ts +18 -0
  150. package/dist/ui/components/icon/icon-theme.test.ts +169 -0
  151. package/dist/ui/components/icon/icon-theme.ts +227 -0
  152. package/dist/ui/components/icon/icon.test.ts +85 -25
  153. package/dist/ui/components/icon/icon.ts +38 -21
  154. package/dist/ui/components/input/input.test.ts +178 -101
  155. package/dist/ui/components/input/input.ts +15 -12
  156. package/dist/ui/components/label/label.test.ts +11 -24
  157. package/dist/ui/components/line-chart/index.ts +2 -3
  158. package/dist/ui/components/line-chart/line-chart.test.ts +149 -56
  159. package/dist/ui/components/line-chart/line-chart.ts +83 -241
  160. package/dist/ui/components/list/list.test.ts +119 -21
  161. package/dist/ui/components/list/list.ts +21 -15
  162. package/dist/ui/components/menu/index.ts +25 -0
  163. package/dist/ui/components/menu/menu-placement.test.ts +153 -0
  164. package/dist/ui/components/menu/menu-placement.ts +159 -0
  165. package/dist/ui/components/menu/menu-popup.test.ts +632 -0
  166. package/dist/ui/components/menu/menu-popup.ts +544 -0
  167. package/dist/ui/components/menu/menu.test.ts +429 -0
  168. package/dist/ui/components/menu/menu.ts +415 -0
  169. package/dist/ui/components/nav-drawer/nav-drawer.test.ts +435 -191
  170. package/dist/ui/components/nav-drawer/nav-drawer.ts +127 -56
  171. package/dist/ui/components/pagination/pagination.test.ts +177 -65
  172. package/dist/ui/components/pagination/pagination.ts +55 -8
  173. package/dist/ui/components/progress/progress.test.ts +102 -46
  174. package/dist/ui/components/progress/progress.ts +52 -54
  175. package/dist/ui/components/radio-group/radio-group.test.ts +128 -83
  176. package/dist/ui/components/radio-group/radio-group.ts +24 -24
  177. package/dist/ui/components/scatter-chart/index.ts +2 -3
  178. package/dist/ui/components/scatter-chart/scatter-chart.test.ts +131 -26
  179. package/dist/ui/components/scatter-chart/scatter-chart.ts +104 -250
  180. package/dist/ui/components/scroll-area/scroll-area.test.ts +95 -24
  181. package/dist/ui/components/scroll-area/scroll-area.ts +8 -8
  182. package/dist/ui/components/select/select.test.ts +138 -78
  183. package/dist/ui/components/select/select.ts +17 -11
  184. package/dist/ui/components/separator/separator.test.ts +18 -17
  185. package/dist/ui/components/sheet/sheet.test.ts +64 -11
  186. package/dist/ui/components/sheet/sheet.ts +9 -1
  187. package/dist/ui/components/skeleton/skeleton.test.ts +24 -7
  188. package/dist/ui/components/skeleton/skeleton.ts +10 -16
  189. package/dist/ui/components/spinner/spinner.test.ts +67 -22
  190. package/dist/ui/components/spinner/spinner.ts +48 -41
  191. package/dist/ui/components/switch/switch.test.ts +116 -46
  192. package/dist/ui/components/switch/switch.ts +8 -13
  193. package/dist/ui/components/tab-bar/index.ts +1 -0
  194. package/dist/ui/components/tab-bar/tab-bar.test.ts +280 -0
  195. package/dist/ui/components/tab-bar/tab-bar.ts +230 -0
  196. package/dist/ui/components/tabs/tabs.test.ts +215 -157
  197. package/dist/ui/components/tabs/tabs.ts +110 -118
  198. package/dist/ui/components/textarea/textarea.test.ts +206 -100
  199. package/dist/ui/components/textarea/textarea.ts +14 -12
  200. package/dist/ui/components/toast/toast.test.ts +436 -315
  201. package/dist/ui/components/toast/toast.ts +62 -55
  202. package/dist/ui/components/toggle/toggle.test.ts +247 -140
  203. package/dist/ui/theme/chart-colors.test.ts +35 -0
  204. package/dist/ui/theme/dark.css +46 -2
  205. package/dist/ui/theme/default.css +63 -7
  206. package/dist/ui/theme/icon-colors.test.ts +35 -0
  207. package/dist/ui/theme/rgb-channels.test.ts +84 -0
  208. package/dist/ui/theme/tailwind-build.test.ts +114 -0
  209. package/dist/ui/theme/tailwind-css.test.ts +83 -0
  210. package/dist/ui/theme/tailwind-plugin.test.ts +42 -2
  211. package/dist/ui/theme/tailwind-plugin.ts +98 -25
  212. package/dist/ui/theme/tailwind.css +113 -0
  213. package/dist/ui/utils/animate.test.ts +13 -1
  214. package/dist/utils/analyze.d.ts +14 -0
  215. package/dist/utils/analyze.js +15 -0
  216. package/dist/utils/analyze.js.map +1 -1
  217. package/dist/utils/analyze.test.js +14 -1
  218. package/dist/utils/analyze.test.js.map +1 -1
  219. package/dist/utils/installed.d.ts +29 -0
  220. package/dist/utils/installed.js +43 -0
  221. package/dist/utils/installed.js.map +1 -0
  222. package/dist/utils/installed.test.d.ts +1 -0
  223. package/dist/utils/installed.test.js +57 -0
  224. package/dist/utils/installed.test.js.map +1 -0
  225. package/dist/utils/interactive.d.ts +9 -0
  226. package/dist/utils/interactive.js +10 -0
  227. package/dist/utils/interactive.js.map +1 -0
  228. package/dist/utils/interactive.test.d.ts +1 -0
  229. package/dist/utils/interactive.test.js +24 -0
  230. package/dist/utils/interactive.test.js.map +1 -0
  231. package/dist/utils/packages.d.ts +36 -0
  232. package/dist/utils/packages.js +70 -0
  233. package/dist/utils/packages.js.map +1 -0
  234. package/dist/utils/packages.test.d.ts +1 -0
  235. package/dist/utils/packages.test.js +89 -0
  236. package/dist/utils/packages.test.js.map +1 -0
  237. package/dist/utils/resolve-paths.d.ts +17 -1
  238. package/dist/utils/resolve-paths.js +21 -3
  239. package/dist/utils/resolve-paths.js.map +1 -1
  240. package/dist/utils/resolve-paths.test.js +38 -2
  241. package/dist/utils/resolve-paths.test.js.map +1 -1
  242. package/dist/utils/tailwind.d.ts +25 -0
  243. package/dist/utils/tailwind.js +51 -0
  244. package/dist/utils/tailwind.js.map +1 -0
  245. package/dist/utils/tailwind.test.d.ts +1 -0
  246. package/dist/utils/tailwind.test.js +71 -0
  247. package/dist/utils/tailwind.test.js.map +1 -0
  248. package/package.json +13 -1
  249. package/dist/ui/components/area-chart/area-chart-render.test.ts +0 -121
  250. package/dist/ui/components/bar-chart/bar-chart-render.test.ts +0 -174
  251. package/dist/ui/components/button/button-render.test.ts +0 -113
  252. package/dist/ui/components/button-group/button-group-render.test.ts +0 -146
  253. package/dist/ui/components/candlestick-chart/candlestick-chart-render.test.ts +0 -174
  254. package/dist/ui/components/cartesian-chart/cartesian-chart-render.test.ts +0 -188
  255. package/dist/ui/components/cartesian-chart/cartesian-chart.test.ts +0 -525
  256. package/dist/ui/components/cartesian-chart/cartesian-chart.ts +0 -1140
  257. package/dist/ui/components/cartesian-chart/index.ts +0 -18
  258. package/dist/ui/components/line-chart/line-chart-render.test.ts +0 -121
  259. package/dist/ui/components/scatter-chart/scatter-chart-render.test.ts +0 -157
@@ -1,8 +1,24 @@
1
- import { Component, signal } from '@angular/core';
2
- import { render, waitForUpdate } from '@blotch/angular-lynx-testing-library';
1
+ import { Component, signal, viewChild } from '@angular/core';
2
+ import {
3
+ fireEvent,
4
+ render,
5
+ waitForUpdate,
6
+ } from '@blotch/angular-lynx-testing-library';
3
7
  import { LYNX_ELEMENTS } from '@blotch/angular-lynx';
4
- import { describe, expect, it } from 'vitest';
5
- import { setInputSignal } from '../../../test-utils/set-input-signal';
8
+ import {
9
+ type MockInstance,
10
+ afterEach,
11
+ beforeEach,
12
+ describe,
13
+ expect,
14
+ it,
15
+ vi,
16
+ } from 'vitest';
17
+ import { DURATION } from '../../utils/animate';
18
+ import {
19
+ type LynxMainThread,
20
+ lynxMainThread,
21
+ } from '../../../test-utils/lynx-main-thread';
6
22
  import {
7
23
  UiAlertDialog,
8
24
  UiAlertDialogDescription,
@@ -12,7 +28,6 @@ import {
12
28
  } from './alert-dialog';
13
29
 
14
30
  @Component({
15
- standalone: true,
16
31
  imports: [
17
32
  UiAlertDialog,
18
33
  UiAlertDialogHeader,
@@ -22,120 +37,219 @@ import {
22
37
  LYNX_ELEMENTS,
23
38
  ],
24
39
  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>
40
+ @if (shown()) {
41
+ <ui-alert-dialog
42
+ [(open)]="open"
43
+ class="max-w-sm"
44
+ (closed)="closes.set(closes() + 1)"
45
+ >
46
+ <ui-alert-dialog-header class="gap-2">
47
+ <ui-alert-dialog-title class="text-xl">Sure?</ui-alert-dialog-title>
48
+ <ui-alert-dialog-description class="italic">
49
+ Cannot undo
50
+ </ui-alert-dialog-description>
51
+ </ui-alert-dialog-header>
52
+ <ui-alert-dialog-footer class="justify-start">
53
+ <text>Buttons</text>
54
+ </ui-alert-dialog-footer>
55
+ </ui-alert-dialog>
56
+ }
32
57
  `,
33
58
  })
34
59
  class AlertDialogHost {
35
- open = true;
60
+ readonly open = signal(false);
61
+ // Takes the alert dialog out of the tree, as an `@if` or a route change
62
+ // would.
63
+ readonly shown = signal(true);
64
+ readonly closes = signal(0);
65
+ readonly dialog = viewChild.required(UiAlertDialog);
36
66
  }
37
67
 
68
+ const renderAlertDialog = async () => {
69
+ const result = await render(AlertDialogHost);
70
+ const panel = () =>
71
+ result.container.querySelector('[accessibility-exclusive-focus]')!;
72
+ return {
73
+ ...result,
74
+ host: result.componentRef.instance as AlertDialogHost,
75
+ overlay: () => result.container.querySelector('overlay')!,
76
+ panel,
77
+ backdrop: () => panel().parentElement!,
78
+ };
79
+ };
80
+
81
+ /**
82
+ * Waits for the alert dialog's open or close sequence, which runs on timers, then
83
+ * renders what it changed.
84
+ */
85
+ const after = async (ms: number) => {
86
+ await new Promise((resolve) => setTimeout(resolve, ms));
87
+ await waitForUpdate();
88
+ };
89
+
90
+ /**
91
+ * Long enough for the open sequence, including the panel's 30ms stagger. The
92
+ * sequence is chained timers, and on Windows each can fire up to a clock
93
+ * tick (about 16ms) late, so 50ms was too short on CI's Windows runner.
94
+ */
95
+ const OPENED = 150;
96
+ /** Long enough for the close animation and the hide after it, as above. */
97
+ const CLOSED = DURATION.normal + 150;
98
+
99
+ /** `__ElementAnimate`'s first argument for starting an animation. */
100
+ const START = 0;
101
+ /** `__ElementAnimate`'s first argument for canceling an animation. */
102
+ const CANCEL = 3;
103
+
38
104
  describe('UiAlertDialog', () => {
39
- it('renders header, title, description, and footer', async () => {
40
- const { getByText } = await render(AlertDialogHost);
105
+ // Watched per test: the testing library resets the main thread after each.
106
+ let animate: MockInstance<LynxMainThread['__ElementAnimate']>;
107
+ beforeEach(() => {
108
+ animate = vi.spyOn(lynxMainThread(), '__ElementAnimate');
109
+ });
110
+ afterEach(() => animate.mockRestore());
111
+
112
+ it('renders each part with its projected content', async () => {
113
+ const { getByText } = await renderAlertDialog();
41
114
  expect(getByText('Sure?')).toBeTruthy();
42
115
  expect(getByText('Cannot undo')).toBeTruthy();
43
116
  expect(getByText('Buttons')).toBeTruthy();
44
117
  });
45
118
 
46
- it('panel tap is a no-op (catchtap stops bubbling)', async () => {
47
- const { componentRef } = await render(UiAlertDialog);
48
- const inst = componentRef.instance as UiAlertDialog;
49
- expect(() =>
50
- (inst as unknown as { onPanelTap: () => void }).onPanelTap(),
51
- ).not.toThrow();
119
+ it("merges each part's class with its own, the given class winning", async () => {
120
+ const { container, getByText, panel } = await renderAlertDialog();
121
+ expect(panel().classList).toContain('bg-background');
122
+ expect(panel().classList).toContain('max-w-sm');
123
+ expect(getByText('Sure?').classList).toContain('font-semibold');
124
+ expect(getByText('Sure?').classList).toContain('text-xl');
125
+ expect(getByText('Cannot undo').classList).toContain('text-sm');
126
+ expect(getByText('Cannot undo').classList).toContain('italic');
127
+ // Each part's own view, not its host element, which keeps the bare class.
128
+ const header = container.querySelector('.flex-col.gap-2');
129
+ expect(header?.classList).not.toContain('gap-1.5');
130
+ const footer = container.querySelector('.pt-4');
131
+ expect(footer?.classList).toContain('justify-start');
132
+ expect(footer?.classList).not.toContain('justify-end');
52
133
  });
53
134
 
54
- it('open effect: showing then hiding runs the animate-in/out lifecycles', async () => {
55
- // Monkey-patch backdrop/panel refs so #animateIn / #animateOut execute in
56
- // full instead of bailing on !backdrop || !panel.
57
- const { componentRef, container } = await render(UiAlertDialog);
58
- const inst = componentRef.instance as UiAlertDialog;
59
- const view = container.querySelector('view') as Element;
60
- (inst as unknown as { backdropRef: unknown }).backdropRef = signal({
61
- nativeElement: view,
62
- });
63
- (inst as unknown as { panelRef: unknown }).panelRef = signal({
64
- nativeElement: view,
65
- });
66
-
67
- inst.open.set(true);
68
- await waitForUpdate();
69
- await waitForUpdate();
70
- await waitForUpdate();
71
-
72
- inst.open.set(false);
73
- await new Promise((r) => setTimeout(r, 320));
74
- await waitForUpdate();
75
- expect(inst.open()).toBe(false);
135
+ it('stays hidden until it is opened', async () => {
136
+ const { host, overlay, panel } = await renderAlertDialog();
137
+ await after(CLOSED);
138
+ expect(overlay().getAttribute('style')).toContain('display: none');
139
+ expect(panel().getAttribute('accessibility-exclusive-focus')).toBe('false');
140
+ expect(animate).not.toHaveBeenCalled();
141
+ // Starting closed isn't closing: `closed` waits for a real close.
142
+ expect(host.closes()).toBe(0);
76
143
  });
77
144
 
78
- it('open effect bails when backdrop/panel refs are undefined', async () => {
79
- // Covers the early-return branches in #animateIn / #animateOut.
80
- const { componentRef } = await render(UiAlertDialog);
81
- const inst = componentRef.instance as UiAlertDialog;
82
- inst.open.set(true);
83
- await waitForUpdate();
84
- await waitForUpdate();
85
- inst.open.set(false);
86
- await waitForUpdate();
87
- expect(inst.open()).toBe(false);
145
+ it('shows the overlay and animates the backdrop, then the panel, in', async () => {
146
+ const { host, overlay, backdrop, panel } = await renderAlertDialog();
147
+ host.open.set(true);
148
+ await after(OPENED);
149
+ expect(overlay().getAttribute('style')).not.toContain('display: none');
150
+ expect(animate).toHaveBeenCalledWith(backdrop(), [
151
+ START,
152
+ expect.any(String),
153
+ [{ opacity: 0 }, { opacity: 1 }],
154
+ expect.objectContaining({ duration: DURATION.normal }),
155
+ ]);
156
+ expect(animate).toHaveBeenCalledWith(panel(), [
157
+ START,
158
+ expect.any(String),
159
+ [
160
+ { transform: 'scale(0.9) translateY(10px)', opacity: 0 },
161
+ { transform: 'scale(1) translateY(0px)', opacity: 1 },
162
+ ],
163
+ expect.objectContaining({ duration: DURATION.slow }),
164
+ ]);
88
165
  });
89
166
 
90
- it('overlayStyle switches between visible and hidden variants', async () => {
91
- const { componentRef, container } = await render(UiAlertDialog);
92
- const inst = componentRef.instance as UiAlertDialog;
93
- let overlay = container.querySelector('overlay');
94
- expect(overlay?.getAttribute('style')).toContain('display: none');
95
-
96
- const view = container.querySelector('view') as Element;
97
- (inst as unknown as { backdropRef: unknown }).backdropRef = signal({
98
- nativeElement: view,
99
- });
100
- (inst as unknown as { panelRef: unknown }).panelRef = signal({
101
- nativeElement: view,
102
- });
103
- inst.open.set(true);
104
- for (let i = 0; i < 10; i++) await waitForUpdate();
105
- overlay = container.querySelector('overlay');
106
- expect(overlay?.getAttribute('style')).not.toContain('display: none');
167
+ it('moves a screen reader into the panel and keeps it there while open', async () => {
168
+ const { host, panel } = await renderAlertDialog();
169
+ const invoke = vi.spyOn(host.dialog().panelRef().nativeElement, 'invoke');
170
+ host.open.set(true);
171
+ await after(OPENED);
172
+ expect(invoke).toHaveBeenCalledWith('requestAccessibilityFocus');
173
+ expect(panel().getAttribute('accessibility-exclusive-focus')).toBe('true');
107
174
  });
108
175
 
109
- it('accepts a user class on the panel', async () => {
110
- const { componentRef, container } = await render(UiAlertDialog);
111
- const inst = componentRef.instance as UiAlertDialog;
112
- setInputSignal(inst.userClass, 'custom-panel');
113
- await waitForUpdate();
114
- const views = Array.from(container.querySelectorAll('view'));
115
- expect(
116
- views.some((el) => el.getAttribute('class')?.includes('custom-panel')),
117
- ).toBe(true);
118
- });
119
- });
176
+ it('animates out, then hides and emits closed', async () => {
177
+ const { host, overlay, backdrop, panel } = await renderAlertDialog();
178
+ host.open.set(true);
179
+ await after(OPENED);
180
+ animate.mockClear();
120
181
 
121
- describe('Alert dialog sub-components', () => {
122
- it('UiAlertDialogHeader renders projected content', async () => {
123
- const { container } = await render(UiAlertDialogHeader);
124
- expect(container.querySelector('view')).toBeTruthy();
182
+ host.open.set(false);
183
+ await after(CLOSED);
184
+ expect(animate).toHaveBeenCalledWith(backdrop(), [
185
+ START,
186
+ expect.any(String),
187
+ [{ opacity: 1 }, { opacity: 0 }],
188
+ expect.any(Object),
189
+ ]);
190
+ expect(animate).toHaveBeenCalledWith(panel(), [
191
+ START,
192
+ expect.any(String),
193
+ [
194
+ { transform: 'scale(1) translateY(0px)', opacity: 1 },
195
+ { transform: 'scale(0.95) translateY(10px)', opacity: 0 },
196
+ ],
197
+ expect.any(Object),
198
+ ]);
199
+ expect(overlay().getAttribute('style')).toContain('display: none');
200
+ expect(panel().getAttribute('accessibility-exclusive-focus')).toBe('false');
201
+ expect(host.closes()).toBe(1);
125
202
  });
126
203
 
127
- it('UiAlertDialogTitle renders projected content', async () => {
128
- const { container } = await render(UiAlertDialogTitle);
129
- expect(container.querySelector('text')).toBeTruthy();
204
+ it('cancels the previous animations when it opens again', async () => {
205
+ const { host, backdrop, panel } = await renderAlertDialog();
206
+ host.open.set(true);
207
+ await after(OPENED);
208
+ host.open.set(false);
209
+ await after(CLOSED);
210
+ animate.mockClear();
211
+
212
+ host.open.set(true);
213
+ await after(OPENED);
214
+ // The close animations hold their last frame (fill: forwards) until
215
+ // they're canceled, which would hide the panel being opened.
216
+ expect(animate).toHaveBeenCalledWith(backdrop(), [
217
+ CANCEL,
218
+ expect.any(String),
219
+ ]);
220
+ expect(animate).toHaveBeenCalledWith(panel(), [CANCEL, expect.any(String)]);
130
221
  });
131
222
 
132
- it('UiAlertDialogDescription renders projected content', async () => {
133
- const { container } = await render(UiAlertDialogDescription);
134
- expect(container.querySelector('text')).toBeTruthy();
223
+ it('stays open when the backdrop or the panel is tapped', async () => {
224
+ const { host, backdrop, panel } = await renderAlertDialog();
225
+ host.open.set(true);
226
+ await after(OPENED);
227
+ // Unlike a dialog, an alert dialog waits for an answer from its actions.
228
+ fireEvent.tap(backdrop());
229
+ // The panel catches its taps, so they don't reach the backdrop either.
230
+ fireEvent.tap(panel(), { eventType: 'catchEvent' });
231
+ await waitForUpdate();
232
+ expect(host.open()).toBe(true);
135
233
  });
136
234
 
137
- it('UiAlertDialogFooter renders projected content', async () => {
138
- const { container } = await render(UiAlertDialogFooter);
139
- expect(container.querySelector('view')).toBeTruthy();
235
+ it('stops its close sequence when it is destroyed while closing', async () => {
236
+ const warn = vi.spyOn(console, 'warn');
237
+ const { host } = await renderAlertDialog();
238
+ host.open.set(true);
239
+ await after(OPENED);
240
+ host.open.set(false);
241
+ await after(0);
242
+ // Destroyed during the close animation. Its close timer
243
+ // (DURATION.normal + 20) used to fire anyway and emit `closed` from the
244
+ // destroyed output, which Angular warns about in dev builds.
245
+ host.shown.set(false);
246
+ await waitForUpdate();
247
+ await after(CLOSED);
248
+ const warnings = warn.mock.calls.map(([message]) => String(message));
249
+ warn.mockRestore();
250
+ expect(warnings.filter((message) => message.includes('NG0953'))).toEqual(
251
+ [],
252
+ );
253
+ expect(host.closes()).toBe(0);
140
254
  });
141
255
  });
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  type ElementRef,
3
+ type OnDestroy,
3
4
  Component,
4
5
  ViewEncapsulation,
5
6
  computed,
@@ -26,20 +27,24 @@ import { cn } from '../../utils/cn';
26
27
 
27
28
  @Component({
28
29
  selector: 'ui-alert-dialog',
29
- standalone: true,
30
30
  imports: [LYNX_ELEMENTS],
31
31
  encapsulation: ViewEncapsulation.None,
32
32
  template: `
33
33
  <overlay [attr.visible]="overlayVisible()" [style]="overlayStyle()">
34
34
  <view #backdrop [class]="backdropClass()">
35
- <view #panel [class]="panelClass()" (catchtap)="onPanelTap()">
35
+ <view
36
+ #panel
37
+ [class]="panelClass()"
38
+ [accessibility-exclusive-focus]="overlayVisible()"
39
+ (catchtap)="onPanelTap()"
40
+ >
36
41
  <ng-content />
37
42
  </view>
38
43
  </view>
39
44
  </overlay>
40
45
  `,
41
46
  })
42
- export class UiAlertDialog {
47
+ export class UiAlertDialog implements OnDestroy {
43
48
  readonly open = model(false);
44
49
  readonly userClass = input<string>('', { alias: 'class' });
45
50
 
@@ -47,27 +52,38 @@ export class UiAlertDialog {
47
52
 
48
53
  protected readonly overlayVisible = signal(false);
49
54
 
50
- readonly backdropRef = viewChild<ElementRef>('backdrop');
51
- readonly panelRef = viewChild<ElementRef>('panel');
55
+ // Required: both are always in the template (the overlay hides with
56
+ // display:none rather than leaving the tree), and the sequences only read
57
+ // them from timers that start after the first render.
58
+ readonly backdropRef = viewChild.required<ElementRef>('backdrop');
59
+ readonly panelRef = viewChild.required<ElementRef>('panel');
52
60
  #backdropAnim?: AnimationHandle;
53
61
  #panelAnim?: AnimationHandle;
54
62
  #hasBeenOpen = false;
63
+ // The open and close sequences' pending timers, so ngOnDestroy can stop
64
+ // them.
65
+ readonly #timers = new Set<ReturnType<typeof setTimeout>>();
55
66
 
56
- constructor() {
57
- // `#hasBeenOpen` prevents the close animation from running on the initial
58
- // effect evaluation when `open` starts as false. Without this guard, the
59
- // first run would call #doClose() immediately and emit `closed` before the
60
- // dialog has ever been opened.
61
- effect(() => {
62
- const isOpen = this.open();
63
- if (isOpen) {
64
- this.#hasBeenOpen = true;
65
- this.#doOpen();
66
- } else if (this.#hasBeenOpen) {
67
- this.#doClose();
68
- }
69
- });
70
- }
67
+ /**
68
+ * Plays the open or close sequence whenever `open` changes. A field rather
69
+ * than a constructor: Angular's test build keeps a function for a
70
+ * constructor that nothing calls, which an app's coverage report would show
71
+ * as uncovered.
72
+ *
73
+ * `#hasBeenOpen` prevents the close animation from running on the initial
74
+ * effect evaluation when `open` starts as false. Without this guard, the
75
+ * first run would call #doClose() immediately and emit `closed` before the
76
+ * dialog has ever been opened.
77
+ */
78
+ protected readonly openAndClose = effect(() => {
79
+ const isOpen = this.open();
80
+ if (isOpen) {
81
+ this.#hasBeenOpen = true;
82
+ this.#doOpen();
83
+ } else if (this.#hasBeenOpen) {
84
+ this.#doClose();
85
+ }
86
+ });
71
87
 
72
88
  /**
73
89
  * `width`/`height`/`top`/`left` are web-only necessities: on web
@@ -84,9 +100,9 @@ export class UiAlertDialog {
84
100
  // `bg-black/50` supplies the dim. It lives on the backdrop specifically so
85
101
  // the existing fadeIn/fadeOut (opacity 0↔1, staggered 30ms ahead of the
86
102
  // panel scale-in) animate the dim in lockstep with the dialog appearing —
87
- // no separate animation needed. A named color with an opacity modifier is
88
- // used deliberately: it compiles to `rgb(0 0 0 / 0.5)`, which Lynx accepts,
89
- // whereas a semantic token like `bg-background/50` would render transparent.
103
+ // no separate animation needed. It's a named color, not a theme one,
104
+ // because the dim is black in light and dark mode alike; it compiles to
105
+ // `rgb(0 0 0 / 0.5)`, which Lynx accepts.
90
106
  protected readonly backdropClass = computed(() =>
91
107
  cn('items-center flex justify-center', 'h-full w-full', 'bg-black/50'),
92
108
  );
@@ -106,15 +122,37 @@ export class UiAlertDialog {
106
122
  */
107
123
  protected onPanelTap(): void {}
108
124
 
125
+ /**
126
+ * The alert dialog can be destroyed while its open or close sequence is still
127
+ * running, for example by an `@if` around it or a route change right after
128
+ * closing it. The rest of the sequence then ran on the destroyed component:
129
+ * it animated detached elements and emitted `closed` from a destroyed
130
+ * output, which Angular warns about in dev builds (NG0953).
131
+ */
132
+ ngOnDestroy(): void {
133
+ for (const timer of this.#timers) clearTimeout(timer);
134
+ }
135
+
136
+ /**
137
+ * `setTimeout`, tracked so that ngOnDestroy can cancel it.
138
+ */
139
+ #later(callback: () => void, ms: number): void {
140
+ const timer = setTimeout(() => {
141
+ this.#timers.delete(timer);
142
+ callback();
143
+ }, ms);
144
+ this.#timers.add(timer);
145
+ }
146
+
109
147
  /**
110
148
  * Two-phase open: make overlay visible first (so native elements exist in
111
149
  * the tree), then animate on the next frame. See nav-drawer for the same
112
150
  * pattern — Lynx animate() requires elements to be mounted before use.
113
151
  */
114
152
  #doOpen(): void {
115
- setTimeout(() => {
153
+ this.#later(() => {
116
154
  this.overlayVisible.set(true);
117
- setTimeout(() => this.#animateIn(), 0);
155
+ this.#later(() => this.#animateIn(), 0);
118
156
  }, 0);
119
157
  }
120
158
 
@@ -123,9 +161,9 @@ export class UiAlertDialog {
123
161
  * causing elements to disappear before the animation finishes.
124
162
  */
125
163
  #doClose(): void {
126
- setTimeout(() => {
164
+ this.#later(() => {
127
165
  this.#animateOut();
128
- setTimeout(() => {
166
+ this.#later(() => {
129
167
  this.overlayVisible.set(false);
130
168
  this.closed.emit();
131
169
  }, DURATION.normal + 20);
@@ -133,19 +171,23 @@ export class UiAlertDialog {
133
171
  }
134
172
 
135
173
  #animateIn(): void {
136
- const backdrop = this.backdropRef()?.nativeElement;
137
- const panel = this.panelRef()?.nativeElement;
138
- if (!backdrop || !panel) return;
174
+ const backdrop = this.backdropRef().nativeElement;
175
+ const panel = this.panelRef().nativeElement;
139
176
 
140
177
  this.#backdropAnim?.cancel();
141
178
  this.#panelAnim?.cancel();
142
179
 
143
180
  this.#backdropAnim = fadeIn(backdrop, { duration: DURATION.normal });
181
+ // Move a screen reader into the panel. It would otherwise stay on whatever
182
+ // opened it, which is now behind the overlay. With the panel's
183
+ // accessibility-exclusive-focus (see the template), it then can't leave
184
+ // the panel until it closes.
185
+ panel.invoke?.('requestAccessibilityFocus');
144
186
 
145
187
  // 30ms stagger: let the backdrop fade start first so the panel scale-in
146
188
  // feels like it's emerging from behind the dimmed overlay rather than
147
189
  // appearing simultaneously with it.
148
- setTimeout(() => {
190
+ this.#later(() => {
149
191
  this.#panelAnim = scaleIn(panel, {
150
192
  duration: DURATION.slow,
151
193
  easing: EASING.springSubtle,
@@ -156,9 +198,8 @@ export class UiAlertDialog {
156
198
  }
157
199
 
158
200
  #animateOut(): void {
159
- const backdrop = this.backdropRef()?.nativeElement;
160
- const panel = this.panelRef()?.nativeElement;
161
- if (!backdrop || !panel) return;
201
+ const backdrop = this.backdropRef().nativeElement;
202
+ const panel = this.panelRef().nativeElement;
162
203
 
163
204
  this.#backdropAnim?.cancel();
164
205
  this.#panelAnim?.cancel();
@@ -176,7 +217,6 @@ export class UiAlertDialog {
176
217
 
177
218
  @Component({
178
219
  selector: 'ui-alert-dialog-header',
179
- standalone: true,
180
220
  imports: [LYNX_ELEMENTS],
181
221
  encapsulation: ViewEncapsulation.None,
182
222
  template: `
@@ -195,7 +235,6 @@ export class UiAlertDialogHeader {
195
235
 
196
236
  @Component({
197
237
  selector: 'ui-alert-dialog-title',
198
- standalone: true,
199
238
  imports: [LYNX_ELEMENTS],
200
239
  encapsulation: ViewEncapsulation.None,
201
240
  template: `<text [class]="textClass()"><ng-content /></text>`,
@@ -210,7 +249,6 @@ export class UiAlertDialogTitle {
210
249
 
211
250
  @Component({
212
251
  selector: 'ui-alert-dialog-description',
213
- standalone: true,
214
252
  imports: [LYNX_ELEMENTS],
215
253
  encapsulation: ViewEncapsulation.None,
216
254
  template: `<text [class]="textClass()"><ng-content /></text>`,
@@ -225,7 +263,6 @@ export class UiAlertDialogDescription {
225
263
 
226
264
  @Component({
227
265
  selector: 'ui-alert-dialog-footer',
228
- standalone: true,
229
266
  imports: [LYNX_ELEMENTS],
230
267
  encapsulation: ViewEncapsulation.None,
231
268
  template: `