@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
@@ -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,
@@ -29,68 +30,66 @@ export class UiProgress {
29
30
  /**
30
31
  * When true, shows an indeterminate (looping) animation
31
32
  */
32
- readonly indeterminate = input(false);
33
+ readonly indeterminate = input(false, { transform: booleanAttribute });
33
34
  readonly userClass = input<string>('', { alias: 'class' });
34
35
 
35
- readonly fillRef = viewChild<ElementRef>('fill');
36
+ readonly fillRef = viewChild.required<ElementRef>('fill');
36
37
  #fillAnim?: AnimationHandle;
37
38
  #indeterminateAnim?: AnimationHandle;
38
39
  #previousPercent: number | null = null;
39
40
 
40
- constructor() {
41
- effect(() => {
42
- const el = this.fillRef()?.nativeElement;
43
- // el.animate() is inert in jsdom. Determinate and indeterminate paths
44
- // are exercised on-device; the effect's animation branch cannot be
45
- // meaningfully verified from a unit test.
46
- /* v8 ignore start */
47
- if (!el) return;
48
-
49
- // Handle indeterminate mode with infinite sliding animation.
50
- // The fill element is 40% of the track width. `translateX` percentages
51
- // are relative to the element's OWN width, so:
52
- // translateX(-100%) = left edge at -40% of track = starts off-screen left
53
- // translateX(250%) = left edge at +100% of track = exits off-screen right
54
- // 250% is the minimum that pushes the 40%-wide fill fully past the right edge.
55
- if (this.indeterminate()) {
56
- this.#fillAnim?.cancel();
57
- this.#indeterminateAnim?.cancel();
58
- this.#indeterminateAnim = el.animate(
59
- [
60
- { transform: 'translateX(-100%)', width: '40%' },
61
- { transform: 'translateX(250%)', width: '40%' },
62
- ],
63
- {
64
- duration: 1200,
65
- easing: EASING.standard,
66
- iterations: Infinity,
67
- },
68
- );
69
- return;
70
- }
71
-
72
- // Determinate mode — animate width changes smoothly.
73
- // `#previousPercent === null` skips the very first effect run so
74
- // the bar doesn't animate from 0% → initial value on mount.
75
- // The equality guard prevents redundant animations if the effect
76
- // re-runs without an actual percent change.
77
- this.#indeterminateAnim?.cancel();
78
- const percent = this.percent();
79
- if (this.#previousPercent === null) {
80
- this.#previousPercent = percent;
81
- return;
82
- }
83
- if (percent === this.#previousPercent) return;
41
+ /**
42
+ * Slides the fill while indeterminate, and animates its width as the value
43
+ * changes. A field rather than a constructor: Angular's test build keeps a
44
+ * function for a constructor that nothing calls, which an app's coverage
45
+ * report would show as uncovered.
46
+ */
47
+ protected readonly fillAnimation = effect(() => {
48
+ const el = this.fillRef().nativeElement;
84
49
 
50
+ // Handle indeterminate mode with infinite sliding animation.
51
+ // The fill element is 40% of the track width. `translateX` percentages
52
+ // are relative to the element's OWN width, so:
53
+ // translateX(-100%) = left edge at -40% of track = starts off-screen left
54
+ // translateX(250%) = left edge at +100% of track = exits off-screen right
55
+ // 250% is the minimum that pushes the 40%-wide fill fully past the right edge.
56
+ if (this.indeterminate()) {
85
57
  this.#fillAnim?.cancel();
86
- this.#fillAnim = el.animate(
87
- [{ width: `${this.#previousPercent}%` }, { width: `${percent}%` }],
88
- { duration: DURATION.slow, easing: EASING.standard, fill: 'forwards' },
58
+ this.#indeterminateAnim?.cancel();
59
+ this.#indeterminateAnim = el.animate(
60
+ [
61
+ { transform: 'translateX(-100%)', width: '40%' },
62
+ { transform: 'translateX(250%)', width: '40%' },
63
+ ],
64
+ {
65
+ duration: 1200,
66
+ easing: EASING.standard,
67
+ iterations: Infinity,
68
+ },
89
69
  );
70
+ return;
71
+ }
72
+
73
+ // Determinate mode — animate width changes smoothly.
74
+ // `#previousPercent === null` skips the very first effect run so
75
+ // the bar doesn't animate from 0% → initial value on mount.
76
+ // The equality guard skips the animation when the effect re-runs without
77
+ // a percent change: leaving indeterminate mode at the same value.
78
+ this.#indeterminateAnim?.cancel();
79
+ const percent = this.percent();
80
+ if (this.#previousPercent === null) {
90
81
  this.#previousPercent = percent;
91
- /* v8 ignore stop */
92
- });
93
- }
82
+ return;
83
+ }
84
+ if (percent === this.#previousPercent) return;
85
+
86
+ this.#fillAnim?.cancel();
87
+ this.#fillAnim = el.animate(
88
+ [{ width: `${this.#previousPercent}%` }, { width: `${percent}%` }],
89
+ { duration: DURATION.slow, easing: EASING.standard, fill: 'forwards' },
90
+ );
91
+ this.#previousPercent = percent;
92
+ });
94
93
 
