@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
@@ -131,6 +131,13 @@ export class UiChartTooltip {
131
131
  }
132
132
  }
133
133
 
134
+ /**
135
+ * `UiChartTooltip`, for `contentChild()`. A call rather than the class itself:
136
+ * for a class, Angular's test build keeps an arrow function that nothing calls,
137
+ * which an app's coverage report would show as uncovered.
138
+ */
139
+ const tooltipDirectiveType = () => UiChartTooltip;
140
+
134
141
  /** Default height, like the browser's 150px replaced-element default, but roomier. */
135
142
  const DEFAULT_HEIGHT = 200;
136
143
  /** A touch that moves less than this is a tap, not a drag. */
@@ -352,7 +359,7 @@ export class UiChart {
352
359
  /** The point a tap landed on, or `null` for a tap on empty space. */
353
360
  readonly pointSelect = output<ChartPoint | null>();
354
361
 
355
- protected readonly tooltipTemplate = contentChild(UiChartTooltip);
362
+ protected readonly tooltipTemplate = contentChild(tooltipDirectiveType());
356
363
 
357
364
  readonly #theme = inject(UI_CHART_THEME);
358
365
  readonly #appearance = inject(LynxTheme);
@@ -405,6 +412,15 @@ export class UiChart {
405
412
  });
406
413
  });
407
414
 
415
+ /**
416
+ * The scene, for what the template draws inside `@if (scene())`: those
417
+ * bindings, and the values below derived for them, are only read while there
418
+ * is one, so they need no fallback for a chart without a size.
419
+ */
420
+ #drawnScene(): ChartScene<any, any, any> {
421
+ return this.scene()!;
422
+ }
423
+
408
424
  readonly #focusModel = computed(() => {
409
425
  const scene = this.scene();
410
426
  return scene ? createChartFocusModel(scene, this.definition()) : null;
@@ -424,24 +440,23 @@ export class UiChart {
424
440
  });
425
441
 
426
442
  /** The scene with mark states (e.g. dim the unfocused bars) resolved. */
427
- readonly #presented = computed(() => {
428
- const scene = this.scene();
429
- return scene
430
- ? resolveMarkStateScene(scene, this.#focusState(), this.#pointer()).scene
431
- : null;
432
- });
443
+ readonly #presented = computed(
444
+ () =>
445
+ resolveMarkStateScene(
446
+ this.#drawnScene(),
447
+ this.#focusState(),
448
+ this.#pointer(),
449
+ ).scene,
450
+ );
433
451
 
434
- readonly #focusPresentation = computed(() => {
435
- const scene = this.#presented();
436
- return scene
437
- ? resolveFocusPresentation(
438
- scene,
439
- this.#focusState(),
440
- this.#pointer(),
441
- null,
442
- )
443
- : { under: [], over: [] };
444
- });
452
+ readonly #focusPresentation = computed(() =>
453
+ resolveFocusPresentation(
454
+ this.#presented(),
455
+ this.#focusState(),
456
+ this.#pointer(),
457
+ null,
458
+ ),
459
+ );
445
460
 
446
461
  readonly #colors = computed(() => {
447
462
  const option = this.colorScheme() ?? this.#theme.colorScheme ?? 'light';
@@ -457,12 +472,12 @@ export class UiChart {
457
472
  });
458
473
 
459
474
  readonly #svgOptions = computed(() => {
460
- const scene = this.scene();
475
+ const scene = this.#drawnScene();
461
476
  const colors = this.#colors();
462
477
  return {
463
- width: scene?.width ?? 0,
464
- height: scene?.height ?? 0,
465
- gradients: scene?.gradients,
478
+ width: scene.width,
479
+ height: scene.height,
480
+ gradients: scene.gradients,
466
481
  idPrefix: this.#idPrefix,
467
482
  resolvePaint: (paint: string) => resolveChartPaint(paint, colors),
468
483
  };
@@ -475,8 +490,7 @@ export class UiChart {
475
490
  );
