@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
@@ -0,0 +1,24 @@
1
+ export {
2
+ UiChart,
3
+ UiChartTooltip,
4
+ type UiChartTooltipContext,
5
+ tooltip,
6
+ } from './chart';
7
+ export {
8
+ type ChartColorScheme,
9
+ type ChartColorSchemeOption,
10
+ type ChartColorTokens,
11
+ CHART_DARK_COLORS,
12
+ CHART_LIGHT_COLORS,
13
+ DEFAULT_CHART_THEME,
14
+ UI_CHART_THEME,
15
+ type UiChartTheme,
16
+ provideChartTheme,
17
+ resolveChartPaint,
18
+ } from './chart-theme';
19
+ export {
20
+ type CartesianPresetOptions,
21
+ type ChartPadding,
22
+ type XYDatum,
23
+ } from './chart-presets';
24
+ export { type ChartViewport, type ChartZoomAxes } from './chart-zoom';
@@ -1,59 +1,141 @@
1
- import { render, waitForUpdate } from '@blotch/angular-lynx-testing-library';
2
- import { describe, expect, it, vi } from 'vitest';
3
- import { setInputSignal } from '../../../test-utils/set-input-signal';
1
+ import { Component, signal } from '@angular/core';
2
+ import {
3
+ fireEvent,
4
+ render,
5
+ waitForUpdate,
6
+ } from '@blotch/angular-lynx-testing-library';
7
+ import {
8
+ type MockInstance,
9
+ afterEach,
10
+ beforeEach,
11
+ describe,
12
+ expect,
13
+ it,
14
+ vi,
15
+ } from 'vitest';
16
+ import {
17
+ type LynxMainThread,
18
+ lynxMainThread,
19
+ } from '../../../test-utils/lynx-main-thread';
4
20
  import { UiCheckbox } from './checkbox';
5
21
 
