@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,97 @@
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
+ import { cva, type VariantProps } from 'class-variance-authority';
12
+
13
+ import { popIn } from '../../utils/animate';
14
+ import { cn } from '../../utils/cn';
15
+
16
+ export const badgeVariants = cva(
17
+ // `self-start` keeps the badge hugging its content. Lynx views default to `linear`
18
+ // layout, where a child with no explicit width stretches to fill the parent's cross
19
+ // axis — so without it a badge renders full-width. (Web shadcn uses `inline-flex` for
20
+ // the same shrink-to-fit; Lynx has no inline display, so align-self is the lever.)
21
+ 'items-center self-start rounded-full px-2.5 py-0.5 flex',
22
+ {
23
+ variants: {
24
+ variant: {
25
+ default: 'bg-primary',
26
+ secondary: 'bg-secondary',
27
+ destructive: 'bg-destructive',
28
+ outline: 'border border-border bg-transparent',
29
+ },
30
+ },
31
+ defaultVariants: {
32
+ variant: 'default',
33
+ },
34
+ },
35
+ );
36
+
37
+ const badgeTextVariants = cva('text-xs font-semibold', {
38
+ variants: {
39
+ variant: {
40
+ default: 'text-primary-foreground',
41
+ secondary: 'text-secondary-foreground',
42
+ destructive: 'text-destructive-foreground',
43
+ outline: 'text-foreground',
44
+ },
45
+ },
46
+ defaultVariants: {
47
+ variant: 'default',
48
+ },
49
+ });
50
+
51
+ export type BadgeVariant = NonNullable<
52
+ VariantProps<typeof badgeVariants>['variant']
53
+ >;
54
+
55
+ @Component({
56
+ selector: 'ui-badge',
57
+ standalone: true,
58
+ imports: [LYNX_ELEMENTS],
59
+ encapsulation: ViewEncapsulation.None,
60
+ template: `
61
+ <view #container [class]="containerClass()">
62
+ <text [class]="labelClass()"><ng-content /></text>
63
+ </view>
64
+ `,
65
+ })
66
+ export class UiBadge {
67
+ readonly variant = input<BadgeVariant>('default');
68
+ /**
69
+ * When true, badge pops in with a spring animation on first render
70
+ */
71
+ readonly animated = input(true);
72
+ readonly userClass = input<string>('', { alias: 'class' });
73
+
74
+ readonly containerRef = viewChild<ElementRef>('container');
75
+
76
+ constructor() {
77
+ effect(() => {
78
+ const el = this.containerRef()?.nativeElement;
79
+ // popIn calls el.animate() which is inert in jsdom (the offscreen web
80
+ // renderer path — see animate.ts's transitionTransform comment). The
81
+ // effect's rendering-adjacent work is not exercisable from a JIT test,
82
+ // so ignore the animation branch for coverage.
83
+ /* v8 ignore next 3 */
84
+ if (el && this.animated()) {
85
+ popIn(el, { duration: 200 });
86
+ }
87
+ });
88
+ }
89
+
90
+ protected readonly containerClass = computed(() =>
91
+ cn(badgeVariants({ variant: this.variant() }), this.userClass()),
92
+ );
93
+
94
+ protected readonly labelClass = computed(() =>
95
+ cn(badgeTextVariants({ variant: this.variant() })),
96
+ );
97
+ }
@@ -0,0 +1 @@
1
+ export { UiBadge, badgeVariants, type BadgeVariant } from './badge';
@@ -0,0 +1,174 @@
1
+ import { render, waitForUpdate } from '@blotch/angular-lynx-testing-library';
2
+ import { describe, expect, it, vi } from 'vitest';
3
+ import { setInputSignal } from '../../../test-utils/set-input-signal';
4
+ import { UiCartesianChart } from '../cartesian-chart/cartesian-chart';
5
+ import { UiBarChart, UiBarSeries } from './bar-chart';
6
+
7
+ describe('UiBarChart', () => {
8
+ it('renders with default configuration', async () => {
9
+ const { container } = await render(UiBarChart);
10
+ expect(container).toBeTruthy();
11
+ });
12
+
13
+ it('folds the baseline into the y-axis domain', async () => {
14
+ const { componentRef } = await render(UiBarChart);
15
+ const inst = componentRef.instance as UiBarChart;
16
+ setInputSignal(inst.data, [
17
+ { x: 0, y: 5 },
18
+ { x: 1, y: 10 },
19
+ { x: 2, y: 8 },
20
+ ]);
21
+ setInputSignal(inst.baseline, 0);
22
+ await waitForUpdate();
23
+ const cast = inst as unknown as {
24
+ xAxis: () => { domain: readonly [number, number] };
25
+ yAxis: () => { domain: readonly [number, number] };
26
+ effectivePadding: () => { left?: number; right?: number };
27
+ };
28
+ expect(cast.yAxis().domain[0]).toBeLessThanOrEqual(0);
29
+ expect(cast.yAxis().domain[1]).toBeGreaterThanOrEqual(10);
30
+ // 3 bars → half-bar inset = 0.5 / (3-1) = 0.25
31
+ expect(cast.effectivePadding().left).toBeCloseTo(0.25);
32
+ expect(cast.effectivePadding().right).toBeCloseTo(0.25);
33
+ });
34
+
35
+ it('honors explicit xMin / xMax / yMin / yMax overrides', async () => {
36
+ const { componentRef } = await render(UiBarChart);
37
+ const inst = componentRef.instance as UiBarChart;
38
+ setInputSignal(inst.data, [
39
+ { x: 0, y: 0 },
40
+ { x: 10, y: 10 },
41
+ ]);
42
+ setInputSignal(inst.xMin, -5);
43
+ setInputSignal(inst.xMax, 20);
44
+ setInputSignal(inst.yMin, -1);
45
+ setInputSignal(inst.yMax, 15);
46
+ await waitForUpdate();
47
+ const cast = inst as unknown as {
48
+ xAxis: () => { domain: readonly [number, number] };
49
+ yAxis: () => { domain: readonly [number, number] };
50
+ };
51
+ expect(cast.xAxis().domain[0]).toBeLessThanOrEqual(-5);
52
+ expect(cast.xAxis().domain[1]).toBeGreaterThanOrEqual(20);
53
+ expect(cast.yAxis().domain[0]).toBeLessThanOrEqual(-1);
54
+ expect(cast.yAxis().domain[1]).toBeGreaterThanOrEqual(15);
55
+ });
56
+
57
+ it('single-datum series drops the half-bar padding', async () => {
58
+ const { componentRef } = await render(UiBarChart);
59
+ const inst = componentRef.instance as UiBarChart;
60
+ setInputSignal(inst.data, [{ x: 0, y: 5 }]);
61
+ await waitForUpdate();
62
+ const cast = inst as unknown as {
63
+ effectivePadding: () => { left?: number; right?: number };
64
+ };
65
+ // n === 1 → half === 0
66
+ expect(cast.effectivePadding().left).toBe(0);
67
+ expect(cast.effectivePadding().right).toBe(0);
68
+ });
69
+
70
+ it('exposes the default tick formatter used by the child cartesian chart', async () => {
71
+ const { componentRef } = await render(UiBarChart);
72
+ const inst = componentRef.instance as UiBarChart;
73
+ expect(inst.xTickFormat()(3.14159)).toBe('3.14');
74
+ expect(inst.yTickFormat()(1.005)).toBe('1');
75
+ });
76
+
77
+ it('user padding overrides the auto half-bar inset per side', async () => {
78
+ const { componentRef } = await render(UiBarChart);
79
+ const inst = componentRef.instance as UiBarChart;
80
+ setInputSignal(inst.data, [
81
+ { x: 0, y: 5 },
82
+ { x: 1, y: 10 },
83
+ ]);
84
+ setInputSignal(inst.padding, { left: 0.1 });
85
+ await waitForUpdate();
86
+ const cast = inst as unknown as {
87
+ effectivePadding: () => { left?: number; right?: number };
88
+ };
89
+ // left explicitly set → wins; right still auto = 0.5
90
+ expect(cast.effectivePadding().left).toBeCloseTo(0.1);
91
+ expect(cast.effectivePadding().right).toBeCloseTo(0.5);
92
+ });
93
+ });
94
+
95
+ describe('UiBarSeries', () => {
96
+ const renderSeries = async () => {
97
+ const parentResult = await render(UiCartesianChart);
98
+ const parent = parentResult.componentRef.instance as UiCartesianChart;
99
+ setInputSignal(parent.xDomain, [0, 10]);
100
+ setInputSignal(parent.yDomain, [0, 10]);
101
+ await waitForUpdate();
102
+ const seriesResult = await render(UiBarSeries, {
103
+ providers: [{ provide: UiCartesianChart, useValue: parent }],
104
+ });
105
+ const series = seriesResult.componentRef.instance as UiBarSeries;
106
+ setInputSignal(series.data, [
107
+ { x: 0, y: 2 },
108
+ { x: 5, y: 8 },
109
+ { x: 10, y: 5 },
110
+ ]);
111
+ await waitForUpdate();
112
+ return { parent, series };
113
+ };
114
+
115
+ it('renders a rectangle per datum with the fallback color', async () => {
116
+ const { series } = await renderSeries();
117
+ const cast = series as unknown as {
118
+ bars: () => { style: string; point: { x: number; y: number } }[];
119
+ };
120
+ const bars = cast.bars();
121
+ expect(bars.length).toBe(3);
122
+ for (const b of bars) {
123
+ expect(b.style).toContain('background-color: var(--primary)');
124
+ }
125
+ });
126
+
127
+ it('cycles through the per-bar palette when colors are provided', async () => {
128
+ const { series } = await renderSeries();
129
+ setInputSignal(series.colors, ['#f00', '#0f0']);
130
+ await waitForUpdate();
131
+ const cast = series as unknown as { bars: () => { style: string }[] };
132
+ const bars = cast.bars();
133
+ expect(bars[0].style).toContain('#f00');
134
+ expect(bars[1].style).toContain('#0f0');
135
+ // wraps around
136
+ expect(bars[2].style).toContain('#f00');
137
+ });
138
+
139
+ it('emits barTap with the source point when a bar is tapped', async () => {
140
+ const { series } = await renderSeries();
141
+ const cb = vi.fn();
142
+ series.barTap.subscribe(cb);
143
+ (
144
+ series as unknown as { onBarTap: (p: { x: number; y: number }) => void }
145
+ ).onBarTap({ x: 1, y: 2 });
146
+ expect(cb).toHaveBeenCalledWith({ x: 1, y: 2 });
147
+ });
148
+
149
+ it('single-datum series uses the fallback bar width', async () => {
150
+ const parentResult = await render(UiCartesianChart);
151
+ const parent = parentResult.componentRef.instance as UiCartesianChart;
152
+ setInputSignal(parent.xDomain, [0, 10]);
153
+ setInputSignal(parent.yDomain, [0, 10]);
154
+ await waitForUpdate();
155
+ const seriesResult = await render(UiBarSeries, {
156
+ providers: [{ provide: UiCartesianChart, useValue: parent }],
157
+ });
158
+ const series = seriesResult.componentRef.instance as UiBarSeries;
159
+ setInputSignal(series.data, [{ x: 5, y: 5 }]);
160
+ await waitForUpdate();
161
+ const cast = series as unknown as { bars: () => { style: string }[] };
162
+ const [bar] = cast.bars();
163
+ // fallback = 24px
164
+ expect(bar.style).toContain('width: 24.00px');
165
+ });
166
+
167
+ it('clamps the baseline into the plot when it lies outside the domain', async () => {
168
+ const { series } = await renderSeries();
169
+ setInputSignal(series.baseline, 999);
170
+ await waitForUpdate();
171
+ const cast = series as unknown as { bars: () => { style: string }[] };
172
+ expect(cast.bars().length).toBe(3);
173
+ });
174
+ });
@@ -0,0 +1,73 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+
3
+ // Mock Angular (and the transitively-loaded cartesian-chart Angular usage) so
4
+ // importing the module needs no framework runtime. Only the exported pure
5
+ // `computeBars` is exercised; the components are never built.
6
+ vi.mock('@angular/core', () => ({
7
+ Component: () => () => {},
8
+ ViewEncapsulation: { None: 0 },
9
+ computed: () => () => {},
10
+ inject: () => ({}),
11
+ input: () => () => {},
12
+ output: () => () => {},
13
+ }));
14
+
15
+ vi.mock('@blotch/angular-lynx', () => ({
16
+ LYNX_ELEMENTS: [],
17
+ // cartesian-chart (imported transitively) registers these in its decorator.
18
+ LynxGestureDetector: class {},
19
+ PanGesture: class {},
20
+ PinchGesture: class {},
21
+ Gesture: { Simultaneous: () => ({}) },
22
+ }));
23
+
24
+ const { computeBars } = await import('./bar-chart');
25
+
26
+ // Screen y grows downward, so a value ABOVE the baseline has a SMALLER pixel y
27
+ // than baseY. Baseline pixel y = 100 in these cases (bottom of the plot).
28
+ describe('computeBars', () => {
29
+ it('draws a positive bar from the baseline up to the value, centered on cx', () => {
30
+ // value projects to y=30 (above the baseline at y=100).
31
+ const [bar] = computeBars([{ cx: 50, valueY: 30 }], 100, 12);
32
+ expect(bar.top).toBe(30); // min(valueY, baseY) — the higher edge
33
+ expect(bar.height).toBe(70); // |valueY - baseY|
34
+ expect(bar.width).toBe(12);
35
+ expect(bar.left).toBe(50 - 6); // cx - barWidth/2
36
+ });
37
+
38
+ it('draws a negative bar growing downward from the baseline', () => {
39
+ // value projects BELOW the baseline, so valueY (140) > baseY (100).
40
+ const [bar] = computeBars([{ cx: 20, valueY: 140 }], 100, 10);
41
+ expect(bar.top).toBe(100); // min(valueY, baseY) = baseY here
42
+ expect(bar.height).toBe(40); // grows down to the value
43
+ });
44
+
45
+ it('draws nothing (zero height) for a value exactly on the baseline', () => {
46
+ // A bar's height is its datum: on the baseline there is nothing to draw, so
47
+ // the default minBarHeight of 0 leaves it invisible rather than a sliver.
48
+ const [bar] = computeBars([{ cx: 0, valueY: 100 }], 100, 12);
49
+ expect(bar.height).toBe(0);
50
+ });
51
+
52
+ it('honors a custom minimum bar height', () => {
53
+ const [bar] = computeBars([{ cx: 0, valueY: 100 }], 100, 12, 3);
54
+ expect(bar.height).toBe(3);
55
+ });
56
+
57
+ it('centers every bar on its own cx', () => {
58
+ const bars = computeBars(
59
+ [
60
+ { cx: 10, valueY: 40 },
61
+ { cx: 60, valueY: 20 },
62
+ ],
63
+ 100,
64
+ 8,
65
+ );
66
+ expect(bars[0].left).toBe(10 - 4);
67
+ expect(bars[1].left).toBe(60 - 4);
68
+ });
69
+
70
+ it('returns an empty array for no bars', () => {
71
+ expect(computeBars([], 100, 12)).toEqual([]);
72
+ });
73
+ });
@@ -0,0 +1,327 @@
1
+ import {
2
+ Component,
3
+ ViewEncapsulation,
4
+ computed,
5
+ inject,
6
+ input,
7
+ output,
8
+ } from '@angular/core';
9
+ import { LYNX_ELEMENTS } from '@blotch/angular-lynx';
10
+
11
+ import {
12
+ type ChartPadding,
13
+ type ChartPoint,
14
+ UiCartesianChart,
15
+ niceScale,
16
+ } from '../cartesian-chart/cartesian-chart';
17
+
18
+ // ---------------------------------------------------------------------------
19
+ // Types
20
+ // ---------------------------------------------------------------------------
21
+
22
+ // A bar is just an (x, y) datum — x is the category position, y the bar's value —
23
+ // so it reuses the shared `ChartPoint` rather than defining its own. That also
24
+ // lets a bar series and a line/area series share one dataset on the same axes.
25
+
26
+ /** One bar after projection into plot-local pixel space. */
27
+ type ProjectedBar = {
28
+ /** Center x of the bar, in px. */
29
+ cx: number;
30
+ /** Pixel y of the bar's value (its far edge from the baseline). */
31
+ valueY: number;
32
+ };
33
+
34
+ /** An axis-aligned rectangle in plot-local pixel space. */
35
+ export type BarShape = {
36
+ left: number;
37
+ top: number;
38
+ width: number;
39
+ height: number;
40
+ };
41
+
42
+ // ---------------------------------------------------------------------------
43
+ // Pure geometry (exported so it can be unit-tested without Angular)
44
+ // ---------------------------------------------------------------------------
45
+
46
+ /**
47
+ * Turns projected bars — already in plot-local pixel space — into rectangles
48
+ * ready to render as `<view>`s. A bar is the simplest chart mark on Lynx: one
49
+ * axis-aligned box from the shared `baseY` (the baseline's pixel y) to the
50
+ * value, so this is a straight geometric mapping with no rotation or fill
51
+ * rasterization (unlike the line and area series).
52
+ *
53
+ * Screen y grows *downward*, so a value above the baseline has a *smaller*
54
+ * `valueY` than `baseY`. Taking `top = min(valueY, baseY)` therefore anchors the
55
+ * rectangle at whichever edge is visually higher, which makes bars for negative
56
+ * values (value below the baseline) grow *downward* from the baseline correctly.
57
+ *
58
+ * Unlike a candlestick doji, `minBarHeight` defaults to `0`: a bar's height *is*
59
+ * its datum, so a value exactly on the baseline should draw nothing rather than
60
+ * a misleading sliver. Callers wanting every bar guaranteed visible can raise it.
61
+ *
62
+ * @param baseY pixel y of the baseline every bar grows from.
63
+ * @param barWidth width of each bar in px.
64
+ * @param minBarHeight floor for the bar height (0 = honest zero-height at baseline).
65
+ */
66
+ export const computeBars = (
67
+ bars: readonly ProjectedBar[],
68
+ baseY: number,
69
+ barWidth: number,
70
+ minBarHeight = 0,
71
+ ): BarShape[] =>
72
+ bars.map((b) => {
73
+ const top = Math.min(b.valueY, baseY);
74
+ const height = Math.max(Math.abs(b.valueY - baseY), minBarHeight);
75
+ return { left: b.cx - barWidth / 2, top, width: barWidth, height };
76
+ });
77
+
78
+ // ---------------------------------------------------------------------------
79
+ // Rendering helpers (mirror the equivalents in line-chart.ts / area-chart.ts —
80
+ // kept local so this module stays self-contained when scaffolded by `dolan add`)
81
+ // ---------------------------------------------------------------------------
82
+
83
+ /**
84
+ * Formats a pixel value for an inline style, avoiding scientific notation.
85
+ */
86
+ const px = (n: number): string => `${n.toFixed(2)}px`;
87
+
88
+ /**
89
+ * Default axis label formatter — plain JS only (no `Intl`, absent in Lynx).
90
+ */
91
+ const defaultTickFormat = (value: number): string =>
92
+ String(Math.round(value * 100) / 100);
93
+
94
+ const clamp = (value: number, lo: number, hi: number): number =>
95
+ Math.max(lo, Math.min(hi, value));
96
+
97
+ // Fallback bar width (px) when there is only a single bar, so there is no
98
+ // neighbor to derive a spacing from. Matches the candlestick series' fallback.
99
+ const SINGLE_BAR_WIDTH = 24;
100
+
101
+ /**
102
+ * A single bar series drawn inside a `<ui-cartesian-chart>`. Reads the shared
103
+ * scales from the parent chart via DI and renders one `<view>` rectangle per
104
+ * datum, from the shared `baseline` up (or down) to the value.
105
+ *
106
+ * ## Why the host is `position: absolute; width/height: 100%`
107
+ * Identical to `UiLineSeries`: Lynx resolves an absolute child against its
108
+ * **direct parent** (there is no `position: static`), so the host itself must be
109
+ * the plot-sized containing block. Every bar is a direct child of it.
110
+ *
111
+ * ## Per-bar colors
112
+ * `color` fills every bar by default. Pass `colors` to give each bar its own
113
+ * hue (a categorical bar chart) — the palette cycles if it is shorter than the
114
+ * data, so a handful of colors tint any number of bars.
115
+ */
116
+ @Component({
117
+ selector: 'ui-bar-series',
118
+ standalone: true,
119
+ imports: [LYNX_ELEMENTS],
120
+ encapsulation: ViewEncapsulation.None,
121
+ host: {
122
+ style:
123
+ 'position: absolute; left: 0px; top: 0px; width: 100%; height: 100%;',
124
+ '[class]': 'userClass()',
125
+ },
126
+ template: `
127
+ @for (bar of bars(); track $index) {
128
+ <view [style]="bar.style" (bindtap)="onBarTap(bar.point)"></view>
129
+ }
130
+ `,
131
+ })
132
+ export class UiBarSeries {
133
+ readonly #chart = inject(UiCartesianChart);
134
+
135
+ readonly data = input.required<ChartPoint[]>();
136
+ /** Fill for every bar. Overridden per bar by `colors` when that is set. */
137
+ readonly color = input('var(--primary)');
138
+ /** Per-bar fills (a categorical palette). Cycles when shorter than the data;
139
+ * an empty array falls back to `color`. */
140
+ readonly colors = input<string[]>([]);
141
+ /** Bar width as a fraction of the spacing between adjacent bars. 0.7 leaves a
142
+ * ~30% gap between bars, the conventional bar-chart look. */
143
+ readonly barWidthRatio = input(0.7);
144
+ /** Data-space y the bars grow from (usually 0). Values below it grow downward. */
145
+ readonly baseline = input(0);
146
+ /** Corner radius of each bar in px (0 = square corners). */
147
+ readonly radius = input(0);
148
+ readonly userClass = input<string>('', { alias: 'class' });
149
+
150
+ readonly barTap = output<ChartPoint>();
151
+
152
+ // Project each datum into plot-local pixel space via the shared scales.
153
+ // Reading the chart's scale signals here makes this recompute whenever the
154
+ // domain or plot size changes.
155
+ readonly #projected = computed(() => {
156
+ const scaleX = this.#chart.xScale();
157
+ const scaleY = this.#chart.yScale();
158
+ return this.data().map((point) => ({
159
+ cx: scaleX(point.x),
160
+ valueY: scaleY(point.y),
161
+ point,
162
+ }));
163
+ });
164
+
165
+ // Bar width = a fraction of the pixel spacing between bar centers, so bars
166
+ // scale with the plot and never overlap. With one bar there is no spacing to
167
+ // measure, so fall back to a fixed width.
168
+ readonly #barWidth = computed(() => {
169
+ const projected = this.#projected();
170
+ const n = projected.length;
171
+ if (n < 2) return SINGLE_BAR_WIDTH;
172
+ const span = Math.abs(projected[n - 1].cx - projected[0].cx);
173
+ return (span / (n - 1)) * this.barWidthRatio();
174
+ });
175
+
176
+ protected readonly bars = computed(() => {
177
+ const projected = this.#projected();
178
+ const fallback = this.color();
179
+ const palette = this.colors();
180
+ const radius = this.radius();
181
+ // Clamp the baseline into the plot so a baseline outside the domain anchors
182
+ // the bars to the nearest edge (top/bottom) instead of drawing off-plot.
183
+ const baseY = clamp(
184
+ this.#chart.yScale()(this.baseline()),
185
+ 0,
186
+ this.#chart.plotHeight(),
187
+ );
188
+ // computeBars works purely from the projected pixel fields; zip its output
189
+ // back with each source point so a bar's tap can emit the original datum.
190
+ return computeBars(projected, baseY, this.#barWidth()).map((shape, i) => {
191
+ const fill = palette.length ? palette[i % palette.length] : fallback;
192
+ return {
193
+ point: projected[i].point,
194
+ style:
195
+ `position: absolute; left: ${px(shape.left)}; top: ${px(shape.top)}; ` +
196
+ `width: ${px(shape.width)}; height: ${px(shape.height)}; ` +
197
+ `background-color: ${fill}; border-radius: ${px(radius)};`,
198
+ };
199
+ });
200
+ });
201
+
202
+ protected onBarTap(point: ChartPoint): void {
203
+ this.barTap.emit(point);
204
+ }
205
+ }
206
+
207
+ /**
208
+ * A ready-to-use bar chart for the common single-series case. Composes a
209
+ * `<ui-cartesian-chart>` with one `<ui-bar-series>` and auto-derives rounded
210
+ * axis domains + ticks from the data. For a line overlay, or full control over
211
+ * the axes, use `<ui-cartesian-chart>` with a `<ui-bar-series>` (and optionally
212
+ * a `<ui-line-series>`) directly.
213
+ */
214
+ @Component({
215
+ selector: 'ui-bar-chart',
216
+ standalone: true,
217
+ imports: [LYNX_ELEMENTS, UiCartesianChart, UiBarSeries],
218
+ encapsulation: ViewEncapsulation.None,
219
+ template: `
220
+ <ui-cartesian-chart
221
+ [xDomain]="xAxis().domain"
222
+ [yDomain]="yAxis().domain"
223
+ [xTicks]="xAxis().ticks"
224
+ [yTicks]="yAxis().ticks"
225
+ [width]="width()"
226
+ [height]="height()"
227
+ [tickCount]="tickCount()"
228
+ [showXGrid]="showXGrid()"
229
+ [xAxisLabel]="xAxisLabel()"
230
+ [yAxisLabel]="yAxisLabel()"
231
+ [padding]="effectivePadding()"
232
+ [xTickFormat]="xTickFormat()"
233
+ [yTickFormat]="yTickFormat()"
234
+ [zoomable]="zoomable()"
235
+ [zoomAxes]="zoomAxes()"
236
+ [showZoomControls]="showZoomControls()"
237
+ [minZoom]="minZoom()"
238
+ [maxZoom]="maxZoom()"
239
+ [zoomStep]="zoomStep()"
240
+ [class]="userClass()"
241
+ >
242
+ <ui-bar-series
243
+ [data]="data()"
244
+ [color]="color()"
245
+ [colors]="colors()"
246
+ [barWidthRatio]="barWidthRatio()"
247
+ [baseline]="baseline()"
248
+ [radius]="radius()"
249
+ (barTap)="barTap.emit($event)"
250
+ />
251
+ </ui-cartesian-chart>
252
+ `,
253
+ })
254
+ export class UiBarChart {
255
+ readonly data = input.required<ChartPoint[]>();
256
+ readonly color = input('var(--primary)');
257
+ readonly colors = input<string[]>([]);
258
+ readonly width = input(320);
259
+ readonly height = input(200);
260
+ readonly tickCount = input(5);
261
+ readonly barWidthRatio = input(0.7);
262
+ /** Data-space y the bars grow from (usually 0). Values below it grow downward. */
263
+ readonly baseline = input(0);
264
+ /** Corner radius of each bar in px. */
265
+ readonly radius = input(0);
266
+ /** Draw vertical gridlines at each x tick. */
267
+ readonly showXGrid = input(false);
268
+ /** Title for the x-axis (centered below the tick labels). */
269
+ readonly xAxisLabel = input<string>('');
270
+ /** Title for the y-axis (rotated in the left gutter). */
271
+ readonly yAxisLabel = input<string>('');
272
+ /** Inner inset of the plot area — merged over the auto half-bar padding
273
+ * (below), so any side you set wins. See {@link ChartPadding}. */
274
+ readonly padding = input<ChartPadding>({});
275
+ /** Force the x-axis lower/upper bound instead of deriving it from the data.
276
+ * Omitted bounds fall back to the data extremes. */
277
+ readonly xMin = input<number | undefined>(undefined);
278
+ readonly xMax = input<number | undefined>(undefined);
279
+ /** Force the y-axis lower/upper bound. The `baseline` is still folded in, so
280
+ * `yMin` only lowers the floor further — it can't clip a bar's base off. */
281
+ readonly yMin = input<number | undefined>(undefined);
282
+ readonly yMax = input<number | undefined>(undefined);
283
+ readonly xTickFormat = input<(value: number) => string>(defaultTickFormat);
284
+ readonly yTickFormat = input<(value: number) => string>(defaultTickFormat);
285
+ readonly userClass = input<string>('', { alias: 'class' });
286
+
287
+ // Pan/zoom — forwarded to the underlying <ui-cartesian-chart>. Off by default
288
+ // so a bar chart stays static (and scroll-view friendly) unless opted in.
289
+ readonly zoomable = input(false);
290
+ readonly zoomAxes = input<'x' | 'y' | 'xy'>('xy');
291
+ readonly showZoomControls = input(true);
292
+ readonly minZoom = input(1);
293
+ readonly maxZoom = input(8);
294
+ readonly zoomStep = input(1.4);
295
+
296
+ readonly barTap = output<ChartPoint>();
297
+
298
+ // Auto-compute rounded axes from the data extremes, letting an explicit
299
+ // min/max override either end. `Math.min/max(...[])` yield ±Infinity for empty
300
+ // data, which `niceScale` handles by padding.
301
+ protected readonly xAxis = computed(() => {
302
+ const xs = this.data().map((p) => p.x);
303
+ const lo = this.xMin() ?? Math.min(...xs);
304
+ const hi = this.xMax() ?? Math.max(...xs);
305
+ return niceScale(lo, hi, this.tickCount());
306
+ });
307
+ // Fold the baseline into the y-extent so the axis always spans the whole bar:
308
+ // a positive-only series still shows the baseline at the bottom, and negative
309
+ // values pull the domain below it. An explicit yMin/yMax overrides the
310
+ // corresponding data-derived end (the baseline still participates in the other).
311
+ protected readonly yAxis = computed(() => {
312
+ const ys = this.data().map((p) => p.y);
313
+ const base = this.baseline();
314
+ const lo = this.yMin() ?? Math.min(base, ...ys);
315
+ const hi = this.yMax() ?? Math.max(base, ...ys);
316
+ return niceScale(lo, hi, this.tickCount());
317
+ });
318
+
319
+ // A bar is centered on its x, so the first and last bars would spill over the
320
+ // plot edges. Insetting the scales by half a bar's spacing keeps them fully
321
+ // inside. User `padding` is spread last, so an explicit side wins.
322
+ protected readonly effectivePadding = computed<ChartPadding>(() => {
323
+ const n = this.data().length;
324
+ const half = n > 1 ? 0.5 / (n - 1) : 0;
325
+ return { left: half, right: half, ...this.padding() };
326
+ });
327
+ }
@@ -0,0 +1,6 @@
1
+ export {
2
+ UiBarChart,
3
+ UiBarSeries,
4
+ computeBars,
5
+ type BarShape,
6
+ } from './bar-chart';