@blotch/dolan 22.2.3 → 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 (185) 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 +36 -3
  4. package/dist/commands/add.test.js.map +1 -1
  5. package/dist/commands/doctor.js +187 -7
  6. package/dist/commands/doctor.js.map +1 -1
  7. package/dist/commands/doctor.test.js +280 -7
  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 +107 -1
  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 +5 -0
  17. package/dist/commands/update.js +126 -45
  18. package/dist/commands/update.js.map +1 -1
  19. package/dist/commands/update.test.js +314 -2
  20. package/dist/commands/update.test.js.map +1 -1
  21. package/dist/lockfile.d.ts +7 -0
  22. package/dist/lockfile.js +36 -0
  23. package/dist/lockfile.js.map +1 -1
  24. package/dist/lockfile.test.d.ts +1 -0
  25. package/dist/lockfile.test.js +25 -0
  26. package/dist/lockfile.test.js.map +1 -0
  27. package/dist/registry.d.ts +6 -0
  28. package/dist/registry.js +48 -2
  29. package/dist/registry.js.map +1 -1
  30. package/dist/registry.test.js +45 -3
  31. package/dist/registry.test.js.map +1 -1
  32. package/dist/test-utils.d.ts +5 -0
  33. package/dist/test-utils.js +6 -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/components/accordion/accordion.test.ts +158 -144
  51. package/dist/ui/components/accordion/accordion.ts +19 -14
  52. package/dist/ui/components/action-sheet/action-sheet.test.ts +272 -187
  53. package/dist/ui/components/action-sheet/action-sheet.ts +40 -34
  54. package/dist/ui/components/alert/alert.test.ts +67 -19
  55. package/dist/ui/components/alert/alert.ts +14 -16
  56. package/dist/ui/components/alert-dialog/alert-dialog.test.ts +206 -139
  57. package/dist/ui/components/alert-dialog/alert-dialog.ts +32 -26
  58. package/dist/ui/components/area-chart/area-chart.test.ts +81 -1
  59. package/dist/ui/components/avatar/avatar.test.ts +89 -45
  60. package/dist/ui/components/badge/badge.test.ts +57 -23
  61. package/dist/ui/components/badge/badge.ts +12 -14
  62. package/dist/ui/components/bar-chart/bar-chart.test.ts +82 -1
  63. package/dist/ui/components/bottom-sheet/bottom-sheet.test.ts +373 -297
  64. package/dist/ui/components/bottom-sheet/bottom-sheet.ts +34 -33
  65. package/dist/ui/components/button/button.test.ts +611 -2
  66. package/dist/ui/components/button/button.ts +431 -62
  67. package/dist/ui/components/button/index.ts +2 -0
  68. package/dist/ui/components/button-group/button-group.test.ts +484 -2
  69. package/dist/ui/components/button-group/button-group.ts +380 -68
  70. package/dist/ui/components/button-group/index.ts +3 -0
  71. package/dist/ui/components/candlestick-chart/candlestick-chart.test.ts +82 -1
  72. package/dist/ui/components/card/card.test.ts +82 -62
  73. package/dist/ui/components/chart/chart-presets.test.ts +197 -1
  74. package/dist/ui/components/chart/chart-presets.ts +8 -1
  75. package/dist/ui/components/chart/chart-theme.ts +12 -0
  76. package/dist/ui/components/chart/chart.test.ts +1017 -0
  77. package/dist/ui/components/chart/chart.ts +56 -43
  78. package/dist/ui/components/checkbox/checkbox.test.ts +124 -42
  79. package/dist/ui/components/checkbox/checkbox.ts +23 -23
  80. package/dist/ui/components/collapsible/collapsible.test.ts +158 -81
  81. package/dist/ui/components/collapsible/collapsible.ts +19 -14
  82. package/dist/ui/components/context-menu/context-menu.test.ts +224 -0
  83. package/dist/ui/components/context-menu/context-menu.ts +110 -0
  84. package/dist/ui/components/context-menu/index.ts +1 -0
  85. package/dist/ui/components/dialog/dialog.test.ts +210 -152
  86. package/dist/ui/components/dialog/dialog.ts +29 -23
  87. package/dist/ui/components/dock/dock-scroll.test.ts +130 -0
  88. package/dist/ui/components/dock/dock-scroll.ts +133 -0
  89. package/dist/ui/components/dock/dock.test.ts +749 -0
  90. package/dist/ui/components/dock/dock.ts +960 -0
  91. package/dist/ui/components/dock/index.ts +2 -0
  92. package/dist/ui/components/empty-state/empty-state.test.ts +28 -39
  93. package/dist/ui/components/glass/concentric-radius.test.ts +13 -0
  94. package/dist/ui/components/glass/concentric-radius.ts +17 -0
  95. package/dist/ui/components/glass/glass-design.test.ts +78 -0
  96. package/dist/ui/components/glass/glass-design.ts +119 -0
  97. package/dist/ui/components/glass/glass.test.ts +338 -0
  98. package/dist/ui/components/glass/glass.ts +360 -0
  99. package/dist/ui/components/glass/index.ts +18 -0
  100. package/dist/ui/components/icon/icon-theme.test.ts +36 -0
  101. package/dist/ui/components/icon/icon-theme.ts +36 -13
  102. package/dist/ui/components/icon/icon.test.ts +52 -41
  103. package/dist/ui/components/input/input.test.ts +178 -101
  104. package/dist/ui/components/input/input.ts +15 -12
  105. package/dist/ui/components/label/label.test.ts +11 -24
  106. package/dist/ui/components/line-chart/line-chart.test.ts +76 -1
  107. package/dist/ui/components/list/list.test.ts +80 -49
  108. package/dist/ui/components/menu/index.ts +25 -0
  109. package/dist/ui/components/menu/menu-placement.test.ts +153 -0
  110. package/dist/ui/components/menu/menu-placement.ts +159 -0
  111. package/dist/ui/components/menu/menu-popup.test.ts +632 -0
  112. package/dist/ui/components/menu/menu-popup.ts +544 -0
  113. package/dist/ui/components/menu/menu.test.ts +429 -0
  114. package/dist/ui/components/menu/menu.ts +415 -0
  115. package/dist/ui/components/nav-drawer/nav-drawer.test.ts +425 -260
  116. package/dist/ui/components/nav-drawer/nav-drawer.ts +72 -56
  117. package/dist/ui/components/pagination/pagination.test.ts +136 -86
  118. package/dist/ui/components/progress/progress.test.ts +102 -46
  119. package/dist/ui/components/progress/progress.ts +52 -54
  120. package/dist/ui/components/radio-group/radio-group.test.ts +128 -83
  121. package/dist/ui/components/radio-group/radio-group.ts +24 -24
  122. package/dist/ui/components/scatter-chart/scatter-chart.test.ts +74 -1
  123. package/dist/ui/components/scroll-area/scroll-area.test.ts +72 -36
  124. package/dist/ui/components/select/select.test.ts +138 -78
  125. package/dist/ui/components/select/select.ts +17 -11
  126. package/dist/ui/components/separator/separator.test.ts +18 -17
  127. package/dist/ui/components/sheet/sheet.test.ts +64 -11
  128. package/dist/ui/components/sheet/sheet.ts +9 -1
  129. package/dist/ui/components/skeleton/skeleton.test.ts +24 -7
  130. package/dist/ui/components/skeleton/skeleton.ts +10 -16
  131. package/dist/ui/components/spinner/spinner.test.ts +64 -46
  132. package/dist/ui/components/spinner/spinner.ts +16 -19
  133. package/dist/ui/components/switch/switch.test.ts +116 -46
  134. package/dist/ui/components/switch/switch.ts +8 -13
  135. package/dist/ui/components/tab-bar/index.ts +1 -0
  136. package/dist/ui/components/tab-bar/tab-bar.test.ts +280 -0
  137. package/dist/ui/components/tab-bar/tab-bar.ts +230 -0
  138. package/dist/ui/components/tabs/tabs.test.ts +215 -157
  139. package/dist/ui/components/tabs/tabs.ts +110 -118
  140. package/dist/ui/components/textarea/textarea.test.ts +206 -100
  141. package/dist/ui/components/textarea/textarea.ts +14 -12
  142. package/dist/ui/components/toast/toast.test.ts +434 -350
  143. package/dist/ui/components/toast/toast.ts +42 -51
  144. package/dist/ui/components/toggle/toggle.test.ts +247 -140
  145. package/dist/ui/theme/chart-colors.test.ts +8 -3
  146. package/dist/ui/theme/dark.css +38 -2
  147. package/dist/ui/theme/default.css +55 -7
  148. package/dist/ui/theme/icon-colors.test.ts +8 -3
  149. package/dist/ui/theme/rgb-channels.test.ts +84 -0
  150. package/dist/ui/theme/tailwind-build.test.ts +114 -0
  151. package/dist/ui/theme/tailwind-css.test.ts +83 -0
  152. package/dist/ui/theme/tailwind-plugin.test.ts +42 -2
  153. package/dist/ui/theme/tailwind-plugin.ts +93 -29
  154. package/dist/ui/theme/tailwind.css +113 -0
  155. package/dist/ui/utils/animate.test.ts +13 -1
  156. package/dist/utils/analyze.d.ts +14 -0
  157. package/dist/utils/analyze.js +15 -0
  158. package/dist/utils/analyze.js.map +1 -1
  159. package/dist/utils/analyze.test.js +14 -1
  160. package/dist/utils/analyze.test.js.map +1 -1
  161. package/dist/utils/installed.d.ts +12 -0
  162. package/dist/utils/installed.js +16 -0
  163. package/dist/utils/installed.js.map +1 -1
  164. package/dist/utils/installed.test.js +17 -1
  165. package/dist/utils/installed.test.js.map +1 -1
  166. package/dist/utils/resolve-paths.d.ts +4 -4
  167. package/dist/utils/resolve-paths.js +5 -7
  168. package/dist/utils/resolve-paths.js.map +1 -1
  169. package/dist/utils/resolve-paths.test.js +9 -5
  170. package/dist/utils/resolve-paths.test.js.map +1 -1
  171. package/dist/utils/tailwind.d.ts +25 -0
  172. package/dist/utils/tailwind.js +51 -0
  173. package/dist/utils/tailwind.js.map +1 -0
  174. package/dist/utils/tailwind.test.d.ts +1 -0
  175. package/dist/utils/tailwind.test.js +71 -0
  176. package/dist/utils/tailwind.test.js.map +1 -0
  177. package/package.json +9 -1
  178. package/dist/ui/components/area-chart/area-chart-render.test.ts +0 -63
  179. package/dist/ui/components/bar-chart/bar-chart-render.test.ts +0 -58
  180. package/dist/ui/components/button/button-render.test.ts +0 -132
  181. package/dist/ui/components/button-group/button-group-render.test.ts +0 -146
  182. package/dist/ui/components/candlestick-chart/candlestick-chart-render.test.ts +0 -59
  183. package/dist/ui/components/chart/chart-render.test.ts +0 -730
  184. package/dist/ui/components/line-chart/line-chart-render.test.ts +0 -83
  185. package/dist/ui/components/scatter-chart/scatter-chart-render.test.ts +0 -64
