@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,7 +1,11 @@
1
- import { Component } from '@angular/core';
2
- import { render } from '@blotch/angular-lynx-testing-library';
1
+ import { Component, signal } from '@angular/core';
2
+ import {
3
+ fireEvent,
4
+ render,
5
+ waitForUpdate,
6
+ } from '@blotch/angular-lynx-testing-library';
3
7
  import { LYNX_ELEMENTS } from '@blotch/angular-lynx';
4
- import { describe, expect, it } from 'vitest';
8
+ import { describe, expect, it, vi } from 'vitest';
5
9
  import {
6
10
  UiSheet,
7
11
  UiSheetDescription,
@@ -11,7 +15,6 @@ import {
11
15
  } from './sheet';
12
16
 
13
17
  @Component({
14
- standalone: true,
15
18
  imports: [
16
19
  UiSheet,
17
20
  UiSheetHeader,
@@ -21,24 +24,74 @@ import {
21
24
  LYNX_ELEMENTS,
22
25
  ],
23
26
  template: `
24
- <ui-sheet [(open)]="open">
25
- <ui-sheet-header>
26
- <ui-sheet-title>Title</ui-sheet-title>
27
- <ui-sheet-description>Body</ui-sheet-description>
27
+ <ui-sheet [(open)]="open" class="gap-4" (closed)="closed()">
28
+ <ui-sheet-header class="items-center">
29
+ <ui-sheet-title class="text-xl">Title</ui-sheet-title>
30
+ <ui-sheet-description class="italic">Body</ui-sheet-description>
28
31
  </ui-sheet-header>
29
- <ui-sheet-footer><text>Buttons</text></ui-sheet-footer>
32
+ <ui-sheet-footer class="flex-row"><text>Buttons</text></ui-sheet-footer>
30
33
  </ui-sheet>
31
34
  `,
32
35
  })
33
36
  class SheetHost {
34
- open = true;
37
+ readonly open = signal(false);
38
+ readonly closed = vi.fn();
35
39
  }
36
40
 
41
+ const renderSheet = async () => {
42
+ const result = await render(SheetHost);
43
+ return {
44
+ ...result,
45
+ host: result.componentRef.instance as SheetHost,
46
+ overlay: result.container.querySelector('overlay')!,
47
+ backdrop: result.container.querySelector('overlay > view')!,
48
+ };
49
+ };
50
+
51
+ /**
52
+ * Opens the sheet and waits out its open sequence: the overlay shows on one
53
+ * tick, and the panel slides in on the next.
54
+ */
55
+ const openSheet = async (host: SheetHost) => {
56
+ host.open.set(true);
57
+ for (let i = 0; i < 3; i++) await waitForUpdate();
58
+ };
59
+
37
60
  describe('UiSheet', () => {
38
61
  it('renders header, title, description, and footer', async () => {
39
- const { getByText } = await render(SheetHost);
62
+ const { getByText } = await renderSheet();
40
63
  expect(getByText('Title')).toBeTruthy();
41
64
  expect(getByText('Body')).toBeTruthy();
42
65
  expect(getByText('Buttons')).toBeTruthy();
43
66
  });
67
+
68
+ it("merges each part's class with its own", async () => {
69
+ const { container, getByText } = await renderSheet();
70
+ // The sheet's class goes on the padded content wrapper, not the panel.
71
+ expect(container.querySelector('.px-6')?.classList).toContain('gap-4');
72
+ expect(container.querySelector('.mb-4')?.classList).toContain(
73
+ 'items-center',
74
+ );
75
+ expect(getByText('Title').classList).toContain('text-xl');
76
+ expect(getByText('Title').classList).toContain('font-semibold');
77
+ expect(getByText('Body').classList).toContain('italic');
78
+ expect(getByText('Body').classList).toContain('text-muted-foreground');
79
+ expect(container.querySelector('.pt-4')?.classList).toContain('flex-row');
80
+ });
81
+
82
+ it('opens with `open`, and reports a dismissal through it and `closed`', async () => {
83
+ const { host, overlay, backdrop } = await renderSheet();
84
+ expect(overlay.getAttribute('visible')).toBe('false');
85
+
86
+ await openSheet(host);
87
+ expect(overlay.getAttribute('visible')).toBe('true');
88
+
89
+ fireEvent.tap(backdrop);
90
+ expect(host.open()).toBe(false);
91
+ // Once the panel has slid out, the overlay hides and `closed` fires.
92
+ await vi.waitFor(() => expect(host.closed).toHaveBeenCalled());
93
+ await waitForUpdate();
94
+ expect(overlay.getAttribute('visible')).toBe('false');
95
+ expect(host.closed).toHaveBeenCalledOnce();
96
+ });
44
97
  });
@@ -26,8 +26,16 @@ import { cn } from '../../utils/cn';
26
26
  standalone: true,
27
27
  imports: [LYNX_ELEMENTS, UiBottomSheet],
28
28
  encapsulation: ViewEncapsulation.None,
29
+ // `open` is bound both ways by hand rather than with `[(open)]`: for a
30
+ // two-way binding Angular also compiles a fallback that assigns the field,
31
+ // which never runs for a signal and so shows as an uncovered branch in an
32
+ // app's coverage report.
29
33
  template: `
30
- <ui-bottom-sheet [(open)]="open" (closed)="closed.emit()">
34
+ <ui-bottom-sheet
35
+ [open]="open()"
36
+ (openChange)="open.set($event)"
37
+ (closed)="closed.emit()"
38
+ >
31
39
  <view [class]="contentClass()">
32
40
  <ng-content />
33
41
  </view>
@@ -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,66 +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';
4
5
  import { ICON_LIGHT_COLORS } from '../icon/icon-theme';
5
6
  import { UiSpinner } from './spinner';
6
7
 
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
+ }
19
+
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
+
7
32
  describe('UiSpinner', () => {
8
- it('renders the SVG at the default size', async () => {
9
- const { container } = await render(UiSpinner);
10
- expect(container).toBeTruthy();
11
- // The default size is 'md' → sizeStyle should render at 24px.
12
- const view = container.querySelector('view');
13
- expect(view?.getAttribute('style')).toContain('24px');
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');
14
37
  });
15
38
 
16
- it('substitutes currentColor when the color input is set', async () => {
17
- const { container, componentRef } = await render(UiSpinner);
18
- setInputSignal((componentRef.instance as UiSpinner).color, 'rgb(0, 0, 0)');
19
- // Flush CD so the computed re-runs and the `content` attribute updates.
20
- await waitForUpdate();
21
- const svg = container.querySelector('svg');
22
- expect(svg?.getAttribute('content')).toContain('rgb(0, 0, 0)');
23
- expect(svg?.getAttribute('content')).not.toContain('currentColor');
39
+ it('resizes for each supported size', async () => {
40
+ const { host, spinner } = await renderSpinner();
41
+ for (const [size, px] of [
42
+ ['xs', 16],
43
+ ['sm', 20],
44
+ ['md', 24],
45
+ ['lg', 32],
46
+ ] as const) {
47
+ host.size.set(size);
48
+ await waitForUpdate();
49
+ expect(spinner().getAttribute('style')).toContain(`width: ${px}px`);
50
+ }
24
51
  });
25
52
 
26
53
  it("draws in the theme's foreground when no color is set", async () => {
27
- const { container } = await render(UiSpinner);
28
- const content = container.querySelector('svg')?.getAttribute('content');
29
- expect(content).toContain(ICON_LIGHT_COLORS['foreground']);
30
- expect(content).not.toContain('currentColor');
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');
31
58
  });
32
59
 
33
- it('resolves a theme token color', async () => {
34
- const { container, componentRef } = await render(UiSpinner);
35
- setInputSignal(
36
- (componentRef.instance as UiSpinner).color,
37
- 'var(--primary-foreground)',
38
- );
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)');
39
63
  await waitForUpdate();
40
- expect(container.querySelector('svg')?.getAttribute('content')).toContain(
41
- ICON_LIGHT_COLORS['primary-foreground'],
42
- );
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']);
43
69
  });
44
70
 
45
- it('applies the class input to the spinner', async () => {
46
- const { container, componentRef } = await render(UiSpinner);
47
- setInputSignal((componentRef.instance as UiSpinner).userClass, 'ml-2');
71
+ it('takes the class input', async () => {
72
+ const { host, spinner } = await renderSpinner();
73
+ host.className.set('ml-2');
48
74
  await waitForUpdate();
49
- expect(container.querySelector('view')?.getAttribute('class')).toBe('ml-2');
75
+ expect(spinner().getAttribute('class')).toBe('ml-2');
50
76
  });
51
77
 
52
- it('resizes for each supported size', async () => {
53
- const { componentRef, container } = await render(UiSpinner);
54
- for (const [size, px] of [
55
- ['xs', 16],
56
- ['sm', 20],
57
- ['md', 24],
58
- ['lg', 32],
59
- ] as const) {
60
- setInputSignal((componentRef.instance as UiSpinner).size, size);
61
- await waitForUpdate();
62
- const view = container.querySelector('view');
63
- expect(view?.getAttribute('style')).toContain(`${px}px`);
64
- }
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();
65
83
  });
66
84
  });
@@ -43,26 +43,23 @@ export class UiSpinner {
43
43
 
44
44
  readonly #resolveColor = injectIconColor();
45
45
 
46
- readonly spinnerRef = viewChild<ElementRef>('spinner');
46
+ readonly spinnerRef = viewChild.required<ElementRef>('spinner');
47
47
 
48
- constructor() {
49
- effect(() => {
50
- const el = this.spinnerRef()?.nativeElement;
51
- // el.animate() is inert in jsdom (see animate.ts's transitionTransform
52
- // rationale). The continuous rotation is exercised on-device.
53
- /* v8 ignore start */
54
- if (!el) return;
55
- el.animate(
56
- [{ transform: 'rotate(0deg)' }, { transform: 'rotate(360deg)' }],
57
- {
58
- duration: 1600,
59
- iterations: Infinity,
60
- easing: 'linear',
61
- },
62
- );
63
- /* v8 ignore stop */
64
- });
65
- }
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
+ );
62
+ });
66
63
 
67
64
  // Lynx SVG elements don't inherit the CSS `color` property into their stroke
68
65
  // attributes, so we substitute the literal `currentColor` in the SVG string
@@ -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';