22
+ @Component({
23
+ imports: [UiCheckbox],
24
+ template: `
25
+ <ui-checkbox
26
+ [(checked)]="checked"
27
+ [disabled]="disabled()"
28
+ class="mr-2"
29
+ (changed)="changes.push($event)"
30
+ />
31
+ `,
32
+ })
33
+ class CheckboxHost {
34
+ readonly checked = signal(false);
35
+ readonly disabled = signal(false);
36
+ readonly changes: boolean[] = [];
37
+ }
38
+
39
+ const renderCheckbox = async () => {
40
+ const result = await render(CheckboxHost);
41
+ return {
42
+ ...result,
43
+ host: result.componentRef.instance as CheckboxHost,
44
+ /** The 44pt tap target around the box. */
45
+ target: () => result.container.querySelector('.h-11')!,
46
+ box: () => result.container.querySelector('.rounded-sm')!,
47
+ checkmark: () => result.getByText('✓'),
48
+ };
49
+ };
50
+
51
+ /** `__ElementAnimate`'s first argument for starting an animation. */
52
+ const START = 0;
53
+ /** `__ElementAnimate`'s first argument for canceling one. */
54
+ const CANCEL = 3;
55
+
6
56
  describe('UiCheckbox', () => {
7
- it('renders unchecked by default', async () => {
8
- const { container } = await render(UiCheckbox);
9
- expect(container).toBeTruthy();
57
+ // Watched per test: the testing library resets the main thread after each.
58
+ let animate: MockInstance<LynxMainThread['__ElementAnimate']>;
59
+ beforeEach(() => {
60
+ animate = vi.spyOn(lynxMainThread(), '__ElementAnimate');
10
61
  });
62
+ afterEach(() => animate.mockRestore());
11
63
 
12
- it('toggle() flips checked and emits the `changed` output', async () => {
13
- const { componentRef } = await render(UiCheckbox);
14
- const inst = componentRef.instance as UiCheckbox;
15
- const changes: boolean[] = [];
16
- inst.changed.subscribe((v) => changes.push(v));
17
-
18
- inst.toggle();
19
- expect(inst.checked()).toBe(true);
20
- inst.toggle();
21
- expect(inst.checked()).toBe(false);
22
- expect(changes).toEqual([true, false]);
64
+ it('renders an empty box with the given class', async () => {
65
+ const { box, checkmark } = await renderCheckbox();
66
+ expect(box().classList).toContain('bg-transparent');
67
+ expect(box().classList).toContain('mr-2');
68
+ expect(checkmark().getAttribute('style')).toContain('opacity: 0');
23
69
  });
24
70
 
25
- it('toggle() is a no-op when disabled is true', async () => {
26
- const { componentRef } = await render(UiCheckbox);
27
- const inst = componentRef.instance as UiCheckbox;
28
- setInputSignal(inst.disabled, true);
29
- const changed = vi.fn();
30
- inst.changed.subscribe(changed);
71
+ it('checks and unchecks on tap, emitting `changed`', async () => {
72
+ const { host, target, box, checkmark } = await renderCheckbox();
31
73
 
32
- inst.toggle();
33
- expect(inst.checked()).toBe(false);
34
- expect(changed).not.toHaveBeenCalled();
74
+ fireEvent.tap(target());
75
+ await waitForUpdate();
76
+ expect(host.checked()).toBe(true);
77
+ expect(box().classList).toContain('bg-primary');
78
+ expect(checkmark().getAttribute('style')).toContain('opacity: 1');
79
+
80
+ fireEvent.tap(target());
81
+ await waitForUpdate();
82
+ expect(host.checked()).toBe(false);
83
+ expect(host.changes).toEqual([true, false]);
35
84
  });
36
85
 
37
- it('applies the "checked" box class and reveals the check mark opacity', async () => {
38
- const { componentRef, container } = await render(UiCheckbox);
39
- const inst = componentRef.instance as UiCheckbox;
40
- inst.checked.set(true);
86
+ it("doesn't animate on mount, then pops the checkmark in and out", async () => {
87
+ const { target, checkmark } = await renderCheckbox();
88
+ expect(animate).not.toHaveBeenCalled();
89
+
90
+ fireEvent.tap(target());
41
91
  await waitForUpdate();
92
+ expect(animate).toHaveBeenCalledWith(checkmark(), [
93
+ START,
94
+ expect.any(String),
95
+ [
96
+ { transform: 'scale(0.6)', opacity: 0 },
97
+ { transform: 'scale(1)', opacity: 1 },
98
+ ],
99
+ expect.objectContaining({ fill: 'forwards' }),
100
+ ]);
42
101
 
43
- // The inner check-glyph is opacity: 1 when checked (opacity: 0 otherwise).
44
- const check = container.querySelector('text');
45
- expect(check?.getAttribute('style')).toContain('opacity: 1');
102
+ // Unchecking cuts the pop-in short and pops the checkmark out.
103
+ fireEvent.tap(target());
104
+ await waitForUpdate();
105
+ expect(animate).toHaveBeenCalledWith(checkmark(), [
106
+ CANCEL,
107
+ expect.any(String),
108
+ ]);
109
+ expect(animate).toHaveBeenLastCalledWith(checkmark(), [
110
+ START,
111
+ expect.any(String),
112
+ [
113
+ { transform: 'scale(1)', opacity: 1 },
114
+ { transform: 'scale(0.8)', opacity: 0 },
115
+ ],
116
+ expect.objectContaining({ fill: 'forwards' }),
117
+ ]);
118
+ });
119
+
120
+ it("doesn't animate two taps in one frame, which leave it as it was", async () => {
121
+ const { host, target } = await renderCheckbox();
122
+ fireEvent.tap(target());
123
+ fireEvent.tap(target());
124
+ await waitForUpdate();
125
+ expect(host.checked()).toBe(false);
126
+ expect(host.changes).toEqual([true, false]);
127
+ expect(animate).not.toHaveBeenCalled();
46
128
  });
47
129
 
48
- it('applies the disabled dim when disabled input is set', async () => {
49
- const { componentRef, container } = await render(UiCheckbox);
50
- setInputSignal((componentRef.instance as UiCheckbox).disabled, true);
130
+ it('dims and ignores taps while disabled', async () => {
131
+ const { host, target, box } = await renderCheckbox();
132
+ host.disabled.set(true);
133
+ await waitForUpdate();
134
+ expect(box().classList).toContain('opacity-50');
135
+
136
+ fireEvent.tap(target());
51
137
  await waitForUpdate();
52
- // opacity-50 class lands on the box wrapper's class attribute.
53
- const boxes = container.querySelectorAll('view');
54
- const hasDim = Array.from(boxes).some((el) =>
55
- el.getAttribute('class')?.includes('opacity-50'),
56
- );
57
- expect(hasDim).toBe(true);
138
+ expect(host.checked()).toBe(false);
139
+ expect(host.changes).toEqual([]);
58
140
  });
59
141
  });
@@ -43,34 +43,34 @@ export class UiCheckbox {
43
43
 
44
44
  readonly changed = output<boolean>();
45
45
 
46
- readonly checkmarkRef = viewChild<ElementRef>('checkmark');
46
+ readonly checkmarkRef = viewChild.required<ElementRef>('checkmark');
47
47
  #checkAnim?: AnimationHandle;
48
48
  #previousChecked?: boolean;
49
49
 
50
- constructor() {
51
- // Animate the checkmark on state transitions (skip initial render).
52
- // `#previousChecked === undefined` guards the first run — we don't want to
53
- // pop-in the checkmark when the component mounts already-checked.
54
- // The `isChecked === this.#previousChecked` guard prevents a second animation
55
- // from firing if the effect re-runs without an actual state change.
56
- effect(() => {
57
- const isChecked = this.checked();
58
- const el = this.checkmarkRef()?.nativeElement;
59
- // popIn/popOut call el.animate() which is inert in jsdom — the
60
- // check-mark animation is exercised on-device.
61
- /* v8 ignore start */
62
- if (!el || this.#previousChecked === undefined) {
63
- this.#previousChecked = isChecked;
64
- return;
65
- }
66
- if (isChecked === this.#previousChecked) return;
50
+ /**
51
+ * Pops the checkmark in when checked and out when unchecked. A field rather
52
+ * than a constructor: Angular's test build keeps a function for a
53
+ * constructor that nothing calls, which an app's coverage report would show
54
+ * as uncovered.
55
+ *
56
+ * `#previousChecked === undefined` guards the first run: we don't want to
57
+ * pop in the checkmark when the component mounts already checked. The
58
+ * equality guard skips a run that isn't a change: `checked` set and set
59
+ * back before the effect runs (two taps in one frame) still re-runs it.
60
+ */
61
+ protected readonly checkmarkAnimation = effect(() => {
62
+ const isChecked = this.checked();
63
+ const el = this.checkmarkRef().nativeElement;
64
+ if (this.#previousChecked === undefined) {
67
65
  this.#previousChecked = isChecked;
66
+ return;
67
+ }
68
+ if (isChecked === this.#previousChecked) return;
69
+ this.#previousChecked = isChecked;
68
70
 
69
- this.#checkAnim?.cancel();
70
- this.#checkAnim = isChecked ? popIn(el) : popOut(el);
71
- /* v8 ignore stop */
72
- });
73
- }
71
+ this.#checkAnim?.cancel();
72
+ this.#checkAnim = isChecked ? popIn(el) : popOut(el);
73
+ });
74
74
 
75
75
  protected readonly boxClass = computed(() =>
76
76
  cn(
@@ -1,8 +1,23 @@
1
1
  import { Component, signal } from '@angular/core';
2
- import { render, waitForUpdate } from '@blotch/angular-lynx-testing-library';
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 {
18
+ type LynxMainThread,
19
+ lynxMainThread,
20
+ } from '../../../test-utils/lynx-main-thread';
6
21
  import {
7
22
  UiCollapsible,
8
23
  UiCollapsibleContent,
@@ -10,103 +25,165 @@ import {
10
25
  } from './collapsible';
11
26
 
12
27
  @Component({
13
- standalone: true,
14
- imports: [UiCollapsible, LYNX_ELEMENTS],
15
- template: `<ui-collapsible><text>Content</text></ui-collapsible>`,
28
+ imports: [
29
+ UiCollapsible,
30
+ UiCollapsibleTrigger,
31
+ UiCollapsibleContent,
32
+ LYNX_ELEMENTS,
33
+ ],
34
+ template: `
35
+ <ui-collapsible [(open)]="open" [disabled]="disabled()" class="gap-2">
36
+ <ui-collapsible-trigger class="px-2">
37
+ <text>Toggle</text>
38
+ </ui-collapsible-trigger>
39
+ <ui-collapsible-content class="pt-1">
40
+ <text>Details</text>
41
+ </ui-collapsible-content>
42
+ </ui-collapsible>
43
+ `,
16
44
  })
17
- class CollapsibleHost {}
45
+ class CollapsibleHost {
46
+ readonly open = signal(false);
47
+ readonly disabled = signal(false);
48
+ }
49
+
50
+ const renderCollapsible = async () => {
51
+ const result = await render(CollapsibleHost);
52
+ return {
53
+ ...result,
54
+ host: result.componentRef.instance as CollapsibleHost,
55
+ // The trigger's own view holds the tap listener; the projected text is
56
+ // inside it.
57
+ trigger: () => result.getByText('Toggle').parentElement!,
58
+ // The content's own view wraps the projected text while it's open.
59
+ content: () => result.queryByText('Details')?.parentElement ?? null,
60
+ };
61
+ };
62
+
63
+ /** `__ElementAnimate`'s first argument for starting an animation. */
64
+ const START = 0;
65
+ /** `__ElementAnimate`'s first argument for canceling an animation. */
66
+ const CANCEL = 3;
18
67
 
19
68
  describe('UiCollapsible', () => {
20
- it('renders projected content', async () => {
21
- const { getByText } = await render(CollapsibleHost);
22
- expect(getByText('Content')).toBeTruthy();
23
- });
69
+ it("merges each part's class with its own", async () => {
70
+ const { container, host, trigger, content } = await renderCollapsible();
71
+ // The host element carries the class too, so look for the view that has
72
+ // both its own and the given class.
73
+ expect(container.querySelector('.flex-col.gap-2')).not.toBeNull();
74
+ expect(trigger().classList).toContain('flex-row');
75
+ expect(trigger().classList).toContain('px-2');
24
76
 
25
- it('toggle() flips the open state', async () => {
26
- const { componentRef } = await render(UiCollapsible);
27
- const inst = componentRef.instance as UiCollapsible;
28
- expect(inst.open()).toBe(false);
29
- inst.toggle();
30
- expect(inst.open()).toBe(true);
31
- inst.toggle();
32
- expect(inst.open()).toBe(false);
77
+ host.open.set(true);
78
+ await waitForUpdate();
79
+ expect(content()!.classList).toContain('flex-col');
80
+ expect(content()!.classList).toContain('pt-1');
33
81
  });
34
82
 
35
- it('toggle() is a no-op when disabled', async () => {
36
- const { componentRef } = await render(UiCollapsible);
37
- const inst = componentRef.instance as UiCollapsible;
38
- setInputSignal(inst.disabled, true);
39
- inst.toggle();
40
- expect(inst.open()).toBe(false);
41
- });
42
- });
83
+ it('opens and closes when the trigger is tapped', async () => {
84
+ const { host, trigger, content } = await renderCollapsible();
85
+ // Closed, the content isn't mounted at all.
86
+ expect(content()).toBeNull();
43
87
 
44
- describe('UiCollapsibleTrigger', () => {
45
- it('onTap delegates to the parent collapsible.toggle()', async () => {
46
- const parentResult = await render(UiCollapsible);
47
- const parent = parentResult.componentRef.instance as UiCollapsible;
88
+ fireEvent.tap(trigger());
89
+ await waitForUpdate();
90
+ expect(content()).not.toBeNull();
91
+ expect(host.open()).toBe(true);
48
92
 
49
- const triggerResult = await render(UiCollapsibleTrigger, {
50
- providers: [{ provide: UiCollapsible, useValue: parent }],
51
- });
52
- const trigger = triggerResult.componentRef.instance as UiCollapsibleTrigger;
53
- (trigger as unknown as { onTap: () => void }).onTap();
54
- expect(parent.open()).toBe(true);
93
+ fireEvent.tap(trigger());
94
+ await waitForUpdate();
95
+ expect(content()).toBeNull();
96
+ expect(host.open()).toBe(false);
55
97
  });
56
98
 
57
- it('applies disabled dim class when parent is disabled', async () => {
58
- const parentResult = await render(UiCollapsible);
59
- const parent = parentResult.componentRef.instance as UiCollapsible;
60
- setInputSignal(parent.disabled, true);
61
-
62
- const triggerResult = await render(UiCollapsibleTrigger, {
63
- providers: [{ provide: UiCollapsible, useValue: parent }],
64
- });
99
+ it('opens when its `open` is set', async () => {
100
+ const { host, content } = await renderCollapsible();
101
+ host.open.set(true);
65
102
  await waitForUpdate();
66
- const view = triggerResult.container.querySelector('view');
67
- expect(view?.getAttribute('class')).toContain('opacity-50');
103
+ expect(content()).not.toBeNull();
68
104
  });
69
- });
70
105
 
71
- describe('UiCollapsibleContent', () => {
72
- it('runs the reveal animation on open via setTimeout', async () => {
73
- const parentResult = await render(UiCollapsible);
74
- const parent = parentResult.componentRef.instance as UiCollapsible;
106
+ it('dims its trigger and ignores taps while disabled', async () => {
107
+ const { host, trigger, content } = await renderCollapsible();
108
+ expect(trigger().classList).not.toContain('opacity-50');
75
109
 
76
- const contentResult = await render(UiCollapsibleContent, {
77
- providers: [{ provide: UiCollapsible, useValue: parent }],
78
- });
79
- const content = contentResult.componentRef.instance as UiCollapsibleContent;
80
- // JIT + jsdom doesn't populate the `#content` viewChild — patch it with a
81
- // signal returning the outer view so the setTimeout body's `el` check
82
- // passes and revealIn actually runs.
83
- const view = contentResult.container.querySelector('view') as Element;
84
- (content as unknown as { contentRef: unknown }).contentRef = signal({
85
- nativeElement: view,
86
- });
87
-
88
- parent.open.set(true);
110
+ host.disabled.set(true);
89
111
  await waitForUpdate();
90
- await waitForUpdate(); // wait for the effect's setTimeout(_, 0) to fire
112
+ expect(trigger().classList).toContain('opacity-50');
91
113
 
92
- // Toggle off then on again to hit the cancel path with `#anim` already set.
93
- parent.open.set(false);
94
- await waitForUpdate();
95
- parent.open.set(true);
96
- await waitForUpdate();
114
+ fireEvent.tap(trigger());
97
115
  await waitForUpdate();
98
- expect(parent.open()).toBe(true);
116
+ expect(content()).toBeNull();
117
+ expect(host.open()).toBe(false);
99
118
  });
100
119
 
101
- it('bails out inside setTimeout when contentRef is undefined', async () => {
102
- const parentResult = await render(UiCollapsible);
103
- const parent = parentResult.componentRef.instance as UiCollapsible;
104
- await render(UiCollapsibleContent, {
105
- providers: [{ provide: UiCollapsible, useValue: parent }],
120
+ describe('reveal animation', () => {
121
+ // Watched per test: the testing library resets the main thread after each.
122
+ let animate: MockInstance<LynxMainThread['__ElementAnimate']>;
123
+ beforeEach(() => {
124
+ animate = vi.spyOn(lynxMainThread(), '__ElementAnimate');
125
+ });
126
+ afterEach(() => animate.mockRestore());
127
+
128
+ /**
129
+ * The keys of the animations started on an element.
130
+ */
131
+ const startedOn = (element: Element | null) =>
132
+ animate.mock.calls
133
+ .filter(
134
+ ([target, [operation]]) => target === element && operation === START,
135
+ )
136
+ .map(([, [, key]]) => key);
137
+
138
+ it('slides the content down into place each time it opens', async () => {
139
+ const { trigger, content } = await renderCollapsible();
140
+
141
+ fireEvent.tap(trigger());
142
+ // The first update runs the effect, which waits a macrotask so the
143
+ // content is committed to Lynx's tree; the second lets that run.
144
+ await waitForUpdate();
145
+ await waitForUpdate();
146
+ expect(animate).toHaveBeenCalledWith(content(), [
147
+ START,
148
+ expect.any(String),
149
+ [
150
+ { transform: 'translateY(-8px)', opacity: 0 },
151
+ { transform: 'translateY(0)', opacity: 1 },
152
+ ],
153
+ // No fill: once it ends, the content's inline resting style applies.
154
+ expect.objectContaining({ fill: 'none' }),
155
+ ]);
156
+ const [firstKey] = startedOn(content());
157
+
158
+ fireEvent.tap(trigger());
159
+ await waitForUpdate();
160
+ fireEvent.tap(trigger());
161
+ await waitForUpdate();
162
+ await waitForUpdate();
163
+ // Reopening stops the previous reveal before it plays a new one on the
164
+ // content's new view.
165
+ expect(animate).toHaveBeenCalledWith(expect.anything(), [
166
+ CANCEL,
167
+ firstKey,
168
+ ]);
169
+ expect(startedOn(content())).toHaveLength(1);
170
+ });
171
+
172
+ it("doesn't animate content that closed before its reveal began", async () => {
173
+ const { componentRef, trigger, content } = await renderCollapsible();
174
+
175
+ fireEvent.tap(trigger());
176
+ // The effect has scheduled the reveal for the next macrotask.
177
+ await waitForUpdate();
178
+ // Close it before then, and render that straight away.
179
+ fireEvent.tap(trigger());
180
+ componentRef.changeDetectorRef.detectChanges();
181
+ expect(content()).toBeNull();
182
+
183
+ await waitForUpdate();
184
+ expect(
185
+ animate.mock.calls.filter(([, [operation]]) => operation === START),
186
+ ).toEqual([]);
106
187
  });
107
- parent.open.set(true);
108
- await waitForUpdate();
109
- await waitForUpdate();
110
- expect(parent.open()).toBe(true);
111
188
  });
112
189
  });
@@ -101,20 +101,25 @@ export class UiCollapsibleContent {
101
101
  readonly contentRef = viewChild<ElementRef>('content');
102
102
  #anim?: AnimationHandle;
103
103
 
104
- constructor() {
105
- // Animate the reveal whenever the panel opens. contentRef only resolves while
106
- // the `@if` content is mounted; defer one macrotask so the freshly (re)created
107
- // element is committed to the native tree before we animate it.
108
- effect(() => {
109
- if (!this.collapsible.open()) return;
110
- setTimeout(() => {
111
- const el = this.contentRef()?.nativeElement;
112
- if (!el) return;
113
- this.#anim?.cancel();
114
- this.#anim = revealIn(el, { fromY: -8, fill: 'none' });
115
- }, 0);
116
- });
117
- }
104
+ /**
105
+ * Animates the reveal whenever the panel opens. A field rather than a
106
+ * constructor: Angular's test build keeps a function for a constructor that
107
+ * nothing calls, which an app's coverage report would show as uncovered.
108
+ *
109
+ * contentRef only resolves while the `@if` content is mounted; defer one
110
+ * macrotask so the freshly (re)created element is committed to the native
111
+ * tree before we animate it. By then the panel may have closed again, which
112
+ * leaves nothing to animate.
113
+ */
114
+ protected readonly revealing = effect(() => {
115
+ if (!this.collapsible.open()) return;
116
+ setTimeout(() => {
117
+ const el = this.contentRef()?.nativeElement;
118
+ if (!el) return;
119
+ this.#anim?.cancel();
120
+ this.#anim = revealIn(el, { fromY: -8, fill: 'none' });
121
+ }, 0);
122
+ });
118
123
 
119
124
  protected readonly contentClass = computed(() =>
120
125
  cn('flex-col flex', this.userClass()),