@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,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,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 { lineChartDefinition } from './line-chart';
10
+ import type { XYDatum } from '../chart/chart-presets';
11
+ import { UiLineChart, lineChartDefinition } from './line-chart';
5
12
 
6
13
  const data = [
7
14
  { x: 0, y: 10 },
@@ -75,3 +82,71 @@ describe('lineChartDefinition', () => {
75
82
  expect(lineChartDefinition({ data, tooltip: false }).tooltip).toBe(false);
76
83
  });
77
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]]);
151
+ });
152
+ });