@blotch/dolan 22.2.2 → 22.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (259) hide show
  1. package/dist/commands/add.d.ts +6 -2
  2. package/dist/commands/add.js +41 -11
  3. package/dist/commands/add.js.map +1 -1
  4. package/dist/commands/add.test.js +162 -9
  5. package/dist/commands/add.test.js.map +1 -1
  6. package/dist/commands/diff.d.ts +2 -1
  7. package/dist/commands/diff.js +38 -25
  8. package/dist/commands/diff.js.map +1 -1
  9. package/dist/commands/diff.test.js +72 -11
  10. package/dist/commands/diff.test.js.map +1 -1
  11. package/dist/commands/doctor.js +228 -34
  12. package/dist/commands/doctor.js.map +1 -1
  13. package/dist/commands/doctor.test.js +367 -10
  14. package/dist/commands/doctor.test.js.map +1 -1
  15. package/dist/commands/eject.js +7 -0
  16. package/dist/commands/eject.js.map +1 -1
  17. package/dist/commands/eject.test.js +39 -0
  18. package/dist/commands/eject.test.js.map +1 -1
  19. package/dist/commands/info.js +3 -2
  20. package/dist/commands/info.js.map +1 -1
  21. package/dist/commands/init.d.ts +37 -1
  22. package/dist/commands/init.js +169 -40
  23. package/dist/commands/init.js.map +1 -1
  24. package/dist/commands/init.test.js +392 -27
  25. package/dist/commands/init.test.js.map +1 -1
  26. package/dist/commands/install-packages.d.ts +6 -0
  27. package/dist/commands/install-packages.js +20 -0
  28. package/dist/commands/install-packages.js.map +1 -0
  29. package/dist/commands/install-packages.test.d.ts +1 -0
  30. package/dist/commands/install-packages.test.js +55 -0
  31. package/dist/commands/install-packages.test.js.map +1 -0
  32. package/dist/commands/list.js +21 -14
  33. package/dist/commands/list.js.map +1 -1
  34. package/dist/commands/list.test.js +62 -5
  35. package/dist/commands/list.test.js.map +1 -1
  36. package/dist/commands/outdated.js +11 -10
  37. package/dist/commands/outdated.js.map +1 -1
  38. package/dist/commands/outdated.test.js +20 -5
  39. package/dist/commands/outdated.test.js.map +1 -1
  40. package/dist/commands/remove.d.ts +4 -0
  41. package/dist/commands/remove.js +17 -4
  42. package/dist/commands/remove.js.map +1 -1
  43. package/dist/commands/remove.test.js +53 -2
  44. package/dist/commands/remove.test.js.map +1 -1
  45. package/dist/commands/theme.js +1 -1
  46. package/dist/commands/theme.js.map +1 -1
  47. package/dist/commands/update.d.ts +11 -0
  48. package/dist/commands/update.js +174 -58
  49. package/dist/commands/update.js.map +1 -1
  50. package/dist/commands/update.test.js +587 -33
  51. package/dist/commands/update.test.js.map +1 -1
  52. package/dist/index.js +7 -0
  53. package/dist/index.js.map +1 -1
  54. package/dist/lockfile.d.ts +7 -0
  55. package/dist/lockfile.js +36 -0
  56. package/dist/lockfile.js.map +1 -1
  57. package/dist/lockfile.test.d.ts +1 -0
  58. package/dist/lockfile.test.js +25 -0
  59. package/dist/lockfile.test.js.map +1 -0
  60. package/dist/registry.d.ts +14 -0
  61. package/dist/registry.js +78 -24
  62. package/dist/registry.js.map +1 -1
  63. package/dist/registry.test.js +119 -38
  64. package/dist/registry.test.js.map +1 -1
  65. package/dist/test-utils.d.ts +5 -0
  66. package/dist/test-utils.js +6 -1
  67. package/dist/test-utils.js.map +1 -1
  68. package/dist/testing/fire-lynx-event.d.ts +14 -0
  69. package/dist/testing/fire-lynx-event.js +19 -0
  70. package/dist/testing/fire-lynx-event.js.map +1 -0
  71. package/dist/testing/fire-lynx-event.test.d.ts +1 -0
  72. package/dist/testing/fire-lynx-event.test.js +44 -0
  73. package/dist/testing/fire-lynx-event.test.js.map +1 -0
  74. package/dist/testing/lynx-main-thread.d.ts +22 -0
  75. package/dist/testing/lynx-main-thread.js +11 -0
  76. package/dist/testing/lynx-main-thread.js.map +1 -0
  77. package/dist/testing/lynx-main-thread.test.d.ts +1 -0
  78. package/dist/testing/lynx-main-thread.test.js +24 -0
  79. package/dist/testing/lynx-main-thread.test.js.map +1 -0
  80. package/dist/testing/set-input-signal.d.ts +14 -0
  81. package/dist/testing/set-input-signal.js +21 -0
  82. package/dist/testing/set-input-signal.js.map +1 -0
  83. package/dist/testing/set-input-signal.test.d.ts +1 -0
  84. package/dist/testing/set-input-signal.test.js +20 -0
  85. package/dist/testing/set-input-signal.test.js.map +1 -0
  86. package/dist/ui/components/accordion/accordion.test.ts +158 -144
  87. package/dist/ui/components/accordion/accordion.ts +19 -14
  88. package/dist/ui/components/action-sheet/action-sheet.test.ts +283 -151
  89. package/dist/ui/components/action-sheet/action-sheet.ts +77 -43
  90. package/dist/ui/components/alert/alert.test.ts +67 -19
  91. package/dist/ui/components/alert/alert.ts +14 -16
  92. package/dist/ui/components/alert-dialog/alert-dialog.test.ts +208 -94
  93. package/dist/ui/components/alert-dialog/alert-dialog.ts +75 -38
  94. package/dist/ui/components/area-chart/area-chart.test.ts +134 -119
  95. package/dist/ui/components/area-chart/area-chart.ts +100 -315
  96. package/dist/ui/components/area-chart/index.ts +2 -3
  97. package/dist/ui/components/avatar/avatar.test.ts +89 -45
  98. package/dist/ui/components/badge/badge.test.ts +57 -23
  99. package/dist/ui/components/badge/badge.ts +12 -14
  100. package/dist/ui/components/bar-chart/bar-chart.test.ts +172 -45
  101. package/dist/ui/components/bar-chart/bar-chart.ts +140 -294
  102. package/dist/ui/components/bar-chart/index.ts +3 -3
  103. package/dist/ui/components/bottom-sheet/bottom-sheet.test.ts +386 -128
  104. package/dist/ui/components/bottom-sheet/bottom-sheet.ts +170 -53
  105. package/dist/ui/components/button/button.test.ts +611 -2
  106. package/dist/ui/components/button/button.ts +434 -49
  107. package/dist/ui/components/button/index.ts +2 -0
  108. package/dist/ui/components/button-group/button-group.test.ts +484 -2
  109. package/dist/ui/components/button-group/button-group.ts +380 -68
  110. package/dist/ui/components/button-group/index.ts +3 -0
  111. package/dist/ui/components/candlestick-chart/candlestick-chart.test.ts +183 -58
  112. package/dist/ui/components/candlestick-chart/candlestick-chart.ts +150 -323
  113. package/dist/ui/components/candlestick-chart/index.ts +3 -4
  114. package/dist/ui/components/card/card.test.ts +82 -62
  115. package/dist/ui/components/chart/chart-focus.test.ts +132 -0
  116. package/dist/ui/components/chart/chart-focus.ts +115 -0
  117. package/dist/ui/components/chart/chart-presets.test.ts +335 -0
  118. package/dist/ui/components/chart/chart-presets.ts +278 -0
  119. package/dist/ui/components/chart/chart-scene.test.ts +589 -0
  120. package/dist/ui/components/chart/chart-scene.ts +483 -0
  121. package/dist/ui/components/chart/chart-theme.test.ts +85 -0
  122. package/dist/ui/components/chart/chart-theme.ts +210 -0
  123. package/dist/ui/components/chart/chart-zoom.test.ts +159 -0
  124. package/dist/ui/components/chart/chart-zoom.ts +162 -0
  125. package/dist/ui/components/chart/chart.test.ts +1017 -0
  126. package/dist/ui/components/chart/chart.ts +934 -0
  127. package/dist/ui/components/chart/index.ts +24 -0
  128. package/dist/ui/components/checkbox/checkbox.test.ts +124 -42
  129. package/dist/ui/components/checkbox/checkbox.ts +23 -23
  130. package/dist/ui/components/collapsible/collapsible.test.ts +158 -81
  131. package/dist/ui/components/collapsible/collapsible.ts +19 -14
  132. package/dist/ui/components/context-menu/context-menu.test.ts +224 -0
  133. package/dist/ui/components/context-menu/context-menu.ts +110 -0
  134. package/dist/ui/components/context-menu/index.ts +1 -0
  135. package/dist/ui/components/dialog/dialog.test.ts +213 -108
  136. package/dist/ui/components/dialog/dialog.ts +72 -35
  137. package/dist/ui/components/dock/dock-scroll.test.ts +130 -0
  138. package/dist/ui/components/dock/dock-scroll.ts +133 -0
  139. package/dist/ui/components/dock/dock.test.ts +749 -0
  140. package/dist/ui/components/dock/dock.ts +960 -0
  141. package/dist/ui/components/dock/index.ts +2 -0
  142. package/dist/ui/components/empty-state/empty-state.test.ts +28 -39
  143. package/dist/ui/components/glass/concentric-radius.test.ts +13 -0
  144. package/dist/ui/components/glass/concentric-radius.ts +17 -0
  145. package/dist/ui/components/glass/glass-design.test.ts +78 -0
  146. package/dist/ui/components/glass/glass-design.ts +119 -0
  147. package/dist/ui/components/glass/glass.test.ts +338 -0
  148. package/dist/ui/components/glass/glass.ts +360 -0
  149. package/dist/ui/components/glass/index.ts +18 -0
  150. package/dist/ui/components/icon/icon-theme.test.ts +169 -0
  151. package/dist/ui/components/icon/icon-theme.ts +227 -0
  152. package/dist/ui/components/icon/icon.test.ts +85 -25
  153. package/dist/ui/components/icon/icon.ts +38 -21
  154. package/dist/ui/components/input/input.test.ts +178 -101
  155. package/dist/ui/components/input/input.ts +15 -12
  156. package/dist/ui/components/label/label.test.ts +11 -24
  157. package/dist/ui/components/line-chart/index.ts +2 -3
  158. package/dist/ui/components/line-chart/line-chart.test.ts +149 -56
  159. package/dist/ui/components/line-chart/line-chart.ts +83 -241
  160. package/dist/ui/components/list/list.test.ts +119 -21
  161. package/dist/ui/components/list/list.ts +21 -15
  162. package/dist/ui/components/menu/index.ts +25 -0
  163. package/dist/ui/components/menu/menu-placement.test.ts +153 -0
  164. package/dist/ui/components/menu/menu-placement.ts +159 -0
  165. package/dist/ui/components/menu/menu-popup.test.ts +632 -0
  166. package/dist/ui/components/menu/menu-popup.ts +544 -0
  167. package/dist/ui/components/menu/menu.test.ts +429 -0
  168. package/dist/ui/components/menu/menu.ts +415 -0
  169. package/dist/ui/components/nav-drawer/nav-drawer.test.ts +435 -191
  170. package/dist/ui/components/nav-drawer/nav-drawer.ts +127 -56
  171. package/dist/ui/components/pagination/pagination.test.ts +177 -65
  172. package/dist/ui/components/pagination/pagination.ts +55 -8
  173. package/dist/ui/components/progress/progress.test.ts +102 -46
  174. package/dist/ui/components/progress/progress.ts +52 -54
  175. package/dist/ui/components/radio-group/radio-group.test.ts +128 -83
  176. package/dist/ui/components/radio-group/radio-group.ts +24 -24
  177. package/dist/ui/components/scatter-chart/index.ts +2 -3
  178. package/dist/ui/components/scatter-chart/scatter-chart.test.ts +131 -26
  179. package/dist/ui/components/scatter-chart/scatter-chart.ts +104 -250
  180. package/dist/ui/components/scroll-area/scroll-area.test.ts +95 -24
  181. package/dist/ui/components/scroll-area/scroll-area.ts +8 -8
  182. package/dist/ui/components/select/select.test.ts +138 -78
  183. package/dist/ui/components/select/select.ts +17 -11
  184. package/dist/ui/components/separator/separator.test.ts +18 -17
  185. package/dist/ui/components/sheet/sheet.test.ts +64 -11
  186. package/dist/ui/components/sheet/sheet.ts +9 -1
  187. package/dist/ui/components/skeleton/skeleton.test.ts +24 -7
  188. package/dist/ui/components/skeleton/skeleton.ts +10 -16
  189. package/dist/ui/components/spinner/spinner.test.ts +67 -22
  190. package/dist/ui/components/spinner/spinner.ts +48 -41
  191. package/dist/ui/components/switch/switch.test.ts +116 -46
  192. package/dist/ui/components/switch/switch.ts +8 -13
  193. package/dist/ui/components/tab-bar/index.ts +1 -0
  194. package/dist/ui/components/tab-bar/tab-bar.test.ts +280 -0
  195. package/dist/ui/components/tab-bar/tab-bar.ts +230 -0
  196. package/dist/ui/components/tabs/tabs.test.ts +215 -157
  197. package/dist/ui/components/tabs/tabs.ts +110 -118
  198. package/dist/ui/components/textarea/textarea.test.ts +206 -100
  199. package/dist/ui/components/textarea/textarea.ts +14 -12
  200. package/dist/ui/components/toast/toast.test.ts +436 -315
  201. package/dist/ui/components/toast/toast.ts +62 -55
  202. package/dist/ui/components/toggle/toggle.test.ts +247 -140
  203. package/dist/ui/theme/chart-colors.test.ts +35 -0
  204. package/dist/ui/theme/dark.css +46 -2
  205. package/dist/ui/theme/default.css +63 -7
  206. package/dist/ui/theme/icon-colors.test.ts +35 -0
  207. package/dist/ui/theme/rgb-channels.test.ts +84 -0
  208. package/dist/ui/theme/tailwind-build.test.ts +114 -0
  209. package/dist/ui/theme/tailwind-css.test.ts +83 -0
  210. package/dist/ui/theme/tailwind-plugin.test.ts +42 -2
  211. package/dist/ui/theme/tailwind-plugin.ts +98 -25
  212. package/dist/ui/theme/tailwind.css +113 -0
  213. package/dist/ui/utils/animate.test.ts +13 -1
  214. package/dist/utils/analyze.d.ts +14 -0
  215. package/dist/utils/analyze.js +15 -0
  216. package/dist/utils/analyze.js.map +1 -1
  217. package/dist/utils/analyze.test.js +14 -1
  218. package/dist/utils/analyze.test.js.map +1 -1
  219. package/dist/utils/installed.d.ts +29 -0
  220. package/dist/utils/installed.js +43 -0
  221. package/dist/utils/installed.js.map +1 -0
  222. package/dist/utils/installed.test.d.ts +1 -0
  223. package/dist/utils/installed.test.js +57 -0
  224. package/dist/utils/installed.test.js.map +1 -0
  225. package/dist/utils/interactive.d.ts +9 -0
  226. package/dist/utils/interactive.js +10 -0
  227. package/dist/utils/interactive.js.map +1 -0
  228. package/dist/utils/interactive.test.d.ts +1 -0
  229. package/dist/utils/interactive.test.js +24 -0
  230. package/dist/utils/interactive.test.js.map +1 -0
  231. package/dist/utils/packages.d.ts +36 -0
  232. package/dist/utils/packages.js +70 -0
  233. package/dist/utils/packages.js.map +1 -0
  234. package/dist/utils/packages.test.d.ts +1 -0
  235. package/dist/utils/packages.test.js +89 -0
  236. package/dist/utils/packages.test.js.map +1 -0
  237. package/dist/utils/resolve-paths.d.ts +17 -1
  238. package/dist/utils/resolve-paths.js +21 -3
  239. package/dist/utils/resolve-paths.js.map +1 -1
  240. package/dist/utils/resolve-paths.test.js +38 -2
  241. package/dist/utils/resolve-paths.test.js.map +1 -1
  242. package/dist/utils/tailwind.d.ts +25 -0
  243. package/dist/utils/tailwind.js +51 -0
  244. package/dist/utils/tailwind.js.map +1 -0
  245. package/dist/utils/tailwind.test.d.ts +1 -0
  246. package/dist/utils/tailwind.test.js +71 -0
  247. package/dist/utils/tailwind.test.js.map +1 -0
  248. package/package.json +13 -1
  249. package/dist/ui/components/area-chart/area-chart-render.test.ts +0 -121
  250. package/dist/ui/components/bar-chart/bar-chart-render.test.ts +0 -174
  251. package/dist/ui/components/button/button-render.test.ts +0 -113
  252. package/dist/ui/components/button-group/button-group-render.test.ts +0 -146
  253. package/dist/ui/components/candlestick-chart/candlestick-chart-render.test.ts +0 -174
  254. package/dist/ui/components/cartesian-chart/cartesian-chart-render.test.ts +0 -188
  255. package/dist/ui/components/cartesian-chart/cartesian-chart.test.ts +0 -525
  256. package/dist/ui/components/cartesian-chart/cartesian-chart.ts +0 -1140
  257. package/dist/ui/components/cartesian-chart/index.ts +0 -18
  258. package/dist/ui/components/line-chart/line-chart-render.test.ts +0 -121
  259. package/dist/ui/components/scatter-chart/scatter-chart-render.test.ts +0 -157
