@blotch/dolan 22.2.3 → 22.2.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (227) hide show
  1. package/dist/commands/add.js +9 -1
  2. package/dist/commands/add.js.map +1 -1
  3. package/dist/commands/add.test.js +38 -3
  4. package/dist/commands/add.test.js.map +1 -1
  5. package/dist/commands/doctor.js +258 -21
  6. package/dist/commands/doctor.js.map +1 -1
  7. package/dist/commands/doctor.test.js +425 -11
  8. package/dist/commands/doctor.test.js.map +1 -1
  9. package/dist/commands/init.d.ts +2 -2
  10. package/dist/commands/init.js +38 -18
  11. package/dist/commands/init.js.map +1 -1
  12. package/dist/commands/init.test.js +105 -2
  13. package/dist/commands/init.test.js.map +1 -1
  14. package/dist/commands/theme.js +1 -1
  15. package/dist/commands/theme.js.map +1 -1
  16. package/dist/commands/update.d.ts +7 -0
  17. package/dist/commands/update.js +194 -50
  18. package/dist/commands/update.js.map +1 -1
  19. package/dist/commands/update.test.js +570 -3
  20. package/dist/commands/update.test.js.map +1 -1
  21. package/dist/lockfile.d.ts +17 -0
  22. package/dist/lockfile.js +56 -1
  23. package/dist/lockfile.js.map +1 -1
  24. package/dist/lockfile.test.d.ts +1 -0
  25. package/dist/lockfile.test.js +65 -0
  26. package/dist/lockfile.test.js.map +1 -0
  27. package/dist/registry.d.ts +6 -0
  28. package/dist/registry.js +119 -11
  29. package/dist/registry.js.map +1 -1
  30. package/dist/registry.test.js +61 -6
  31. package/dist/registry.test.js.map +1 -1
  32. package/dist/test-utils.d.ts +27 -0
  33. package/dist/test-utils.js +45 -1
  34. package/dist/test-utils.js.map +1 -1
  35. package/dist/testing/fire-lynx-event.d.ts +14 -0
  36. package/dist/testing/fire-lynx-event.js +19 -0
  37. package/dist/testing/fire-lynx-event.js.map +1 -0
  38. package/dist/testing/fire-lynx-event.test.d.ts +1 -0
  39. package/dist/testing/fire-lynx-event.test.js +44 -0
  40. package/dist/testing/fire-lynx-event.test.js.map +1 -0
  41. package/dist/testing/lynx-main-thread.d.ts +22 -0
  42. package/dist/testing/lynx-main-thread.js +11 -0
  43. package/dist/testing/lynx-main-thread.js.map +1 -0
  44. package/dist/testing/lynx-main-thread.test.d.ts +1 -0
  45. package/dist/testing/lynx-main-thread.test.js +24 -0
  46. package/dist/testing/lynx-main-thread.test.js.map +1 -0
  47. package/dist/testing/set-input-signal.d.ts +5 -4
  48. package/dist/testing/set-input-signal.js +5 -4
  49. package/dist/testing/set-input-signal.js.map +1 -1
  50. package/dist/ui/boolean-inputs.test.ts +76 -0
  51. package/dist/ui/components/accordion/accordion.test.ts +158 -144
  52. package/dist/ui/components/accordion/accordion.ts +19 -14
  53. package/dist/ui/components/action-sheet/action-sheet.test.ts +637 -183
  54. package/dist/ui/components/action-sheet/action-sheet.ts +339 -55
  55. package/dist/ui/components/action-sheet/index.ts +1 -0
  56. package/dist/ui/components/alert/alert.test.ts +67 -19
  57. package/dist/ui/components/alert/alert.ts +14 -16
  58. package/dist/ui/components/alert-dialog/alert-dialog.test.ts +740 -129
  59. package/dist/ui/components/alert-dialog/alert-dialog.ts +672 -58
  60. package/dist/ui/components/alert-dialog/index.ts +5 -0
  61. package/dist/ui/components/area-chart/area-chart.test.ts +81 -1
  62. package/dist/ui/components/area-chart/area-chart.ts +3 -2
  63. package/dist/ui/components/avatar/avatar.test.ts +89 -45
  64. package/dist/ui/components/badge/badge.test.ts +57 -23
  65. package/dist/ui/components/badge/badge.ts +14 -15
  66. package/dist/ui/components/bar-chart/bar-chart.test.ts +82 -1
  67. package/dist/ui/components/bottom-sheet/bottom-sheet.test.ts +1314 -273
  68. package/dist/ui/components/bottom-sheet/bottom-sheet.ts +1046 -119
  69. package/dist/ui/components/bottom-sheet/index.ts +7 -1
  70. package/dist/ui/components/button/button.test.ts +646 -2
  71. package/dist/ui/components/button/button.ts +443 -64
  72. package/dist/ui/components/button/index.ts +2 -0
  73. package/dist/ui/components/button-group/button-group.test.ts +528 -2
  74. package/dist/ui/components/button-group/button-group.ts +428 -70
  75. package/dist/ui/components/button-group/index.ts +3 -0
  76. package/dist/ui/components/candlestick-chart/candlestick-chart.test.ts +82 -1
  77. package/dist/ui/components/card/card.test.ts +97 -61
  78. package/dist/ui/components/card/card.ts +2 -1
  79. package/dist/ui/components/chart/chart-presets.test.ts +197 -1
  80. package/dist/ui/components/chart/chart-presets.ts +14 -7
  81. package/dist/ui/components/chart/chart-theme.ts +12 -0
  82. package/dist/ui/components/chart/chart.test.ts +1031 -0
  83. package/dist/ui/components/chart/chart.ts +66 -46
  84. package/dist/ui/components/checkbox/checkbox.test.ts +124 -42
  85. package/dist/ui/components/checkbox/checkbox.ts +25 -24
  86. package/dist/ui/components/collapsible/collapsible.test.ts +158 -81
  87. package/dist/ui/components/collapsible/collapsible.ts +21 -15
  88. package/dist/ui/components/context-menu/context-menu.test.ts +224 -0
  89. package/dist/ui/components/context-menu/context-menu.ts +110 -0
  90. package/dist/ui/components/context-menu/index.ts +1 -0
  91. package/dist/ui/components/dialog/dialog.test.ts +210 -152
  92. package/dist/ui/components/dialog/dialog.ts +35 -29
  93. package/dist/ui/components/dock/dock-scroll.test.ts +130 -0
  94. package/dist/ui/components/dock/dock-scroll.ts +142 -0
  95. package/dist/ui/components/dock/dock-search.test.ts +594 -0
  96. package/dist/ui/components/dock/dock.test.ts +752 -0
  97. package/dist/ui/components/dock/dock.ts +1438 -0
  98. package/dist/ui/components/dock/index.ts +2 -0
  99. package/dist/ui/components/dynamic-color/dynamic-color.test.ts +144 -0
  100. package/dist/ui/components/dynamic-color/dynamic-color.ts +146 -0
  101. package/dist/ui/components/dynamic-color/index.ts +5 -0
  102. package/dist/ui/components/empty-state/empty-state.test.ts +28 -39
  103. package/dist/ui/components/glass/concentric-radius.test.ts +13 -0
  104. package/dist/ui/components/glass/concentric-radius.ts +17 -0
  105. package/dist/ui/components/glass/glass-design.test.ts +78 -0
  106. package/dist/ui/components/glass/glass-design.ts +119 -0
  107. package/dist/ui/components/glass/glass.test.ts +376 -0
  108. package/dist/ui/components/glass/glass.ts +430 -0
  109. package/dist/ui/components/glass/index.ts +19 -0
  110. package/dist/ui/components/icon/icon-theme.test.ts +64 -0
  111. package/dist/ui/components/icon/icon-theme.ts +70 -16
  112. package/dist/ui/components/icon/icon.test.ts +52 -41
  113. package/dist/ui/components/input/input.test.ts +178 -101
  114. package/dist/ui/components/input/input.ts +17 -13
  115. package/dist/ui/components/label/label.test.ts +11 -24
  116. package/dist/ui/components/label/label.ts +8 -2
  117. package/dist/ui/components/line-chart/line-chart.test.ts +76 -1
  118. package/dist/ui/components/line-chart/line-chart.ts +3 -2
  119. package/dist/ui/components/list/list.test.ts +80 -49
  120. package/dist/ui/components/list/list.ts +5 -4
  121. package/dist/ui/components/menu/index.ts +25 -0
  122. package/dist/ui/components/menu/menu-placement.test.ts +153 -0
  123. package/dist/ui/components/menu/menu-placement.ts +159 -0
  124. package/dist/ui/components/menu/menu-popup.test.ts +636 -0
  125. package/dist/ui/components/menu/menu-popup.ts +565 -0
  126. package/dist/ui/components/menu/menu.test.ts +523 -0
  127. package/dist/ui/components/menu/menu.ts +455 -0
  128. package/dist/ui/components/nav-drawer/nav-drawer.test.ts +425 -260
  129. package/dist/ui/components/nav-drawer/nav-drawer.ts +75 -58
  130. package/dist/ui/components/navigation-bar/index.ts +1 -0
  131. package/dist/ui/components/navigation-bar/navigation-bar.test.ts +425 -0
  132. package/dist/ui/components/navigation-bar/navigation-bar.ts +411 -0
  133. package/dist/ui/components/pagination/pagination.test.ts +136 -86
  134. package/dist/ui/components/popover/index.ts +9 -0
  135. package/dist/ui/components/popover/popover-panel.test.ts +735 -0
  136. package/dist/ui/components/popover/popover-panel.ts +603 -0
  137. package/dist/ui/components/popover/popover.test.ts +286 -0
  138. package/dist/ui/components/popover/popover.ts +207 -0
  139. package/dist/ui/components/progress/progress.test.ts +102 -46
  140. package/dist/ui/components/progress/progress.ts +54 -55
  141. package/dist/ui/components/radio-group/radio-group.test.ts +128 -83
  142. package/dist/ui/components/radio-group/radio-group.ts +26 -25
  143. package/dist/ui/components/scatter-chart/scatter-chart.test.ts +74 -1
  144. package/dist/ui/components/scroll-area/scroll-area.test.ts +72 -36
  145. package/dist/ui/components/scroll-area/scroll-area.ts +4 -3
  146. package/dist/ui/components/search-field/index.ts +15 -0
  147. package/dist/ui/components/search-field/keyboard.test.ts +147 -0
  148. package/dist/ui/components/search-field/keyboard.ts +123 -0
  149. package/dist/ui/components/search-field/search-field.test.ts +549 -0
  150. package/dist/ui/components/search-field/search-field.ts +628 -0
  151. package/dist/ui/components/segmented-control/index.ts +8 -0
  152. package/dist/ui/components/segmented-control/segmented-control.test.ts +861 -0
  153. package/dist/ui/components/segmented-control/segmented-control.ts +884 -0
  154. package/dist/ui/components/select/select.test.ts +138 -78
  155. package/dist/ui/components/select/select.ts +19 -12
  156. package/dist/ui/components/separator/separator.test.ts +18 -17
  157. package/dist/ui/components/sheet/sheet.test.ts +151 -11
  158. package/dist/ui/components/sheet/sheet.ts +68 -5
  159. package/dist/ui/components/skeleton/skeleton.test.ts +24 -7
  160. package/dist/ui/components/skeleton/skeleton.ts +10 -16
  161. package/dist/ui/components/slider/index.ts +1 -0
  162. package/dist/ui/components/slider/slider.test.ts +695 -0
  163. package/dist/ui/components/slider/slider.ts +901 -0
  164. package/dist/ui/components/spinner/spinner.test.ts +64 -46
  165. package/dist/ui/components/spinner/spinner.ts +16 -19
  166. package/dist/ui/components/switch/index.ts +1 -1
  167. package/dist/ui/components/switch/switch.test.ts +534 -48
  168. package/dist/ui/components/switch/switch.ts +613 -48
  169. package/dist/ui/components/tab-bar/index.ts +1 -0
  170. package/dist/ui/components/tab-bar/tab-bar.test.ts +280 -0
  171. package/dist/ui/components/tab-bar/tab-bar.ts +230 -0
  172. package/dist/ui/components/tabs/index.ts +7 -1
  173. package/dist/ui/components/tabs/tabs.test.ts +484 -150
  174. package/dist/ui/components/tabs/tabs.ts +344 -149
  175. package/dist/ui/components/textarea/textarea.test.ts +206 -100
  176. package/dist/ui/components/textarea/textarea.ts +16 -13
  177. package/dist/ui/components/toast/toast.test.ts +434 -350
  178. package/dist/ui/components/toast/toast.ts +42 -51
  179. package/dist/ui/components/toggle/index.ts +1 -0
  180. package/dist/ui/components/toggle/toggle.test.ts +559 -146
  181. package/dist/ui/components/toggle/toggle.ts +241 -26
  182. package/dist/ui/components/toolbar/bar-scroll.test.ts +310 -0
  183. package/dist/ui/components/toolbar/bar-scroll.ts +372 -0
  184. package/dist/ui/components/toolbar/index.ts +12 -0
  185. package/dist/ui/components/toolbar/toolbar.test.ts +438 -0
  186. package/dist/ui/components/toolbar/toolbar.ts +535 -0
  187. package/dist/ui/theme/chart-colors.test.ts +8 -3
  188. package/dist/ui/theme/dark.css +38 -2
  189. package/dist/ui/theme/default.css +55 -7
  190. package/dist/ui/theme/icon-colors.test.ts +8 -3
  191. package/dist/ui/theme/rgb-channels.test.ts +84 -0
  192. package/dist/ui/theme/tailwind-build.test.ts +114 -0
  193. package/dist/ui/theme/tailwind-css.test.ts +83 -0
  194. package/dist/ui/theme/tailwind-plugin.test.ts +42 -2
  195. package/dist/ui/theme/tailwind-plugin.ts +93 -29
  196. package/dist/ui/theme/tailwind.css +113 -0
  197. package/dist/ui/utils/animate.test.ts +13 -1
  198. package/dist/utils/analyze.d.ts +19 -0
  199. package/dist/utils/analyze.js +16 -0
  200. package/dist/utils/analyze.js.map +1 -1
  201. package/dist/utils/analyze.test.js +14 -1
  202. package/dist/utils/analyze.test.js.map +1 -1
  203. package/dist/utils/installed.d.ts +25 -1
  204. package/dist/utils/installed.js +33 -0
  205. package/dist/utils/installed.js.map +1 -1
  206. package/dist/utils/installed.test.js +55 -1
  207. package/dist/utils/installed.test.js.map +1 -1
  208. package/dist/utils/resolve-paths.d.ts +4 -4
  209. package/dist/utils/resolve-paths.js +5 -7
  210. package/dist/utils/resolve-paths.js.map +1 -1
  211. package/dist/utils/resolve-paths.test.js +9 -5
  212. package/dist/utils/resolve-paths.test.js.map +1 -1
  213. package/dist/utils/tailwind.d.ts +45 -0
  214. package/dist/utils/tailwind.js +90 -0
  215. package/dist/utils/tailwind.js.map +1 -0
  216. package/dist/utils/tailwind.test.d.ts +1 -0
  217. package/dist/utils/tailwind.test.js +134 -0
  218. package/dist/utils/tailwind.test.js.map +1 -0
  219. package/package.json +9 -1
  220. package/dist/ui/components/area-chart/area-chart-render.test.ts +0 -63
  221. package/dist/ui/components/bar-chart/bar-chart-render.test.ts +0 -58
  222. package/dist/ui/components/button/button-render.test.ts +0 -132
  223. package/dist/ui/components/button-group/button-group-render.test.ts +0 -146
  224. package/dist/ui/components/candlestick-chart/candlestick-chart-render.test.ts +0 -59
  225. package/dist/ui/components/chart/chart-render.test.ts +0 -730
  226. package/dist/ui/components/line-chart/line-chart-render.test.ts +0 -83
  227. package/dist/ui/components/scatter-chart/scatter-chart-render.test.ts +0 -64
