@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,13 +1,30 @@
1
+ import { Component } from '@angular/core';
1
2
  import { render } from '@blotch/angular-lynx-testing-library';
2
- import { describe, expect, it } from 'vitest';
3
+ import { describe, expect, it, vi } from 'vitest';
4
+ import { lynxMainThread } from '../../../test-utils/lynx-main-thread';
3
5
  import { UiSkeleton } from './skeleton';
4
6
 
7
+ @Component({
8
+ imports: [UiSkeleton],
9
+ template: `<ui-skeleton class="h-4 w-32" />`,
10
+ })
11
+ class SkeletonHost {}
12
+
5
13
  describe('UiSkeleton', () => {
6
- it('renders and starts the pulse animation', async () => {
7
- // The `effect()` schedules pulse(el) once the view child resolves — just
8
- // asserting no throw covers the effect body, host template, and computed
9
- // class binding in a single mount.
10
- const { container } = await render(UiSkeleton);
11
- expect(container).toBeTruthy();
14
+ it('renders a muted placeholder with the given class', async () => {
15
+ const { container } = await render(SkeletonHost);
16
+ const skeleton = container.querySelector('.bg-muted');
17
+ expect(skeleton?.classList).toContain('h-4');
18
+ expect(skeleton?.classList).toContain('w-32');
19
+ });
20
+
21
+ it('pulses', async () => {
22
+ const animate = vi.spyOn(lynxMainThread(), '__ElementAnimate');
23
+ const { container } = await render(SkeletonHost);
24
+ expect(animate).toHaveBeenCalledWith(
25
+ container.querySelector('.bg-muted'),
26
+ expect.any(Array),
27
+ );
28
+ animate.mockRestore();
12
29
  });
13
30
  });
@@ -9,7 +9,7 @@ import {
9
9
  } from '@angular/core';
10
10
  import { LYNX_ELEMENTS } from '@blotch/angular-lynx';
11
11
 
12
- import { type AnimationHandle, pulse } from '../../utils/animate';
12
+ import { pulse } from '../../utils/animate';
13
13
  import { cn } from '../../utils/cn';
14
14
 
15
15
  @Component({
@@ -22,22 +22,16 @@ import { cn } from '../../utils/cn';
22
22
  export class UiSkeleton {
23
23
  readonly userClass = input<string>('', { alias: 'class' });
24
24
 
25
- readonly skeletonRef = viewChild<ElementRef>('skeleton');
26
- #pulseAnim?: AnimationHandle;
25
+ readonly skeletonRef = viewChild.required<ElementRef>('skeleton');
27
26
 
28
- constructor() {
29
- // Start the pulse animation once the element is available
30
- effect(() => {
31
- const el = this.skeletonRef()?.nativeElement;
32
- // pulse() calls el.animate() which is inert in jsdom — coverage
33
- // for the pulsing loop is exercised on-device.
34
- /* v8 ignore start */
35
- if (!el) return;
36
- this.#pulseAnim?.cancel();
37
- this.#pulseAnim = pulse(el);
38
- /* v8 ignore stop */
39
- });
40
- }
27
+ /**
28
+ * Pulses the skeleton. A field rather than a constructor: Angular's test
29
+ * build keeps a function for a constructor that nothing calls, which an
30
+ * app's coverage report would show as uncovered.
31
+ */
32
+ protected readonly pulsing = effect(() => {
33
+ pulse(this.skeletonRef().nativeElement);
34
+ });
41
35
 
42
36
  protected readonly skeletonClass = computed(() =>
43
37
  cn('rounded-md bg-muted', this.userClass()),
@@ -1,39 +1,84 @@
1
+ import { Component, signal } from '@angular/core';
1
2
  import { render, waitForUpdate } from '@blotch/angular-lynx-testing-library';
2
- import { describe, expect, it } from 'vitest';
3
- import { setInputSignal } from '../../../test-utils/set-input-signal';
3
+ import { describe, expect, it, vi } from 'vitest';
4
+ import { lynxMainThread } from '../../../test-utils/lynx-main-thread';
5
+ import { ICON_LIGHT_COLORS } from '../icon/icon-theme';
4
6
  import { UiSpinner } from './spinner';
5
7
 
6
- describe('UiSpinner', () => {
7
- it('renders the SVG at the default size', async () => {
8
- const { container } = await render(UiSpinner);
9
- expect(container).toBeTruthy();
10
- // The default size is 'md' → sizeStyle should render at 24px.
11
- const view = container.querySelector('view');
12
- expect(view?.getAttribute('style')).toContain('24px');
13
- });
8
+ @Component({
9
+ imports: [UiSpinner],
10
+ template: `
11
+ <ui-spinner [size]="size()" [color]="color()" [class]="className()" />
12
+ `,
13
+ })
14
+ class SpinnerHost {
15
+ readonly size = signal<'xs' | 'sm' | 'md' | 'lg'>('md');
16
+ readonly color = signal<string | undefined>(undefined);
17
+ readonly className = signal('');
18
+ }
14
19
 
15
- it('substitutes currentColor when the color input is set', async () => {
16
- const { container, componentRef } = await render(UiSpinner);
17
- setInputSignal((componentRef.instance as UiSpinner).color, 'rgb(0, 0, 0)');
18
- // Flush CD so the computed re-runs and the `content` attribute updates.
19
- await waitForUpdate();
20
- const svg = container.querySelector('svg');
21
- expect(svg?.getAttribute('content')).toContain('rgb(0, 0, 0)');
22
- expect(svg?.getAttribute('content')).not.toContain('currentColor');
20
+ const renderSpinner = async () => {
21
+ const result = await render(SpinnerHost);
22
+ return {
23
+ ...result,
24
+ host: result.componentRef.instance as SpinnerHost,
25
+ // The spinner's own view: the one that holds the svg.
26
+ spinner: () => result.container.querySelector('svg')!.parentElement!,
27
+ svgContent: () =>
28
+ result.container.querySelector('svg')!.getAttribute('content'),
29
+ };
30
+ };
31
+
32
+ describe('UiSpinner', () => {
33
+ it('renders the loader at the default size', async () => {
34
+ const { spinner } = await renderSpinner();
35
+ expect(spinner().getAttribute('style')).toContain('width: 24px');
36
+ expect(spinner().getAttribute('style')).toContain('height: 24px');
23
37
  });
24
38
 
25
39
  it('resizes for each supported size', async () => {
26
- const { componentRef, container } = await render(UiSpinner);
40
+ const { host, spinner } = await renderSpinner();
27
41
  for (const [size, px] of [
28
42
  ['xs', 16],
29
43
  ['sm', 20],
30
44
  ['md', 24],
31
45
  ['lg', 32],
32
46
  ] as const) {
33
- setInputSignal((componentRef.instance as UiSpinner).size, size);
47
+ host.size.set(size);
34
48
  await waitForUpdate();
35
- const view = container.querySelector('view');
36
- expect(view?.getAttribute('style')).toContain(`${px}px`);
49
+ expect(spinner().getAttribute('style')).toContain(`width: ${px}px`);
37
50
  }
38
51
  });
52
+
53
+ it("draws in the theme's foreground when no color is set", async () => {
54
+ // Lynx's SVG parser reads neither `currentColor` nor CSS variables.
55
+ const { svgContent } = await renderSpinner();
56
+ expect(svgContent()).toContain(ICON_LIGHT_COLORS['foreground']);
57
+ expect(svgContent()).not.toContain('currentColor');
58
+ });
59
+
60
+ it('draws in a literal color or a theme token', async () => {
61
+ const { host, svgContent } = await renderSpinner();
62
+ host.color.set('rgb(0, 0, 0)');
63
+ await waitForUpdate();
64
+ expect(svgContent()).toContain('rgb(0, 0, 0)');
65
+
66
+ host.color.set('var(--primary-foreground)');
67
+ await waitForUpdate();
68
+ expect(svgContent()).toContain(ICON_LIGHT_COLORS['primary-foreground']);
69
+ });
70
+
71
+ it('takes the class input', async () => {
72
+ const { host, spinner } = await renderSpinner();
73
+ host.className.set('ml-2');
74
+ await waitForUpdate();
75
+ expect(spinner().getAttribute('class')).toBe('ml-2');
76
+ });
77
+
78
+ it('turns continuously', async () => {
79
+ const animate = vi.spyOn(lynxMainThread(), '__ElementAnimate');
80
+ const { spinner } = await renderSpinner();
81
+ expect(animate).toHaveBeenCalledWith(spinner(), expect.any(Array));
82
+ animate.mockRestore();
83
+ });
39
84
  });
@@ -9,13 +9,15 @@ import {
9
9
  } from '@angular/core';
10
10
  import { LYNX_ELEMENTS } from '@blotch/angular-lynx';
11
11
 
12
+ import { cn } from '../../utils/cn';
13
+ import { injectIconColor } from '../icon/icon-theme';
14
+
12
15
  const LOADER_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2v4"/><path d="m16.2 7.8 2.9-2.9"/><path d="M18 12h4"/><path d="m16.2 16.2 2.9 2.9"/><path d="M12 18v4"/><path d="m4.9 19.1 2.9-2.9"/><path d="M2 12h4"/><path d="m4.9 4.9 2.9 2.9"/></svg>`;
13
16
 
14
17
  const SIZE_MAP = { xs: 16, sm: 20, md: 24, lg: 32 } as const;
15
18
 
16
19
  @Component({
17
20
  selector: 'ui-spinner',
18
- standalone: true,
19
21
  imports: [LYNX_ELEMENTS],
20
22
  encapsulation: ViewEncapsulation.None,
21
23
  // SVG markup is passed via [attr.content] rather than as child elements.
@@ -24,58 +26,63 @@ const SIZE_MAP = { xs: 16, sm: 20, md: 24, lg: 32 } as const;
24
26
  // SVG markup string as the `content` attribute is the Lynx-native way to
25
27
  // embed vector graphics.
26
28
  template: `
27
- <view #spinner [style]="sizeStyle()">
29
+ <view #spinner [class]="containerClass()" [style]="sizeStyle()">
28
30
  <svg [attr.content]="svgContent()" style="width: 100%; height: 100%;" />
29
31
  </view>
30
32
  `,
31
33
  })
32
34
  export class UiSpinner {
33
35
  readonly size = input<'xs' | 'sm' | 'md' | 'lg'>('md');
36
+ /**
37
+ * A literal color or a theme token (`var(--primary)`). Without one, the
38
+ * spinner takes the color its container sets (see `UI_ICON_COLOR`), then the
39
+ * theme's foreground.
40
+ */
34
41
  readonly color = input<string | undefined>(undefined);
42
+ readonly userClass = input<string>('', { alias: 'class' });
35
43
 
36
- readonly spinnerRef = viewChild<ElementRef>('spinner');
44
+ readonly #resolveColor = injectIconColor();
37
45
 
38
- constructor() {
39
- effect(() => {
40
- const el = this.spinnerRef()?.nativeElement;
41
- // el.animate() is inert in jsdom (see animate.ts's transitionTransform
42
- // rationale). The continuous rotation is exercised on-device.
43
- /* v8 ignore start */
44
- if (!el) return;
45
- el.animate(
46
- [{ transform: 'rotate(0deg)' }, { transform: 'rotate(360deg)' }],
47
- {
48
- duration: 1600,
49
- iterations: Infinity,
50
- easing: 'linear',
51
- },
52
- );
53
- /* v8 ignore stop */
54
- });
55
- }
46
+ readonly spinnerRef = viewChild.required<ElementRef>('spinner');
56
47
 
57
- protected readonly svgContent = computed(() => {
58
- const c = this.color();
59
- if (c) {
60
- // Lynx SVG elements don't inherit the CSS `color` property into their
61
- // stroke attributes, so we substitute the literal `currentColor` in the
62
- // SVG string rather than relying on `currentColor` propagation via CSS.
63
- //
64
- // Uses split().join() instead of String.prototype.replaceAll(). replaceAll
65
- // is ES2021, but Lynx's main thread runs on PrimJS at an ES2019 target;
66
- // because it's a runtime method (not syntax) the build's down-leveling does
67
- // not polyfill it, so it simply doesn't exist there. Calling it throws
68
- // "main-thread.js exception: not a function" from inside Angular change
69
- // detection, which aborts the whole tick before Lynx flushes the element
70
- // tree — so the spinner never paints, and the error count climbs on every
71
- // render as each tick re-runs and re-throws. split/join is ES2019-safe and
72
- // is preferred over `replace(/.../g)` here because the search term is a
73
- // plain literal and needs no regex escaping.
74
- return LOADER_SVG.split('currentColor').join(c);
75
- }
76
- return LOADER_SVG;
48
+ /**
49
+ * Turns the spinner for as long as it's shown. A field rather than a
50
+ * constructor: Angular's test build keeps a function for a constructor that
51
+ * nothing calls, which an app's coverage report would show as uncovered.
52
+ */
53
+ protected readonly rotation = effect(() => {
54
+ this.spinnerRef().nativeElement.animate(
55
+ [{ transform: 'rotate(0deg)' }, { transform: 'rotate(360deg)' }],
56
+ {
57
+ duration: 1600,
58
+ iterations: Infinity,
59
+ easing: 'linear',
60
+ },
61
+ );
77
62
  });
78
63
 
64
+ // Lynx SVG elements don't inherit the CSS `color` property into their stroke
65
+ // attributes, so we substitute the literal `currentColor` in the SVG string
66
+ // rather than relying on `currentColor` propagation via CSS. The color is
67
+ // resolved first: the SVG parser can't read `var(--x)` either (see
68
+ // icon-theme.ts).
69
+ //
70
+ // Uses split().join() instead of String.prototype.replaceAll(). replaceAll is
71
+ // ES2021, but Lynx's main thread runs on PrimJS at an ES2019 target; because
72
+ // it's a runtime method (not syntax) the build's down-leveling does not
73
+ // polyfill it, so it simply doesn't exist there. Calling it throws
74
+ // "main-thread.js exception: not a function" from inside Angular change
75
+ // detection, which aborts the whole tick before Lynx flushes the element tree
76
+ // — so the spinner never paints, and the error count climbs on every render
77
+ // as each tick re-runs and re-throws. split/join is ES2019-safe and is
78
+ // preferred over `replace(/.../g)` here because the search term is a plain
79
+ // literal and needs no regex escaping.
80
+ protected readonly svgContent = computed(() =>
81
+ LOADER_SVG.split('currentColor').join(this.#resolveColor(this.color())),
82
+ );
83
+
84
+ protected readonly containerClass = computed(() => cn(this.userClass()));
85
+
79
86
  protected readonly sizeStyle = computed(() => {
80
87
  const px = SIZE_MAP[this.size()];
81
88
  return `width: ${px}px; height: ${px}px;`;
@@ -1,63 +1,133 @@
1
- import { render, waitForUpdate } from '@blotch/angular-lynx-testing-library';
2
- import { describe, expect, it, vi } from 'vitest';
3
- import { setInputSignal } from '../../../test-utils/set-input-signal';
1
+ import { Component, signal } from '@angular/core';
2
+ import {
3
+ fireEvent,
4
+ render,
5
+ waitForUpdate,
6
+ } from '@blotch/angular-lynx-testing-library';
7
+ import {
8
+ type MockInstance,
9
+ afterEach,
10
+ beforeEach,
11
+ describe,
12
+ expect,
13
+ it,
14
+ vi,
15
+ } from 'vitest';
16
+ import {
17
+ type LynxMainThread,
18
+ lynxMainThread,
19
+ } from '../../../test-utils/lynx-main-thread';
4
20
  import { UiSwitch } from './switch';
5
21
 
22
+ @Component({
23
+ imports: [UiSwitch],
24
+ template: `
25
+ <ui-switch
26
+ [(checked)]="checked"
27
+ [disabled]="disabled()"
28
+ class="ml-2"
29
+ (changed)="changes.push($event)"
30
+ />
31
+ `,
32
+ })
33
+ class SwitchHost {
34
+ readonly checked = signal(false);
35
+ readonly disabled = signal(false);
36
+ readonly changes: boolean[] = [];
37
+ }
38
+
39
+ const renderSwitch = async () => {
40
+ const result = await render(SwitchHost);
41
+ return {
42
+ ...result,
43
+ host: result.componentRef.instance as SwitchHost,
44
+ track: () => result.container.querySelector('.flex-row')!,
45
+ thumb: () => result.container.querySelector('.bg-background')!,
46
+ };
47
+ };
48
+
49
+ /** `__ElementAnimate`'s first argument for starting an animation. */
50
+ const START = 0;
51
+ /** `__ElementAnimate`'s first argument for canceling one. */
52
+ const CANCEL = 3;
53
+
6
54
  describe('UiSwitch', () => {
7
- it('renders in the off position by default', async () => {
8
- const { container } = await render(UiSwitch);
9
- expect(container).toBeTruthy();
55
+ // Watched per test: the testing library resets the main thread after each.
56
+ let animate: MockInstance<LynxMainThread['__ElementAnimate']>;
57
+ beforeEach(() => {
58
+ animate = vi.spyOn(lynxMainThread(), '__ElementAnimate');
10
59
  });
60
+ afterEach(() => animate.mockRestore());
11
61
 
12
- it('toggle() flips checked and emits `changed`', async () => {
13
- const { componentRef } = await render(UiSwitch);
14
- const inst = componentRef.instance as UiSwitch;
15
- const emitted: boolean[] = [];
16
- inst.changed.subscribe((v) => emitted.push(v));
17
-
18
- inst.toggle();
19
- expect(inst.checked()).toBe(true);
20
- inst.toggle();
21
- expect(inst.checked()).toBe(false);
22
- expect(emitted).toEqual([true, false]);
62
+ it('renders off, with the thumb at the left and the given class', async () => {
63
+ const { track, thumb } = await renderSwitch();
64
+ expect(track().classList).toContain('bg-input');
65
+ expect(track().classList).toContain('ml-2');
66
+ expect(thumb().getAttribute('style')).toContain('translateX(2px)');
23
67
  });
24
68
 
25
- it('toggle() is a no-op when disabled', async () => {
26
- const { componentRef } = await render(UiSwitch);
27
- const inst = componentRef.instance as UiSwitch;
28
- setInputSignal(inst.disabled, true);
29
- const listener = vi.fn();
30
- inst.changed.subscribe(listener);
69
+ it('turns on and off on tap, emitting `changed`', async () => {
70
+ const { host, track, thumb } = await renderSwitch();
31
71
 
32
- inst.toggle();
33
- expect(inst.checked()).toBe(false);
34
- expect(listener).not.toHaveBeenCalled();
72
+ fireEvent.tap(track());
73
+ await waitForUpdate();
74
+ expect(host.checked()).toBe(true);
75
+ expect(track().classList).toContain('bg-primary');
76
+ expect(thumb().getAttribute('style')).toContain('translateX(22px)');
77
+
78
+ fireEvent.tap(track());
79
+ await waitForUpdate();
80
+ expect(host.checked()).toBe(false);
81
+ expect(host.changes).toEqual([true, false]);
35
82
  });
36
83
 
37
- it('positions the thumb differently in ON vs OFF state', async () => {
38
- const { componentRef, container } = await render(UiSwitch);
39
- const inst = componentRef.instance as UiSwitch;
40
- let thumb = container.querySelectorAll('view')[1];
41
- expect(thumb?.getAttribute('style')).toContain('translateX(2px)');
84
+ it('springs the thumb across the track, squishing it halfway', async () => {
85
+ const { track, thumb } = await renderSwitch();
42
86
 
43
- inst.checked.set(true);
87
+ fireEvent.tap(track());
88
+ expect(animate).toHaveBeenCalledWith(thumb(), [
89
+ START,
90
+ expect.any(String),
91
+ [
92
+ { transform: 'translateX(2px) scale(1)' },
93
+ { transform: 'translateX(12px) scale(1.1)' },
94
+ { transform: 'translateX(22px) scale(1)' },
95
+ ],
96
+ expect.objectContaining({ fill: 'forwards' }),
97
+ ]);
98
+
99
+ // Turning it back off cuts the first spring short and springs it back.
100
+ fireEvent.tap(track());
101
+ expect(animate).toHaveBeenCalledWith(thumb(), [CANCEL, expect.any(String)]);
102
+ expect(animate).toHaveBeenLastCalledWith(thumb(), [
103
+ START,
104
+ expect.any(String),
105
+ [
106
+ { transform: 'translateX(22px) scale(1)' },
107
+ { transform: 'translateX(12px) scale(1.1)' },
108
+ { transform: 'translateX(2px) scale(1)' },
109
+ ],
110
+ expect.objectContaining({ fill: 'forwards' }),
111
+ ]);
112
+ });
113
+
114
+ it('follows its checked binding', async () => {
115
+ const { host, thumb } = await renderSwitch();
116
+ host.checked.set(true);
44
117
  await waitForUpdate();
45
- thumb = container.querySelectorAll('view')[1];
46
- expect(thumb?.getAttribute('style')).toContain('translateX(22px)');
118
+ expect(thumb().getAttribute('style')).toContain('translateX(22px)');
47
119
  });
48
120
 
49
- it('applies opacity-50 when disabled and includes a user class', async () => {
50
- // Covers the disabled=true branch of trackClass() (line 62) and the
51
- // userClass() concatenation. Without a render + detectChanges cycle the
52
- // computed never runs with disabled=true and the branch stays uncovered.
53
- const { componentRef, container } = await render(UiSwitch);
54
- const inst = componentRef.instance as UiSwitch;
55
- setInputSignal(inst.disabled, true);
56
- setInputSignal(inst.userClass, 'custom-cls');
121
+ it('dims and ignores taps while disabled', async () => {
122
+ const { host, track } = await renderSwitch();
123
+ host.disabled.set(true);
124
+ await waitForUpdate();
125
+ expect(track().classList).toContain('opacity-50');
126
+
127
+ fireEvent.tap(track());
57
128
  await waitForUpdate();
58
- const track = container.querySelectorAll('view')[0];
59
- const cls = track?.getAttribute('class') ?? '';
60
- expect(cls).toContain('opacity-50');
61
- expect(cls).toContain('custom-cls');
129
+ expect(host.checked()).toBe(false);
130
+ expect(host.changes).toEqual([]);
131
+ expect(animate).not.toHaveBeenCalled();
62
132
  });
63
133
  });
@@ -52,7 +52,8 @@ export class UiSwitch {
52
52
 
53
53
  readonly changed = output<boolean>();
54
54
 
55
- readonly thumbRef = viewChild<ElementRef>('thumb');
55
+ /** The thumb is always in the template, so toggle() can always slide it. */
56
+ readonly thumbRef = viewChild.required<ElementRef>('thumb');
56
57
  #activeAnimation?: AnimationHandle;
57
58
 
58
59
  protected readonly trackClass = computed(() =>
@@ -76,21 +77,15 @@ export class UiSwitch {
76
77
  toggle(): void {
77
78
  if (this.disabled()) return;
78
79
 
79
- const thumb = this.thumbRef()?.nativeElement;
80
+ const thumb = this.thumbRef().nativeElement;
80
81
  const wasChecked = this.checked();
81
82
  const next = !wasChecked;
82
83
 
83
- // springTranslateX calls thumb.animate() which is inert in jsdom (see
84
- // animate.ts). The spring physics of the thumb is exercised on-device.
85
- /* v8 ignore start */
86
- if (thumb) {
87
- this.#activeAnimation?.cancel();
88
- const from = wasChecked ? THUMB_OFFSET_ON : THUMB_OFFSET_OFF;
89
- const to = next ? THUMB_OFFSET_ON : THUMB_OFFSET_OFF;
90
- // Spring translate with squish effect at midpoint
91
- this.#activeAnimation = springTranslateX(thumb, from, to);
92
- }
93
- /* v8 ignore stop */
84
+ this.#activeAnimation?.cancel();
85
+ const from = wasChecked ? THUMB_OFFSET_ON : THUMB_OFFSET_OFF;
86
+ const to = next ? THUMB_OFFSET_ON : THUMB_OFFSET_OFF;
87
+ // Spring translate with squish effect at midpoint
88
+ this.#activeAnimation = springTranslateX(thumb, from, to);
94
89
 
95
90
  this.checked.set(next);
96
91
  this.changed.emit(next);
@@ -0,0 +1 @@
1
+ export { UiTabBar, UiTabBarItem } from './tab-bar';