476
491
  protected readonly marksSvg = computed(
477
492
  () =>
478
- renderChartSvg(this.#presented()?.nodes ?? [], this.#svgOptions()) ||
479
- EMPTY_SVG,
493
+ renderChartSvg(this.#presented().nodes, this.#svgOptions()) || EMPTY_SVG,
480
494
  );
481
495
  protected readonly overSvg = computed(
482
496
  () =>
@@ -485,18 +499,14 @@ export class UiChart {
485
499
  );
486
500
 
487
501
  protected readonly layerStyle = computed(() => {
488
- const scene = this.scene();
489
- return `position: absolute; left: 0px; top: 0px; width: ${scene?.width ?? 0}px; height: ${scene?.height ?? 0}px;`;
502
+ const scene = this.#drawnScene();
503
+ return `position: absolute; left: 0px; top: 0px; width: ${scene.width}px; height: ${scene.height}px;`;
490
504
  });
491
505
 
492
506
  readonly #labels = computed<readonly ChartLabel[]>(() => {
493
507
  const presentation = this.#focusPresentation();
494
508
  return collectChartLabels(
495
- [
496
- ...presentation.under,
497
- ...(this.#presented()?.nodes ?? []),
498
- ...presentation.over,
499
- ],
509
+ [...presentation.under, ...this.#presented().nodes, ...presentation.over],
500
510
  {
501
511
  defaultColor: this.#theme.chart.foreground ?? 'var(--foreground)',
502
512
  focus: this.#focusState(),
@@ -600,18 +610,21 @@ export class UiChart {
600
610
  protected readonly zoomButtonClass =
601
611
  'flex h-7 w-7 items-center justify-center rounded-md border border-border bg-card';
602
612
 
603
- constructor() {
604
- // A new scene (data, size or zoom changed) has new point objects. Keep the
605
- // focus on the same datum when it's still there, as TanStack's hosts do.
606
- effect(() => {
607
- const model = this.#focusModel();
608
- const previous = untracked(this.#focused);
609
- if (!model || previous.length === 0) return;
610
- const restored = model.restore(previous[0]!);
611
- this.#focused.set(restored ? model.group(restored) : EMPTY_POINTS);
612
- if (!restored) this.#pinned.set(false);
613
- });
614
- }
613
+ /**
614
+ * Keeps the focus on the same datum when a new scene (data, size or zoom
615
+ * changed) brings new point objects, as TanStack's hosts do. A field rather
616
+ * than a constructor: Angular's test build keeps a function for a
617
+ * constructor that nothing calls, which an app's coverage report would show
618
+ * as uncovered.
619
+ */
620
+ protected readonly restoringFocus = effect(() => {
621
+ const model = this.#focusModel();
622
+ const previous = untracked(this.#focused);
623
+ if (!model || previous.length === 0) return;
624
+ const restored = model.restore(previous[0]!);
625
+ this.#focused.set(restored ? model.group(restored) : EMPTY_POINTS);
626
+ if (!restored) this.#pinned.set(false);
627
+ });
615
628
 
616
629
  /**
617
630
  * ---------------------------------------------------------------------------
@@ -1,59 +1,141 @@
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 { UiCheckbox } from './checkbox';
5
21
 
22
+ @Component({
23
+ imports: [UiCheckbox],
24
+ template: `
25
+ <ui-checkbox
26
+ [(checked)]="checked"
27
+ [disabled]="disabled()"
28
+ class="mr-2"
29
+ (changed)="changes.push($event)"
30
+ />
31
+ `,
32
+ })
33
+ class CheckboxHost {
34
+ readonly checked = signal(false);
35
+ readonly disabled = signal(false);
36
+ readonly changes: boolean[] = [];
37
+ }
38
+
39
+ const renderCheckbox = async () => {
40
+ const result = await render(CheckboxHost);
41
+ return {
42
+ ...result,
43
+ host: result.componentRef.instance as CheckboxHost,
44
+ /** The 44pt tap target around the box. */
45
+ target: () => result.container.querySelector('.h-11')!,
46
+ box: () => result.container.querySelector('.rounded-sm')!,
47
+ checkmark: () => result.getByText('✓'),
48
+ };
49
+ };
50
+
51
+ /** `__ElementAnimate`'s first argument for starting an animation. */
52
+ const START = 0;
53
+ /** `__ElementAnimate`'s first argument for canceling one. */
54
+ const CANCEL = 3;
55
+
6
56
  describe('UiCheckbox', () => {
7
- it('renders unchecked by default', async () => {
8
- const { container } = await render(UiCheckbox);
9
- expect(container).toBeTruthy();
57
+ // Watched per test: the testing library resets the main thread after each.
58
+ let animate: MockInstance<LynxMainThread['__ElementAnimate']>;
59
+ beforeEach(() => {
60
+ animate = vi.spyOn(lynxMainThread(), '__ElementAnimate');
10
61
  });
62
+ afterEach(() => animate.mockRestore());
11
63
 
12
- it('toggle() flips checked and emits the `changed` output', async () => {
13
- const { componentRef } = await render(UiCheckbox);
14
- const inst = componentRef.instance as UiCheckbox;
15
- const changes: boolean[] = [];
16
- inst.changed.subscribe((v) => changes.push(v));
17
-
18
- inst.toggle();
19
- expect(inst.checked()).toBe(true);
20
- inst.toggle();
21
- expect(inst.checked()).toBe(false);
22
- expect(changes).toEqual([true, false]);
64
+ it('renders an empty box with the given class', async () => {
65
+ const { box, checkmark } = await renderCheckbox();
66
+ expect(box().classList).toContain('bg-transparent');
67
+ expect(box().classList).toContain('mr-2');
68
+ expect(checkmark().getAttribute('style')).toContain('opacity: 0');
23
69
  });
24
70
 
25
- it('toggle() is a no-op when disabled is true', async () => {
26
- const { componentRef } = await render(UiCheckbox);
27
- const inst = componentRef.instance as UiCheckbox;
28
- setInputSignal(inst.disabled, true);
29
- const changed = vi.fn();
30
- inst.changed.subscribe(changed);
71
+ it('checks and unchecks on tap, emitting `changed`', async () => {
72
+ const { host, target, box, checkmark } = await renderCheckbox();
31
73
 
32
- inst.toggle();
33
- expect(inst.checked()).toBe(false);
34
- expect(changed).not.toHaveBeenCalled();
74
+ fireEvent.tap(target());
75
+ await waitForUpdate();
76
+ expect(host.checked()).toBe(true);
77
+ expect(box().classList).toContain('bg-primary');
78
+ expect(checkmark().getAttribute('style')).toContain('opacity: 1');
79
+
80
+ fireEvent.tap(target());
81
+ await waitForUpdate();
82
+ expect(host.checked()).toBe(false);
83
+ expect(host.changes).toEqual([true, false]);
35
84
  });
36
85
 
37
- it('applies the "checked" box class and reveals the check mark opacity', async () => {
38
- const { componentRef, container } = await render(UiCheckbox);
39
- const inst = componentRef.instance as UiCheckbox;
40
- inst.checked.set(true);
86
+ it("doesn't animate on mount, then pops the checkmark in and out", async () => {
87
+ const { target, checkmark } = await renderCheckbox();
88
+ expect(animate).not.toHaveBeenCalled();
89
+
90
+ fireEvent.tap(target());
41
91
  await waitForUpdate();
92
+ expect(animate).toHaveBeenCalledWith(checkmark(), [
93
+ START,
94
+ expect.any(String),
95
+ [
96
+ { transform: 'scale(0.6)', opacity: 0 },
97
+ { transform: 'scale(1)', opacity: 1 },
98
+ ],
99
+ expect.objectContaining({ fill: 'forwards' }),
100
+ ]);
42
101
 
43
- // The inner check-glyph is opacity: 1 when checked (opacity: 0 otherwise).
44
- const check = container.querySelector('text');
45
- expect(check?.getAttribute('style')).toContain('opacity: 1');
102
+ // Unchecking cuts the pop-in short and pops the checkmark out.
103
+ fireEvent.tap(target());
104
+ await waitForUpdate();
105
+ expect(animate).toHaveBeenCalledWith(checkmark(), [
106
+ CANCEL,
107
+ expect.any(String),
108
+ ]);
109
+ expect(animate).toHaveBeenLastCalledWith(checkmark(), [
110
+ START,
111
+ expect.any(String),
112
+ [
113
+ { transform: 'scale(1)', opacity: 1 },
114
+ { transform: 'scale(0.8)', opacity: 0 },
115
+ ],
116
+ expect.objectContaining({ fill: 'forwards' }),
117
+ ]);
118
+ });
119
+
120
+ it("doesn't animate two taps in one frame, which leave it as it was", async () => {
121
+ const { host, target } = await renderCheckbox();
122
+ fireEvent.tap(target());
123
+ fireEvent.tap(target());
124
+ await waitForUpdate();
125
+ expect(host.checked()).toBe(false);
126
+ expect(host.changes).toEqual([true, false]);
127
+ expect(animate).not.toHaveBeenCalled();
46
128
  });
47
129
 
48
- it('applies the disabled dim when disabled input is set', async () => {
49
- const { componentRef, container } = await render(UiCheckbox);
50
- setInputSignal((componentRef.instance as UiCheckbox).disabled, true);
130
+ it('dims and ignores taps while disabled', async () => {
131
+ const { host, target, box } = await renderCheckbox();
132
+ host.disabled.set(true);
133
+ await waitForUpdate();
134
+ expect(box().classList).toContain('opacity-50');
135
+
136
+ fireEvent.tap(target());
51
137
  await waitForUpdate();
52
- // opacity-50 class lands on the box wrapper's class attribute.
53
- const boxes = container.querySelectorAll('view');
54
- const hasDim = Array.from(boxes).some((el) =>
55
- el.getAttribute('class')?.includes('opacity-50'),
56
- );
57
- expect(hasDim).toBe(true);
138
+ expect(host.checked()).toBe(false);
139
+ expect(host.changes).toEqual([]);
58
140
  });
59
141
  });
@@ -43,34 +43,34 @@ export class UiCheckbox {
43
43
 
44
44
  readonly changed = output<boolean>();
45
45
 
46
- readonly checkmarkRef = viewChild<ElementRef>('checkmark');
46
+ readonly checkmarkRef = viewChild.required<ElementRef>('checkmark');
47
47
  #checkAnim?: AnimationHandle;
48
48
  #previousChecked?: boolean;
49
49
 
50
- constructor() {
51
- // Animate the checkmark on state transitions (skip initial render).
52
- // `#previousChecked === undefined` guards the first run — we don't want to
53
- // pop-in the checkmark when the component mounts already-checked.
54
- // The `isChecked === this.#previousChecked` guard prevents a second animation
55
- // from firing if the effect re-runs without an actual state change.
56
- effect(() => {
57
- const isChecked = this.checked();
58
- const el = this.checkmarkRef()?.nativeElement;
59
- // popIn/popOut call el.animate() which is inert in jsdom — the
60
- // check-mark animation is exercised on-device.
61
- /* v8 ignore start */
62
- if (!el || this.#previousChecked === undefined) {
63
- this.#previousChecked = isChecked;
64
- return;
65
- }
66
- if (isChecked === this.#previousChecked) return;
50
+ /**
51
+ * Pops the checkmark in when checked and out when unchecked. A field rather
52
+ * than a constructor: Angular's test build keeps a function for a
53
+ * constructor that nothing calls, which an app's coverage report would show
54
+ * as uncovered.
55
+ *
56
+ * `#previousChecked === undefined` guards the first run: we don't want to
57
+ * pop in the checkmark when the component mounts already checked. The
58
+ * equality guard skips a run that isn't a change: `checked` set and set
59
+ * back before the effect runs (two taps in one frame) still re-runs it.
60
+ */
61
+ protected readonly checkmarkAnimation = effect(() => {
62
+ const isChecked = this.checked();
63
+ const el = this.checkmarkRef().nativeElement;
64
+ if (this.#previousChecked === undefined) {
67
65
  this.#previousChecked = isChecked;
66
+ return;
67
+ }
68
+ if (isChecked === this.#previousChecked) return;
69
+ this.#previousChecked = isChecked;
68
70
 
69
- this.#checkAnim?.cancel();
70
- this.#checkAnim = isChecked ? popIn(el) : popOut(el);
71
- /* v8 ignore stop */
72
- });
73
- }
71
+ this.#checkAnim?.cancel();
72
+ this.#checkAnim = isChecked ? popIn(el) : popOut(el);
73
+ });
74
74
 
75
75
  protected readonly boxClass = computed(() =>
76
76
  cn(