@blotch/dolan 22.2.3 → 22.2.5

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 (227) 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 +38 -3
  4. package/dist/commands/add.test.js.map +1 -1
  5. package/dist/commands/doctor.js +258 -21
  6. package/dist/commands/doctor.js.map +1 -1
  7. package/dist/commands/doctor.test.js +425 -11
  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 +105 -2
  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 +7 -0
  17. package/dist/commands/update.js +194 -50
  18. package/dist/commands/update.js.map +1 -1
  19. package/dist/commands/update.test.js +570 -3
  20. package/dist/commands/update.test.js.map +1 -1
  21. package/dist/lockfile.d.ts +17 -0
  22. package/dist/lockfile.js +56 -1
  23. package/dist/lockfile.js.map +1 -1
  24. package/dist/lockfile.test.d.ts +1 -0
  25. package/dist/lockfile.test.js +65 -0
  26. package/dist/lockfile.test.js.map +1 -0
  27. package/dist/registry.d.ts +6 -0
  28. package/dist/registry.js +119 -11
  29. package/dist/registry.js.map +1 -1
  30. package/dist/registry.test.js +61 -6
  31. package/dist/registry.test.js.map +1 -1
  32. package/dist/test-utils.d.ts +27 -0
  33. package/dist/test-utils.js +45 -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/boolean-inputs.test.ts +76 -0
  51. package/dist/ui/components/accordion/accordion.test.ts +158 -144
  52. package/dist/ui/components/accordion/accordion.ts +19 -14
  53. package/dist/ui/components/action-sheet/action-sheet.test.ts +637 -183
  54. package/dist/ui/components/action-sheet/action-sheet.ts +339 -55
  55. package/dist/ui/components/action-sheet/index.ts +1 -0
  56. package/dist/ui/components/alert/alert.test.ts +67 -19
  57. package/dist/ui/components/alert/alert.ts +14 -16
  58. package/dist/ui/components/alert-dialog/alert-dialog.test.ts +740 -129
  59. package/dist/ui/components/alert-dialog/alert-dialog.ts +672 -58
  60. package/dist/ui/components/alert-dialog/index.ts +5 -0
  61. package/dist/ui/components/area-chart/area-chart.test.ts +81 -1
  62. package/dist/ui/components/area-chart/area-chart.ts +3 -2
  63. package/dist/ui/components/avatar/avatar.test.ts +89 -45
  64. package/dist/ui/components/badge/badge.test.ts +57 -23
  65. package/dist/ui/components/badge/badge.ts +14 -15
  66. package/dist/ui/components/bar-chart/bar-chart.test.ts +82 -1
  67. package/dist/ui/components/bottom-sheet/bottom-sheet.test.ts +1314 -273
  68. package/dist/ui/components/bottom-sheet/bottom-sheet.ts +1046 -119
  69. package/dist/ui/components/bottom-sheet/index.ts +7 -1
  70. package/dist/ui/components/button/button.test.ts +646 -2
  71. package/dist/ui/components/button/button.ts +443 -64
  72. package/dist/ui/components/button/index.ts +2 -0
  73. package/dist/ui/components/button-group/button-group.test.ts +528 -2
  74. package/dist/ui/components/button-group/button-group.ts +428 -70
  75. package/dist/ui/components/button-group/index.ts +3 -0
  76. package/dist/ui/components/candlestick-chart/candlestick-chart.test.ts +82 -1
  77. package/dist/ui/components/card/card.test.ts +97 -61
  78. package/dist/ui/components/card/card.ts +2 -1
  79. package/dist/ui/components/chart/chart-presets.test.ts +197 -1
  80. package/dist/ui/components/chart/chart-presets.ts +14 -7
  81. package/dist/ui/components/chart/chart-theme.ts +12 -0
  82. package/dist/ui/components/chart/chart.test.ts +1031 -0
  83. package/dist/ui/components/chart/chart.ts +66 -46
  84. package/dist/ui/components/checkbox/checkbox.test.ts +124 -42
  85. package/dist/ui/components/checkbox/checkbox.ts +25 -24
  86. package/dist/ui/components/collapsible/collapsible.test.ts +158 -81
  87. package/dist/ui/components/collapsible/collapsible.ts +21 -15
  88. package/dist/ui/components/context-menu/context-menu.test.ts +224 -0
  89. package/dist/ui/components/context-menu/context-menu.ts +110 -0
  90. package/dist/ui/components/context-menu/index.ts +1 -0
  91. package/dist/ui/components/dialog/dialog.test.ts +210 -152
  92. package/dist/ui/components/dialog/dialog.ts +35 -29
  93. package/dist/ui/components/dock/dock-scroll.test.ts +130 -0
  94. package/dist/ui/components/dock/dock-scroll.ts +142 -0
  95. package/dist/ui/components/dock/dock-search.test.ts +594 -0
  96. package/dist/ui/components/dock/dock.test.ts +752 -0
  97. package/dist/ui/components/dock/dock.ts +1438 -0
  98. package/dist/ui/components/dock/index.ts +2 -0
  99. package/dist/ui/components/dynamic-color/dynamic-color.test.ts +144 -0
  100. package/dist/ui/components/dynamic-color/dynamic-color.ts +146 -0
  101. package/dist/ui/components/dynamic-color/index.ts +5 -0
  102. package/dist/ui/components/empty-state/empty-state.test.ts +28 -39
  103. package/dist/ui/components/glass/concentric-radius.test.ts +13 -0
  104. package/dist/ui/components/glass/concentric-radius.ts +17 -0
  105. package/dist/ui/components/glass/glass-design.test.ts +78 -0
  106. package/dist/ui/components/glass/glass-design.ts +119 -0
  107. package/dist/ui/components/glass/glass.test.ts +376 -0
  108. package/dist/ui/components/glass/glass.ts +430 -0
  109. package/dist/ui/components/glass/index.ts +19 -0
  110. package/dist/ui/components/icon/icon-theme.test.ts +64 -0
  111. package/dist/ui/components/icon/icon-theme.ts +70 -16
  112. package/dist/ui/components/icon/icon.test.ts +52 -41
  113. package/dist/ui/components/input/input.test.ts +178 -101
  114. package/dist/ui/components/input/input.ts +17 -13
  115. package/dist/ui/components/label/label.test.ts +11 -24
  116. package/dist/ui/components/label/label.ts +8 -2
  117. package/dist/ui/components/line-chart/line-chart.test.ts +76 -1
  118. package/dist/ui/components/line-chart/line-chart.ts +3 -2
  119. package/dist/ui/components/list/list.test.ts +80 -49
  120. package/dist/ui/components/list/list.ts +5 -4
  121. package/dist/ui/components/menu/index.ts +25 -0
  122. package/dist/ui/components/menu/menu-placement.test.ts +153 -0
  123. package/dist/ui/components/menu/menu-placement.ts +159 -0
  124. package/dist/ui/components/menu/menu-popup.test.ts +636 -0
  125. package/dist/ui/components/menu/menu-popup.ts +565 -0
  126. package/dist/ui/components/menu/menu.test.ts +523 -0
  127. package/dist/ui/components/menu/menu.ts +455 -0
  128. package/dist/ui/components/nav-drawer/nav-drawer.test.ts +425 -260
  129. package/dist/ui/components/nav-drawer/nav-drawer.ts +75 -58
  130. package/dist/ui/components/navigation-bar/index.ts +1 -0
  131. package/dist/ui/components/navigation-bar/navigation-bar.test.ts +425 -0
  132. package/dist/ui/components/navigation-bar/navigation-bar.ts +411 -0
  133. package/dist/ui/components/pagination/pagination.test.ts +136 -86
  134. package/dist/ui/components/popover/index.ts +9 -0
  135. package/dist/ui/components/popover/popover-panel.test.ts +735 -0
  136. package/dist/ui/components/popover/popover-panel.ts +603 -0
  137. package/dist/ui/components/popover/popover.test.ts +286 -0
  138. package/dist/ui/components/popover/popover.ts +207 -0
  139. package/dist/ui/components/progress/progress.test.ts +102 -46
  140. package/dist/ui/components/progress/progress.ts +54 -55
  141. package/dist/ui/components/radio-group/radio-group.test.ts +128 -83
  142. package/dist/ui/components/radio-group/radio-group.ts +26 -25
  143. package/dist/ui/components/scatter-chart/scatter-chart.test.ts +74 -1
  144. package/dist/ui/components/scroll-area/scroll-area.test.ts +72 -36
  145. package/dist/ui/components/scroll-area/scroll-area.ts +4 -3
  146. package/dist/ui/components/search-field/index.ts +15 -0
  147. package/dist/ui/components/search-field/keyboard.test.ts +147 -0
  148. package/dist/ui/components/search-field/keyboard.ts +123 -0
  149. package/dist/ui/components/search-field/search-field.test.ts +549 -0
  150. package/dist/ui/components/search-field/search-field.ts +628 -0
  151. package/dist/ui/components/segmented-control/index.ts +8 -0
  152. package/dist/ui/components/segmented-control/segmented-control.test.ts +861 -0
  153. package/dist/ui/components/segmented-control/segmented-control.ts +884 -0
  154. package/dist/ui/components/select/select.test.ts +138 -78
  155. package/dist/ui/components/select/select.ts +19 -12
  156. package/dist/ui/components/separator/separator.test.ts +18 -17
  157. package/dist/ui/components/sheet/sheet.test.ts +151 -11
  158. package/dist/ui/components/sheet/sheet.ts +68 -5
  159. package/dist/ui/components/skeleton/skeleton.test.ts +24 -7
  160. package/dist/ui/components/skeleton/skeleton.ts +10 -16
  161. package/dist/ui/components/slider/index.ts +1 -0
  162. package/dist/ui/components/slider/slider.test.ts +695 -0
  163. package/dist/ui/components/slider/slider.ts +901 -0
  164. package/dist/ui/components/spinner/spinner.test.ts +64 -46
  165. package/dist/ui/components/spinner/spinner.ts +16 -19
  166. package/dist/ui/components/switch/index.ts +1 -1
  167. package/dist/ui/components/switch/switch.test.ts +534 -48
  168. package/dist/ui/components/switch/switch.ts +613 -48
  169. package/dist/ui/components/tab-bar/index.ts +1 -0
  170. package/dist/ui/components/tab-bar/tab-bar.test.ts +280 -0
  171. package/dist/ui/components/tab-bar/tab-bar.ts +230 -0
  172. package/dist/ui/components/tabs/index.ts +7 -1
  173. package/dist/ui/components/tabs/tabs.test.ts +484 -150
  174. package/dist/ui/components/tabs/tabs.ts +344 -149
  175. package/dist/ui/components/textarea/textarea.test.ts +206 -100
  176. package/dist/ui/components/textarea/textarea.ts +16 -13
  177. package/dist/ui/components/toast/toast.test.ts +434 -350
  178. package/dist/ui/components/toast/toast.ts +42 -51
  179. package/dist/ui/components/toggle/index.ts +1 -0
  180. package/dist/ui/components/toggle/toggle.test.ts +559 -146
  181. package/dist/ui/components/toggle/toggle.ts +241 -26
  182. package/dist/ui/components/toolbar/bar-scroll.test.ts +310 -0
  183. package/dist/ui/components/toolbar/bar-scroll.ts +372 -0
  184. package/dist/ui/components/toolbar/index.ts +12 -0
  185. package/dist/ui/components/toolbar/toolbar.test.ts +438 -0
  186. package/dist/ui/components/toolbar/toolbar.ts +535 -0
  187. package/dist/ui/theme/chart-colors.test.ts +8 -3
  188. package/dist/ui/theme/dark.css +38 -2
  189. package/dist/ui/theme/default.css +55 -7
  190. package/dist/ui/theme/icon-colors.test.ts +8 -3
  191. package/dist/ui/theme/rgb-channels.test.ts +84 -0
  192. package/dist/ui/theme/tailwind-build.test.ts +114 -0
  193. package/dist/ui/theme/tailwind-css.test.ts +83 -0
  194. package/dist/ui/theme/tailwind-plugin.test.ts +42 -2
  195. package/dist/ui/theme/tailwind-plugin.ts +93 -29
  196. package/dist/ui/theme/tailwind.css +113 -0
  197. package/dist/ui/utils/animate.test.ts +13 -1
  198. package/dist/utils/analyze.d.ts +19 -0
  199. package/dist/utils/analyze.js +16 -0
  200. package/dist/utils/analyze.js.map +1 -1
  201. package/dist/utils/analyze.test.js +14 -1
  202. package/dist/utils/analyze.test.js.map +1 -1
  203. package/dist/utils/installed.d.ts +25 -1
  204. package/dist/utils/installed.js +33 -0
  205. package/dist/utils/installed.js.map +1 -1
  206. package/dist/utils/installed.test.js +55 -1
  207. package/dist/utils/installed.test.js.map +1 -1
  208. package/dist/utils/resolve-paths.d.ts +4 -4
  209. package/dist/utils/resolve-paths.js +5 -7
  210. package/dist/utils/resolve-paths.js.map +1 -1
  211. package/dist/utils/resolve-paths.test.js +9 -5
  212. package/dist/utils/resolve-paths.test.js.map +1 -1
  213. package/dist/utils/tailwind.d.ts +45 -0
  214. package/dist/utils/tailwind.js +90 -0
  215. package/dist/utils/tailwind.js.map +1 -0
  216. package/dist/utils/tailwind.test.d.ts +1 -0
  217. package/dist/utils/tailwind.test.js +134 -0
  218. package/dist/utils/tailwind.test.js.map +1 -0
  219. package/package.json +9 -1
  220. package/dist/ui/components/area-chart/area-chart-render.test.ts +0 -63
  221. package/dist/ui/components/bar-chart/bar-chart-render.test.ts +0 -58
  222. package/dist/ui/components/button/button-render.test.ts +0 -132
  223. package/dist/ui/components/button-group/button-group-render.test.ts +0 -146
  224. package/dist/ui/components/candlestick-chart/candlestick-chart-render.test.ts +0 -59
  225. package/dist/ui/components/chart/chart-render.test.ts +0 -730
  226. package/dist/ui/components/line-chart/line-chart-render.test.ts +0 -83
  227. package/dist/ui/components/scatter-chart/scatter-chart-render.test.ts +0 -64