@@ -4,6 +4,7 @@ import {
4
4
  Directive,
5
5
  TemplateRef,
6
6
  ViewEncapsulation,
7
+ booleanAttribute,
7
8
  computed,
8
9
  contentChild,
9
10
  effect,
@@ -46,6 +47,7 @@ import type {
46
47
  } from '@tanstack/charts/types';
47
48
 
48
49
  import { cn } from '../../utils/cn';
50
+ import { UI_THEME_COLORS } from '../icon/icon-theme';
49
51
  import { adjacentFocusPoint, createChartFocusModel } from './chart-focus';
50
52
  import {
51
53
  type ChartLabel,
@@ -131,6 +133,13 @@ export class UiChartTooltip {
131
133
  }
132
134
  }
133
135
 
136
+ /**
137
+ * `UiChartTooltip`, for `contentChild()`. A call rather than the class itself:
138
+ * for a class, Angular's test build keeps an arrow function that nothing calls,
139
+ * which an app's coverage report would show as uncovered.
140
+ */
141
+ const tooltipDirectiveType = () => UiChartTooltip;
142
+
134
143
  /** Default height, like the browser's 150px replaced-element default, but roomier. */
135
144
  const DEFAULT_HEIGHT = 200;
136
145
  /** A touch that moves less than this is a tap, not a drag. */
@@ -335,9 +344,9 @@ export class UiChart {
335
344
  * light unless the app says otherwise; see `ChartColorSchemeOption`.
336
345
  */
337
346
  readonly colorScheme = input<Exclude<ChartColorSchemeOption, Function>>();
338
- readonly zoomable = input(false);
347
+ readonly zoomable = input(false, { transform: booleanAttribute });
339
348
  readonly zoomAxes = input<ChartZoomAxes>('xy');
340
- readonly showZoomControls = input(true);
349
+ readonly showZoomControls = input(true, { transform: booleanAttribute });
341
350
  readonly minZoom = input(1);
342
351
  readonly maxZoom = input(8);
343
352
  /** Factor each zoom button press zooms by. */
@@ -352,10 +361,11 @@ export class UiChart {
352
361
  /** The point a tap landed on, or `null` for a tap on empty space. */
353
362
  readonly pointSelect = output<ChartPoint | null>();
354
363
 
355
- protected readonly tooltipTemplate = contentChild(UiChartTooltip);
364
+ protected readonly tooltipTemplate = contentChild(tooltipDirectiveType());
356
365
 
357
366
  readonly #theme = inject(UI_CHART_THEME);
358
367
  readonly #appearance = inject(LynxTheme);
368
+ readonly #replacedColors = inject(UI_THEME_COLORS, { optional: true });
359
369
  readonly #idPrefix = `chart${nextChartId++}`;
360
370
  readonly #runtime = createChartRuntime({ defaultTheme: this.#theme.chart });
361
371
  readonly #measureText = lynxTextMeasurer();
@@ -405,6 +415,15 @@ export class UiChart {
405
415
  });
406
416
  });
