@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,111 +1,203 @@
1
- import { signal } from '@angular/core';
2
- import { render, waitForUpdate } from '@blotch/angular-lynx-testing-library';
3
- import { describe, expect, it, vi } from 'vitest';
4
- 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';
5
20
  import { UiInput } from './input';
6
21
 
7
- describe('UiInput', () => {
8
- it('renders with default configuration', async () => {
9
- const { container } = await render(UiInput);
10
- expect(container).toBeTruthy();
11
- });
22
+ @Component({
23
+ imports: [UiInput],
24
+ template: `
25
+ <ui-input
26
+ [(value)]="email"
27
+ [label]="label()"
28
+ placeholder="you@example.com"
29
+ type="email"
30
+ [helperText]="helperText()"
31
+ [error]="error()"
32
+ [disabled]="disabled()"
33
+ class="w-full"
34
+ (changed)="changes.push($event)"
35
+ (focused)="events.push('focused')"
36
+ (blurred)="events.push('blurred')"
37
+ />
38
+ `,
39
+ })
40
+ class InputHost {
41
+ readonly email = signal('');
42
+ readonly label = signal('');
43
+ readonly helperText = signal('');
44
+ readonly error = signal('');
45
+ readonly disabled = signal(false);
46
+ readonly changes: string[] = [];
47
+ readonly events: string[] = [];
48
+ }
49
+
50
+ const renderInput = async () => {
51
+ const result = await render(InputHost);
52
+ return {
53
+ ...result,
54
+ host: result.componentRef.instance as InputHost,
55
+ field: () => result.container.querySelector('input')!,
56
+ // The field's own column; the host element renders as a view too.
57
+ column: () => result.container.querySelector('.flex-col')!,
58
+ wrapper: () => result.container.querySelector('.bg-muted')!,
59
+ // The layer that draws the focus ring around the field.
60
+ ring: () => result.container.querySelector('.absolute') as HTMLElement,
61
+ };
62
+ };
63
+
64
+ /** `__ElementAnimate`'s first argument for starting an animation. */
65
+ const START = 0;
66
+ /** `__ElementAnimate`'s first argument for canceling an animation. */
67
+ const CANCEL = 3;
12
68
 
13
- it('onInput() updates the model and emits `changed`', async () => {
14
- const { componentRef } = await render(UiInput);
15
- const inst = componentRef.instance as unknown as UiInput & {
16
- onInput: (e: { detail: { value: string } }) => void;
17
- };
18
- const emitted: string[] = [];
19
- inst.changed.subscribe((v) => emitted.push(v));
20
- inst.onInput({ detail: { value: 'hello' } });
21
- expect(inst.value()).toBe('hello');
22
- expect(emitted).toEqual(['hello']);
69
+ describe('UiInput', () => {
70
+ // Watched per test: the testing library resets the main thread after each.
71
+ let animate: MockInstance<LynxMainThread['__ElementAnimate']>;
72
+ beforeEach(() => {
73
+ animate = vi.spyOn(lynxMainThread(), '__ElementAnimate');
23
74
  });
75
+ afterEach(() => animate.mockRestore());
24
76
 
25
- it('onInput() is a no-op when disabled', async () => {
26
- const { componentRef } = await render(UiInput);
27
- const inst = componentRef.instance as unknown as UiInput & {
28
- onInput: (e: { detail: { value: string } }) => void;
29
- };
30
- setInputSignal(inst.disabled, true);
31
- const emitted = vi.fn();
32
- inst.changed.subscribe(emitted);
33
- inst.onInput({ detail: { value: 'blocked' } });
34
- expect(inst.value()).toBe('');
35
- expect(emitted).not.toHaveBeenCalled();
77
+ it('renders a native input with its placeholder and type, and no label', async () => {
78
+ const { field, column, queryByText } = await renderInput();
79
+ expect(field().getAttribute('placeholder')).toBe('you@example.com');
80
+ expect(field().getAttribute('type')).toBe('email');
81
+ expect(column().classList).toContain('w-full');
82
+ expect(queryByText('you@example.com')).toBeNull();
36
83
  });
37
84
 
38
- it('onFocus() flips the focused state and emits `focused`', async () => {
39
- const { componentRef, container } = await render(UiInput);
40
- const inst = componentRef.instance as unknown as UiInput & {
41
- onFocus: () => void;
42
- };
43
- const emitted = vi.fn();
44
- inst.focused.subscribe(emitted);
45
- inst.onFocus();
85
+ it('shows its label', async () => {
86
+ const { host, getByText } = await renderInput();
87
+ host.label.set('Email');
46
88
  await waitForUpdate();
47
- expect(emitted).toHaveBeenCalledTimes(1);
48
- // Focus turns the overlay opacity to 1 via ringVisible().
49
- const overlay = container.querySelector('[style*="box-shadow"]');
50
- expect(overlay?.getAttribute('style')).toContain('opacity');
89
+ expect(getByText('Email').classList).toContain('font-medium');
51
90
  });
52
91
 
53
- it('onBlur() clears focused state and emits `blurred`', async () => {
54
- const { componentRef } = await render(UiInput);
55
- const inst = componentRef.instance as unknown as UiInput & {
56
- onFocus: () => void;
57
- onBlur: () => void;
58
- };
59
- const emitted = vi.fn();
60
- inst.blurred.subscribe(emitted);
61
- inst.onFocus();
62
- inst.onBlur();
63
- expect(emitted).toHaveBeenCalledTimes(1);
92
+ it('updates `value` and emits `changed` as the user types', async () => {
93
+ const { host, field } = await renderInput();
94
+ // Lynx puts the typed text in the event's detail, not on its target.
95
+ fireEvent.input(field(), { detail: { value: 'jane@' } });
96
+ fireEvent.input(field(), { detail: { value: 'jane@example.com' } });
97
+ expect(host.email()).toBe('jane@example.com');
98
+ expect(host.changes).toEqual(['jane@', 'jane@example.com']);
64
99
  });
65
100
 
66
- it('error input triggers the shake animation via effect', async () => {
67
- // The effect reads error() + containerRef.nativeElement and calls shake().
68
- // Setting error to a non-empty value while it was empty exercises the branch.
69
- // JIT + jsdom doesn't populate template-ref viewChild queries, so we
70
- // monkey-patch containerRef with a signal returning a fake ElementRef so the
71
- // effect's `el` check passes and the shake path runs.
72
- const { componentRef, container } = await render(UiInput);
73
- const inst = componentRef.instance as UiInput;
74
- const view = container.querySelector('view') as Element;
75
- (inst as unknown as { containerRef: unknown }).containerRef = signal({
76
- nativeElement: view,
77
- });
78
- setInputSignal(inst.error, 'Required');
79
- await waitForUpdate();
101
+ it("dims and ignores typing while it's disabled", async () => {
102
+ const { host, field, wrapper } = await renderInput();
103
+ host.disabled.set(true);
80
104
  await waitForUpdate();
81
- // The error text should render and the destructive ring shadow should apply.
82
- expect(container.textContent).toContain('Required');
105
+ expect(wrapper().classList).toContain('opacity-50');
106
+
107
+ fireEvent.input(field(), { detail: { value: 'blocked' } });
108
+ expect(host.email()).toBe('');
109
+ expect(host.changes).toEqual([]);
110
+ });
83
111
 
84
- // Re-trigger the shake (previous error cleared → then set) so `#shakeAnim`
85
- // is set once and canceled the next time — covers both branches of `?.`.
86
- setInputSignal(inst.error, '');
112
+ it('disables the native input only while disabled', async () => {
113
+ // Lynx treats any `disabled` value, even false, as disabled, so the
114
+ // attribute has to go rather than be set to false.
115
+ const setAttribute = vi.spyOn(lynxMainThread(), '__SetAttribute');
116
+ const { host, field } = await renderInput();
117
+ expect(setAttribute).not.toHaveBeenCalledWith(
118
+ field(),
119
+ 'disabled',
120
+ expect.anything(),
121
+ );
122
+
123
+ host.disabled.set(true);
87
124
  await waitForUpdate();
125
+ expect(setAttribute).toHaveBeenLastCalledWith(field(), 'disabled', 'true');
126
+ setAttribute.mockRestore();
127
+ });
128
+
129
+ it('shows a focus ring while focused, and emits `focused` and `blurred`', async () => {
130
+ const { host, field, ring } = await renderInput();
131
+ expect(ring().style.getPropertyValue('opacity')).toBe('0');
132
+
133
+ fireEvent.focus(field());
88
134
  await waitForUpdate();
89
- setInputSignal(inst.error, 'Required again');
135
+ expect(ring().style.getPropertyValue('opacity')).toBe('1');
136
+ expect(ring().style.getPropertyValue('box-shadow')).toContain(
137
+ 'var(--ring)',
138
+ );
139
+
140
+ fireEvent.blur(field());
90
141
  await waitForUpdate();
142
+ expect(ring().style.getPropertyValue('opacity')).toBe('0');
143
+ expect(host.events).toEqual(['focused', 'blurred']);
144
+ });
145
+
146
+ it('shows its helper text', async () => {
147
+ const { host, getByText } = await renderInput();
148
+ host.helperText.set("We'll never share it.");
91
149
  await waitForUpdate();
92
- expect(container.textContent).toContain('Required again');
150
+ expect(getByText("We'll never share it.").classList).toContain(
151
+ 'text-muted-foreground',
152
+ );
93
153
  });
94
154
 
95
- it('renders helper text when no error is present', async () => {
96
- const { componentRef, container } = await render(UiInput);
97
- const inst = componentRef.instance as UiInput;
98
- setInputSignal(inst.helperText, 'Enter your name');
155
+ it('shows an error in place of the helper text, with a destructive ring', async () => {
156
+ const { host, ring, getByText, queryByText } = await renderInput();
157
+ host.helperText.set("We'll never share it.");
158
+ host.error.set('Enter a valid email');
99
159
  await waitForUpdate();
100
- expect(container.textContent).toContain('Enter your name');
160
+ expect(getByText('Enter a valid email').classList).toContain(
161
+ 'text-destructive',
162
+ );
163
+ expect(queryByText("We'll never share it.")).toBeNull();
164
+ // An invalid field keeps its ring even when it isn't focused.
165
+ expect(ring().style.getPropertyValue('opacity')).toBe('1');
166
+ expect(ring().style.getPropertyValue('box-shadow')).toContain(
167
+ 'var(--destructive)',
168
+ );
101
169
  });
102
170
 
103
- it('renders the label when provided', async () => {
104
- const { componentRef, container } = await render(UiInput);
105
- const inst = componentRef.instance as UiInput;
106
- setInputSignal(inst.label, 'Name');
171
+ it('shakes when an error appears, but not when it only changes', async () => {
172
+ const { host, column } = await renderInput();
173
+ const shakes = () =>
174
+ animate.mock.calls.filter(([, [action]]) => action === START);
175
+ expect(shakes()).toEqual([]);
176
+
177
+ host.error.set('Required');
178
+ await waitForUpdate();
179
+ expect(animate).toHaveBeenCalledWith(column(), [
180
+ START,
181
+ expect.any(String),
182
+ expect.arrayContaining([{ transform: 'translateX(-4px)' }]),
183
+ expect.anything(),
184
+ ]);
185
+ expect(shakes()).toHaveLength(1);
186
+
187
+ host.error.set('Enter a valid email');
107
188
  await waitForUpdate();
108
- expect(container.textContent).toContain('Name');
189
+ expect(shakes()).toHaveLength(1);
190
+
191
+ // Once fixed, the next error shakes again, replacing the last shake.
192
+ host.error.set('');
193
+ await waitForUpdate();
194
+ host.error.set('Required');
195
+ await waitForUpdate();
196
+ expect(shakes()).toHaveLength(2);
197
+ expect(animate).toHaveBeenCalledWith(column(), [
198
+ CANCEL,
199
+ expect.any(String),
200
+ ]);
109
201
  });
110
202
 
111
203
  it('makes the wrapper a stacking context so the field scrolls with it', async () => {
@@ -113,22 +205,7 @@ describe('UiInput', () => {
113
205
  // when an ancestor inside the scroll content is a stacking context. Without
114
206
  // z-0 on the wrapper, the native field (and its placeholder) stays fixed on
115
207
  // screen while the page scrolls.
116
- const { container } = await render(UiInput);
117
- const wrapper = container.querySelector(
118
- '[class*="rounded-xl"][class*="bg-muted"]',
119
- );
120
- expect(wrapper?.getAttribute('class')?.split(/\s+/)).toContain('z-0');
121
- });
122
-
123
- it('applies the disabled dim class when disabled', async () => {
124
- // Covers the true branch of `disabled() && 'opacity-50'` in inputWrapperClass.
125
- const { componentRef, container } = await render(UiInput);
126
- const inst = componentRef.instance as UiInput;
127
- setInputSignal(inst.disabled, true);
128
- await waitForUpdate();
129
- const wrapper = container.querySelector(
130
- '[class*="rounded-xl"][class*="bg-muted"]',
131
- );
132
- expect(wrapper?.getAttribute('class')).toContain('opacity-50');
208
+ const { wrapper } = await renderInput();
209
+ expect(wrapper().classList).toContain('z-0');
133
210
  });
134
211
  });
@@ -102,18 +102,21 @@ export class UiInput implements FormValueControl<string> {
102
102
  #shakeAnim?: AnimationHandle;
103
103
  #previousError = '';
104
104
 
105
- constructor() {
106
- // Shake the input when an error appears (transition from no-error to error)
107
- effect(() => {
108
- const err = this.error();
109
- const el = this.containerRef()?.nativeElement;
110
- if (err && !this.#previousError && el) {
111
- this.#shakeAnim?.cancel();
112
- this.#shakeAnim = shake(el);
113
- }
114
- this.#previousError = err;
115
- });
116
- }
105
+ /**
106
+ * Shakes the field when an error appears (a change from no error to one). A
107
+ * field rather than a constructor: Angular's test build keeps a function for
108
+ * a constructor that nothing calls, which an app's coverage report would show
109
+ * as uncovered.
110
+ */
111
+ protected readonly shakeOnError = effect(() => {
112
+ const err = this.error();
113
+ const el = this.containerRef()?.nativeElement;
114
+ if (err && !this.#previousError && el) {
115
+ this.#shakeAnim?.cancel();
116
+ this.#shakeAnim = shake(el);
117
+ }
118
+ this.#previousError = err;
119
+ });
117
120
 
118
121
  protected readonly containerClass = computed(() =>
119
122
  cn('flex-col gap-1.5 flex', this.userClass()),
@@ -1,38 +1,25 @@
1
- import { Component } from '@angular/core';
1
+ import { Component, signal } from '@angular/core';
2
2
  import { render, waitForUpdate } from '@blotch/angular-lynx-testing-library';
3
- import { LYNX_ELEMENTS } from '@blotch/angular-lynx';
4
3
  import { describe, expect, it } from 'vitest';
5
- import { setInputSignal } from '../../../test-utils/set-input-signal';
6
4
  import { UiLabel } from './label';
7
5
 
8
6
  @Component({
9
- standalone: true,
10
- imports: [UiLabel, LYNX_ELEMENTS],
11
- template: `<ui-label>{{ label }}</ui-label>`,
7
+ imports: [UiLabel],
8
+ template: `<ui-label [disabled]="disabled()">Name</ui-label>`,
12
9
  })
13
10
  class LabelHost {
14
- label = 'Name';
11
+ readonly disabled = signal(false);
15
12
  }
16
13
 
17
14
  describe('UiLabel', () => {
18
- it('renders projected text', async () => {
19
- const { getByText } = await render(LabelHost);
20
- expect(getByText('Name')).toBeTruthy();
21
- });
22
-
23
- it('renders standalone with no host wrapper (default disabled=false)', async () => {
24
- const { container } = await render(UiLabel);
25
- const textEl = container.querySelector('text');
26
- expect(textEl?.getAttribute('class')).toContain('font-medium');
27
- });
15
+ it('renders its text, dimmed while disabled', async () => {
16
+ const { componentRef, getByText } = await render(LabelHost);
17
+ const label = getByText('Name');
18
+ expect(label.classList).toContain('font-medium');
19
+ expect(label.classList).not.toContain('opacity-50');
28
20
 
29
- it('applies opacity when disabled flips to true', async () => {
30
- // JIT template bindings don't wire signal inputs — drive the input
31
- // directly to exercise the `disabled() && 'opacity-50'` truthy branch.
32
- const { componentRef, container } = await render(UiLabel);
33
- setInputSignal((componentRef.instance as UiLabel).disabled, true);
21
+ (componentRef.instance as LabelHost).disabled.set(true);
34
22
  await waitForUpdate();
35
- const textEl = container.querySelector('text');
36
- expect(textEl?.getAttribute('class')).toContain('opacity-50');
23
+ expect(label.classList).toContain('opacity-50');
37
24
  });
38
25
  });
@@ -1,6 +1,5 @@
1
1
  export {
2
+ type LineChartOptions,
2
3
  UiLineChart,
3
- UiLineSeries,
4
- computeLineSegments,
5
- type LineSegment,
4
+ lineChartDefinition,
6
5
  } from './line-chart';
@@ -1,59 +1,152 @@
1
+ import { Component, signal } from '@angular/core';
2
+ import {
3
+ fireEvent,
4
+ render,
5
+ waitForUpdate,
6
+ } from '@blotch/angular-lynx-testing-library';
7
+ import { createChartRuntime } from '@tanstack/charts/runtime';
8
+ import type { ChartDefinition, SceneNode } from '@tanstack/charts/types';
1
9
  import { describe, expect, it } from 'vitest';
2
- import { computeLineSegments } from './line-chart';
3
-
4
- describe('computeLineSegments', () => {
5
- it('produces a horizontal segment (angle 0)', () => {
6
- const segments = computeLineSegments([
7
- { x: 0, y: 10 },
8
- { x: 100, y: 10 },
9
- ]);
10
- expect(segments).toHaveLength(1);
11
- expect(segments[0].x).toBe(0);
12
- expect(segments[0].y).toBe(10);
13
- expect(segments[0].length).toBeCloseTo(100);
14
- expect(segments[0].angle).toBeCloseTo(0);
15
- });
16
-
17
- it('measures a vertical segment as 90 degrees (screen y-down)', () => {
18
- const segments = computeLineSegments([
19
- { x: 0, y: 0 },
20
- { x: 0, y: 50 },
21
- ]);
22
- expect(segments[0].length).toBeCloseTo(50);
23
- expect(segments[0].angle).toBeCloseTo(90);
24
- });
25
-
26
- it('measures a 45-degree diagonal', () => {
27
- const segments = computeLineSegments([
28
- { x: 0, y: 0 },
29
- { x: 10, y: 10 },
30
- ]);
31
- expect(segments[0].length).toBeCloseTo(Math.sqrt(200));
32
- expect(segments[0].angle).toBeCloseTo(45);
33
- });
34
-
35
- it('emits one segment per consecutive pair', () => {
36
- const segments = computeLineSegments([
37
- { x: 0, y: 0 },
38
- { x: 10, y: 5 },
39
- { x: 20, y: 0 },
40
- ]);
41
- expect(segments).toHaveLength(2);
42
- });
43
-
44
- it('skips zero-length (duplicate) points', () => {
45
- const segments = computeLineSegments([
46
- { x: 5, y: 5 },
47
- { x: 5, y: 5 },
48
- { x: 10, y: 5 },
49
- ]);
50
- expect(segments).toHaveLength(1);
51
- expect(segments[0].x).toBe(5);
52
- expect(segments[0].length).toBeCloseTo(5);
53
- });
54
-
55
- it('returns no segments for fewer than two points', () => {
56
- expect(computeLineSegments([])).toEqual([]);
57
- expect(computeLineSegments([{ x: 1, y: 1 }])).toEqual([]);
10
+ import type { XYDatum } from '../chart/chart-presets';
11
+ import { UiLineChart, lineChartDefinition } from './line-chart';
12
+
13
+ const data = [
14
+ { x: 0, y: 10 },
15
+ { x: 5, y: 30 },
16
+ { x: 10, y: 20 },
17
+ ];
18
+
19
+ /**
20
+ * Every drawn shape in a definition's scene, labels excluded.
21
+ */
22
+ const shapes = (definition: ChartDefinition<any, any, any>) => {
23
+ const scene = createChartRuntime().render(definition, {
24
+ width: 300,
25
+ height: 200,
26
+ });
27
+ const found: SceneNode[] = [];
28
+ const walk = (nodes: readonly SceneNode[]) => {
29
+ for (const node of nodes) {
30
+ if (node.kind === 'group') {
31
+ if (!node.focus) walk(node.children);
32
+ } else if (node.kind !== 'label') found.push(node);
33
+ }
34
+ };
35
+ walk(scene.nodes);
36
+ return { scene, found };
37
+ };
38
+
39
+ describe('lineChartDefinition', () => {
40
+ it('draws the line and a dot per point', () => {
41
+ const { found } = shapes(lineChartDefinition({ data }));
42
+ expect(found.filter((n) => n.kind === 'polyline')).toHaveLength(1);
43
+ expect(found.filter((n) => n.kind === 'dot')).toHaveLength(3);
44
+ const line = found.find((n) => n.kind === 'polyline')!;
45
+ expect(line.style).toMatchObject({
46
+ stroke: 'var(--primary)',
47
+ strokeWidth: 2,
48
+ });
49
+ });
50
+
51
+ it('can leave the dots out', () => {
52
+ for (const options of [{ showDots: false }, { dotRadius: 0 }]) {
53
+ const { found } = shapes(lineChartDefinition({ data, ...options }));
54
+ expect(found.some((n) => n.kind === 'dot')).toBe(false);
55
+ }
56
+ });
57
+
58
+ it('draws a curve through the points when smooth', () => {
59
+ const { found } = shapes(
60
+ lineChartDefinition({ data, smooth: true, color: 'red' }),
61
+ );
62
+ const line = found.find((n) => n.kind === 'polyline') as { path?: string };
63
+ expect(line.path).toContain('C');
64
+ });
65
+
66
+ it('fits the axes to the data and honors the shared options', () => {
67
+ const { scene } = shapes(
68
+ lineChartDefinition({
69
+ data,
70
+ yMin: 0,
71
+ yMax: 40,
72
+ showGrid: false,
73
+ tooltip: false,
74
+ }),
75
+ );
76
+ expect(scene.scales['y']!.domain).toEqual([0, 40]);
77
+ expect(scene.scales['x']!.domain).toEqual([0, 10]);
78
+ });
79
+
80
+ it('includes the tooltip unless turned off', () => {
81
+ expect(lineChartDefinition({ data }).tooltip).toBeTruthy();
82
+ expect(lineChartDefinition({ data, tooltip: false }).tooltip).toBe(false);
83
+ });
84
+ });
85
+
86
+ @Component({
87
+ imports: [UiLineChart],
88
+ template: `
89
+ <ui-line-chart
90
+ [data]="data"
91
+ [width]="300"
92
+ [showDots]="showDots()"
93
+ color="red"
94
+ yAxisLabel="Revenue"
95
+ (pointTap)="tapped.push($event)"
96
+ />
97
+ `,
98
+ })
99
+ class LineChartHost {
100
+ readonly data = data;
101
+ readonly showDots = signal(true);
102
+ /** Every `pointTap`, in order. */
103
+ readonly tapped: XYDatum[] = [];
104
+ }
105
+
106
+ /**
107
+ * The SVG the chart's marks layer draws.
108
+ */
109
+ const marksSvg = (container: Element) =>
110
+ container.querySelectorAll('svg')[1]!.getAttribute('content')!;
111
+
112
+ /**
113
+ * Where the chart drew each dot.
114
+ */
115
+ const dotsDrawn = (container: Element) =>
116
+ [
117
+ ...marksSvg(container).matchAll(/<circle cx="([\d.-]+)" cy="([\d.-]+)"/g),
118
+ ].map(([, x, y]) => ({ x: Number(x), y: Number(y) }));
119
+
120
+ /**
121
+ * Touches a spot of the chart and lifts the finger right there: a tap.
122
+ */
123
+ const tap = async (container: Element, at: { x: number; y: number }) => {
124
+ const root = container.querySelector('[accessibility-label]')!;
125
+ fireEvent.touchstart(root, { touches: [at] });
126
+ fireEvent.touchend(root, { touches: [at] });
127
+ await waitForUpdate();
128
+ };
129
+
130
+ describe('UiLineChart', () => {
131
+ it('draws its data with its inputs', async () => {
132
+ const { componentRef, container, getByText } = await render(LineChartHost);
133
+ expect(marksSvg(container)).toContain('stroke="red"');
134
+ expect(dotsDrawn(container)).toHaveLength(3);
135
+ expect(getByText('Revenue')).toBeTruthy();
136
+
137
+ (componentRef.instance as LineChartHost).showDots.set(false);
138
+ await waitForUpdate();
139
+ expect(dotsDrawn(container)).toHaveLength(0);
140
+ });
141
+
142
+ it("emits a tapped point's datum, and nothing for a tap off the points", async () => {
143
+ const { componentRef, container } = await render(LineChartHost);
144
+ const host = componentRef.instance as LineChartHost;
145
+ const middle = dotsDrawn(container)[1]!;
146
+ await tap(container, { x: middle.x + 1, y: middle.y + 1 });
147
+ expect(host.tapped).toEqual([data[1]]);
148
+
149
+ await tap(container, { x: -100, y: -100 });
150
+ expect(host.tapped).toEqual([data[1]]);
58
151
  });
59
152
  });