@@ -1,9 +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, vi } from 'vitest';
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 {
19
+ type LynxMainThread,
20
+ lynxMainThread,
21
+ } from '../../../test-utils/lynx-main-thread';
7
22
  import {
8
23
  UiAlertDialog,
9
24
  UiAlertDialogDescription,
@@ -22,167 +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
 
38
- describe('UiAlertDialog', () => {
39
- it('stops its close timers when it is destroyed while closing', async () => {
40
- 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);
46
- 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));
52
- const warnings = warn.mock.calls.map(([message]) => String(message));
53
- warn.mockRestore();
54
- expect(warnings.filter((message) => message.includes('NG0953'))).toEqual(
55
- [],
56
- );
57
- });
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;
58
103
 
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');
104
+ describe('UiAlertDialog', () => {
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');
78
109
  });
110
+ afterEach(() => animate.mockRestore());
79
111
 
80
- it('renders header, title, description, and footer', async () => {
81
- const { getByText } = await render(AlertDialogHost);
112
+ it('renders each part with its projected content', async () => {
113
+ const { getByText } = await renderAlertDialog();
82
114
  expect(getByText('Sure?')).toBeTruthy();
83
115
  expect(getByText('Cannot undo')).toBeTruthy();
84
116
  expect(getByText('Buttons')).toBeTruthy();
85
117
  });
86
118
 
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();
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');
93
133
  });
