@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
  UiDialog,
8
24
  UiDialogDescription,
@@ -12,7 +28,6 @@ import {
12
28
  } from './dialog';
13
29
 
14
30
  @Component({
15
- standalone: true,
16
31
  imports: [
17
32
  UiDialog,
18
33
  UiDialogHeader,
@@ -22,136 +37,226 @@ import {
22
37
  LYNX_ELEMENTS,
23
38
  ],
24
39
  template: `
25
- <ui-dialog [(open)]="open">
26
- <ui-dialog-header>
27
- <ui-dialog-title>Title</ui-dialog-title>
28
- <ui-dialog-description>Description</ui-dialog-description>
29
- </ui-dialog-header>
30
- <ui-dialog-footer><text>Actions</text></ui-dialog-footer>
31
- </ui-dialog>
40
+ @if (shown()) {
41
+ <ui-dialog
42
+ [(open)]="open"
43
+ class="max-w-sm"
44
+ (closed)="closes.set(closes() + 1)"
45
+ >
46
+ <ui-dialog-header class="gap-2">
47
+ <ui-dialog-title class="text-xl">Title</ui-dialog-title>
48
+ <ui-dialog-description class="italic">
49
+ Description
50
+ </ui-dialog-description>
51
+ </ui-dialog-header>
52
+ <ui-dialog-footer class="justify-start">
53
+ <text>Actions</text>
54
+ </ui-dialog-footer>
55
+ </ui-dialog>
56
+ }
32
57
  `,
33
58
  })
34
59
  class DialogHost {
35
- open = true;
60
+ readonly open = signal(false);
61
+ // Takes the dialog out of the tree, as an `@if` or a route change would.
62
+ readonly shown = signal(true);
63
+ readonly closes = signal(0);
64
+ readonly dialog = viewChild.required(UiDialog);
36
65
  }
37
66
 
67
+ const renderDialog = async () => {
68
+ const result = await render(DialogHost);
69
+ const panel = () =>
70
+ result.container.querySelector('[accessibility-exclusive-focus]')!;
71
+ return {
72
+ ...result,
73
+ host: result.componentRef.instance as DialogHost,
74
+ overlay: () => result.container.querySelector('overlay')!,
75
+ panel,
76
+ backdrop: () => panel().parentElement!,
77
+ };
78
+ };
79
+
80
+ /**
81
+ * Waits for the dialog's open or close sequence, which runs on timers, then
82
+ * renders what it changed.
83
+ */
84
+ const after = async (ms: number) => {
85
+ await new Promise((resolve) => setTimeout(resolve, ms));
86
+ await waitForUpdate();
87
+ };
88
+
89
+ /**
90
+ * Long enough for the open sequence, including the panel's 30ms stagger. The
91
+ * sequence is chained timers, and on Windows each can fire up to a clock
92
+ * tick (about 16ms) late, so 50ms was too short on CI's Windows runner.
93
+ */
94
+ const OPENED = 150;
95
+ /** Long enough for the close animation and the hide after it, as above. */
96
+ const CLOSED = DURATION.normal + 150;
97
+
98
+ /** `__ElementAnimate`'s first argument for starting an animation. */
99
+ const START = 0;
100
+ /** `__ElementAnimate`'s first argument for canceling an animation. */
101
+ const CANCEL = 3;
102
+
38
103
  describe('UiDialog', () => {
39
- it('renders header, title, description, and footer', async () => {
40
- const { getByText } = await render(DialogHost);
104
+ // Watched per test: the testing library resets the main thread after each.
105
+ let animate: MockInstance<LynxMainThread['__ElementAnimate']>;
106
+ beforeEach(() => {
107
+ animate = vi.spyOn(lynxMainThread(), '__ElementAnimate');
108
+ });
109
+ afterEach(() => animate.mockRestore());
110
+
111
+ it('renders each part with its projected content', async () => {
112
+ const { getByText } = await renderDialog();
41
113
  expect(getByText('Title')).toBeTruthy();
42
114
  expect(getByText('Description')).toBeTruthy();
43
115
  expect(getByText('Actions')).toBeTruthy();
44
116
  });
45
117
 
46
- it('backdrop tap closes the dialog', async () => {
47
- const { componentRef } = await render(UiDialog);
48
- const inst = componentRef.instance as UiDialog;
49
- inst.open.set(true);
50
- await waitForUpdate();
51
- (inst as unknown as { onBackdropTap: () => void }).onBackdropTap();
52
- expect(inst.open()).toBe(false);
118
+ it("merges each part's class with its own, the given class winning", async () => {
119
+ const { container, getByText, panel } = await renderDialog();
120
+ expect(panel().classList).toContain('bg-background');
121
+ expect(panel().classList).toContain('max-w-sm');
122
+ expect(getByText('Title').classList).toContain('font-semibold');
123
+ expect(getByText('Title').classList).toContain('text-xl');
124
+ expect(getByText('Description').classList).toContain('text-sm');
125
+ expect(getByText('Description').classList).toContain('italic');
126
+ // Each part's own view, not its host element, which keeps the bare class.
127
+ const header = container.querySelector('.flex-col.gap-2');
128
+ expect(header?.classList).not.toContain('gap-1.5');
129
+ const footer = container.querySelector('.pt-4');
130
+ expect(footer?.classList).toContain('justify-start');
131
+ expect(footer?.classList).not.toContain('justify-end');
53
132
  });
54
133
 
55
- it('panel tap is a no-op (catchtap stops bubbling)', async () => {
56
- const { componentRef } = await render(UiDialog);
57
- const inst = componentRef.instance as UiDialog;
58
- expect(() =>
59
- (inst as unknown as { onPanelTap: () => void }).onPanelTap(),
60
- ).not.toThrow();
134
+ it('stays hidden until it is opened', async () => {
135
+ const { host, overlay, panel } = await renderDialog();
136
+ await after(CLOSED);
137
+ expect(overlay().getAttribute('style')).toContain('display: none');
138
+ expect(panel().getAttribute('accessibility-exclusive-focus')).toBe('false');
139
+ expect(animate).not.toHaveBeenCalled();
140
+ // Starting closed isn't closing: `closed` waits for a real close.
141
+ expect(host.closes()).toBe(0);
61
142
  });
62
143
 
63
- it('open effect: showing then hiding runs the animate-in and animate-out lifecycles', async () => {
64
- // Monkey-patch backdrop/panel refs so #animateIn / #animateOut execute in
65
- // full instead of bailing on !backdrop || !panel.
66
- const { componentRef, container } = await render(UiDialog);
67
- const inst = componentRef.instance as UiDialog;
68
- const view = container.querySelector('view') as Element;
69
- (inst as unknown as { backdropRef: unknown }).backdropRef = signal({
70
- nativeElement: view,
71
- });
72
- (inst as unknown as { panelRef: unknown }).panelRef = signal({
73
- nativeElement: view,
74
- });
75
-
76
- inst.open.set(true);
77
- await waitForUpdate();
78
- // Wait for setTimeout chains to flush (nested setTimeouts inside #doOpen).
79
- await waitForUpdate();
80
- await waitForUpdate();
81
-
82
- // Now close it — exercises #doClose + #animateOut. The nested setTimeout
83
- // inside #doClose runs DURATION.normal (250ms) + 20ms later; wait real time.
84
- inst.open.set(false);
85
- await new Promise((r) => setTimeout(r, 320));
86
- await waitForUpdate();
87
- expect(inst.open()).toBe(false);
144
+ it('shows the overlay and animates the backdrop, then the panel, in', async () => {
145
+ const { host, overlay, backdrop, panel } = await renderDialog();
146
+ host.open.set(true);
147
+ await after(OPENED);
148
+ expect(overlay().getAttribute('style')).not.toContain('display: none');
149
+ expect(animate).toHaveBeenCalledWith(backdrop(), [
150
+ START,
151
+ expect.any(String),
152
+ [{ opacity: 0 }, { opacity: 1 }],
153
+ expect.objectContaining({ duration: DURATION.normal }),
154
+ ]);
155
+ expect(animate).toHaveBeenCalledWith(panel(), [
156
+ START,
157
+ expect.any(String),
158
+ [
159
+ { transform: 'scale(0.9) translateY(10px)', opacity: 0 },
160
+ { transform: 'scale(1) translateY(0px)', opacity: 1 },
161
+ ],
162
+ expect.objectContaining({ duration: DURATION.slow }),
163
+ ]);
88
164
  });
89
165
 
90
- it('open effect bails when backdrop/panel refs are undefined', async () => {
91
- // Covers the early-return branches in #animateIn / #animateOut.
92
- const { componentRef } = await render(UiDialog);
93
- const inst = componentRef.instance as UiDialog;
94
- inst.open.set(true);
95
- await waitForUpdate();
96
- await waitForUpdate();
97
- inst.open.set(false);
98
- await waitForUpdate();
99
- expect(inst.open()).toBe(false);
166
+ it('moves a screen reader into the panel and keeps it there while open', async () => {
167
+ const { host, panel } = await renderDialog();
168
+ const invoke = vi.spyOn(host.dialog().panelRef().nativeElement, 'invoke');
169
+ host.open.set(true);
170
+ await after(OPENED);
171
+ expect(invoke).toHaveBeenCalledWith('requestAccessibilityFocus');
172
+ expect(panel().getAttribute('accessibility-exclusive-focus')).toBe('true');
100
173
  });
101
174
 
102
- it('overlayStyle switches between visible and hidden variants', async () => {
103
- const { componentRef, container } = await render(UiDialog);
104
- const inst = componentRef.instance as UiDialog;
105
- // Default hidden — overlay carries display:none.
106
- let overlay = container.querySelector('overlay');
107
- expect(overlay?.getAttribute('style')).toContain('display: none');
108
-
109
- // Open + advance so overlayVisible flips true.
110
- const view = container.querySelector('view') as Element;
111
- (inst as unknown as { backdropRef: unknown }).backdropRef = signal({
112
- nativeElement: view,
113
- });
114
- (inst as unknown as { panelRef: unknown }).panelRef = signal({
115
- nativeElement: view,
116
- });
117
- inst.open.set(true);
118
- for (let i = 0; i < 10; i++) await waitForUpdate();
119
- overlay = container.querySelector('overlay');
120
- expect(overlay?.getAttribute('style')).not.toContain('display: none');
121
- });
175
+ it('animates out, then hides and emits closed', async () => {
176
+ const { host, overlay, backdrop, panel } = await renderDialog();
177
+ host.open.set(true);
178
+ await after(OPENED);
179
+ animate.mockClear();
122
180
 
123
- it('accepts a user class on the panel', async () => {
124
- const { componentRef, container } = await render(UiDialog);
125
- const inst = componentRef.instance as UiDialog;
126
- setInputSignal(inst.userClass, 'custom-panel');
127
- await waitForUpdate();
128
- const panelCandidates = Array.from(container.querySelectorAll('view'));
129
- expect(
130
- panelCandidates.some((el) =>
131
- el.getAttribute('class')?.includes('custom-panel'),
132
- ),
133
- ).toBe(true);
181
+ host.open.set(false);
182
+ await after(CLOSED);
183
+ expect(animate).toHaveBeenCalledWith(backdrop(), [
184
+ START,
185
+ expect.any(String),
186
+ [{ opacity: 1 }, { opacity: 0 }],
187
+ expect.any(Object),
188
+ ]);
189
+ expect(animate).toHaveBeenCalledWith(panel(), [
190
+ START,
191
+ expect.any(String),
192
+ [
193
+ { transform: 'scale(1) translateY(0px)', opacity: 1 },
194
+ { transform: 'scale(0.95) translateY(10px)', opacity: 0 },
195
+ ],
196
+ expect.any(Object),
197
+ ]);
198
+ expect(overlay().getAttribute('style')).toContain('display: none');
199
+ expect(panel().getAttribute('accessibility-exclusive-focus')).toBe('false');
200
+ expect(host.closes()).toBe(1);
134
201
  });
135
- });
136
202
 
137
- describe('Dialog sub-components', () => {
138
- it('UiDialogHeader renders projected content', async () => {
139
- const { container } = await render(UiDialogHeader);
140
- expect(container.querySelector('view')).toBeTruthy();
203
+ it('cancels the previous animations when it opens again', async () => {
204
+ const { host, backdrop, panel } = await renderDialog();
205
+ host.open.set(true);
206
+ await after(OPENED);
207
+ host.open.set(false);
208
+ await after(CLOSED);
209
+ animate.mockClear();
210
+
211
+ host.open.set(true);
212
+ await after(OPENED);
213
+ // The close animations hold their last frame (fill: forwards) until
214
+ // they're canceled, which would hide the panel being opened.
215
+ expect(animate).toHaveBeenCalledWith(backdrop(), [
216
+ CANCEL,
217
+ expect.any(String),
218
+ ]);
219
+ expect(animate).toHaveBeenCalledWith(panel(), [CANCEL, expect.any(String)]);
141
220
  });
142
221
 
143
- it('UiDialogTitle renders projected content', async () => {
144
- const { container } = await render(UiDialogTitle);
145
- expect(container.querySelector('text')).toBeTruthy();
222
+ it('closes when the backdrop is tapped', async () => {
223
+ const { host, backdrop } = await renderDialog();
224
+ host.open.set(true);
225
+ await after(OPENED);
226
+ fireEvent.tap(backdrop());
227
+ expect(host.open()).toBe(false);
228
+ await after(CLOSED);
229
+ expect(host.closes()).toBe(1);
146
230
  });
147
231
 
148
- it('UiDialogDescription renders projected content', async () => {
149
- const { container } = await render(UiDialogDescription);
150
- expect(container.querySelector('text')).toBeTruthy();
232
+ it('stays open when the panel is tapped', async () => {
233
+ const { host, panel } = await renderDialog();
234
+ host.open.set(true);
235
+ await after(OPENED);
236
+ // The panel catches its taps, so they don't reach the backdrop.
237
+ fireEvent.tap(panel(), { eventType: 'catchEvent' });
238
+ await waitForUpdate();
239
+ expect(host.open()).toBe(true);
151
240
  });
152
241
 
153
- it('UiDialogFooter renders projected content', async () => {
154
- const { container } = await render(UiDialogFooter);
155
- expect(container.querySelector('view')).toBeTruthy();
242
+ it('stops its close sequence when it is destroyed while closing', async () => {
243
+ const warn = vi.spyOn(console, 'warn');
244
+ const { host } = await renderDialog();
245
+ host.open.set(true);
246
+ await after(OPENED);
247
+ host.open.set(false);
248
+ await after(0);
249
+ // Destroyed during the close animation. Its close timer
250
+ // (DURATION.normal + 20) used to fire anyway and emit `closed` from the
251
+ // destroyed output, which Angular warns about in dev builds.
252
+ host.shown.set(false);
253
+ await waitForUpdate();
254
+ await after(CLOSED);
255
+ const warnings = warn.mock.calls.map(([message]) => String(message));
256
+ warn.mockRestore();
257
+ expect(warnings.filter((message) => message.includes('NG0953'))).toEqual(
258
+ [],
259
+ );
260
+ expect(host.closes()).toBe(0);
156
261
  });
157
262
  });
@@ -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-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()" (bindtap)="onBackdropTap()">
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 UiDialog {
47
+ export class UiDialog implements OnDestroy {
43
48
  readonly open = model(false);
44
49
  readonly userClass = input<string>('', { alias: 'class' });
45
50
 
@@ -47,27 +52,38 @@ export class UiDialog {
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
- // very first run would call #doClose() immediately, resulting in a no-op
60
- // animation that still hides the overlay and emits `closed` unexpectedly.
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
+ * very first run would call #doClose() immediately, resulting in a no-op
76
+ * animation that still hides the overlay and emits `closed` unexpectedly.
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
  * Hide the overlay with `display:none` on web where `visible` attr has no
@@ -110,15 +126,37 @@ export class UiDialog {
110
126
  */
111
127
  protected onPanelTap(): void {}
112
128
 
129
+ /**
130
+ * The dialog can be destroyed while its open or close sequence is still
131
+ * running, for example by an `@if` around it or a route change right after
132
+ * closing it. The rest of the sequence then ran on the destroyed component:
133
+ * it animated detached elements and emitted `closed` from a destroyed
134
+ * output, which Angular warns about in dev builds (NG0953).
135
+ */
136
+ ngOnDestroy(): void {
137
+ for (const timer of this.#timers) clearTimeout(timer);
138
+ }
139
+
140
+ /**
141
+ * `setTimeout`, tracked so that ngOnDestroy can cancel it.
142
+ */
143
+ #later(callback: () => void, ms: number): void {
144
+ const timer = setTimeout(() => {
145
+ this.#timers.delete(timer);
146
+ callback();
147
+ }, ms);
148
+ this.#timers.add(timer);
149
+ }
150
+
113
151
  /**
114
152
  * Two-phase open: make overlay visible first (so native elements exist in
115
153
  * the tree), then animate on the next frame. See nav-drawer for the same
116
154
  * pattern — Lynx animate() requires elements to be mounted before use.
117
155
  */
118
156
  #doOpen(): void {
119
- setTimeout(() => {
157
+ this.#later(() => {
120
158
  this.overlayVisible.set(true);
121
- setTimeout(() => this.#animateIn(), 0);
159
+ this.#later(() => this.#animateIn(), 0);
122
160
  }, 0);
123
161
  }
124
162
 
@@ -127,9 +165,9 @@ export class UiDialog {
127
165
  * causing elements to disappear before the animation finishes.
128
166
  */
129
167
  #doClose(): void {
130
- setTimeout(() => {
168
+ this.#later(() => {
131
169
  this.#animateOut();
132
- setTimeout(() => {
170
+ this.#later(() => {
133
171
  this.overlayVisible.set(false);
134
172
  this.closed.emit();
135
173
  }, DURATION.normal + 20);
@@ -137,18 +175,22 @@ export class UiDialog {
137
175
  }
138
176
 
139
177
  #animateIn(): void {
140
- const backdrop = this.backdropRef()?.nativeElement;
141
- const panel = this.panelRef()?.nativeElement;
142
- if (!backdrop || !panel) return;
178
+ const backdrop = this.backdropRef().nativeElement;
179
+ const panel = this.panelRef().nativeElement;
143
180
 
144
181
  this.#backdropAnim?.cancel();
145
182
  this.#panelAnim?.cancel();
146
183
 
147
184
  // Backdrop fades in first
148
185
  this.#backdropAnim = fadeIn(backdrop, { duration: DURATION.normal });
186
+ // Move a screen reader into the panel. It would otherwise stay on whatever
187
+ // opened it, which is now behind the overlay. With the panel's
188
+ // accessibility-exclusive-focus (see the template), it then can't leave
189
+ // the panel until it closes.
190
+ panel.invoke?.('requestAccessibilityFocus');
149
191
 
150
192
  // Panel scales in with gentle spring (staggered 30ms after backdrop)
151
- setTimeout(() => {
193
+ this.#later(() => {
152
194
  this.#panelAnim = scaleIn(panel, {
153
195
  duration: DURATION.slow,
154
196
  easing: EASING.springSubtle,
@@ -159,9 +201,8 @@ export class UiDialog {
159
201
  }
160
202
 
161
203
  #animateOut(): void {
162
- const backdrop = this.backdropRef()?.nativeElement;
163
- const panel = this.panelRef()?.nativeElement;
164
- if (!backdrop || !panel) return;
204
+ const backdrop = this.backdropRef().nativeElement;
205
+ const panel = this.panelRef().nativeElement;
165
206
 
166
207
  this.#backdropAnim?.cancel();
167
208
  this.#panelAnim?.cancel();
@@ -181,7 +222,6 @@ export class UiDialog {
181
222
 
182
223
  @Component({
183
224
  selector: 'ui-dialog-header',
184
- standalone: true,
185
225
  imports: [LYNX_ELEMENTS],
186
226
  encapsulation: ViewEncapsulation.None,
187
227
  template: `
@@ -200,7 +240,6 @@ export class UiDialogHeader {
200
240
 
201
241
  @Component({
202
242
  selector: 'ui-dialog-title',
203
- standalone: true,
204
243
  imports: [LYNX_ELEMENTS],
205
244
  encapsulation: ViewEncapsulation.None,
206
245
  template: `<text [class]="textClass()"><ng-content /></text>`,
@@ -215,7 +254,6 @@ export class UiDialogTitle {
215
254
 
216
255
  @Component({
217
256
  selector: 'ui-dialog-description',
218
- standalone: true,
219
257
  imports: [LYNX_ELEMENTS],
220
258
  encapsulation: ViewEncapsulation.None,
221
259
  template: `<text [class]="textClass()"><ng-content /></text>`,
@@ -230,7 +268,6 @@ export class UiDialogDescription {
230
268
 
231
269
  @Component({
232
270
  selector: 'ui-dialog-footer',
233
- standalone: true,
234
271
  imports: [LYNX_ELEMENTS],
235
272
  encapsulation: ViewEncapsulation.None,
236
273
  template: `