407
417
 
418
+ /**
419
+ * The scene, for what the template draws inside `@if (scene())`: those
420
+ * bindings, and the values below derived for them, are only read while there
421
+ * is one, so they need no fallback for a chart without a size.
422
+ */
423
+ #drawnScene(): ChartScene<any, any, any> {
424
+ return this.scene()!;
425
+ }
426
+
408
427
  readonly #focusModel = computed(() => {
409
428
  const scene = this.scene();
410
429
  return scene ? createChartFocusModel(scene, this.definition()) : null;
@@ -424,24 +443,23 @@ export class UiChart {
424
443
  });
425
444
 
426
445
  /** 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
- });
446
+ readonly #presented = computed(
447
+ () =>
448
+ resolveMarkStateScene(
449
+ this.#drawnScene(),
450
+ this.#focusState(),
451
+ this.#pointer(),
452
+ ).scene,
453
+ );
433
454
 
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
- });
455
+ readonly #focusPresentation = computed(() =>
456
+ resolveFocusPresentation(
457
+ this.#presented(),
458
+ this.#focusState(),
459
+ this.#pointer(),
460
+ null,
461
+ ),
462
+ );
445
463
 
446
464
  readonly #colors = computed(() => {
447
465
  const option = this.colorScheme() ?? this.#theme.colorScheme ?? 'light';
@@ -453,16 +471,20 @@ export class UiChart {
453
471
  : typeof option === 'function'
454
472
  ? option()
455
473
  : option;
456
- return scheme === 'dark' ? this.#theme.dark : this.#theme.light;
474
+ // Colors set at runtime above the chart, such as Android's wallpaper
475
+ // colors, take the place of the theme's (see UI_THEME_COLORS).
476
+ const replaced = this.#replacedColors?.()[scheme];
477
+ const colors = scheme === 'dark' ? this.#theme.dark : this.#theme.light;
478
+ return replaced ? { ...colors, ...replaced } : colors;
457
479
  });
458
480
 
459
481
  readonly #svgOptions = computed(() => {
460
- const scene = this.scene();
482
+ const scene = this.#drawnScene();
461
483
  const colors = this.#colors();
462
484
  return {
463
- width: scene?.width ?? 0,
464
- height: scene?.height ?? 0,
465
- gradients: scene?.gradients,
485
+ width: scene.width,
486
+ height: scene.height,
487
+ gradients: scene.gradients,
466
488
  idPrefix: this.#idPrefix,
467
489
  resolvePaint: (paint: string) => resolveChartPaint(paint, colors),
468
490
  };
@@ -475,8 +497,7 @@ export class UiChart {
475
497
  );
476
498
  protected readonly marksSvg = computed(
477
499
  () =>
478
- renderChartSvg(this.#presented()?.nodes ?? [], this.#svgOptions()) ||
479
- EMPTY_SVG,
500
+ renderChartSvg(this.#presented().nodes, this.#svgOptions()) || EMPTY_SVG,
480
501
  );
481
502
  protected readonly overSvg = computed(
482
503
  () =>
@@ -485,18 +506,14 @@ export class UiChart {
485
506
  );
486
507
 
487
508
  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;`;
509
+ const scene = this.#drawnScene();
510
+ return `position: absolute; left: 0px; top: 0px; width: ${scene.width}px; height: ${scene.height}px;`;
490
511
  });
491
512
 
492
513
  readonly #labels = computed<readonly ChartLabel[]>(() => {
493
514
  const presentation = this.#focusPresentation();
494
515
  return collectChartLabels(
495
- [
496
- ...presentation.under,
497
- ...(this.#presented()?.nodes ?? []),
498
- ...presentation.over,
499
- ],
516
+ [...presentation.under, ...this.#presented().nodes, ...presentation.over],
500
517
  {
501
518
  defaultColor: this.#theme.chart.foreground ?? 'var(--foreground)',
502
519
  focus: this.#focusState(),
@@ -600,18 +617,21 @@ export class UiChart {
600
617
  protected readonly zoomButtonClass =
601
618
  'flex h-7 w-7 items-center justify-center rounded-md border border-border bg-card';
602
619
 
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
- }
620
+ /**
621
+ * Keeps the focus on the same datum when a new scene (data, size or zoom
622
+ * changed) brings new point objects, as TanStack's hosts do. A field rather
623
+ * than a constructor: Angular's test build keeps a function for a
624
+ * constructor that nothing calls, which an app's coverage report would show
625
+ * as uncovered.
626
+ */
627
+ protected readonly restoringFocus = effect(() => {
628
+ const model = this.#focusModel();
629
+ const previous = untracked(this.#focused);
630
+ if (!model || previous.length === 0) return;
631
+ const restored = model.restore(previous[0]!);
632
+ this.#focused.set(restored ? model.group(restored) : EMPTY_POINTS);
633
+ if (!restored) this.#pinned.set(false);
634
+ });
615
635
 
616
636
  /**
617
637
  * ---------------------------------------------------------------------------
@@ -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
  });
@@ -2,6 +2,7 @@ import {
2
2
  type ElementRef,
3
3
  Component,
4
4
  ViewEncapsulation,
5
+ booleanAttribute,
5
6
  computed,
6
7
  effect,
7
8
  input,
@@ -38,39 +39,39 @@ import { cn } from '../../utils/cn';
38
39
  })
39
40
  export class UiCheckbox {
40
41
  readonly checked = model(false);
41
- readonly disabled = input(false);
42
+ readonly disabled = input(false, { transform: booleanAttribute });
42
43
  readonly userClass = input<string>('', { alias: 'class' });
43
44
 
44
45
  readonly changed = output<boolean>();
45
46
 
46
- readonly checkmarkRef = viewChild<ElementRef>('checkmark');
47
+ readonly checkmarkRef = viewChild.required<ElementRef>('checkmark');
47
48
  #checkAnim?: AnimationHandle;
48
49
  #previousChecked?: boolean;
49
50
 
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;
51
+ /**
52
+ * Pops the checkmark in when checked and out when unchecked. A field rather
53
+ * than a constructor: Angular's test build keeps a function for a
54
+ * constructor that nothing calls, which an app's coverage report would show
55
+ * as uncovered.
56
+ *
57
+ * `#previousChecked === undefined` guards the first run: we don't want to
58
+ * pop in the checkmark when the component mounts already checked. The
59
+ * equality guard skips a run that isn't a change: `checked` set and set
60
+ * back before the effect runs (two taps in one frame) still re-runs it.
61
+ */
62
+ protected readonly checkmarkAnimation = effect(() => {
63
+ const isChecked = this.checked();
64
+ const el = this.checkmarkRef().nativeElement;
65
+ if (this.#previousChecked === undefined) {
67
66
  this.#previousChecked = isChecked;
67
+ return;
68
+ }
69
+ if (isChecked === this.#previousChecked) return;
70
+ this.#previousChecked = isChecked;
68
71
 
69
- this.#checkAnim?.cancel();
70
- this.#checkAnim = isChecked ? popIn(el) : popOut(el);
71
- /* v8 ignore stop */
72
- });
73
- }
72
+ this.#checkAnim?.cancel();
73
+ this.#checkAnim = isChecked ? popIn(el) : popOut(el);
74
+ });
74
75
 
75
76
  protected readonly boxClass = computed(() =>
76
77
  cn(