@@ -4,4 +4,9 @@ export {
4
4
  UiAlertDialogTitle,
5
5
  UiAlertDialogDescription,
6
6
  UiAlertDialogFooter,
7
+ UiAlertDialogAction,
8
+ type AlertDialogVariant,
9
+ type AlertDialogLook,
10
+ type AlertDialogActionVariant,
11
+ type AlertDialogFooterOrientation,
7
12
  } from './alert-dialog';
@@ -1,7 +1,14 @@
1
+ import { Component, signal } from '@angular/core';
2
+ import {
3
+ fireEvent,
4
+ render,
5
+ waitForUpdate,
6
+ } from '@blotch/angular-lynx-testing-library';
1
7
  import { createChartRuntime } from '@tanstack/charts/runtime';
2
8
  import type { ChartDefinition, SceneNode } from '@tanstack/charts/types';
3
9
  import { describe, expect, it } from 'vitest';
4
- import { areaChartDefinition } from './area-chart';
10
+ import type { XYDatum } from '../chart/chart-presets';
11
+ import { UiAreaChart, areaChartDefinition } from './area-chart';
5
12
 
6
13
  const data = [
7
14
  { x: 0, y: 4 },
@@ -69,3 +76,76 @@ describe('areaChartDefinition', () => {
69
76
  expect(definition.tooltip).toBe(false);
70
77
  });
71
78
  });