94
134
 
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,
103
- });
104
- (inst as unknown as { panelRef: unknown }).panelRef = signal({
105
- nativeElement: view,
106
- });
107
-
108
- inst.open.set(true);
109
- await waitForUpdate();
110
- await waitForUpdate();
111
- await waitForUpdate();
112
-
113
- inst.open.set(false);
114
- await new Promise((r) => setTimeout(r, 320));
115
- await waitForUpdate();
116
- 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);
117
143
  });
118
144
 
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();
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
+ ]);
135
165
  });
136
166
 
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');
142
-
143
- const view = container.querySelector('view') as Element;
144
- (inst as unknown as { backdropRef: unknown }).backdropRef = signal({
145
- nativeElement: view,
146
- });
147
- (inst as unknown as { panelRef: unknown }).panelRef = signal({
148
- nativeElement: view,
149
- });
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
- });
155
-
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);
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');
165
174
  });
166
- });
167
175
 
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();
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();
181
+
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);
172
202
  });
173
203
 
174
- it('UiAlertDialogTitle renders projected content', async () => {
175
- const { container } = await render(UiAlertDialogTitle);
176
- 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)]);
177
221
  });
178
222
 
179
- it('UiAlertDialogDescription renders projected content', async () => {
180
- const { container } = await render(UiAlertDialogDescription);
181
- 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);
182
233
  });
