@blotch/dolan 0.0.0-stage → 22.2.0

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 (273) hide show
  1. package/dist/commands/add.d.ts +22 -0
  2. package/dist/commands/add.js +132 -0
  3. package/dist/commands/add.js.map +1 -0
  4. package/dist/commands/add.test.d.ts +1 -0
  5. package/dist/commands/add.test.js +209 -0
  6. package/dist/commands/add.test.js.map +1 -0
  7. package/dist/commands/diff.d.ts +13 -0
  8. package/dist/commands/diff.js +155 -0
  9. package/dist/commands/diff.js.map +1 -0
  10. package/dist/commands/diff.test.d.ts +1 -0
  11. package/dist/commands/diff.test.js +327 -0
  12. package/dist/commands/diff.test.js.map +1 -0
  13. package/dist/commands/doctor.d.ts +15 -0
  14. package/dist/commands/doctor.js +248 -0
  15. package/dist/commands/doctor.js.map +1 -0
  16. package/dist/commands/doctor.test.d.ts +1 -0
  17. package/dist/commands/doctor.test.js +301 -0
  18. package/dist/commands/doctor.test.js.map +1 -0
  19. package/dist/commands/eject.d.ts +11 -0
  20. package/dist/commands/eject.js +55 -0
  21. package/dist/commands/eject.js.map +1 -0
  22. package/dist/commands/eject.test.d.ts +1 -0
  23. package/dist/commands/eject.test.js +151 -0
  24. package/dist/commands/eject.test.js.map +1 -0
  25. package/dist/commands/info.d.ts +1 -0
  26. package/dist/commands/info.js +73 -0
  27. package/dist/commands/info.js.map +1 -0
  28. package/dist/commands/info.test.d.ts +1 -0
  29. package/dist/commands/info.test.js +143 -0
  30. package/dist/commands/info.test.js.map +1 -0
  31. package/dist/commands/init.d.ts +1 -0
  32. package/dist/commands/init.js +98 -0
  33. package/dist/commands/init.js.map +1 -0
  34. package/dist/commands/init.test.d.ts +1 -0
  35. package/dist/commands/init.test.js +154 -0
  36. package/dist/commands/init.test.js.map +1 -0
  37. package/dist/commands/list.d.ts +16 -0
  38. package/dist/commands/list.js +124 -0
  39. package/dist/commands/list.js.map +1 -0
  40. package/dist/commands/list.test.d.ts +1 -0
  41. package/dist/commands/list.test.js +285 -0
  42. package/dist/commands/list.test.js.map +1 -0
  43. package/dist/commands/outdated.d.ts +13 -0
  44. package/dist/commands/outdated.js +116 -0
  45. package/dist/commands/outdated.js.map +1 -0
  46. package/dist/commands/outdated.test.d.ts +1 -0
  47. package/dist/commands/outdated.test.js +208 -0
  48. package/dist/commands/outdated.test.js.map +1 -0
  49. package/dist/commands/remove.d.ts +18 -0
  50. package/dist/commands/remove.js +161 -0
  51. package/dist/commands/remove.js.map +1 -0
  52. package/dist/commands/remove.test.d.ts +1 -0
  53. package/dist/commands/remove.test.js +361 -0
  54. package/dist/commands/remove.test.js.map +1 -0
  55. package/dist/commands/theme.d.ts +1 -0
  56. package/dist/commands/theme.js +66 -0
  57. package/dist/commands/theme.js.map +1 -0
  58. package/dist/commands/theme.test.d.ts +1 -0
  59. package/dist/commands/theme.test.js +133 -0
  60. package/dist/commands/theme.test.js.map +1 -0
  61. package/dist/commands/update.d.ts +31 -0
  62. package/dist/commands/update.js +601 -0
  63. package/dist/commands/update.js.map +1 -0
  64. package/dist/commands/update.test.d.ts +1 -0
  65. package/dist/commands/update.test.js +1233 -0
  66. package/dist/commands/update.test.js.map +1 -0
  67. package/dist/config.d.ts +11 -0
  68. package/dist/config.js +19 -0
  69. package/dist/config.js.map +1 -0
  70. package/dist/config.test.d.ts +1 -0
  71. package/dist/config.test.js +48 -0
  72. package/dist/config.test.js.map +1 -0
  73. package/dist/index.d.ts +2 -0
  74. package/dist/index.js +89 -0
  75. package/dist/index.js.map +1 -0
  76. package/dist/lockfile.d.ts +21 -0
  77. package/dist/lockfile.js +39 -0
  78. package/dist/lockfile.js.map +1 -0
  79. package/dist/registry.d.ts +14 -0
  80. package/dist/registry.js +85 -0
  81. package/dist/registry.js.map +1 -0
  82. package/dist/registry.test.d.ts +1 -0
  83. package/dist/registry.test.js +95 -0
  84. package/dist/registry.test.js.map +1 -0
  85. package/dist/test-utils.d.ts +16 -0
  86. package/dist/test-utils.js +65 -0
  87. package/dist/test-utils.js.map +1 -0
  88. package/dist/ui/components/accordion/accordion.test.ts +194 -0
  89. package/dist/ui/components/accordion/accordion.ts +173 -0
  90. package/dist/ui/components/accordion/index.ts +6 -0
  91. package/dist/ui/components/action-sheet/action-sheet.test.ts +211 -0
  92. package/dist/ui/components/action-sheet/action-sheet.ts +353 -0
  93. package/dist/ui/components/action-sheet/index.ts +6 -0
  94. package/dist/ui/components/alert/alert.test.ts +48 -0
  95. package/dist/ui/components/alert/alert.ts +104 -0
  96. package/dist/ui/components/alert/index.ts +1 -0
  97. package/dist/ui/components/alert-dialog/alert-dialog.test.ts +141 -0
  98. package/dist/ui/components/alert-dialog/alert-dialog.ts +243 -0
  99. package/dist/ui/components/alert-dialog/index.ts +7 -0
  100. package/dist/ui/components/area-chart/area-chart-render.test.ts +121 -0
  101. package/dist/ui/components/area-chart/area-chart.test.ts +158 -0
  102. package/dist/ui/components/area-chart/area-chart.ts +349 -0
  103. package/dist/ui/components/area-chart/index.ts +6 -0
  104. package/dist/ui/components/aspect-ratio/aspect-ratio.test.ts +10 -0
  105. package/dist/ui/components/aspect-ratio/aspect-ratio.ts +28 -0
  106. package/dist/ui/components/aspect-ratio/index.ts +1 -0
  107. package/dist/ui/components/avatar/avatar.test.ts +69 -0
  108. package/dist/ui/components/avatar/avatar.ts +131 -0
  109. package/dist/ui/components/avatar/index.ts +1 -0
  110. package/dist/ui/components/badge/badge.test.ts +49 -0
  111. package/dist/ui/components/badge/badge.ts +97 -0
  112. package/dist/ui/components/badge/index.ts +1 -0
  113. package/dist/ui/components/bar-chart/bar-chart-render.test.ts +174 -0
  114. package/dist/ui/components/bar-chart/bar-chart.test.ts +73 -0
  115. package/dist/ui/components/bar-chart/bar-chart.ts +327 -0
  116. package/dist/ui/components/bar-chart/index.ts +6 -0
  117. package/dist/ui/components/bottom-sheet/bottom-sheet.test.ts +163 -0
  118. package/dist/ui/components/bottom-sheet/bottom-sheet.ts +339 -0
  119. package/dist/ui/components/bottom-sheet/index.ts +1 -0
  120. package/dist/ui/components/button/button-render.test.ts +113 -0
  121. package/dist/ui/components/button/button.test.ts +70 -0
  122. package/dist/ui/components/button/button.ts +170 -0
  123. package/dist/ui/components/button/index.ts +6 -0
  124. package/dist/ui/components/button-group/button-group-render.test.ts +146 -0
  125. package/dist/ui/components/button-group/button-group.test.ts +54 -0
  126. package/dist/ui/components/button-group/button-group.ts +209 -0
  127. package/dist/ui/components/button-group/index.ts +8 -0
  128. package/dist/ui/components/candlestick-chart/candlestick-chart-render.test.ts +174 -0
  129. package/dist/ui/components/candlestick-chart/candlestick-chart.test.ts +96 -0
  130. package/dist/ui/components/candlestick-chart/candlestick-chart.ts +355 -0
  131. package/dist/ui/components/candlestick-chart/index.ts +7 -0
  132. package/dist/ui/components/card/card.test.ts +110 -0
  133. package/dist/ui/components/card/card.ts +162 -0
  134. package/dist/ui/components/card/index.ts +8 -0
  135. package/dist/ui/components/cartesian-chart/cartesian-chart-render.test.ts +188 -0
  136. package/dist/ui/components/cartesian-chart/cartesian-chart.test.ts +549 -0
  137. package/dist/ui/components/cartesian-chart/cartesian-chart.ts +1140 -0
  138. package/dist/ui/components/cartesian-chart/index.ts +18 -0
  139. package/dist/ui/components/checkbox/checkbox.test.ts +59 -0
  140. package/dist/ui/components/checkbox/checkbox.ts +96 -0
  141. package/dist/ui/components/checkbox/index.ts +1 -0
  142. package/dist/ui/components/collapsible/collapsible.test.ts +112 -0
  143. package/dist/ui/components/collapsible/collapsible.ts +122 -0
  144. package/dist/ui/components/collapsible/index.ts +5 -0
  145. package/dist/ui/components/dialog/dialog.test.ts +157 -0
  146. package/dist/ui/components/dialog/dialog.ts +248 -0
  147. package/dist/ui/components/dialog/index.ts +7 -0
  148. package/dist/ui/components/empty-state/empty-state.test.ts +60 -0
  149. package/dist/ui/components/empty-state/empty-state.ts +46 -0
  150. package/dist/ui/components/empty-state/index.ts +1 -0
  151. package/dist/ui/components/icon/icon.test.ts +52 -0
  152. package/dist/ui/components/icon/icon.ts +54 -0
  153. package/dist/ui/components/icon/icons.ts +569 -0
  154. package/dist/ui/components/icon/index.ts +2 -0
  155. package/dist/ui/components/input/index.ts +1 -0
  156. package/dist/ui/components/input/input.test.ts +134 -0
  157. package/dist/ui/components/input/input.ts +189 -0
  158. package/dist/ui/components/label/index.ts +1 -0
  159. package/dist/ui/components/label/label.test.ts +38 -0
  160. package/dist/ui/components/label/label.ts +24 -0
  161. package/dist/ui/components/line-chart/index.ts +6 -0
  162. package/dist/ui/components/line-chart/line-chart-render.test.ts +121 -0
  163. package/dist/ui/components/line-chart/line-chart.test.ts +81 -0
  164. package/dist/ui/components/line-chart/line-chart.ts +273 -0
  165. package/dist/ui/components/list/index.ts +1 -0
  166. package/dist/ui/components/list/list.test.ts +35 -0
  167. package/dist/ui/components/list/list.ts +71 -0
  168. package/dist/ui/components/nav-drawer/index.ts +8 -0
  169. package/dist/ui/components/nav-drawer/nav-drawer.test.ts +267 -0
  170. package/dist/ui/components/nav-drawer/nav-drawer.ts +504 -0
  171. package/dist/ui/components/pagination/index.ts +1 -0
  172. package/dist/ui/components/pagination/pagination.test.ts +96 -0
  173. package/dist/ui/components/pagination/pagination.ts +147 -0
  174. package/dist/ui/components/progress/index.ts +1 -0
  175. package/dist/ui/components/progress/progress.test.ts +71 -0
  176. package/dist/ui/components/progress/progress.ts +118 -0
  177. package/dist/ui/components/radio-group/index.ts +1 -0
  178. package/dist/ui/components/radio-group/radio-group.test.ts +115 -0
  179. package/dist/ui/components/radio-group/radio-group.ts +129 -0
  180. package/dist/ui/components/scatter-chart/index.ts +6 -0
  181. package/dist/ui/components/scatter-chart/scatter-chart-render.test.ts +157 -0
  182. package/dist/ui/components/scatter-chart/scatter-chart.test.ts +57 -0
  183. package/dist/ui/components/scatter-chart/scatter-chart.ts +277 -0
  184. package/dist/ui/components/scroll-area/index.ts +1 -0
  185. package/dist/ui/components/scroll-area/scroll-area.test.ts +40 -0
  186. package/dist/ui/components/scroll-area/scroll-area.ts +73 -0
  187. package/dist/ui/components/select/index.ts +1 -0
  188. package/dist/ui/components/select/select.test.ts +107 -0
  189. package/dist/ui/components/select/select.ts +146 -0
  190. package/dist/ui/components/separator/index.ts +1 -0
  191. package/dist/ui/components/separator/separator.test.ts +26 -0
  192. package/dist/ui/components/separator/separator.ts +45 -0
  193. package/dist/ui/components/sheet/index.ts +7 -0
  194. package/dist/ui/components/sheet/sheet.test.ts +44 -0
  195. package/dist/ui/components/sheet/sheet.ts +114 -0
  196. package/dist/ui/components/skeleton/index.ts +1 -0
  197. package/dist/ui/components/skeleton/skeleton.test.ts +13 -0
  198. package/dist/ui/components/skeleton/skeleton.ts +45 -0
  199. package/dist/ui/components/spinner/index.ts +1 -0
  200. package/dist/ui/components/spinner/spinner.test.ts +39 -0
  201. package/dist/ui/components/spinner/spinner.ts +83 -0
  202. package/dist/ui/components/switch/index.ts +1 -0
  203. package/dist/ui/components/switch/switch.test.ts +63 -0
  204. package/dist/ui/components/switch/switch.ts +98 -0
  205. package/dist/ui/components/tabs/index.ts +1 -0
  206. package/dist/ui/components/tabs/tabs.test.ts +199 -0
  207. package/dist/ui/components/tabs/tabs.ts +317 -0
  208. package/dist/ui/components/textarea/index.ts +1 -0
  209. package/dist/ui/components/textarea/textarea.test.ts +138 -0
  210. package/dist/ui/components/textarea/textarea.ts +221 -0
  211. package/dist/ui/components/toast/index.ts +9 -0
  212. package/dist/ui/components/toast/toast-state.test.ts +54 -0
  213. package/dist/ui/components/toast/toast-state.ts +46 -0
  214. package/dist/ui/components/toast/toast.test.ts +342 -0
  215. package/dist/ui/components/toast/toast.ts +527 -0
  216. package/dist/ui/components/toggle/index.ts +7 -0
  217. package/dist/ui/components/toggle/toggle.test.ts +186 -0
  218. package/dist/ui/components/toggle/toggle.ts +218 -0
  219. package/dist/ui/components/typography/index.ts +5 -0
  220. package/dist/ui/components/typography/typography.test.ts +55 -0
  221. package/dist/ui/components/typography/typography.ts +44 -0
  222. package/dist/ui/theme/dark.css +41 -0
  223. package/dist/ui/theme/default.css +56 -0
  224. package/dist/ui/theme/tailwind-plugin.test.ts +124 -0
  225. package/dist/ui/theme/tailwind-plugin.ts +151 -0
  226. package/dist/ui/utils/animate.test.ts +324 -0
  227. package/dist/ui/utils/animate.ts +544 -0
  228. package/dist/ui/utils/cn.test.ts +27 -0
  229. package/dist/ui/utils/cn.ts +6 -0
  230. package/dist/utils/analyze.d.ts +32 -0
  231. package/dist/utils/analyze.js +109 -0
  232. package/dist/utils/analyze.js.map +1 -0
  233. package/dist/utils/analyze.test.d.ts +1 -0
  234. package/dist/utils/analyze.test.js +146 -0
  235. package/dist/utils/analyze.test.js.map +1 -0
  236. package/dist/utils/animate.d.ts +210 -0
  237. package/dist/utils/animate.js +423 -0
  238. package/dist/utils/animate.js.map +1 -0
  239. package/dist/utils/animate.test.d.ts +1 -0
  240. package/dist/utils/animate.test.js +365 -0
  241. package/dist/utils/animate.test.js.map +1 -0
  242. package/dist/utils/banner.d.ts +1 -0
  243. package/dist/utils/banner.js +15 -0
  244. package/dist/utils/banner.js.map +1 -0
  245. package/dist/utils/banner.test.d.ts +1 -0
  246. package/dist/utils/banner.test.js +29 -0
  247. package/dist/utils/banner.test.js.map +1 -0
  248. package/dist/utils/cn.d.ts +2 -0
  249. package/dist/utils/cn.js +6 -0
  250. package/dist/utils/cn.js.map +1 -0
  251. package/dist/utils/cn.test.d.ts +1 -0
  252. package/dist/utils/cn.test.js +21 -0
  253. package/dist/utils/cn.test.js.map +1 -0
  254. package/dist/utils/format.d.ts +31 -0
  255. package/dist/utils/format.js +95 -0
  256. package/dist/utils/format.js.map +1 -0
  257. package/dist/utils/format.test.d.ts +1 -0
  258. package/dist/utils/format.test.js +71 -0
  259. package/dist/utils/format.test.js.map +1 -0
  260. package/dist/utils/logger.d.ts +6 -0
  261. package/dist/utils/logger.js +16 -0
  262. package/dist/utils/logger.js.map +1 -0
  263. package/dist/utils/logger.test.d.ts +1 -0
  264. package/dist/utils/logger.test.js +36 -0
  265. package/dist/utils/logger.test.js.map +1 -0
  266. package/dist/utils/resolve-paths.d.ts +12 -0
  267. package/dist/utils/resolve-paths.js +29 -0
  268. package/dist/utils/resolve-paths.js.map +1 -0
  269. package/dist/utils/resolve-paths.test.d.ts +1 -0
  270. package/dist/utils/resolve-paths.test.js +52 -0
  271. package/dist/utils/resolve-paths.test.js.map +1 -0
  272. package/package.json +56 -4
  273. package/README.md +0 -3