79
+
80
+ @Component({
81
+ imports: [UiAreaChart],
82
+ template: `
83
+ <ui-area-chart
84
+ [data]="data"
85
+ [width]="300"
86
+ [baseline]="baseline()"
87
+ [fillOpacity]="0.3"
88
+ (pointTap)="tapped.push($event)"
89
+ />
90
+ `,
91
+ })
92
+ class AreaChartHost {
93
+ readonly data = data;
94
+ readonly baseline = signal(0);
95
+ /** Every `pointTap`, in order. */
96
+ readonly tapped: XYDatum[] = [];
97
+ }
98
+
99
+ /**
100
+ * The SVG the chart's marks layer draws.
101
+ */
102
+ const marksSvg = (container: Element) =>
103
+ container.querySelectorAll('svg')[1]!.getAttribute('content')!;
104
+
105
+ /**
106
+ * Where the chart drew each dot.
107
+ */
108
+ const dotsDrawn = (container: Element) =>
109
+ [
110
+ ...marksSvg(container).matchAll(/<circle cx="([\d.-]+)" cy="([\d.-]+)"/g),
111
+ ].map(([, x, y]) => ({ x: Number(x), y: Number(y) }));
112
+
113
+ /**
114
+ * Touches a spot of the chart and lifts the finger right there: a tap.
115
+ */
116
+ const tap = async (container: Element, at: { x: number; y: number }) => {
117
+ const root = container.querySelector('[accessibility-label]')!;
118
+ fireEvent.touchstart(root, { touches: [at] });
119
+ fireEvent.touchend(root, { touches: [at] });
120
+ await waitForUpdate();
121
+ };
122
+
123
+ /**
124
+ * The texts of the chart's labels, such as its ticks.
125
+ */
126
+ const labelTexts = (container: Element) =>
127
+ [...container.querySelectorAll('text')].map((text) => text.textContent);
128
+
129
+ describe('UiAreaChart', () => {
130
+ it('fills under its line with its inputs, keeping the baseline in view', async () => {
131
+ const { componentRef, container } = await render(AreaChartHost);
132
+ expect(marksSvg(container)).toContain('fill-opacity="0.3"');
133
+ expect(dotsDrawn(container)).toHaveLength(3);
134
+ expect(labelTexts(container)).not.toContain('-20');
135
+
136
+ (componentRef.instance as AreaChartHost).baseline.set(-20);
137
+ await waitForUpdate();
138
+ expect(labelTexts(container)).toContain('-20');
139
+ });
140
+
141
+ it("emits a tapped point's datum, and nothing for a tap off the points", async () => {
142
+ const { componentRef, container } = await render(AreaChartHost);
143
+ const host = componentRef.instance as AreaChartHost;
144
+ const first = dotsDrawn(container)[0]!;
145
+ await tap(container, { x: first.x + 1, y: first.y + 1 });
146
+ expect(host.tapped).toEqual([data[0]]);
147
+
148
+ await tap(container, { x: -100, y: -100 });
149
+ expect(host.tapped).toEqual([data[0]]);
150
+ });
151
+ });
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  Component,
3
3
  ViewEncapsulation,
