@blotch/dolan 22.2.2 → 22.2.3

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 (154) hide show
  1. package/dist/commands/add.d.ts +6 -2
  2. package/dist/commands/add.js +32 -10
  3. package/dist/commands/add.js.map +1 -1
  4. package/dist/commands/add.test.js +129 -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 +41 -27
  12. package/dist/commands/doctor.js.map +1 -1
  13. package/dist/commands/doctor.test.js +93 -9
  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 +149 -40
  23. package/dist/commands/init.js.map +1 -1
  24. package/dist/commands/init.test.js +285 -26
  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/update.d.ts +6 -0
  46. package/dist/commands/update.js +59 -24
  47. package/dist/commands/update.js.map +1 -1
  48. package/dist/commands/update.test.js +273 -31
  49. package/dist/commands/update.test.js.map +1 -1
  50. package/dist/index.js +7 -0
  51. package/dist/index.js.map +1 -1
  52. package/dist/registry.d.ts +8 -0
  53. package/dist/registry.js +32 -24
  54. package/dist/registry.js.map +1 -1
  55. package/dist/registry.test.js +76 -37
  56. package/dist/registry.test.js.map +1 -1
  57. package/dist/testing/set-input-signal.d.ts +13 -0
  58. package/dist/testing/set-input-signal.js +20 -0
  59. package/dist/testing/set-input-signal.js.map +1 -0
  60. package/dist/testing/set-input-signal.test.d.ts +1 -0
  61. package/dist/testing/set-input-signal.test.js +20 -0
  62. package/dist/testing/set-input-signal.test.js.map +1 -0
  63. package/dist/ui/components/action-sheet/action-sheet.test.ts +48 -1
  64. package/dist/ui/components/action-sheet/action-sheet.ts +37 -9
  65. package/dist/ui/components/alert-dialog/alert-dialog.test.ts +49 -2
  66. package/dist/ui/components/alert-dialog/alert-dialog.ts +43 -12
  67. package/dist/ui/components/area-chart/area-chart-render.test.ts +52 -110
  68. package/dist/ui/components/area-chart/area-chart.test.ts +58 -123
  69. package/dist/ui/components/area-chart/area-chart.ts +100 -315
  70. package/dist/ui/components/area-chart/index.ts +2 -3
  71. package/dist/ui/components/bar-chart/bar-chart-render.test.ts +47 -163
  72. package/dist/ui/components/bar-chart/bar-chart.test.ts +83 -37
  73. package/dist/ui/components/bar-chart/bar-chart.ts +140 -294
  74. package/dist/ui/components/bar-chart/index.ts +3 -3
  75. package/dist/ui/components/bottom-sheet/bottom-sheet.test.ts +191 -9
  76. package/dist/ui/components/bottom-sheet/bottom-sheet.ts +136 -20
  77. package/dist/ui/components/button/button-render.test.ts +19 -0
  78. package/dist/ui/components/button/button.ts +18 -2
  79. package/dist/ui/components/candlestick-chart/candlestick-chart-render.test.ts +48 -163
  80. package/dist/ui/components/candlestick-chart/candlestick-chart.test.ts +106 -62
  81. package/dist/ui/components/candlestick-chart/candlestick-chart.ts +150 -323
  82. package/dist/ui/components/candlestick-chart/index.ts +3 -4
  83. package/dist/ui/components/chart/chart-focus.test.ts +132 -0
  84. package/dist/ui/components/chart/chart-focus.ts +115 -0
  85. package/dist/ui/components/chart/chart-presets.test.ts +139 -0
  86. package/dist/ui/components/chart/chart-presets.ts +271 -0
  87. package/dist/ui/components/chart/chart-render.test.ts +730 -0
  88. package/dist/ui/components/chart/chart-scene.test.ts +589 -0
  89. package/dist/ui/components/chart/chart-scene.ts +483 -0
  90. package/dist/ui/components/chart/chart-theme.test.ts +85 -0
  91. package/dist/ui/components/chart/chart-theme.ts +198 -0
  92. package/dist/ui/components/chart/chart-zoom.test.ts +159 -0
  93. package/dist/ui/components/chart/chart-zoom.ts +162 -0
  94. package/dist/ui/components/chart/chart.ts +921 -0
  95. package/dist/ui/components/chart/index.ts +24 -0
  96. package/dist/ui/components/dialog/dialog.test.ts +49 -2
  97. package/dist/ui/components/dialog/dialog.ts +43 -12
  98. package/dist/ui/components/icon/icon-theme.test.ts +133 -0
  99. package/dist/ui/components/icon/icon-theme.ts +204 -0
  100. package/dist/ui/components/icon/icon.test.ts +51 -2
  101. package/dist/ui/components/icon/icon.ts +38 -21
  102. package/dist/ui/components/line-chart/index.ts +2 -3
  103. package/dist/ui/components/line-chart/line-chart-render.test.ts +69 -107
  104. package/dist/ui/components/line-chart/line-chart.test.ts +63 -45
  105. package/dist/ui/components/line-chart/line-chart.ts +83 -241
  106. package/dist/ui/components/list/list.test.ts +69 -2
  107. package/dist/ui/components/list/list.ts +21 -15
  108. package/dist/ui/components/nav-drawer/nav-drawer.test.ts +80 -1
  109. package/dist/ui/components/nav-drawer/nav-drawer.ts +66 -11
  110. package/dist/ui/components/pagination/pagination.test.ts +62 -0
  111. package/dist/ui/components/pagination/pagination.ts +55 -8
  112. package/dist/ui/components/scatter-chart/index.ts +2 -3
  113. package/dist/ui/components/scatter-chart/scatter-chart-render.test.ts +51 -144
  114. package/dist/ui/components/scatter-chart/scatter-chart.test.ts +57 -25
  115. package/dist/ui/components/scatter-chart/scatter-chart.ts +104 -250
  116. package/dist/ui/components/scroll-area/scroll-area.test.ts +37 -2
  117. package/dist/ui/components/scroll-area/scroll-area.ts +8 -8
  118. package/dist/ui/components/spinner/spinner.test.ts +27 -0
  119. package/dist/ui/components/spinner/spinner.ts +33 -23
  120. package/dist/ui/components/toast/toast.test.ts +37 -0
  121. package/dist/ui/components/toast/toast.ts +20 -4
  122. package/dist/ui/theme/chart-colors.test.ts +30 -0
  123. package/dist/ui/theme/dark.css +8 -0
  124. package/dist/ui/theme/default.css +8 -0
  125. package/dist/ui/theme/icon-colors.test.ts +30 -0
  126. package/dist/ui/theme/tailwind-plugin.ts +10 -1
  127. package/dist/utils/installed.d.ts +17 -0
  128. package/dist/utils/installed.js +27 -0
  129. package/dist/utils/installed.js.map +1 -0
  130. package/dist/utils/installed.test.d.ts +1 -0
  131. package/dist/utils/installed.test.js +41 -0
  132. package/dist/utils/installed.test.js.map +1 -0
  133. package/dist/utils/interactive.d.ts +9 -0
  134. package/dist/utils/interactive.js +10 -0
  135. package/dist/utils/interactive.js.map +1 -0
  136. package/dist/utils/interactive.test.d.ts +1 -0
  137. package/dist/utils/interactive.test.js +24 -0
  138. package/dist/utils/interactive.test.js.map +1 -0
  139. package/dist/utils/packages.d.ts +36 -0
  140. package/dist/utils/packages.js +70 -0
  141. package/dist/utils/packages.js.map +1 -0
  142. package/dist/utils/packages.test.d.ts +1 -0
  143. package/dist/utils/packages.test.js +89 -0
  144. package/dist/utils/packages.test.js.map +1 -0
  145. package/dist/utils/resolve-paths.d.ts +17 -1
  146. package/dist/utils/resolve-paths.js +23 -3
  147. package/dist/utils/resolve-paths.js.map +1 -1
  148. package/dist/utils/resolve-paths.test.js +34 -2
  149. package/dist/utils/resolve-paths.test.js.map +1 -1
  150. package/package.json +5 -1
  151. package/dist/ui/components/cartesian-chart/cartesian-chart-render.test.ts +0 -188
  152. package/dist/ui/components/cartesian-chart/cartesian-chart.test.ts +0 -525
  153. package/dist/ui/components/cartesian-chart/cartesian-chart.ts +0 -1140
  154. package/dist/ui/components/cartesian-chart/index.ts +0 -18