95
94
  protected readonly percent = computed(() => {
96
95
  const m = this.max();
@@ -100,9 +99,9 @@ export class UiProgress {
100
99
 
101
100
  protected readonly trackClass = computed(() =>
102
101
  cn(
103
- // bg-muted (solid, theme-aware) instead of bg-primary/20: opacity
104
- // modifiers on semantic colors don't work on Lynx (colors are opaque
105
- // rgba values with no separable channels).
102
+ // bg-muted (solid, theme-aware) instead of bg-primary/20: a modifier
103
+ // needs the theme's --primary-rgb, and in a theme copied before it
104
+ // existed the track would disappear.
106
105
  'h-2 w-full rounded-full bg-muted overflow-hidden',
107
106
  this.userClass(),
108
107
  ),
@@ -1,115 +1,160 @@
1
1
  import { Component, signal } from '@angular/core';
2
- import { render, waitForUpdate } from '@blotch/angular-lynx-testing-library';
3
- import { LYNX_ELEMENTS } from '@blotch/angular-lynx';
4
- import { describe, expect, it, vi } from 'vitest';
5
- import { setInputSignal } from '../../../test-utils/set-input-signal';
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';
6
20
  import { UiRadioGroup, UiRadioGroupItem } from './radio-group';
7
21
 
8
22
  @Component({
9
- standalone: true,
10
- imports: [UiRadioGroup, UiRadioGroupItem, LYNX_ELEMENTS],
23
+ imports: [UiRadioGroup, UiRadioGroupItem],
11
24
  template: `
12
- <ui-radio-group [(value)]="value">
13
- <ui-radio-group-item value="a">A</ui-radio-group-item>
14
- <ui-radio-group-item value="b">B</ui-radio-group-item>
25
+ <ui-radio-group
26
+ [(value)]="value"
27
+ [disabled]="disabled()"
28
+ class="mt-2"
29
+ (changed)="changes.push($event)"
30
+ >
31
+ <ui-radio-group-item value="a" class="pl-1">Apple</ui-radio-group-item>
32
+ <ui-radio-group-item value="b">Banana</ui-radio-group-item>
15
33
  </ui-radio-group>
16
34
  `,
17
35
  })
18
36
  class RadioGroupHost {
19
- value = 'a';
37
+ readonly value = signal('a');
38
+ readonly disabled = signal(false);
39
+ readonly changes: string[] = [];
20
40
  }
21
41
 
42
+ const renderGroup = async () => {
43
+ const result = await render(RadioGroupHost);
44
+ /**
45
+ * An item's row, which takes the tap: its label's parent.
46
+ */
47
+ const item = (label: string) => result.getByText(label).parentElement!;
48
+ /**
49
+ * An item's dot, inside the circle that's the row's first child.
50
+ */
51
+ const dot = (label: string) =>
52
+ item(label).firstElementChild!.firstElementChild!;
53
+ return {
54
+ ...result,
55
+ host: result.componentRef.instance as RadioGroupHost,
56
+ item,
57
+ dot,
58
+ };
59
+ };
60
+
61
+ /** `__ElementAnimate`'s first argument for starting an animation. */
62
+ const START = 0;
63
+ /** `__ElementAnimate`'s first argument for canceling one. */
64
+ const CANCEL = 3;
65
+
22
66
  describe('UiRadioGroup', () => {
23
- it('renders each radio item', async () => {
24
- const { getByText } = await render(RadioGroupHost);
25
- expect(getByText('A')).toBeTruthy();
26
- expect(getByText('B')).toBeTruthy();
67
+ // Watched per test: the testing library resets the main thread after each.
68
+ let animate: MockInstance<LynxMainThread['__ElementAnimate']>;
69
+ beforeEach(() => {
70
+ animate = vi.spyOn(lynxMainThread(), '__ElementAnimate');
27
71
  });
72
+ afterEach(() => animate.mockRestore());
28
73
 
29
- it('select() updates the model and emits `changed`', async () => {
30
- const { componentRef } = await render(UiRadioGroup);
31
- const inst = componentRef.instance as UiRadioGroup;
32
- const emitted: string[] = [];
33
- inst.changed.subscribe((v) => emitted.push(v));
34
- inst.select('a');
35
- expect(inst.value()).toBe('a');
36
- inst.select('b');
37
- expect(inst.value()).toBe('b');
38
- expect(emitted).toEqual(['a', 'b']);
74
+ it('renders each item with its label and class', async () => {
75
+ const { container, item } = await renderGroup();
76
+ expect(container.querySelector('.flex-col')?.classList).toContain('mt-2');
77
+ expect(item('Apple').classList).toContain('pl-1');
78
+ expect(item('Banana').classList).toContain('h-11');
39
79
  });
40
80
 
41
- it('select() is a no-op when disabled', async () => {
42
- const { componentRef } = await render(UiRadioGroup);
43
- const inst = componentRef.instance as UiRadioGroup;
44
- setInputSignal(inst.disabled, true);
45
- const emitted = vi.fn();
46
- inst.changed.subscribe(emitted);
47
- inst.select('x');
48
- expect(inst.value()).toBe('');
49
- expect(emitted).not.toHaveBeenCalled();
81
+ it('shows the dot of the selected item only', async () => {
82
+ const { dot } = await renderGroup();
83
+ expect(dot('Apple').getAttribute('style')).toContain('opacity: 1');
84
+ expect(dot('Banana').getAttribute('style')).toContain('opacity: 0');
50
85
  });
51
- });
52
-
53
- describe('UiRadioGroupItem', () => {
54
- it('select() delegates to the parent group', async () => {
55
- const parentResult = await render(UiRadioGroup);
56
- const parent = parentResult.componentRef.instance as UiRadioGroup;
57
86
 
58
- const itemResult = await render(UiRadioGroupItem, {
59
- providers: [{ provide: UiRadioGroup, useValue: parent }],
60
- });
61
- const item = itemResult.componentRef.instance as UiRadioGroupItem;
62
- setInputSignal(item.itemValue, 'a');
87
+ it('selects the tapped item, updating the value and emitting `changed`', async () => {
88
+ const { host, item, dot } = await renderGroup();
89
+ fireEvent.tap(item('Banana'));
63
90
  await waitForUpdate();
64
-
65
- item.select();
66
- expect(parent.value()).toBe('a');
91
+ expect(host.value()).toBe('b');
92
+ expect(host.changes).toEqual(['b']);
93
+ expect(dot('Banana').getAttribute('style')).toContain('opacity: 1');
94
+ expect(dot('Apple').getAttribute('style')).toContain('opacity: 0');
67
95
  });
68
96
 
69
- it('select() is a no-op when the parent group is disabled', async () => {
70
- const parentResult = await render(UiRadioGroup);
71
- const parent = parentResult.componentRef.instance as UiRadioGroup;
72
- setInputSignal(parent.disabled, true);
97
+ it("doesn't animate the dot on mount, then pops it in and out as the selection changes", async () => {
98
+ const { host, dot } = await renderGroup();
99
+ // A pre-selected item shows its dot without animating it in.
100
+ expect(animate).not.toHaveBeenCalled();
73
101
 
74
- const itemResult = await render(UiRadioGroupItem, {
75
- providers: [{ provide: UiRadioGroup, useValue: parent }],
76
- });
77
- const item = itemResult.componentRef.instance as UiRadioGroupItem;
78
- setInputSignal(item.itemValue, 'a');
102
+ host.value.set('b');
79
103
  await waitForUpdate();
104
+ // The new selection's dot pops in from a smaller scale...
105
+ expect(animate).toHaveBeenCalledWith(dot('Banana'), [
106
+ START,
107
+ expect.any(String),
108
+ [
109
+ { transform: 'scale(0.6)', opacity: 0 },
110
+ { transform: 'scale(1)', opacity: 1 },
111
+ ],
112
+ expect.objectContaining({ fill: 'forwards' }),
113
+ ]);
114
+ // ...and the old one pops out.
115
+ expect(animate).toHaveBeenCalledWith(dot('Apple'), [
116
+ START,
117
+ expect.any(String),
118
+ [
119
+ { transform: 'scale(1)', opacity: 1 },
120
+ { transform: 'scale(0.8)', opacity: 0 },
121
+ ],
122
+ expect.objectContaining({ fill: 'forwards' }),
123
+ ]);
80
124
 
81
- item.select();
82
- expect(parent.value()).toBe('');
125
+ // Selecting it again cuts its pop-out short.
126
+ host.value.set('a');
127
+ await waitForUpdate();
128
+ expect(animate).toHaveBeenCalledWith(dot('Apple'), [
129
+ CANCEL,
130
+ expect.any(String),
131
+ ]);
83
132
  });
84
133
 
85
- it('effect runs the popIn / popOut animation on selection change', async () => {
86
- // The effect skips the first run (initial mount) then swaps between popIn
87
- // (select) and popOut (deselect). Exercise both branches by toggling the
88
- // parent's value across two mounted items.
89
- // JIT + jsdom does not populate the `#dot` viewChild query, so
90
- // monkey-patch dotRef with a signal returning a fake ElementRef so the
91
- // `if (!el || previousSelected === undefined)` guard doesn't short-circuit.
92
- const parentResult = await render(UiRadioGroup);
93
- const parent = parentResult.componentRef.instance as UiRadioGroup;
94
-
95
- const itemResult = await render(UiRadioGroupItem, {
96
- providers: [{ provide: UiRadioGroup, useValue: parent }],
97
- });
98
- const item = itemResult.componentRef.instance as UiRadioGroupItem;
99
- setInputSignal(item.itemValue, 'a');
100
- const view = itemResult.container.querySelector('view') as Element;
101
- (item as unknown as { dotRef: unknown }).dotRef = signal({
102
- nativeElement: view,
103
- });
134
+ it('ignores taps while disabled, and dims its items', async () => {
135
+ const { host, item } = await renderGroup();
136
+ host.disabled.set(true);
104
137
  await waitForUpdate();
138
+ expect(item('Apple').classList).toContain('opacity-50');
105
139
 
106
- // Effect first run initializes `#previousSelected`, then returns.
107
- // Select => popIn branch.
108
- parent.value.set('a');
140
+ fireEvent.tap(item('Banana'));
109
141
  await waitForUpdate();
110
- // Deselect => popOut branch.
111
- parent.value.set('b');
142
+ expect(host.value()).toBe('a');
143
+ expect(host.changes).toEqual([]);
144
+ });
145
+
146
+ it("can't be selected through its own select() while disabled", async () => {
147
+ // An item checks the group first, but select() is public: a caller that
148
+ // goes straight to the group gets the same rule.
149
+ const { componentRef } = await render(UiRadioGroup);
150
+ componentRef.setInput('disabled', true);
112
151
  await waitForUpdate();
113
- expect(parent.value()).toBe('b');
152
+ const group = componentRef.instance as UiRadioGroup;
153
+ const changed = vi.fn();
154
+ group.changed.subscribe(changed);
155
+
156
+ group.select('b');
157
+ expect(group.value()).toBe('');
158
+ expect(changed).not.toHaveBeenCalled();
114
159
  });
115
160
  });
@@ -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
  inject,
@@ -28,7 +29,7 @@ import { cn } from '../../utils/cn';
28
29
  })
29
30
  export class UiRadioGroup {
30
31
  readonly value = model<string>('');
31
- readonly disabled = input(false);
32
+ readonly disabled = input(false, { transform: booleanAttribute });
32
33
  readonly userClass = input<string>('', { alias: 'class' });
33
34
 
34
35
  readonly changed = output<string>();
@@ -70,36 +71,36 @@ export class UiRadioGroupItem {
70
71
  readonly itemValue = input.required<string>({ alias: 'value' });
71
72
  readonly userClass = input<string>('', { alias: 'class' });
72
73
 
73
- readonly dotRef = viewChild<ElementRef>('dot');
74
+ readonly dotRef = viewChild.required<ElementRef>('dot');
74
75
  #dotAnim?: AnimationHandle;
75
- #previousSelected?: boolean;
76
+ #mounted = false;
76
77
 
77
78
  protected readonly isSelected = computed(
78
79
  () => this.#group.value() === this.itemValue(),
79
80
  );
80
81
 
81
- constructor() {
82
- // Same guard pattern as UiCheckbox: `#previousSelected === undefined` skips
83
- // the first run so a pre-selected item doesn't animate in on mount.
84
- // The equality guard prevents duplicate animations if the effect re-runs.
85
- effect(() => {
86
- const selected = this.isSelected();
87
- const el = this.dotRef()?.nativeElement;
88
- if (!el || this.#previousSelected === undefined) {
89
- this.#previousSelected = selected;
90
- return;
91
- }
92
- // Defensive early return: with only `isSelected()` as a reactive input,
93
- // Angular's computed equality dedupes same-value emissions so the effect
94
- // never re-runs with an unchanged `selected`. Kept for safety on-device.
95
- /* v8 ignore next 1 */
96
- if (selected === this.#previousSelected) return;
97
- this.#previousSelected = selected;
98
-
99
- this.#dotAnim?.cancel();
100
- this.#dotAnim = selected ? popIn(el) : popOut(el);
101
- });
102
- }
82
+ /**
83
+ * Pops the dot in when the item is selected and out when it's deselected.
84
+ * A field rather than a constructor: Angular's test build keeps a function
85
+ * for a constructor that nothing calls, which an app's coverage report
86
+ * would show as uncovered.
87
+ *
88
+ * The first run is the mount, skipped so a pre-selected item doesn't
89
+ * animate in. Every later run is a change of selection: `isSelected` is a
90
+ * computed, which only notifies when its value changes, and the dot is
91
+ * always in the template, so its query never changes.
92
+ */
93
+ protected readonly dotAnimation = effect(() => {
94
+ const selected = this.isSelected();
95
+ const el = this.dotRef().nativeElement;
96
+ if (!this.#mounted) {
97
+ this.#mounted = true;
98
+ return;
99
+ }
100
+
101
+ this.#dotAnim?.cancel();
102
+ this.#dotAnim = selected ? popIn(el) : popOut(el);
103
+ });
103
104
 
104
105
  protected readonly itemClass = computed(() =>
105
106
  cn(
@@ -1,3 +1,9 @@
1
+ import { Component, signal } from '@angular/core';
2
+ import {
3
+ fireEvent,
4
+ render,
5
+ waitForUpdate,
6
+ } from '@blotch/angular-lynx-testing-library';
1
7
  import { createChartRuntime } from '@tanstack/charts/runtime';
2
8
  import type {
3
9
  ChartDefinition,
@@ -5,7 +11,8 @@ import type {
5
11
  SceneNode,
6
12
  } from '@tanstack/charts/types';
7
13
  import { describe, expect, it } from 'vitest';
8
- import { scatterChartDefinition } from './scatter-chart';
14
+ import type { XYDatum } from '../chart/chart-presets';
15
+ import { UiScatterChart, scatterChartDefinition } from './scatter-chart';
9
16
 
10
17
  const data = [
11
18
  { x: 150, y: 50 },
@@ -65,3 +72,69 @@ describe('scatterChartDefinition', () => {
65
72
  expect(scene.scales['x']!.domain).toEqual([150, 170]);
66
73
  });
67
74
  });
75
+
76
+ @Component({
77
+ imports: [UiScatterChart],
78
+ template: `
79
+ <ui-scatter-chart
80
+ [data]="data"
81
+ [width]="300"
82
+ [radius]="radius()"
83
+ (pointTap)="tapped.push($event)"
84
+ />
85
+ `,
86
+ })
87
+ class ScatterChartHost {
88
+ readonly data = data;
89
+ readonly radius = signal(7);
90
+ /** Every `pointTap`, in order. */
91
+ readonly tapped: XYDatum[] = [];
92
+ }
93
+
94
+ /**
95
+ * The SVG the chart's marks layer draws.
96
+ */
97
+ const marksSvg = (container: Element) =>
98
+ container.querySelectorAll('svg')[1]!.getAttribute('content')!;
99
+
100
+ /**
101
+ * Where the chart drew each dot, and how big.
102
+ */
103
+ const dotsDrawn = (container: Element) =>
104
+ [
105
+ ...marksSvg(container).matchAll(
106
+ /<circle cx="([\d.-]+)" cy="([\d.-]+)" r="([\d.-]+)"/g,
107
+ ),
108
+ ].map(([, x, y, r]) => ({ x: Number(x), y: Number(y), r: Number(r) }));
109
+
110
+ /**
111
+ * Touches a spot of the chart and lifts the finger right there: a tap.
112
+ */
113
+ const tap = async (container: Element, at: { x: number; y: number }) => {
114
+ const root = container.querySelector('[accessibility-label]')!;
115
+ fireEvent.touchstart(root, { touches: [at] });
116
+ fireEvent.touchend(root, { touches: [at] });
117
+ await waitForUpdate();
118
+ };
119
+
120
+ describe('UiScatterChart', () => {
121
+ it('draws a dot per datum with its inputs', async () => {
122
+ const { componentRef, container } = await render(ScatterChartHost);
123
+ expect(dotsDrawn(container).map((dot) => dot.r)).toEqual([7, 7, 7]);
124
+
125
+ (componentRef.instance as ScatterChartHost).radius.set(3);
126
+ await waitForUpdate();
127
+ expect(dotsDrawn(container).map((dot) => dot.r)).toEqual([3, 3, 3]);
128
+ });
129
+
130
+ it("emits a tapped point's datum, and nothing for a tap off the points", async () => {
131
+ const { componentRef, container } = await render(ScatterChartHost);
132
+ const host = componentRef.instance as ScatterChartHost;
133
+ const last = dotsDrawn(container)[2]!;
134
+ await tap(container, { x: last.x, y: last.y });
135
+ expect(host.tapped).toEqual([data[2]]);
136
+
137
+ await tap(container, { x: -100, y: -100 });
138
+ expect(host.tapped).toEqual([data[2]]);
139
+ });
140
+ });