183
234
 
184
- it('UiAlertDialogFooter renders projected content', async () => {
185
- const { container } = await render(UiAlertDialogFooter);
186
- 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);
187
254
  });
188
255
  });
@@ -52,8 +52,11 @@ export class UiAlertDialog implements OnDestroy {
52
52
 
53
53
  protected readonly overlayVisible = signal(false);
54
54
 
55
- readonly backdropRef = viewChild<ElementRef>('backdrop');
56
- 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');
57
60
  #backdropAnim?: AnimationHandle;
58
61
  #panelAnim?: AnimationHandle;
59
62
  #hasBeenOpen = false;
@@ -61,21 +64,26 @@ export class UiAlertDialog implements OnDestroy {
61
64
  // them.
62
65
  readonly #timers = new Set<ReturnType<typeof setTimeout>>();
63
66
 
64
- constructor() {
65
- // `#hasBeenOpen` prevents the close animation from running on the initial
66
- // effect evaluation when `open` starts as false. Without this guard, the
67
- // first run would call #doClose() immediately and emit `closed` before the
68
- // dialog has ever been opened.
69
- effect(() => {
70
- const isOpen = this.open();
71
- if (isOpen) {
72
- this.#hasBeenOpen = true;
73
- this.#doOpen();
74
- } else if (this.#hasBeenOpen) {
75
- this.#doClose();
76
- }
77
- });
78
- }
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
+ });
79
87
 