@@ -8,20 +8,40 @@ import {
8
8
  import { LYNX_ELEMENTS } from '@blotch/angular-lynx';
9
9
 
10
10
  import { cn } from '../../utils/cn';
11
+ import { injectIconColor } from '../icon/icon-theme';
11
12
 
12
13
  const CHEVRON_LEFT = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m15 18-6-6 6-6"/></svg>`;
13
14
  const CHEVRON_RIGHT = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m9 18 6-6-6-6"/></svg>`;
14
15
 
16
+ /**
17
+ * What a screen reader says for a page button, unless `pageLabel` is set.
18
+ */
19
+ const defaultPageLabel = (page: number, current: boolean): string =>
20
+ current ? `Page ${page}, current page` : `Page ${page}`;
21
+
15
22
  @Component({
16
23
  selector: 'ui-pagination',
17
- standalone: true,
18
24
  imports: [LYNX_ELEMENTS],
19
25
  encapsulation: ViewEncapsulation.None,
26
+ // Each button is one accessibility element with a label: without them a
27
+ // screen reader found the arrows unlabeled (they're only an SVG) and read
28
+ // each number as plain text, with nothing saying it's a button or which page
29
+ // is current. The "..." gaps are hidden, since they aren't controls.
30
+ //
31
+ // The chevrons are drawn in the theme's foreground, like the page numbers
32
+ // next to them. An SVG can't read the text color classes, so the color is
33
+ // resolved and written into the markup (see icon-theme.ts).
20
34
  template: `
21
35
  <view [class]="containerClass()">
22
- <view [class]="navButtonClass(page() <= 1)" (bindtap)="prev()">
36
+ <view
37
+ [class]="navButtonClass(page() <= 1)"
38
+ [accessibility-element]="true"
39
+ accessibility-traits="button"
40
+ [accessibility-label]="previousLabel()"
41
+ (bindtap)="prev()"
42
+ >
23
43
  <svg
24
- [attr.content]="chevronLeftSvg"
44
+ [attr.content]="chevronLeftSvg()"
25
45
  style="width: 16px; height: 16px;"
26
46
  />
27
47
  </view>
@@ -30,6 +50,11 @@ const CHEVRON_RIGHT = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 2
30
50
  @if (item.type === 'page') {
31
51
  <view
32
52
  [class]="pageButtonClass(item.value === page())"
53
+ [accessibility-element]="true"
54
+ accessibility-traits="button"
55
+ [accessibility-label]="
56
+ pageLabel()(item.value, item.value === page())
57
+ "
33
58
  (bindtap)="goTo(item.value)"
34
59
  >
35
60
  <text [class]="pageTextClass(item.value === page())">{{
@@ -37,15 +62,24 @@ const CHEVRON_RIGHT = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 2
37
62
  }}</text>
38
63
  </view>
39
64
  } @else {
40
- <view class="h-8 w-8 items-center flex justify-center">
65
+ <view
66
+ class="h-8 w-8 items-center flex justify-center"
67
+ [accessibility-elements-hidden]="true"
68
+ >
41
69
  <text class="text-xs text-muted-foreground">...</text>
42
70
  </view>
43
71
  }
44
72
  }
45
73
 
46
- <view [class]="navButtonClass(page() >= totalPages())" (bindtap)="next()">
74
+ <view
75
+ [class]="navButtonClass(page() >= totalPages())"
76
+ [accessibility-element]="true"
77
+ accessibility-traits="button"
78
+ [accessibility-label]="nextLabel()"
79
+ (bindtap)="next()"
80
+ >
47
81
  <svg
48
- [attr.content]="chevronRightSvg"
82
+ [attr.content]="chevronRightSvg()"
49
83
  style="width: 16px; height: 16px;"
50
84
  />
51
85
  </view>
@@ -56,10 +90,23 @@ export class UiPagination {
56
90
  readonly page = model(1);
57
91
  readonly totalPages = input.required<number>();
58
92
  readonly siblingCount = input(1);
93
+ /** Screen reader label for the previous arrow. */
94
+ readonly previousLabel = input('Previous page');
95
+ /** Screen reader label for the next arrow. */
96
+ readonly nextLabel = input('Next page');
97
+ /** Screen reader label for a page button, given its page and whether it's current. */
98
+ readonly pageLabel =
99
+ input<(page: number, current: boolean) => string>(defaultPageLabel);
59
100
  readonly userClass = input<string>('', { alias: 'class' });
60
101
 
61
- protected readonly chevronLeftSvg = CHEVRON_LEFT;
62
- protected readonly chevronRightSvg = CHEVRON_RIGHT;
102
+ readonly #resolveColor = injectIconColor();
103
+ // split/join, not replaceAll(): Lynx's main thread (PrimJS, ES2019) lacks it.
104
+ protected readonly chevronLeftSvg = computed(() =>
105
+ CHEVRON_LEFT.split('currentColor').join(this.#resolveColor(undefined)),
106
+ );
107
+ protected readonly chevronRightSvg = computed(() =>
108
+ CHEVRON_RIGHT.split('currentColor').join(this.#resolveColor(undefined)),
109
+ );
63
110
 
64
111
  // Sliding-window pagination algorithm:
65
112
  // Always shows page 1 and the last page. Around the current page, shows
@@ -1,71 +1,127 @@
1
- import { Component } from '@angular/core';
1
+ import { Component, signal } from '@angular/core';
2
2
  import { render, waitForUpdate } from '@blotch/angular-lynx-testing-library';
3
- import { LYNX_ELEMENTS } from '@blotch/angular-lynx';
4
- import { describe, expect, it } from 'vitest';
5
- import { setInputSignal } from '../../../test-utils/set-input-signal';
3
+ import {
4
+ type MockInstance,
5
+ afterEach,
6
+ beforeEach,
7
+ describe,
8
+ expect,
9
+ it,
10
+ vi,
11
+ } from 'vitest';
12
+ import {
13
+ type LynxMainThread,
14
+ lynxMainThread,
15
+ } from '../../../test-utils/lynx-main-thread';
6
16
  import { UiProgress } from './progress';
7
17
 
8
18
  @Component({
9
- standalone: true,
10
- imports: [UiProgress, LYNX_ELEMENTS],
11
- template: `<ui-progress [value]="value" [indeterminate]="indeterminate" />`,
19
+ imports: [UiProgress],
20
+ template: `
21
+ <ui-progress
22
+ [value]="value()"
23
+ [max]="max()"
24
+ [indeterminate]="indeterminate()"
25
+ class="h-3"
26
+ />
27
+ `,
12
28
  })
13
29
  class ProgressHost {
14
- value = 40;
15
- indeterminate = false;
30
+ readonly value = signal(40);
31
+ readonly max = signal(100);
32
+ readonly indeterminate = signal(false);
16
33
  }
17
34
 
35
+ const renderProgress = async () => {
36
+ const result = await render(ProgressHost);
37
+ return {
38
+ ...result,
39
+ host: result.componentRef.instance as ProgressHost,
40
+ track: () => result.container.querySelector('.bg-muted')!,
41
+ fill: () => result.container.querySelector('.bg-primary')!,
42
+ };
43
+ };
44
+
45
+ /** `__ElementAnimate`'s first argument for starting an animation. */
46
+ const START = 0;
47
+
18
48
  describe('UiProgress', () => {
19
- it('renders at the given value', async () => {
20
- const { container } = await render(ProgressHost);
21
- expect(container).toBeTruthy();
49
+ // Watched per test: the testing library resets the main thread after each.
50
+ let animate: MockInstance<LynxMainThread['__ElementAnimate']>;
51
+ beforeEach(() => {
52
+ animate = vi.spyOn(lynxMainThread(), '__ElementAnimate');
22
53
  });
54
+ afterEach(() => animate.mockRestore());
23
55
 
24
- it('renders indeterminate mode without throwing', async () => {
25
- @Component({
26
- standalone: true,
27
- imports: [UiProgress, LYNX_ELEMENTS],
28
- template: `<ui-progress [indeterminate]="true" />`,
29
- })
30
- class IndeterminateHost {}
31
- const { container } = await render(IndeterminateHost);
32
- expect(container).toBeTruthy();
56
+ it('fills the track by the value out of max', async () => {
57
+ const { fill, track } = await renderProgress();
58
+ expect(fill().getAttribute('style')).toContain('width: 40%');
59
+ expect(track().classList).toContain('h-3');
33
60
  });
34
61
 
35
- it('scales the fill width by the value/max ratio', async () => {
36
- // JIT template bindings are inert against input(); drive the signals so
37
- // the percent/fillStyle computed signals actually run.
38
- const { componentRef, container } = await render(UiProgress);
39
- setInputSignal((componentRef.instance as UiProgress).value, 25);
40
- setInputSignal((componentRef.instance as UiProgress).max, 100);
62
+ it('keeps the fill between 0% and 100%', async () => {
63
+ const { host, fill } = await renderProgress();
64
+ host.value.set(500);
65
+ await waitForUpdate();
66
+ expect(fill().getAttribute('style')).toContain('width: 100%');
67
+
68
+ host.value.set(-5);
69
+ await waitForUpdate();
70
+ expect(fill().getAttribute('style')).toContain('width: 0%');
71
+
72
+ // A max of 0 would divide by zero.
73
+ host.value.set(50);
74
+ host.max.set(0);
41
75
  await waitForUpdate();
42
- const fill = container.querySelectorAll('view')[1];
43
- expect(fill?.getAttribute('style')).toContain('width: 25%');
76
+ expect(fill().getAttribute('style')).toContain('width: 0%');
44
77
  });
45
78
 
46
- it('clamps the fill to 100% when the value exceeds max', async () => {
47
- const { componentRef, container } = await render(UiProgress);
48
- setInputSignal((componentRef.instance as UiProgress).value, 500);
49
- setInputSignal((componentRef.instance as UiProgress).max, 100);
79
+ it("doesn't animate the first value, then animates each change", async () => {
80
+ const { host, fill } = await renderProgress();
81
+ expect(animate).not.toHaveBeenCalled();
82
+
83
+ host.value.set(80);
50
84
  await waitForUpdate();
51
- const fill = container.querySelectorAll('view')[1];
52
- expect(fill?.getAttribute('style')).toContain('width: 100%');
85
+ expect(animate).toHaveBeenCalledWith(fill(), [
86
+ START,
87
+ expect.any(String),
88
+ [{ width: '40%' }, { width: '80%' }],
89
+ expect.objectContaining({ fill: 'forwards' }),
90
+ ]);
53
91
  });
54
92
 
55
- it('clamps the fill to 0% when max is zero (division safety)', async () => {
56
- const { componentRef, container } = await render(UiProgress);
57
- setInputSignal((componentRef.instance as UiProgress).value, 50);
58
- setInputSignal((componentRef.instance as UiProgress).max, 0);
93
+ it('slides a 40% fill across the track while indeterminate', async () => {
94
+ const { host, fill } = await renderProgress();
95
+ host.value.set(60);
96
+ await waitForUpdate();
97
+ animate.mockClear();
98
+
99
+ host.indeterminate.set(true);
59
100
  await waitForUpdate();
60
- const fill = container.querySelectorAll('view')[1];
61
- expect(fill?.getAttribute('style')).toContain('width: 0%');
101
+ expect(fill().getAttribute('style')).toContain('width: 40%');
102
+ expect(animate).toHaveBeenCalledWith(fill(), [
103
+ START,
104
+ expect.any(String),
105
+ [
106
+ { transform: 'translateX(-100%)', width: '40%' },
107
+ { transform: 'translateX(250%)', width: '40%' },
108
+ ],
109
+ expect.objectContaining({ iterations: Infinity }),
110
+ ]);
62
111
  });
63
112
 
64
- it('uses the fixed 40% fill width when indeterminate is true', async () => {
65
- const { componentRef, container } = await render(UiProgress);
66
- setInputSignal((componentRef.instance as UiProgress).indeterminate, true);
113
+ it("returns to the value without animating when it hasn't changed", async () => {
114
+ const { host, fill } = await renderProgress();
115
+ host.indeterminate.set(true);
116
+ await waitForUpdate();
117
+ animate.mockClear();
118
+
119
+ host.indeterminate.set(false);
67
120
  await waitForUpdate();
68
- const fill = container.querySelectorAll('view')[1];
69
- expect(fill?.getAttribute('style')).toContain('width: 40%');
121
+ expect(fill().getAttribute('style')).toContain('width: 40%');
122
+ // Only the sliding animation is canceled; nothing new starts.
123
+ expect(
124
+ animate.mock.calls.map(([, [operation]]) => operation),
125
+ ).not.toContain(START);
70
126
  });
71
127
  });
@@ -32,65 +32,63 @@ export class UiProgress {
32
32
  readonly indeterminate = input(false);
33
33
  readonly userClass = input<string>('', { alias: 'class' });
34
34
 
35
- readonly fillRef = viewChild<ElementRef>('fill');
35
+ readonly fillRef = viewChild.required<ElementRef>('fill');
36
36
  #fillAnim?: AnimationHandle;
37
37
  #indeterminateAnim?: AnimationHandle;
38
38
  #previousPercent: number | null = null;
39
39
 
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;
40
+ /**
41
+ * Slides the fill while indeterminate, and animates its width as the value
42
+ * changes. A field rather than a constructor: Angular's test build keeps a
43
+ * function for a constructor that nothing calls, which an app's coverage
44
+ * report would show as uncovered.
45
+ */
46
+ protected readonly fillAnimation = effect(() => {
47
+ const el = this.fillRef().nativeElement;
84
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()) {
85
56
  this.#fillAnim?.cancel();
86
- this.#fillAnim = el.animate(
87
- [{ width: `${this.#previousPercent}%` }, { width: `${percent}%` }],
88
- { duration: DURATION.slow, easing: EASING.standard, fill: 'forwards' },
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
+ },
89
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 skips the animation when the effect re-runs without
76
+ // a percent change: leaving indeterminate mode at the same value.
77
+ this.#indeterminateAnim?.cancel();
78
+ const percent = this.percent();
79
+ if (this.#previousPercent === null) {
90
80
  this.#previousPercent = percent;
91
- /* v8 ignore stop */
92
- });
93
- }
81
+ return;
82
+ }
83
+ if (percent === this.#previousPercent) return;
84
+
85
+ this.#fillAnim?.cancel();
86
+ this.#fillAnim = el.animate(
87
+ [{ width: `${this.#previousPercent}%` }, { width: `${percent}%` }],
88
+ { duration: DURATION.slow, easing: EASING.standard, fill: 'forwards' },
89
+ );
90
+ this.#previousPercent = percent;
91
+ });
94
92
 
95
93
  protected readonly percent = computed(() => {
96
94
  const m = this.max();
@@ -100,9 +98,9 @@ export class UiProgress {
100
98
 
101
99
  protected readonly trackClass = computed(() =>
102
100
  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).
101
+ // bg-muted (solid, theme-aware) instead of bg-primary/20: a modifier
102
+ // needs the theme's --primary-rgb, and in a theme copied before it
103
+ // existed the track would disappear.
106
104
  'h-2 w-full rounded-full bg-muted overflow-hidden',
107
105
  this.userClass(),
108
106
  ),
@@ -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
  });