@@ -1,35 +1,67 @@
1
+ import { createChartRuntime } from '@tanstack/charts/runtime';
2
+ import type {
3
+ ChartDefinition,
4
+ SceneDot,
5
+ SceneNode,
6
+ } from '@tanstack/charts/types';
1
7
  import { describe, expect, it } from 'vitest';
2
- import { computeDots } from './scatter-chart';
8
+ import { scatterChartDefinition } from './scatter-chart';
3
9
 
4
- describe('computeDots', () => {
5
- it('centers a dot on its point (top-left offset by the radius)', () => {
6
- const [dot] = computeDots([{ cx: 50, cy: 30, r: 4 }]);
7
- expect(dot.left).toBe(46); // cx - r
8
- expect(dot.top).toBe(26); // cy - r
9
- expect(dot.size).toBe(8); // r * 2 — the box the circle fills
10
+ const data = [
11
+ { x: 150, y: 50 },
12
+ { x: 160, y: 65 },
13
+ { x: 170, y: 70 },
14
+ ];
15
+
16
+ /**
17
+ * Every dot in a definition's scene.
18
+ */
19
+ const dots = (definition: ChartDefinition<any, any, any>) => {
20
+ const scene = createChartRuntime().render(definition, {
21
+ width: 300,
22
+ height: 200,
10
23
  });
24
+ const found: SceneDot[] = [];
25
+ const walk = (nodes: readonly SceneNode[]) => {
26
+ for (const node of nodes) {
27
+ if (node.kind === 'group') {
28
+ if (!node.focus) walk(node.children);
29
+ } else if (node.kind === 'dot') found.push(node);
30
+ }
31
+ };
32
+ walk(scene.nodes);
33
+ return { scene, found };
34
+ };
11
35
 
12
- it('honors a per-point radius (a bubble chart)', () => {
13
- const [small, big] = computeDots([
14
- { cx: 10, cy: 10, r: 2 },
15
- { cx: 40, cy: 40, r: 9 },
16
- ]);
17
- expect(small.size).toBe(4);
18
- expect(small.left).toBe(8);
19
- expect(big.size).toBe(18);
20
- expect(big.left).toBe(31); // 40 - 9
36
+ describe('scatterChartDefinition', () => {
37
+ it('draws a dot per point, inset from the frame', () => {
38
+ const { scene, found } = dots(scatterChartDefinition({ data }));
39
+ expect(found).toHaveLength(3);
40
+ expect(found[0]!.radius).toBe(4);
41
+ expect(found[0]!.style?.fill).toBe('var(--primary)');
42
+ // The 4% inset widens the x domain past the data.
43
+ expect(scene.scales['x']!.domain[0]).toBeLessThan(150);
21
44
  });
22
45
 
23
- it('keeps each dot centered on its own coordinates', () => {
24
- const dots = computeDots([
25
- { cx: 0, cy: 100, r: 3 },
26
- { cx: 100, cy: 0, r: 3 },
27
- ]);
28
- expect(dots[0]).toEqual({ left: -3, top: 97, size: 6 });
29
- expect(dots[1]).toEqual({ left: 97, top: -3, size: 6 });
46
+ it('sizes points individually for a bubble chart', () => {
47
+ const { found } = dots(
48
+ scatterChartDefinition({ data, sizes: [2, 8], radius: 5 }),
49
+ );
50
+ expect(found.map((dot) => dot.radius)).toEqual([2, 8, 5]);
30
51
  });
31
52
 
32
- it('returns an empty array for no points', () => {
33
- expect(computeDots([])).toEqual([]);
53
+ it('cycles per-point colors and keeps explicit padding', () => {
54
+ const { scene, found } = dots(
55
+ scatterChartDefinition({
56
+ data,
57
+ colors: ['red', 'blue'],
58
+ xMin: 150,
59
+ xMax: 170,
60
+ padding: { left: 0, right: 0, top: 0, bottom: 0 },
61
+ tooltip: false,
62
+ }),
63
+ );
64
+ expect(found.map((dot) => dot.style?.fill)).toEqual(['red', 'blue', 'red']);
65
+ expect(scene.scales['x']!.domain).toEqual([150, 170]);
34
66
  });
35
67
  });
@@ -2,276 +2,130 @@ import {
2
2
  Component,
3
3
  ViewEncapsulation,
4
4
  computed,
5
- inject,
6
5
  input,
7
6
  output,
8
7
  } from '@angular/core';
9
- import { LYNX_ELEMENTS } from '@blotch/angular-lynx';
8
+ import { defineChart, dot } from '@tanstack/charts/universal';
9
+ import type { ChartPoint } from '@tanstack/charts/types';
10
10
 
11
+ import { UiChart, tooltip } from '../chart/chart';
11
12
  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 scatter mark is just an (x, y) datum, so it reuses the shared `ChartPoint`
23
- // rather than defining its own. That also lets a scatter series and a line
24
- // series (e.g. a regression line) share one dataset on the same axes.
25
-
26
- /** One point after projection into plot-local pixel space, with its dot radius. */
27
- type ProjectedDot = {
28
- /** Center x of the dot, in px. */
29
- cx: number;
30
- /** Center y of the dot, in px. */
31
- cy: number;
32
- /** Dot radius in px (varies per point for a bubble chart). */
33
- r: number;
34
- };
35
-
36
- /** A square (rendered as a circle via border-radius) in plot-local pixel space. */
37
- export type DotShape = { left: number; top: number; size: number };
38
-
39
- // ---------------------------------------------------------------------------
40
- // Pure geometry (exported so it can be unit-tested without Angular)
41
- // ---------------------------------------------------------------------------
42
-
43
- /**
44
- * Turns projected points — already in plot-local pixel space — into square boxes
45
- * centered on each point, ready to render as circular `<view>`s (the caller sets
46
- * `border-radius` to half the size). A scatter mark is the simplest chart mark
47
- * of all: one box per datum, positioned so the point sits at its center.
48
- *
49
- * Each dot's radius comes from the point itself, so a per-point radius (a bubble
50
- * chart) and a uniform radius (a plain scatter) share this one mapping.
51
- */
52
- export const computeDots = (dots: readonly ProjectedDot[]): DotShape[] =>
53
- dots.map((d) => ({ left: d.cx - d.r, top: d.cy - d.r, size: d.r * 2 }));
54
-
55
- // ---------------------------------------------------------------------------
56
- // Rendering helpers (mirror the equivalents in line-chart.ts / bar-chart.ts —
57
- // kept local so this module stays self-contained when scaffolded by `dolan add`)
58
- // ---------------------------------------------------------------------------
13
+ type CartesianPresetOptions,
14
+ PRESET_CHART_BINDINGS,
15
+ UiCartesianPreset,
16
+ type XYDatum,
17
+ presetAxes,
18
+ } from '../chart/chart-presets';
19
+
20
+ export type ScatterChartOptions = {
21
+ readonly data: readonly XYDatum[];
22
+ /** Any CSS color; theme tokens like `var(--primary)` follow dark mode. */
23
+ readonly color?: string;
24
+ /** One color per point, cycling; overrides `color` when not empty. */
25
+ readonly colors?: readonly string[];
26
+ /** Dot radius in px. */
27
+ readonly radius?: number;
28
+ /** One radius per point (a bubble chart); points past its end use `radius`. */
29
+ readonly sizes?: readonly number[];
30
+ } & CartesianPresetOptions;
59
31
 
60
32
  /**
61
- * Formats a pixel value for an inline style, avoiding scientific notation.
33
+ * Scatter plots inset every side by 4% unless told otherwise, so dots at the
34
+ * extremes aren't cut in half by the frame.
62
35
  */
63
- const px = (n: number): string => `${n.toFixed(2)}px`;
64
-
65
- /**
66
- * Default axis label formatter — plain JS only (no `Intl`, absent in Lynx).
67
- */
68
- const defaultTickFormat = (value: number): string =>
69
- String(Math.round(value * 100) / 100);
70
-
71
- // Default inner inset (fraction of the plot) for the wrapper. Unlike the line
72
- // chart, a scatter's marks are dots at the data extremes, so a small inset on
73
- // every side keeps the edge dots from being clipped by the frame.
74
36
  const SCATTER_INSET = 0.04;
75
37
 
76
- /**
77
- * A single scatter series drawn inside a `<ui-cartesian-chart>`. Reads the shared
78
- * scales from the parent chart via DI and renders one circular `<view>` per datum
79
- * at its (x, y) position.
80
- *
81
- * ## Why the host is `position: absolute; width/height: 100%`
82
- * Identical to `UiLineSeries`: Lynx resolves an absolute child against its
83
- * **direct parent** (there is no `position: static`), so the host itself must be
84
- * the plot-sized containing block. Every dot is a direct child of it.
85
- *
86
- * ## Per-point colors and sizes
87
- * `color` fills every dot by default; pass `colors` to tint each dot (a
88
- * categorical scatter) — the palette cycles if it is shorter than the data. Pass
89
- * `sizes` to give each dot its own radius (a bubble chart); a point with no entry
90
- * falls back to the uniform `radius`.
91
- */
92
- @Component({
93
- selector: 'ui-scatter-series',
94
- standalone: true,
95
- imports: [LYNX_ELEMENTS],
96
- encapsulation: ViewEncapsulation.None,
97
- host: {
98
- style:
99
- 'position: absolute; left: 0px; top: 0px; width: 100%; height: 100%;',
100
- '[class]': 'userClass()',
101
- },
102
- template: `
103
- @for (dot of dots(); track $index) {
104
- <view [style]="dot.style" (bindtap)="onPointTap(dot.point)"></view>
105
- }
106
- `,
107
- })
108
- export class UiScatterSeries {
109
- readonly #chart = inject(UiCartesianChart);
110
-
111
- readonly data = input.required<ChartPoint[]>();
112
- /** Fill for every dot. Overridden per dot by `colors` when that is set. */
113
- readonly color = input('var(--primary)');
114
- /** Per-dot fills (a categorical palette). Cycles when shorter than the data;
115
- * an empty array falls back to `color`. */
116
- readonly colors = input<string[]>([]);
117
- /** Uniform dot radius in px. Overridden per dot by `sizes`. */
118
- readonly radius = input(4);
119
- /** Per-dot radii in px (a bubble chart). A point with no entry uses `radius`. */
120
- readonly sizes = input<number[]>([]);
121
- readonly userClass = input<string>('', { alias: 'class' });
122
-
123
- readonly pointTap = output<ChartPoint>();
124
-
125
- // Project each datum into plot-local pixel space via the shared scales, picking
126
- // up a per-point radius when `sizes` supplies one. Reading the chart's scale
127
- // signals here makes this recompute whenever the domain or plot size changes.
128
- readonly #projected = computed(() => {
129
- const scaleX = this.#chart.xScale();
130
- const scaleY = this.#chart.yScale();
131
- const sizes = this.sizes();
132
- const r = this.radius();
133
- return this.data().map((point, i) => ({
134
- cx: scaleX(point.x),
135
- cy: scaleY(point.y),
136
- r: sizes[i] ?? r,
137
- point,
138
- }));
139
- });
140
-
141
- protected readonly dots = computed(() => {
142
- const projected = this.#projected();
143
- const fallback = this.color();
144
- const palette = this.colors();
145
- // computeDots works purely from the projected pixel fields; zip its output
146
- // back with each source point so a dot's tap can emit the original datum.
147
- // A circle is a square with border-radius of half its size.
148
- return computeDots(projected).map((shape, i) => {
149
- const fill = palette.length ? palette[i % palette.length] : fallback;
150
- return {
151
- point: projected[i].point,
152
- style:
153
- `position: absolute; left: ${px(shape.left)}; top: ${px(shape.top)}; ` +
154
- `width: ${px(shape.size)}; height: ${px(shape.size)}; ` +
155
- `background-color: ${fill}; border-radius: ${px(shape.size / 2)};`,
156
- };
157
- });
38
+ /** The definition `<ui-scatter-chart>` renders. */
39
+ export const scatterChartDefinition = (options: ScatterChartOptions) => {
40
+ const {
41
+ data,
42
+ color = 'var(--primary)',
43
+ colors = [],
44
+ radius = 4,
45
+ sizes = [],
46
+ } = options;
47
+ const padding = options.padding ?? {};
48
+ return defineChart({
49
+ marks: [
50
+ dot(data, {
51
+ x: 'x',
52
+ y: 'y',
53
+ r: sizes.length ? (_d, { index }) => sizes[index] ?? radius : radius,
54
+ // dot's fill is a single color, so per-point colors go through the
55
+ // color scale, keyed by point index.
56
+ ...(colors.length
57
+ ? { color: (_d: XYDatum, { index }: { index: number }) => index }
58
+ : { fill: color }),
59
+ }),
60
+ ],
61
+ scales: presetAxes(
62
+ {
63
+ ...options,
64
+ padding: {
65
+ top: padding.top ?? SCATTER_INSET,
66
+ right: padding.right ?? SCATTER_INSET,
67
+ bottom: padding.bottom ?? SCATTER_INSET,
68
+ left: padding.left ?? SCATTER_INSET,
69
+ },
70
+ },
71
+ { x: data.map((d) => d.x), y: data.map((d) => d.y) },
72
+ ),
73
+ ...(colors.length
74
+ ? {
75
+ color: {
76
+ domain: data.map((_d, index) => index),
77
+ range: data.map((_d, index) => colors[index % colors.length]!),
78
+ },
79
+ }
80
+ : {}),
81
+ tooltip: options.tooltip === false ? false : tooltip,
158
82
  });
159
-
160
- protected onPointTap(point: ChartPoint): void {
161
- this.pointTap.emit(point);
162
- }
163
- }
83
+ };
164
84
 
165
85
  /**
166
- * A ready-to-use scatter chart for the common single-series case. Composes a
167
- * `<ui-cartesian-chart>` with one `<ui-scatter-series>` and auto-derives rounded
168
- * axis domains + ticks from the data. For a regression/trend line on top, or
169
- * full control over the axes, use `<ui-cartesian-chart>` with a
170
- * `<ui-scatter-series>` (and optionally a `<ui-line-series>`) directly.
86
+ * A scatter plot, or a bubble chart with `sizes`. For a trend line or several
87
+ * groups with a legend, build a definition with `dot` (and `linearRegressionY`)
88
+ * and render it with `<ui-chart>`.
89
+ *
90
+ * ```html
91
+ * <ui-scatter-chart [data]="heightWeight" xAxisLabel="Height" yAxisLabel="Weight" />
92
+ * ```
171
93
  */
172
94
  @Component({
173
95
  selector: 'ui-scatter-chart',
174
- standalone: true,
175
- imports: [LYNX_ELEMENTS, UiCartesianChart, UiScatterSeries],
96
+ imports: [UiChart],
176
97
  encapsulation: ViewEncapsulation.None,
177
98
  template: `
178
- <ui-cartesian-chart
179
- [xDomain]="xAxis().domain"
180
- [yDomain]="yAxis().domain"
181
- [xTicks]="xAxis().ticks"
182
- [yTicks]="yAxis().ticks"
183
- [width]="width()"
184
- [height]="height()"
185
- [tickCount]="tickCount()"
186
- [showXGrid]="showXGrid()"
187
- [xAxisLabel]="xAxisLabel()"
188
- [yAxisLabel]="yAxisLabel()"
189
- [padding]="effectivePadding()"
190
- [xTickFormat]="xTickFormat()"
191
- [yTickFormat]="yTickFormat()"
192
- [zoomable]="zoomable()"
193
- [zoomAxes]="zoomAxes()"
194
- [showZoomControls]="showZoomControls()"
195
- [minZoom]="minZoom()"
196
- [maxZoom]="maxZoom()"
197
- [zoomStep]="zoomStep()"
198
- [class]="userClass()"
199
- >
200
- <ui-scatter-series
201
- [data]="data()"
202
- [color]="color()"
203
- [colors]="colors()"
204
- [radius]="radius()"
205
- [sizes]="sizes()"
206
- (pointTap)="pointTap.emit($event)"
207
- />
208
- </ui-cartesian-chart>
99
+ <ui-chart
100
+ [definition]="definition()"
101
+ ${PRESET_CHART_BINDINGS}
102
+ (pointSelect)="onSelect($event)"
103
+ />
209
104
  `,
210
105
  })
211
- export class UiScatterChart {
212
- readonly data = input.required<ChartPoint[]>();
106
+ export class UiScatterChart extends UiCartesianPreset<XYDatum> {
107
+ readonly data = input.required<readonly XYDatum[]>();
213
108
  readonly color = input('var(--primary)');
214
- readonly colors = input<string[]>([]);
109
+ readonly colors = input<readonly string[]>([]);
215
110
  readonly radius = input(4);
216
- readonly sizes = input<number[]>([]);
217
- readonly width = input(320);
218
- readonly height = input(200);
219
- readonly tickCount = input(5);
220
- /** Draw vertical gridlines at each x tick (horizontal ones are on by default). */
221
- readonly showXGrid = input(false);
222
- /** Title for the x-axis (centered below the tick labels). */
223
- readonly xAxisLabel = input<string>('');
224
- /** Title for the y-axis (rotated in the left gutter). */
225
- readonly yAxisLabel = input<string>('');
226
- /** Inner inset of the plot area — merged over the default all-side inset
227
- * (below), so any side you set wins. See {@link ChartPadding}. */
228
- readonly padding = input<ChartPadding>({});
229
- /** Force the x-axis lower/upper bound instead of deriving it from the data.
230
- * Omitted bounds fall back to the data extremes. */
231
- readonly xMin = input<number | undefined>(undefined);
232
- readonly xMax = input<number | undefined>(undefined);
233
- /** Force the y-axis lower/upper bound (e.g. to compare two charts on one scale). */
234
- readonly yMin = input<number | undefined>(undefined);
235
- readonly yMax = input<number | undefined>(undefined);
236
- readonly xTickFormat = input<(value: number) => string>(defaultTickFormat);
237
- readonly yTickFormat = input<(value: number) => string>(defaultTickFormat);
238
- readonly userClass = input<string>('', { alias: 'class' });
239
-
240
- // Pan/zoom — forwarded to the underlying <ui-cartesian-chart>. Off by default.
241
- readonly zoomable = input(false);
242
- readonly zoomAxes = input<'x' | 'y' | 'xy'>('xy');
243
- readonly showZoomControls = input(true);
244
- readonly minZoom = input(1);
245
- readonly maxZoom = input(8);
246
- readonly zoomStep = input(1.4);
247
-
248
- readonly pointTap = output<ChartPoint>();
249
-
250
- // Auto-compute rounded axes from the data extremes, letting an explicit
251
- // min/max override either end. Unlike the bar/area charts there is no baseline
252
- // to fold in — both axes simply fit the data. `Math.min/max(...[])` yield
253
- // ±Infinity for empty data, which `niceScale` handles by padding.
254
- protected readonly xAxis = computed(() => {
255
- const xs = this.data().map((p) => p.x);
256
- const lo = this.xMin() ?? Math.min(...xs);
257
- const hi = this.xMax() ?? Math.max(...xs);
258
- return niceScale(lo, hi, this.tickCount());
259
- });
260
- protected readonly yAxis = computed(() => {
261
- const ys = this.data().map((p) => p.y);
262
- const lo = this.yMin() ?? Math.min(...ys);
263
- const hi = this.yMax() ?? Math.max(...ys);
264
- return niceScale(lo, hi, this.tickCount());
265
- });
266
-
267
- // Dots sit at the data extremes, so without an inset the edge dots would be
268
- // clipped by the frame. Default a small inset on every side; user `padding` is
269
- // spread last, so an explicit side wins.
270
- protected readonly effectivePadding = computed<ChartPadding>(() => ({
271
- top: SCATTER_INSET,
272
- right: SCATTER_INSET,
273
- bottom: SCATTER_INSET,
274
- left: SCATTER_INSET,
275
- ...this.padding(),
276
- }));
111
+ readonly sizes = input<readonly number[]>([]);
112
+
113
+ /** The tapped point's datum. */
114
+ readonly pointTap = output<XYDatum>();
115
+
116
+ protected readonly definition = computed(() =>
117
+ scatterChartDefinition({
118
+ ...this.commonOptions(),
119
+ data: this.data(),
120
+ color: this.color(),
121
+ colors: this.colors(),
122
+ radius: this.radius(),
123
+ sizes: this.sizes(),
124
+ }),
125
+ );
126
+
127
+ protected onSelect(point: ChartPoint | null): void {
128
+ const datum = this.datumOf(point);
129
+ if (datum) this.pointTap.emit(datum);
130
+ }
277
131
  }
@@ -1,17 +1,35 @@
1
1
  import { Component } from '@angular/core';
2
2
  import { render, waitForUpdate } from '@blotch/angular-lynx-testing-library';
3
3
  import { LYNX_ELEMENTS } from '@blotch/angular-lynx';
4
- import { describe, expect, it } from 'vitest';
4
+ import { describe, expect, it, vi } from 'vitest';
5
5
  import { setInputSignal } from '../../../test-utils/set-input-signal';
6
6
  import { UiScrollArea } from './scroll-area';
7
7
 
8
8
  @Component({
9
- standalone: true,
10
9
  imports: [UiScrollArea, LYNX_ELEMENTS],
11
10
  template: `<ui-scroll-area><text>Body</text></ui-scroll-area>`,
12
11
  })
13
12
  class ScrollAreaHost {}
14
13
 
14
+ /**
15
+ * The values each boolean reached Lynx with, through `__SetAttribute`.
16
+ */
17
+ const watchAttributes = () => {
18
+ const mainThread = (
19
+ globalThis as unknown as {
20
+ lynxTestingEnv: { mainThread: { globalThis: Record<string, unknown> } };
21
+ }
22
+ ).lynxTestingEnv.mainThread.globalThis;
23
+ const setAttribute = vi.spyOn(mainThread, '__SetAttribute' as never);
24
+ return {
25
+ last: (name: string): unknown =>
26
+ (setAttribute.mock.calls as unknown[][])
27
+ .filter((call) => call[1] === name)
28
+ .at(-1)?.[2],
29
+ restore: () => setAttribute.mockRestore(),
30
+ };
31
+ };
32
+
15
33
  describe('UiScrollArea', () => {
16
34
  it('renders projected content in the scroll view', async () => {
17
35
  const { getByText } = await render(ScrollAreaHost);
@@ -37,4 +55,21 @@ describe('UiScrollArea', () => {
37
55
  const rowEl = container.querySelector('.flex-row');
38
56
  expect(rowEl).toBeTruthy();
39
57
  });
58
+
59
+ it('hands Lynx real booleans, so the defaults that are on can be turned off', async () => {
60
+ // A false used to remove the attribute, which leaves Lynx's default: on.
61
+ const attributes = watchAttributes();
62
+ const { componentRef } = await render(UiScrollArea);
63
+ const inst = componentRef.instance as UiScrollArea;
64
+ expect(attributes.last('bounces')).toBe(true);
65
+ expect(attributes.last('scroll-bar-enable')).toBe(true);
66
+ setInputSignal(inst.bounces, false);
67
+ setInputSignal(inst.scrollBar, false);
68
+ setInputSignal(inst.nestedScroll, true);
69
+ await waitForUpdate();
70
+ expect(attributes.last('bounces')).toBe(false);
71
+ expect(attributes.last('scroll-bar-enable')).toBe(false);
72
+ expect(attributes.last('enable-nested-scroll')).toBe(true);
73
+ attributes.restore();
74
+ });
40
75
  });
@@ -11,19 +11,19 @@ import { cn } from '../../utils/cn';
11
11
 
12
12
  @Component({
13
13
  selector: 'ui-scroll-area',
14
- standalone: true,
15
14
  imports: [LYNX_ELEMENTS],
16
15
  encapsulation: ViewEncapsulation.None,
17
- // `|| undefined` on boolean attributes removes the attribute entirely when
18
- // false. Passing the string "false" (what Angular produces for a false
19
- // binding without `|| undefined`) is still treated as truthy by Lynx's
20
- // native attribute parser, so absence is the only way to truly disable.
16
+ // The booleans are property bindings to <scroll-view>'s inputs, which hand
17
+ // Lynx a real `false`. They used to be `[attr.x]="x() || undefined"`, which
18
+ // removed the attribute when false, and a missing attribute means Lynx's
19
+ // default: on, for `bounces` and `scroll-bar-enable`. So neither could be
20
+ // turned off. (`[attr.x]` would send the string "false" instead.)
21
21
  template: `
22
22
  <scroll-view
23
23
  [attr.scroll-orientation]="orientation()"
24
- [attr.bounces]="bounces() || undefined"
25
- [attr.scroll-bar-enable]="scrollBar() || undefined"
26
- [attr.enable-nested-scroll]="nestedScroll() || undefined"
24
+ [bounces]="bounces()"
25
+ [scroll-bar-enable]="scrollBar()"
26
+ [enable-nested-scroll]="nestedScroll()"
27
27
  [attr.upper-threshold]="upperThreshold()"
28
28
  [attr.lower-threshold]="lowerThreshold()"
29
29
  [class]="scrollClass()"
@@ -1,6 +1,7 @@
1
1
  import { render, waitForUpdate } from '@blotch/angular-lynx-testing-library';
2
2
  import { describe, expect, it } from 'vitest';
3
3
  import { setInputSignal } from '../../../test-utils/set-input-signal';
4
+ import { ICON_LIGHT_COLORS } from '../icon/icon-theme';
4
5
  import { UiSpinner } from './spinner';
5
6
 
6
7
  describe('UiSpinner', () => {
@@ -22,6 +23,32 @@ describe('UiSpinner', () => {
22
23
  expect(svg?.getAttribute('content')).not.toContain('currentColor');
23
24
  });
24
25
 
26
+ it("draws in the theme's foreground when no color is set", async () => {
27
+ const { container } = await render(UiSpinner);
28
+ const content = container.querySelector('svg')?.getAttribute('content');
29
+ expect(content).toContain(ICON_LIGHT_COLORS['foreground']);
30
+ expect(content).not.toContain('currentColor');
31
+ });
32
+
33
+ it('resolves a theme token color', async () => {
34
+ const { container, componentRef } = await render(UiSpinner);
35
+ setInputSignal(
36
+ (componentRef.instance as UiSpinner).color,
37
+ 'var(--primary-foreground)',
38
+ );
39
+ await waitForUpdate();
40
+ expect(container.querySelector('svg')?.getAttribute('content')).toContain(
41
+ ICON_LIGHT_COLORS['primary-foreground'],
42
+ );
43
+ });
44
+
45
+ it('applies the class input to the spinner', async () => {
46
+ const { container, componentRef } = await render(UiSpinner);
47
+ setInputSignal((componentRef.instance as UiSpinner).userClass, 'ml-2');
48
+ await waitForUpdate();
49
+ expect(container.querySelector('view')?.getAttribute('class')).toBe('ml-2');
50
+ });
51
+
25
52
  it('resizes for each supported size', async () => {
26
53
  const { componentRef, container } = await render(UiSpinner);
27
54
  for (const [size, px] of [