80
88
  /**
81
89
  * `width`/`height`/`top`/`left` are web-only necessities: on web
@@ -92,9 +100,9 @@ export class UiAlertDialog implements OnDestroy {
92
100
  // `bg-black/50` supplies the dim. It lives on the backdrop specifically so
93
101
  // the existing fadeIn/fadeOut (opacity 0↔1, staggered 30ms ahead of the
94
102
  // panel scale-in) animate the dim in lockstep with the dialog appearing —
95
- // no separate animation needed. A named color with an opacity modifier is
96
- // used deliberately: it compiles to `rgb(0 0 0 / 0.5)`, which Lynx accepts,
97
- // 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.
98
106
  protected readonly backdropClass = computed(() =>
99
107
  cn('items-center flex justify-center', 'h-full w-full', 'bg-black/50'),
100
108
  );
@@ -163,9 +171,8 @@ export class UiAlertDialog implements OnDestroy {
163
171
  }
164
172
 
165
173
  #animateIn(): void {
166
- const backdrop = this.backdropRef()?.nativeElement;
167
- const panel = this.panelRef()?.nativeElement;
168
- if (!backdrop || !panel) return;
174
+ const backdrop = this.backdropRef().nativeElement;
175
+ const panel = this.panelRef().nativeElement;
169
176
 
170
177
  this.#backdropAnim?.cancel();
171
178
  this.#panelAnim?.cancel();
@@ -191,9 +198,8 @@ export class UiAlertDialog implements OnDestroy {
191
198
  }
192
199
 
193
200
  #animateOut(): void {
194
- const backdrop = this.backdropRef()?.nativeElement;
195
- const panel = this.panelRef()?.nativeElement;
196
- if (!backdrop || !panel) return;
201
+ const backdrop = this.backdropRef().nativeElement;
202
+ const panel = this.panelRef().nativeElement;
197
203
 
198
204
  this.#backdropAnim?.cancel();
199
205
  this.#panelAnim?.cancel();
@@ -1,7 +1,14 @@
1
+ import { Component, signal } from '@angular/core';
2
+ import {
3
+ fireEvent,
4
+ render,
5
+ waitForUpdate,
6
+ } from '@blotch/angular-lynx-testing-library';
1
7
  import { createChartRuntime } from '@tanstack/charts/runtime';
2
8
  import type { ChartDefinition, SceneNode } from '@tanstack/charts/types';
3
9
  import { describe, expect, it } from 'vitest';
4
- import { areaChartDefinition } from './area-chart';
10
+ import type { XYDatum } from '../chart/chart-presets';
11
+ import { UiAreaChart, areaChartDefinition } from './area-chart';
5
12
 
6
13
  const data = [
7
14
  { x: 0, y: 4 },
@@ -69,3 +76,76 @@ describe('areaChartDefinition', () => {
69
76
  expect(definition.tooltip).toBe(false);
70
77
  });
71
78
  });
79
+
80
+ @Component({
81
+ imports: [UiAreaChart],
82
+ template: `
83
+ <ui-area-chart
84
+ [data]="data"
85
+ [width]="300"
86
+ [baseline]="baseline()"
87
+ [fillOpacity]="0.3"
88
+ (pointTap)="tapped.push($event)"
89
+ />
90
+ `,
91
+ })
92
+ class AreaChartHost {
93
+ readonly data = data;
94
+ readonly baseline = signal(0);
95
+ /** Every `pointTap`, in order. */
96
+ readonly tapped: XYDatum[] = [];
97
+ }
98
+
99
+ /**
100
+ * The SVG the chart's marks layer draws.
101
+ */
102
+ const marksSvg = (container: Element) =>
103
+ container.querySelectorAll('svg')[1]!.getAttribute('content')!;
104
+
105
+ /**
106
+ * Where the chart drew each dot.
107
+ */
108
+ const dotsDrawn = (container: Element) =>
109
+ [
110
+ ...marksSvg(container).matchAll(/<circle cx="([\d.-]+)" cy="([\d.-]+)"/g),
111
+ ].map(([, x, y]) => ({ x: Number(x), y: Number(y) }));
112
+
113
+ /**
114
+ * Touches a spot of the chart and lifts the finger right there: a tap.
115
+ */
116
+ const tap = async (container: Element, at: { x: number; y: number }) => {
117
+ const root = container.querySelector('[accessibility-label]')!;
118
+ fireEvent.touchstart(root, { touches: [at] });
119
+ fireEvent.touchend(root, { touches: [at] });
120
+ await waitForUpdate();
121
+ };
122
+
123
+ /**
124
+ * The texts of the chart's labels, such as its ticks.
125
+ */
126
+ const labelTexts = (container: Element) =>
127
+ [...container.querySelectorAll('text')].map((text) => text.textContent);
128
+
129
+ describe('UiAreaChart', () => {
130
+ it('fills under its line with its inputs, keeping the baseline in view', async () => {
131
+ const { componentRef, container } = await render(AreaChartHost);
132
+ expect(marksSvg(container)).toContain('fill-opacity="0.3"');
133
+ expect(dotsDrawn(container)).toHaveLength(3);
134
+ expect(labelTexts(container)).not.toContain('-20');
135
+
136
+ (componentRef.instance as AreaChartHost).baseline.set(-20);
137
+ await waitForUpdate();
138
+ expect(labelTexts(container)).toContain('-20');
139
+ });
140
+
141
+ it("emits a tapped point's datum, and nothing for a tap off the points", async () => {
142
+ const { componentRef, container } = await render(AreaChartHost);
143
+ const host = componentRef.instance as AreaChartHost;
144
+ const first = dotsDrawn(container)[0]!;
145
+ await tap(container, { x: first.x + 1, y: first.y + 1 });
146
+ expect(host.tapped).toEqual([data[0]]);
147
+
148
+ await tap(container, { x: -100, y: -100 });
149
+ expect(host.tapped).toEqual([data[0]]);
150
+ });
151
+ });