@@ -0,0 +1,147 @@
1
+ import {
2
+ Component,
3
+ ViewEncapsulation,
4
+ computed,
5
+ input,
6
+ model,
7
+ } from '@angular/core';
8
+ import { LYNX_ELEMENTS } from '@blotch/angular-lynx';
9
+
10
+ import { cn } from '../../utils/cn';
11
+
12
+ 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
+ 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
+ @Component({
16
+ selector: 'ui-pagination',
17
+ standalone: true,
18
+ imports: [LYNX_ELEMENTS],
19
+ encapsulation: ViewEncapsulation.None,
20
+ template: `
21
+ <view [class]="containerClass()">
22
+ <view [class]="navButtonClass(page() <= 1)" (bindtap)="prev()">
23
+ <svg
24
+ [attr.content]="chevronLeftSvg"
25
+ style="width: 16px; height: 16px;"
26
+ />
27
+ </view>
28
+
29
+ @for (item of visiblePages(); track item.key) {
30
+ @if (item.type === 'page') {
31
+ <view
32
+ [class]="pageButtonClass(item.value === page())"
33
+ (bindtap)="goTo(item.value)"
34
+ >
35
+ <text [class]="pageTextClass(item.value === page())">{{
36
+ item.value
37
+ }}</text>
38
+ </view>
39
+ } @else {
40
+ <view class="h-8 w-8 items-center flex justify-center">
41
+ <text class="text-xs text-muted-foreground">...</text>
42
+ </view>
43
+ }
44
+ }
45
+
46
+ <view [class]="navButtonClass(page() >= totalPages())" (bindtap)="next()">
47
+ <svg
48
+ [attr.content]="chevronRightSvg"
49
+ style="width: 16px; height: 16px;"
50
+ />
51
+ </view>
52
+ </view>
53
+ `,
54
+ })
55
+ export class UiPagination {
56
+ readonly page = model(1);
57
+ readonly totalPages = input.required<number>();
58
+ readonly siblingCount = input(1);
59
+ readonly userClass = input<string>('', { alias: 'class' });
60
+
61
+ protected readonly chevronLeftSvg = CHEVRON_LEFT;
62
+ protected readonly chevronRightSvg = CHEVRON_RIGHT;
63
+
64
+ // Sliding-window pagination algorithm:
65
+ // Always shows page 1 and the last page. Around the current page, shows
66
+ // `siblingCount` neighbors on each side. If there's a gap between page 1
67
+ // and the window (or window and last page), an ellipsis is inserted.
68
+ // The threshold `5 + siblings * 2` is the max pages that fit without
69
+ // truncation: first + last + up to (2*siblings+1) window + 2 ellipsis slots.
70
+ protected readonly visiblePages = computed(() => {
71
+ const total = this.totalPages();
72
+ const current = this.page();
73
+ const siblings = this.siblingCount();
74
+
75
+ if (total <= 5 + siblings * 2) {
76
+ return Array.from({ length: total }, (_, i) => ({
77
+ type: 'page' as const,
78
+ value: i + 1,
79
+ key: `p${i + 1}`,
80
+ }));
81
+ }
82
+
83
+ const items: { type: 'page' | 'ellipsis'; value: number; key: string }[] =
84
+ [];
85
+
86
+ items.push({ type: 'page', value: 1, key: 'p1' });
87
+
88
+ const rangeStart = Math.max(2, current - siblings);
89
+ const rangeEnd = Math.min(total - 1, current + siblings);
90
+
91
+ if (rangeStart > 2) {
92
+ items.push({ type: 'ellipsis', value: 0, key: 'el' });
93
+ }
94
+
95
+ for (let i = rangeStart; i <= rangeEnd; i++) {
96
+ items.push({ type: 'page', value: i, key: `p${i}` });
97
+ }
98
+
99
+ if (rangeEnd < total - 1) {
100
+ items.push({ type: 'ellipsis', value: 0, key: 'er' });
101
+ }
102
+
103
+ items.push({ type: 'page', value: total, key: `p${total}` });
104
+ return items;
105
+ });
106
+
107
+ protected readonly containerClass = computed(() =>
108
+ cn('flex-row items-center gap-1 flex', this.userClass()),
109
+ );
110
+
111
+ protected navButtonClass(disabled: boolean): string {
112
+ return cn(
113
+ 'h-10 w-10 items-center rounded-md flex justify-center',
114
+ disabled && 'opacity-50',
115
+ );
116
+ }
117
+
118
+ protected pageButtonClass(active: boolean): string {
119
+ return cn(
120
+ 'h-10 w-10 items-center rounded-md flex justify-center',
121
+ active ? 'bg-primary' : 'bg-transparent',
122
+ );
123
+ }
124
+
125
+ protected pageTextClass(active: boolean): string {
126
+ return cn(
127
+ 'text-sm font-medium',
128
+ active ? 'text-primary-foreground' : 'text-foreground',
129
+ );
130
+ }
131
+
132
+ protected prev(): void {
133
+ if (this.page() > 1) {
134
+ this.page.update((p) => p - 1);
135
+ }
136
+ }
137
+
138
+ protected next(): void {
139
+ if (this.page() < this.totalPages()) {
140
+ this.page.update((p) => p + 1);
141
+ }
142
+ }
143
+
144
+ protected goTo(p: number): void {
145
+ this.page.set(p);
146
+ }
147
+ }
@@ -0,0 +1 @@
1
+ export { UiProgress } from './progress';
@@ -0,0 +1,71 @@
1
+ import { Component } 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 } from 'vitest';
5
+ import { setInputSignal } from '../../../test-utils/set-input-signal';
6
+ import { UiProgress } from './progress';
7
+
8
+ @Component({
9
+ standalone: true,
10
+ imports: [UiProgress, LYNX_ELEMENTS],
11
+ template: `<ui-progress [value]="value" [indeterminate]="indeterminate" />`,
12
+ })
13
+ class ProgressHost {
14
+ value = 40;
15
+ indeterminate = false;
16
+ }
17
+
18
+ describe('UiProgress', () => {
19
+ it('renders at the given value', async () => {
20
+ const { container } = await render(ProgressHost);
21
+ expect(container).toBeTruthy();
22
+ });
23
+
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();
33
+ });
34
+
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);
41
+ await waitForUpdate();
42
+ const fill = container.querySelectorAll('view')[1];
43
+ expect(fill?.getAttribute('style')).toContain('width: 25%');
44
+ });
45
+
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);
50
+ await waitForUpdate();
51
+ const fill = container.querySelectorAll('view')[1];
52
+ expect(fill?.getAttribute('style')).toContain('width: 100%');
53
+ });
54
+
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);
59
+ await waitForUpdate();
60
+ const fill = container.querySelectorAll('view')[1];
61
+ expect(fill?.getAttribute('style')).toContain('width: 0%');
62
+ });
63
+
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);
67
+ await waitForUpdate();
68
+ const fill = container.querySelectorAll('view')[1];
69
+ expect(fill?.getAttribute('style')).toContain('width: 40%');
70
+ });
71
+ });
@@ -0,0 +1,118 @@
1
+ import {
2
+ type ElementRef,
3
+ Component,
4
+ ViewEncapsulation,
5
+ computed,
6
+ effect,
7
+ input,
8
+ viewChild,
9
+ } from '@angular/core';
10
+ import { LYNX_ELEMENTS } from '@blotch/angular-lynx';
11
+
12
+ import { type AnimationHandle, DURATION, EASING } from '../../utils/animate';
13
+ import { cn } from '../../utils/cn';
14
+
15
+ @Component({
16
+ selector: 'ui-progress',
17
+ standalone: true,
18
+ imports: [LYNX_ELEMENTS],
19
+ encapsulation: ViewEncapsulation.None,
20
+ template: `
21
+ <view [class]="trackClass()">
22
+ <view #fill [class]="fillClass()" [style]="fillStyle()" />
23
+ </view>
24
+ `,
25
+ })
26
+ export class UiProgress {
27
+ readonly value = input(0);
28
+ readonly max = input(100);
29
+ /**
30
+ * When true, shows an indeterminate (looping) animation
31
+ */
32
+ readonly indeterminate = input(false);
33
+ readonly userClass = input<string>('', { alias: 'class' });
34
+
35
+ readonly fillRef = viewChild<ElementRef>('fill');
36
+ #fillAnim?: AnimationHandle;
37
+ #indeterminateAnim?: AnimationHandle;
38
+ #previousPercent: number | null = null;
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;
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
+ /* v8 ignore stop */
92
+ });
93
+ }
94
+
95
+ protected readonly percent = computed(() => {
96
+ const m = this.max();
97
+ if (m <= 0) return 0;
98
+ return Math.min(100, Math.max(0, (this.value() / m) * 100));
99
+ });
100
+
101
+ protected readonly trackClass = computed(() =>
102
+ 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).
106
+ 'h-2 w-full rounded-full bg-muted overflow-hidden',
107
+ this.userClass(),
108
+ ),
109
+ );
110
+
111
+ protected readonly fillClass = computed(() =>
112
+ cn('h-full rounded-full bg-primary'),
113
+ );
114
+
115
+ protected readonly fillStyle = computed(() =>
116
+ this.indeterminate() ? 'width: 40%;' : `width: ${this.percent()}%;`,
117
+ );
118
+ }
@@ -0,0 +1 @@
1
+ export { UiRadioGroup, UiRadioGroupItem } from './radio-group';
@@ -0,0 +1,115 @@
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';
6
+ import { UiRadioGroup, UiRadioGroupItem } from './radio-group';
7
+
8
+ @Component({
9
+ standalone: true,
10
+ imports: [UiRadioGroup, UiRadioGroupItem, LYNX_ELEMENTS],
11
+ 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>
15
+ </ui-radio-group>
16
+ `,
17
+ })
18
+ class RadioGroupHost {
19
+ value = 'a';
20
+ }
21
+
22
+ 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();
27
+ });
28
+
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']);
39
+ });
40
+
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();
50
+ });
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
+
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');
63
+ await waitForUpdate();
64
+
65
+ item.select();
66
+ expect(parent.value()).toBe('a');
67
+ });
68
+
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);
73
+
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');
79
+ await waitForUpdate();
80
+
81
+ item.select();
82
+ expect(parent.value()).toBe('');
83
+ });
84
+
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
+ });
104
+ await waitForUpdate();
105
+
106
+ // Effect first run initializes `#previousSelected`, then returns.
107
+ // Select => popIn branch.
108
+ parent.value.set('a');
109
+ await waitForUpdate();
110
+ // Deselect => popOut branch.
111
+ parent.value.set('b');
112
+ await waitForUpdate();
113
+ expect(parent.value()).toBe('b');
114
+ });
115
+ });
@@ -0,0 +1,129 @@
1
+ import {
2
+ type ElementRef,
3
+ Component,
4
+ ViewEncapsulation,
5
+ computed,
6
+ effect,
7
+ inject,
8
+ input,
9
+ model,
10
+ output,
11
+ viewChild,
12
+ } from '@angular/core';
13
+ import { LYNX_ELEMENTS } from '@blotch/angular-lynx';
14
+
15
+ import { type AnimationHandle, popIn, popOut } from '../../utils/animate';
16
+ import { cn } from '../../utils/cn';
17
+
18
+ @Component({
19
+ selector: 'ui-radio-group',
20
+ standalone: true,
21
+ imports: [LYNX_ELEMENTS],
22
+ encapsulation: ViewEncapsulation.None,
23
+ template: `
24
+ <view [class]="containerClass()">
25
+ <ng-content />
26
+ </view>
27
+ `,
28
+ })
29
+ export class UiRadioGroup {
30
+ readonly value = model<string>('');
31
+ readonly disabled = input(false);
32
+ readonly userClass = input<string>('', { alias: 'class' });
33
+
34
+ readonly changed = output<string>();
35
+
36
+ protected readonly containerClass = computed(() =>
37
+ cn('flex-col gap-2 flex', this.userClass()),
38
+ );
39
+
40
+ select(value: string): void {
41
+ if (this.disabled()) return;
42
+ this.value.set(value);
43
+ this.changed.emit(value);
44
+ }
45
+ }
46
+
47
+ @Component({
48
+ selector: 'ui-radio-group-item',
49
+ standalone: true,
50
+ imports: [LYNX_ELEMENTS],
51
+ encapsulation: ViewEncapsulation.None,
52
+ template: `
53
+ <view [class]="itemClass()" (bindtap)="select()">
54
+ <view [class]="circleClass()">
55
+ <view
56
+ #dot
57
+ [class]="dotClass()"
58
+ [style]="
59
+ isSelected() ? 'opacity: 1;' : 'opacity: 0; transform: scale(0.6);'
60
+ "
61
+ />
62
+ </view>
63
+ <text [class]="labelClass()"><ng-content /></text>
64
+ </view>
65
+ `,
66
+ })
67
+ export class UiRadioGroupItem {
68
+ readonly #group = inject(UiRadioGroup);
69
+
70
+ readonly itemValue = input.required<string>({ alias: 'value' });
71
+ readonly userClass = input<string>('', { alias: 'class' });
72
+
73
+ readonly dotRef = viewChild<ElementRef>('dot');
74
+ #dotAnim?: AnimationHandle;
75
+ #previousSelected?: boolean;
76
+
77
+ protected readonly isSelected = computed(
78
+ () => this.#group.value() === this.itemValue(),
79
+ );
80
+
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
+ }
103
+
104
+ protected readonly itemClass = computed(() =>
105
+ cn(
106
+ 'h-11 flex-row items-center gap-3 flex',
107
+ this.#group.disabled() && 'opacity-50',
108
+ this.userClass(),
109
+ ),
110
+ );
111
+
112
+ protected readonly circleClass = computed(() =>
113
+ cn(
114
+ 'h-5 w-5 items-center rounded-full border flex justify-center',
115
+ this.isSelected() ? 'border-primary' : 'border-primary',
116
+ ),
117
+ );
118
+
119
+ protected readonly dotClass = computed(() =>
120
+ cn('h-2.5 w-2.5 rounded-full bg-primary'),
121
+ );
122
+
123
+ protected readonly labelClass = computed(() => cn('text-sm text-foreground'));
124
+
125
+ select(): void {
126
+ if (this.#group.disabled()) return;
127
+ this.#group.select(this.itemValue());
128
+ }
129
+ }
@@ -0,0 +1,6 @@
1
+ export {
2
+ UiScatterChart,
3
+ UiScatterSeries,
4
+ computeDots,
5
+ type DotShape,
6
+ } from './scatter-chart';