4
+ booleanAttribute,
4
5
  computed,
5
6
  input,
6
7
  output,
@@ -105,10 +106,10 @@ export class UiAreaChart extends UiCartesianPreset<XYDatum> {
105
106
  readonly color = input('var(--primary)');
106
107
  readonly strokeWidth = input(2);
107
108
  readonly dotRadius = input(3);
108
- readonly showDots = input(true);
109
+ readonly showDots = input(true, { transform: booleanAttribute });
109
110
  readonly fillOpacity = input(0.2);
110
111
  readonly baseline = input(0);
111
- readonly smooth = input(false);
112
+ readonly smooth = input(false, { transform: booleanAttribute });
112
113
 
113
114
  /** The tapped point's datum. */
114
115
  readonly pointTap = output<XYDatum>();
@@ -1,69 +1,113 @@
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';
4
+ import { fireLynxEvent } from '../../../test-utils/fire-lynx-event';
6
5
  import { UiAvatar } from './avatar';
7
6
 
7
+ type AvatarSize = 'default' | 'sm' | 'lg' | 'xl' | '2xl';
8
+
8
9
  @Component({
9
- standalone: true,
10
- imports: [UiAvatar, LYNX_ELEMENTS],
11
- template: `<ui-avatar [src]="src" [fallback]="fallback" />`,
10
+ imports: [UiAvatar],
11
+ template: `
12
+ <ui-avatar [src]="src()" fallback="AB" [size]="size()" class="border" />
13
+ `,
12
14
  })
13
15
  class AvatarHost {
14
- src = '';
15
- fallback = 'AB';
16
+ readonly src = signal('');
17
+ readonly size = signal<AvatarSize>('default');
16
18
  }
17
19
 
20
+ const renderAvatar = async (src = '') => {
21
+ const result = await render(AvatarHost);
22
+ const host = result.componentRef.instance as AvatarHost;
23
+ host.src.set(src);
24
+ await waitForUpdate();
25
+ return {
26
+ ...result,
27
+ host,
28
+ avatar: () => result.container.querySelector('.overflow-hidden')!,
29
+ image: () => result.container.querySelector('image'),
30
+ /** The pulsing placeholder over the image while it loads. */
31
+ skeleton: () => result.container.querySelector('.inset-0'),
32
+ initials: () => result.queryByText('AB'),
33
+ };
34
+ };
35
+
18
36
  describe('UiAvatar', () => {
19
- it('renders the fallback branch when src is empty', async () => {
20
- const { container } = await render(AvatarHost);
21
- expect(container).toBeTruthy();
37
+ it('shows the initials in a muted circle without an image', async () => {
38
+ const { avatar, image, initials } = await renderAvatar();
39
+ expect(image()).toBeNull();
40
+ expect(initials()?.parentElement?.classList).toContain('bg-muted');
41
+ expect(avatar().classList).toContain('border');
22
42
  });
23
43
 
24
- it('renders without a src (default fallback path)', async () => {
25
- const { container } = await render(UiAvatar);
26
- expect(container).toBeTruthy();
44
+ it('covers the image with a placeholder while it loads', async () => {
45
+ const { image, skeleton, initials } = await renderAvatar(
46
+ 'https://example.com/avatar.png',
47
+ );
48
+ expect(image()?.getAttribute('src')).toBe('https://example.com/avatar.png');
49
+ expect(skeleton()).not.toBeNull();
50
+ expect(initials()).toBeNull();
27
51
  });
28
52
 
29
- it('renders the image branch when src is programmatically set', async () => {
30
- // JIT template bindings are inert against input(); drive src directly so
31
- // the @if(src()) branch fires and the image + skeleton render.
32
- const { componentRef, container } = await render(UiAvatar);
33
- setInputSignal(
34
- (componentRef.instance as UiAvatar).src,
53
+ it('reveals the image once it loads', async () => {
54
+ const { image, skeleton, initials } = await renderAvatar(
35
55
  'https://example.com/avatar.png',
36
56
  );
57
+ fireLynxEvent(image()!, 'load');
37
58
  await waitForUpdate();
38
- expect(container.querySelector('image')).toBeTruthy();
59
+ expect(skeleton()).toBeNull();
60
+ expect(image()).not.toBeNull();
61
+
62
+ // Lynx can dispatch `load` again as the placeholder's animation stops:
63
+ // the avatar stays as it is.
64
+ fireLynxEvent(image()!, 'load');
65
+ await waitForUpdate();
66
+ expect(skeleton()).toBeNull();
67
+ expect(image()).not.toBeNull();
68
+ expect(initials()).toBeNull();
39
69
  });
40
70
 
41
- it('keeps the image load / error handlers idempotent', async () => {
42
- // Directly invoke the protected handlers via the instance to cover the
43
- // re-entrancy latches — Lynx's on-device behavior re-dispatches the load
44
- // event when the skeleton animation tears down.
45
- const { componentRef } = await render(UiAvatar);
46
- const inst = componentRef.instance as unknown as {
47
- onImageLoad: () => void;
48
- onImageError: () => void;
49
- loaded: () => boolean;
50
- errored: () => boolean;
51
- };
52
- inst.onImageLoad();
53
- inst.onImageLoad(); // second call is a no-op (loaded latch)
54
- expect(inst.loaded()).toBe(true);
71
+ it('falls back to the initials when the image fails to load', async () => {
72
+ const { image, skeleton, initials } = await renderAvatar(
73
+ 'https://example.com/missing.png',
74
+ );
75
+ // Lynx can dispatch the event again before the avatar updates, as the
76
+ // placeholder's animation stops: the second one changes nothing.
77
+ fireLynxEvent(image()!, 'error', { detail: { errMsg: 'Not found' } });
78
+ fireLynxEvent(image()!, 'error', { detail: { errMsg: 'Not found' } });
79
+ await waitForUpdate();
80
+ expect(image()).toBeNull();
81
+ expect(skeleton()).toBeNull();
82
+ expect(initials()).not.toBeNull();
83
+ });
55
84
 
56
- inst.onImageError();
57
- inst.onImageError(); // second call is a no-op (errored latch)
58
- expect(inst.errored()).toBe(true);
85
+ it.each<[AvatarSize, string, string]>([
86
+ ['sm', 'h-8', 'text-sm'],
87
+ ['default', 'h-10', 'text-sm'],
88
+ ['lg', 'h-14', 'text-lg'],
89
+ ['xl', 'h-20', 'text-2xl'],
90
+ ['2xl', 'h-28', 'text-3xl'],
91
+ ])('sizes a %s avatar and its initials', async (size, height, text) => {
92
+ const { host, avatar, initials } = await renderAvatar();
93
+ host.size.set(size);
94
+ await waitForUpdate();
95
+ expect(avatar().classList).toContain(height);
96
+ expect(initials()?.classList).toContain(text);
59
97
  });
60
98
 
61
- it('applies size-specific classes for each supported size', async () => {
62
- const { componentRef, container } = await render(UiAvatar);
63
- for (const size of ['sm', 'default', 'lg', 'xl', '2xl'] as const) {
64
- setInputSignal((componentRef.instance as UiAvatar).size, size);
65
- await waitForUpdate();
66
- expect(container).toBeTruthy();
67
- }
99
+ it.each<[AvatarSize, string]>([
100
+ ['sm', 'h-8'],
101
+ ['default', 'h-10'],
102
+ ['lg', 'h-14'],
103
+ ['xl', 'h-20'],
104
+ ['2xl', 'h-28'],
105
+ ])('sizes the image of a %s avatar', async (size, height) => {
106
+ const { host, image } = await renderAvatar(
107
+ 'https://example.com/avatar.png',
108
+ );
109
+ host.size.set(size);
110
+ await waitForUpdate();
111
+ expect(image()?.classList).toContain(height);
68
112
  });
69
113
  });
@@ -1,25 +1,30 @@
1
- import { render } from '@blotch/angular-lynx-testing-library';
2
- import { Component } from '@angular/core';
3
- import { describe, expect, it } from 'vitest';
4
- import { LYNX_ELEMENTS } from '@blotch/angular-lynx';
5
- import { UiBadge, badgeVariants } from './badge';
1
+ import { Component, signal } from '@angular/core';
2
+ import { render, waitForUpdate } from '@blotch/angular-lynx-testing-library';
3
+ import { describe, expect, it, vi } from 'vitest';
4
+ import { lynxMainThread } from '../../../test-utils/lynx-main-thread';
5
+ import { type BadgeVariant, UiBadge, badgeVariants } from './badge';
6
6
 
7
- /**
8
- * Host wrappers project content and bind inputs the way real app templates do.
9
- * Required-input components would need this; UiBadge has none but the pattern
10
- * keeps content projection (<ng-content>) exercised.
11
- */
12
7
  @Component({
13
- standalone: true,
14
- imports: [UiBadge, LYNX_ELEMENTS],
15
- template: `<ui-badge [variant]="variant" [animated]="false"
16
- >Status</ui-badge
17
- >`,
8
+ imports: [UiBadge],
9
+ template: `
10
+ <ui-badge [variant]="variant()" [animated]="animated()" class="ml-1">
11
+ Status
12
+ </ui-badge>
13
+ `,
18
14
  })
19
15
  class BadgeHost {
20
- variant: 'default' | 'secondary' | 'destructive' | 'outline' = 'default';
16
+ readonly variant = signal<BadgeVariant>('default');
17
+ readonly animated = signal(true);
21
18
  }
22
19
 
20
+ /**
21
+ * The keyframes of the animations started on `element`.
22
+ */
23
+ const keyframesOn = (calls: [Element, unknown[]][], element: Element) =>
24
+ calls
25
+ .filter(([target, [operation]]) => target === element && operation === 0)
26
+ .map(([, args]) => args[2]);
27
+
23
28
  describe('badgeVariants', () => {
24
29
  it('returns default variant classes', () => {
25
30
  expect(badgeVariants()).toContain('bg-primary');
@@ -35,15 +40,44 @@ describe('badgeVariants', () => {
35
40
  });
36
41
 
37
42
  describe('UiBadge', () => {
38
- it('renders projected content', async () => {
43
+ it('renders its label in the variant colors', async () => {
44
+ const { componentRef, getByText } = await render(BadgeHost);
45
+ const label = getByText('Status');
46
+ expect(label.classList).toContain('text-primary-foreground');
47
+ expect(label.parentElement?.classList).toContain('bg-primary');
48
+ expect(label.parentElement?.classList).toContain('ml-1');
49
+
50
+ (componentRef.instance as BadgeHost).variant.set('outline');
51
+ await waitForUpdate();
52
+ expect(label.classList).toContain('text-foreground');
53
+ expect(label.parentElement?.classList).toContain('border-border');
54
+ });
55
+
56
+ it('pops in on first render', async () => {
57
+ const animate = vi.spyOn(lynxMainThread(), '__ElementAnimate');
39
58
  const { getByText } = await render(BadgeHost);
40
- expect(getByText('Status')).toBeTruthy();
59
+ const badge = getByText('Status').parentElement!;
60
+ expect(keyframesOn(animate.mock.calls, badge)).toEqual([
61
+ [
62
+ { transform: 'scale(0.6)', opacity: 0 },
63
+ { transform: 'scale(1)', opacity: 1 },
64
+ ],
65
+ ]);
66
+ animate.mockRestore();
41
67
  });
42
68
 
43
- it('runs the pop-in animation when animated is true', async () => {
44
- const { getByText } = await render(UiBadge);
45
- // With no host binding, animated defaults to true → effect runs popIn on the
46
- // container. Just assert it renders without throwing.
47
- expect(getByText).toBeTruthy();
69
+ it("doesn't animate when animated is off", async () => {
70
+ @Component({
71
+ imports: [UiBadge],
72
+ template: `<ui-badge [animated]="false">Status</ui-badge>`,
73
+ })
74
+ class StillBadgeHost {}
75
+
76
+ const animate = vi.spyOn(lynxMainThread(), '__ElementAnimate');
77
+ const { getByText } = await render(StillBadgeHost);
78
+ expect(
79
+ keyframesOn(animate.mock.calls, getByText('Status').parentElement!),
80
+ ).toEqual([]);
81
+ animate.mockRestore();
48
82
  });
49
83
  });
@@ -2,6 +2,7 @@ import {
2
2
  type ElementRef,
3
3
  Component,
4
4
  ViewEncapsulation,
5
+ booleanAttribute,
5
6
  computed,
6
7
  effect,
7
8
  input,
@@ -68,24 +69,22 @@ export class UiBadge {
68
69
  /**
69
70
  * When true, badge pops in with a spring animation on first render
70
71
  */
71
- readonly animated = input(true);
72
+ readonly animated = input(true, { transform: booleanAttribute });
72
73
  readonly userClass = input<string>('', { alias: 'class' });
73
74
 
74
- readonly containerRef = viewChild<ElementRef>('container');
75
+ readonly containerRef = viewChild.required<ElementRef>('container');
75
76
 
76
- constructor() {
77
- effect(() => {
78
- const el = this.containerRef()?.nativeElement;
79
- // popIn calls el.animate() which is inert in jsdom (the offscreen web
80
- // renderer path — see animate.ts's transitionTransform comment). The
81
- // effect's rendering-adjacent work is not exercisable from a JIT test,
82
- // so ignore the animation branch for coverage.
83
- /* v8 ignore next 3 */
84
- if (el && this.animated()) {
85
- popIn(el, { duration: 200 });
86
- }
87
- });
88
- }
77
+ /**
78
+ * Pops the badge in on first render, unless `animated` is off. A field
79
+ * rather than a constructor: Angular's test build keeps a function for a
80
+ * constructor that nothing calls, which an app's coverage report would show
81
+ * as uncovered.
82
+ */
83
+ protected readonly entrance = effect(() => {
84
+ if (this.animated()) {
85
+ popIn(this.containerRef().nativeElement, { duration: 200 });
86
+ }
87
+ });
89
88
 
90
89
  protected readonly containerClass = computed(() =>
91
90
  cn(badgeVariants({ variant: this.variant() }), this.userClass()),
@@ -1,3 +1,9 @@
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 {
3
9
  ChartDefinition,
@@ -5,7 +11,7 @@ import type {
5
11
  SceneRect,
6
12
  } from '@tanstack/charts/types';
7
13
  import { describe, expect, it } from 'vitest';
8
- import { barChartDefinition } from './bar-chart';
14
+ import { type BarDatum, UiBarChart, barChartDefinition } from './bar-chart';
9
15
 
10
16
  /**
11
17
  * Every bar in a definition's scene.
@@ -95,3 +101,78 @@ describe('barChartDefinition', () => {
95
101
  expect(found.map((bar) => bar.style?.fill)).toEqual(['red', 'blue', 'red']);
96
102
  });
97
103
  });
104
+
105
+ @Component({
106
+ imports: [UiBarChart],
107
+ template: `
108
+ <ui-bar-chart
109
+ [data]="data"
110
+ [width]="300"
111
+ [color]="color()"
112
+ [radius]="4"
113
+ (barTap)="tapped.push($event)"
114
+ />
115
+ `,
116
+ })
117
+ class BarChartHost {
118
+ readonly data: readonly BarDatum[] = [
119
+ { x: 'Mon', y: 5 },
120
+ { x: 'Tue', y: 8 },
121
+ ];
122
+ readonly color = signal('red');
123
+ /** Every `barTap`, in order. */
124
+ readonly tapped: BarDatum[] = [];
125
+ }
126
+
127
+ /**
128
+ * The SVG the chart's marks layer draws.
129
+ */
130
+ const marksSvg = (container: Element) =>
131
+ container.querySelectorAll('svg')[1]!.getAttribute('content')!;
132
+
133
+ /**
134
+ * The middle of each bar the chart drew.
135
+ */
136
+ const barsDrawn = (container: Element) =>
137
+ [
138
+ ...marksSvg(container).matchAll(
139
+ /<rect x="([\d.-]+)" y="([\d.-]+)" width="([\d.-]+)" height="([\d.-]+)"[^>]*fill=/g,
140
+ ),
141
+ ].map(([, x, y, width, height]) => ({
142
+ x: Number(x) + Number(width) / 2,
143
+ y: Number(y) + Number(height) / 2,
144
+ }));
145
+
146
+ /**
147
+ * Touches a spot of the chart and lifts the finger right there: a tap.
148
+ */
149
+ const tap = async (container: Element, at: { x: number; y: number }) => {
150
+ const root = container.querySelector('[accessibility-label]')!;
151
+ fireEvent.touchstart(root, { touches: [at] });
152
+ fireEvent.touchend(root, { touches: [at] });
153
+ await waitForUpdate();
154
+ };
155
+
156
+ describe('UiBarChart', () => {
157
+ it('draws a bar per datum with its inputs', async () => {
158
+ const { componentRef, container, getByText } = await render(BarChartHost);
159
+ expect(barsDrawn(container)).toHaveLength(2);
160
+ expect(marksSvg(container)).toContain('fill="red"');
161
+ expect(marksSvg(container)).toContain('rx="4"');
162
+ expect(getByText('Mon')).toBeTruthy();
163
+
164
+ (componentRef.instance as BarChartHost).color.set('blue');
165
+ await waitForUpdate();
166
+ expect(marksSvg(container)).toContain('fill="blue"');
167
+ });
168
+
169
+ it("emits a tapped bar's datum, and nothing for a tap off the bars", async () => {
170
+ const { componentRef, container } = await render(BarChartHost);
171
+ const host = componentRef.instance as BarChartHost;
172
+ await tap(container, barsDrawn(container)[1]!);
173
+ expect(host.tapped).toEqual([host.data[1]]);
174
+
175
+ await tap(container, { x: -100, y: -100 });
176
+ expect(host.tapped).toEqual([host.data[1]]);
177
+ });
178
+ });