@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
@@ -2,272 +2,114 @@ 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 { d3Curve, defineChart, dot, lineY } from '@tanstack/charts/universal';
9
+ import type { ChartMark, ChartPoint } from '@tanstack/charts/types';
10
+ import { curveMonotoneX } from 'd3-shape';
10
11
 
12
+ import { UiChart, tooltip } from '../chart/chart';
11
13
  import {
12
- type ChartPadding,
13
- type ChartPoint,
14
- UiCartesianChart,
15
- niceScale,
16
- sampleSmoothLine,
17
- } from '../cartesian-chart/cartesian-chart';
18
-
19
- // ---------------------------------------------------------------------------
20
- // Pure geometry (exported so it can be unit-tested without Angular)
21
- // ---------------------------------------------------------------------------
22
-
23
- /** A rotated line segment spec: start point (plot-local px), length, angle. */
24
- export type LineSegment = {
25
- x: number;
26
- y: number;
27
- length: number;
28
- /** Rotation in degrees, measured clockwise from the +x axis (screen y-down). */
29
- angle: number;
30
- };
14
+ type CartesianPresetOptions,
15
+ PRESET_CHART_BINDINGS,
16
+ UiCartesianPreset,
17
+ type XYDatum,
18
+ presetAxes,
19
+ } from '../chart/chart-presets';
20
+
21
+ export type LineChartOptions = {
22
+ readonly data: readonly XYDatum[];
23
+ /** Any CSS color; theme tokens like `var(--primary)` follow dark mode. */
24
+ readonly color?: string;
25
+ readonly strokeWidth?: number;
26
+ readonly dotRadius?: number;
27
+ readonly showDots?: boolean;
28
+ /** Draws a monotone curve through the points instead of straight segments. */
29
+ readonly smooth?: boolean;
30
+ } & CartesianPresetOptions;
31
31
 
32
32
  /**
33
- * Turns a polyline — already projected into plot-local pixel space — into a list
34
- * of rotated segment specs. Lynx has no `<line>`/SVG, so each segment is drawn
35
- * as a thin `<view>` rotated about its start point (see `UiLineSeries`).
33
+ * The definition `<ui-line-chart>` renders, for when you want to start from
34
+ * the preset and add marks of your own:
36
35
  *
37
- * The length uses `Math.sqrt(dx² + dy²)` rather than `Math.hypot` for the same
38
- * reason `niceNum` avoids `Math.log10`: `hypot` is a runtime method the Lynx
39
- * build pipeline may not polyfill, whereas `sqrt`/`atan2` are ES5 and always
40
- * present. Zero-length segments (duplicate consecutive points) are skipped so we
41
- * never emit a degenerate 0-width rotated view.
36
+ * ```ts
37
+ * const base = lineChartDefinition({ data });
38
+ * const withTarget = defineChart({ ...base, marks: [...base.marks, ruleY([80])] });
39
+ * ```
42
40
  */
43
- export const computeLineSegments = (
44
- points: readonly ChartPoint[],
45
- ): LineSegment[] => {
46
- const segments: LineSegment[] = [];
47
- for (let i = 0; i < points.length - 1; i++) {
48
- const p1 = points[i];
49
- const p2 = points[i + 1];
50
- const dx = p2.x - p1.x;
51
- const dy = p2.y - p1.y;
52
- const length = Math.sqrt(dx * dx + dy * dy);
53
- if (length === 0) continue;
54
- const angle = (Math.atan2(dy, dx) * 180) / Math.PI;
55
- segments.push({ x: p1.x, y: p1.y, length, angle });
41
+ export const lineChartDefinition = (options: LineChartOptions) => {
42
+ const {
43
+ data,
44
+ color = 'var(--primary)',
45
+ strokeWidth = 2,
46
+ dotRadius = 3,
47
+ showDots = true,
48
+ smooth = false,
49
+ } = options;
50
+ const curve = smooth ? d3Curve(curveMonotoneX) : undefined;
51
+ const marks: ChartMark<XYDatum, any, any, any, any, any, any>[] = [
52
+ lineY(data, { x: 'x', y: 'y', stroke: color, strokeWidth, curve }),
53
+ ];
54
+ if (showDots && dotRadius > 0) {
55
+ marks.push(dot(data, { x: 'x', y: 'y', r: dotRadius, fill: color }));
56
56
  }
57
- return segments;
57
+ return defineChart({
58
+ marks,
59
+ scales: presetAxes(options, {
60
+ x: data.map((d) => d.x),
61
+ y: data.map((d) => d.y),
62
+ }),
63
+ tooltip: options.tooltip === false ? false : tooltip,
64
+ });
58
65
  };
59
66
 
60
- // ---------------------------------------------------------------------------
61
- // Rendering helpers (mirror the equivalents in cartesian-chart.ts — kept local
62
- // so this module stays self-contained when scaffolded by `dolan add`)
63
- // ---------------------------------------------------------------------------
64
-
65
- /**
66
- * Formats a pixel value for an inline style, avoiding scientific notation.
67
- */
68
- const px = (n: number): string => `${n.toFixed(2)}px`;
69
-
70
- /**
71
- * Default axis label formatter — plain JS only (no `Intl`, absent in Lynx).
72
- */
73
- const defaultTickFormat = (value: number): string =>
74
- String(Math.round(value * 100) / 100);
75
-
76
67
  /**
77
- * A single line series drawn inside a `<ui-cartesian-chart>`. Reads the shared
78
- * scales from the parent chart via DI and renders the polyline (rotated `<view>`
79
- * segments) plus a dot at each data point.
68
+ * A single-series line chart. For several series, a reference line or any
69
+ * other mark, build a definition and render it with `<ui-chart>`, starting
70
+ * from {@link lineChartDefinition} if you like.
80
71
  *
81
- * ## Why the host is `position: absolute; width/height: 100%`
82
- * Lynx resolves an absolute child against its **direct parent** (there is no
83
- * `position: static`). The marks are direct children of this host, so the host
84
- * itself must be the plot-sized containing block — it fills the plot area (its
85
- * direct parent, a definite-size box). Sizing an *inner* view instead would fail
86
- * because a content-sized host of absolute-only children collapses to 0×0.
87
- */
88
- @Component({
89
- selector: 'ui-line-series',
90
- standalone: true,
91
- imports: [LYNX_ELEMENTS],
92
- encapsulation: ViewEncapsulation.None,
93
- host: {
94
- style:
95
- 'position: absolute; left: 0px; top: 0px; width: 100%; height: 100%;',
96
- '[class]': 'userClass()',
97
- },
98
- template: `
99
- <!-- Segments render before dots so each dot paints on top of the line
100
- (Lynx paint order follows source order; z-index is unnecessary). -->
101
- @for (segment of segments(); track $index) {
102
- <view [style]="segment.style"></view>
103
- }
104
- @for (dot of dots(); track $index) {
105
- <view [style]="dot.style" (bindtap)="onPointTap(dot.point)"></view>
106
- }
107
- `,
108
- })
109
- export class UiLineSeries {
110
- readonly #chart = inject(UiCartesianChart);
111
-
112
- readonly data = input.required<ChartPoint[]>();
113
- readonly color = input('var(--primary)');
114
- readonly strokeWidth = input(2);
115
- readonly dotRadius = input(3);
116
- readonly showDots = input(true);
117
- /** Draw a smooth (monotone-cubic) curve through the points instead of straight
118
- * segments. Dots still sit on the raw data points. */
119
- readonly smooth = input(false);
120
- readonly userClass = input<string>('', { alias: 'class' });
121
-
122
- readonly pointTap = output<ChartPoint>();
123
-
124
- // Project each data point into plot-local pixel space via the shared scales.
125
- // Reading the chart's scale signals here makes this recompute whenever the
126
- // domain or plot size changes.
127
- readonly #pixelPoints = computed(() => {
128
- const scaleX = this.#chart.xScale();
129
- const scaleY = this.#chart.yScale();
130
- return this.data().map((point) => ({
131
- x: scaleX(point.x),
132
- y: scaleY(point.y),
133
- point,
134
- }));
135
- });
136
-
137
- protected readonly segments = computed(() => {
138
- const stroke = this.strokeWidth();
139
- const fill = this.color();
140
- // Smooth mode replaces the raw polyline with a dense monotone-cubic
141
- // resampling; `computeLineSegments` then rasterizes it identically — the
142
- // curve is just made of many more, shorter rotated views.
143
- const pixels = this.#pixelPoints();
144
- const line = this.smooth() ? sampleSmoothLine(pixels) : pixels;
145
- return computeLineSegments(line).map((s) => ({
146
- style:
147
- `position: absolute; left: ${px(s.x)}; top: ${px(s.y - stroke / 2)}; ` +
148
- `width: ${px(s.length)}; height: ${px(stroke)}; ` +
149
- `background-color: ${fill}; border-radius: ${px(stroke / 2)}; ` +
150
- `transform-origin: 0 50%; transform: rotate(${s.angle.toFixed(2)}deg);`,
151
- }));
152
- });
153
-
154
- protected readonly dots = computed(() => {
155
- if (!this.showDots()) return [];
156
- const radius = this.dotRadius();
157
- const fill = this.color();
158
- return this.#pixelPoints().map((p) => ({
159
- point: p.point,
160
- style:
161
- `position: absolute; left: ${px(p.x - radius)}; top: ${px(p.y - radius)}; ` +
162
- `width: ${px(radius * 2)}; height: ${px(radius * 2)}; ` +
163
- `background-color: ${fill}; border-radius: ${px(radius)};`,
164
- }));
165
- });
166
-
167
- protected onPointTap(point: ChartPoint): void {
168
- this.pointTap.emit(point);
169
- }
170
- }
171
-
172
- /**
173
- * A ready-to-use line chart for the common single-series case. Composes a
174
- * `<ui-cartesian-chart>` with one `<ui-line-series>` and auto-derives rounded
175
- * axis domains + ticks from the data. For multiple series or full control over
176
- * the axes, use `<ui-cartesian-chart>` with `<ui-line-series>` children directly.
72
+ * ```html
73
+ * <ui-line-chart [data]="revenue" [smooth]="true" yAxisLabel="Revenue" />
74
+ * ```
177
75
  */
178
76
  @Component({
179
77
  selector: 'ui-line-chart',
180
- standalone: true,
181
- imports: [LYNX_ELEMENTS, UiCartesianChart, UiLineSeries],
78
+ imports: [UiChart],
182
79
  encapsulation: ViewEncapsulation.None,
183
80
  template: `
184
- <ui-cartesian-chart
185
- [xDomain]="xAxis().domain"
186
- [yDomain]="yAxis().domain"
187
- [xTicks]="xAxis().ticks"
188
- [yTicks]="yAxis().ticks"
189
- [width]="width()"
190
- [height]="height()"
191
- [tickCount]="tickCount()"
192
- [showXGrid]="showXGrid()"
193
- [xAxisLabel]="xAxisLabel()"
194
- [yAxisLabel]="yAxisLabel()"
195
- [padding]="padding()"
196
- [xTickFormat]="xTickFormat()"
197
- [yTickFormat]="yTickFormat()"
198
- [zoomable]="zoomable()"
199
- [zoomAxes]="zoomAxes()"
200
- [showZoomControls]="showZoomControls()"
201
- [minZoom]="minZoom()"
202
- [maxZoom]="maxZoom()"
203
- [zoomStep]="zoomStep()"
204
- [class]="userClass()"
205
- >
206
- <ui-line-series
207
- [data]="data()"
208
- [color]="color()"
209
- [strokeWidth]="strokeWidth()"
210
- [dotRadius]="dotRadius()"
211
- [showDots]="showDots()"
212
- [smooth]="smooth()"
213
- (pointTap)="pointTap.emit($event)"
214
- />
215
- </ui-cartesian-chart>
81
+ <ui-chart
82
+ [definition]="definition()"
83
+ ${PRESET_CHART_BINDINGS}
84
+ (pointSelect)="onSelect($event)"
85
+ />
216
86
  `,
217
87
  })
218
- export class UiLineChart {
219
- readonly data = input.required<ChartPoint[]>();
88
+ export class UiLineChart extends UiCartesianPreset<XYDatum> {
89
+ readonly data = input.required<readonly XYDatum[]>();
220
90
  readonly color = input('var(--primary)');
221
- readonly width = input(320);
222
- readonly height = input(200);
223
- readonly tickCount = input(5);
224
91
  readonly strokeWidth = input(2);
225
92
  readonly dotRadius = input(3);
226
93
  readonly showDots = input(true);
227
- /** Draw a smooth (monotone-cubic) curve through the points. */
228
94
  readonly smooth = input(false);
229
- /** Draw vertical gridlines at each x tick. */
230
- readonly showXGrid = input(false);
231
- /** Title for the x-axis (centered below the tick labels). */
232
- readonly xAxisLabel = input<string>('');
233
- /** Title for the y-axis (rotated in the left gutter). */
234
- readonly yAxisLabel = input<string>('');
235
- /** Inner inset of the plot area, each side a fraction (0–1) — see {@link ChartPadding}. */
236
- readonly padding = input<ChartPadding>({});
237
- /** Force the x-axis lower/upper bound instead of deriving it from the data —
238
- * e.g. `xMin={0}` to anchor the origin. Omitted bounds fall back to the data. */
239
- readonly xMin = input<number | undefined>(undefined);
240
- readonly xMax = input<number | undefined>(undefined);
241
- /** Force the y-axis lower/upper bound (e.g. `yMin={0}` to start at zero). */
242
- readonly yMin = input<number | undefined>(undefined);
243
- readonly yMax = input<number | undefined>(undefined);
244
- readonly xTickFormat = input<(value: number) => string>(defaultTickFormat);
245
- readonly yTickFormat = input<(value: number) => string>(defaultTickFormat);
246
- readonly userClass = input<string>('', { alias: 'class' });
247
95
 
248
- // Pan/zoom — forwarded to the underlying <ui-cartesian-chart>. Off by default.
249
- readonly zoomable = input(false);
250
- readonly zoomAxes = input<'x' | 'y' | 'xy'>('xy');
251
- readonly showZoomControls = input(true);
252
- readonly minZoom = input(1);
253
- readonly maxZoom = input(8);
254
- readonly zoomStep = input(1.4);
255
-
256
- readonly pointTap = output<ChartPoint>();
257
-
258
- // Auto-compute rounded axes from the data extremes, letting an explicit
259
- // min/max override either end. `Math.min/max(...[])` yield ±Infinity for empty
260
- // data, which `niceScale` handles by padding.
261
- protected readonly xAxis = computed(() => {
262
- const xs = this.data().map((p) => p.x);
263
- const lo = this.xMin() ?? Math.min(...xs);
264
- const hi = this.xMax() ?? Math.max(...xs);
265
- return niceScale(lo, hi, this.tickCount());
266
- });
267
- protected readonly yAxis = computed(() => {
268
- const ys = this.data().map((p) => p.y);
269
- const lo = this.yMin() ?? Math.min(...ys);
270
- const hi = this.yMax() ?? Math.max(...ys);
271
- return niceScale(lo, hi, this.tickCount());
272
- });
96
+ /** The tapped point's datum. */
97
+ readonly pointTap = output<XYDatum>();
98
+
99
+ protected readonly definition = computed(() =>
100
+ lineChartDefinition({
101
+ ...this.commonOptions(),
102
+ data: this.data(),
103
+ color: this.color(),
104
+ strokeWidth: this.strokeWidth(),
105
+ dotRadius: this.dotRadius(),
106
+ showDots: this.showDots(),
107
+ smooth: this.smooth(),
108
+ }),
109
+ );
110
+
111
+ protected onSelect(point: ChartPoint | null): void {
112
+ const datum = this.datumOf(point);
113
+ if (datum) this.pointTap.emit(datum);
114
+ }
273
115
  }
@@ -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 { UiList } from './list';
7
7
 
8
8
  @Component({
9
- standalone: true,
10
9
  imports: [UiList, LYNX_ELEMENTS],
11
10
  template: `<ui-list><text>Item</text></ui-list>`,
12
11
  })
13
12
  class ListHost {}
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('UiList', () => {
16
34
  it('renders projected content', async () => {
17
35
  // Lynx `<list>` is virtualized — items are only added to the native tree by
@@ -32,4 +50,53 @@ describe('UiList', () => {
32
50
  const list = container.querySelector('list');
33
51
  expect(list?.getAttribute('style')).toContain('height: 400px');
34
52
  });
53
+
54
+ it('sets the gaps as list styles, which Lynx reads, not attributes', async () => {
55
+ // jsdom drops style properties it doesn't know, so the gaps never show in
56
+ // the style attribute: watch what reaches Lynx's inline style API instead.
57
+ const mainThread = (
58
+ globalThis as unknown as {
59
+ lynxTestingEnv: { mainThread: { globalThis: Record<string, unknown> } };
60
+ }
61
+ ).lynxTestingEnv.mainThread.globalThis;
62
+ const addInlineStyle = vi.spyOn(mainThread, '__AddInlineStyle' as never);
63
+ const { container, componentRef } = await render(UiList);
64
+ const list = componentRef.instance as UiList;
65
+ setInputSignal(list.mainAxisGap, '8px');
66
+ setInputSignal(list.crossAxisGap, '12px');
67
+ await waitForUpdate();
68
+
69
+ const element = container.querySelector('list');
70
+ expect(addInlineStyle).toHaveBeenCalledWith(
71
+ element,
72
+ 'list-main-axis-gap',
73
+ '8px',
74
+ );
75
+ expect(addInlineStyle).toHaveBeenCalledWith(
76
+ element,
77
+ 'list-cross-axis-gap',
78
+ '12px',
79
+ );
80
+ expect(element?.getAttribute('list-main-axis-gap')).toBeNull();
81
+ addInlineStyle.mockRestore();
82
+ });
83
+
84
+ it('hands Lynx real booleans, so the defaults that are on can be turned off', async () => {
85
+ // A false used to remove the attribute, which leaves Lynx's default: on.
86
+ const attributes = watchAttributes();
87
+ const { componentRef } = await render(UiList);
88
+ const inst = componentRef.instance as UiList;
89
+ expect(attributes.last('bounces')).toBe(true);
90
+ expect(attributes.last('scroll-bar-enable')).toBe(true);
91
+ setInputSignal(inst.bounces, false);
92
+ setInputSignal(inst.scrollBar, false);
93
+ setInputSignal(inst.nestedScroll, true);
94
+ setInputSignal(inst.sticky, true);
95
+ await waitForUpdate();
96
+ expect(attributes.last('bounces')).toBe(false);
97
+ expect(attributes.last('scroll-bar-enable')).toBe(false);
98
+ expect(attributes.last('enable-nested-scroll')).toBe(true);
99
+ expect(attributes.last('sticky')).toBe(true);
100
+ attributes.restore();
101
+ });
35
102
  });
@@ -11,25 +11,24 @@ import { cn } from '../../utils/cn';
11
11
 
12
12
  @Component({
13
13
  selector: 'ui-list',
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" is treated as truthy by Lynx's native
19
- // attribute parser, so absence is the only way to truly turn these off.
16
+ // The booleans are property bindings to <list>'s inputs, which hand Lynx a
17
+ // real `false`. They used to be `[attr.x]="x() || undefined"`, which removed
18
+ // the attribute when false, and a missing attribute means Lynx's default: on,
19
+ // for `bounces` and `scroll-bar-enable`. So neither could be turned off.
20
+ // (`[attr.x]` would send the string "false" instead.)
20
21
  template: `
21
22
  <list
22
23
  [attr.list-type]="listType()"
23
24
  [attr.span-count]="spanCount()"
24
25
  [attr.scroll-orientation]="orientation()"
25
- [attr.bounces]="bounces() || undefined"
26
- [attr.scroll-bar-enable]="scrollBar() || undefined"
27
- [attr.enable-nested-scroll]="nestedScroll() || undefined"
28
- [attr.sticky]="sticky() || undefined"
26
+ [bounces]="bounces()"
27
+ [scroll-bar-enable]="scrollBar()"
28
+ [enable-nested-scroll]="nestedScroll()"
29
+ [sticky]="sticky()"
29
30
  [attr.sticky-offset]="stickyOffset()"
30
31
  [attr.preload-buffer-count]="preloadBufferCount()"
31
- [attr.list-main-axis-gap]="mainAxisGap()"
32
- [attr.list-cross-axis-gap]="crossAxisGap()"
33
32
  [attr.lower-threshold-item-count]="lowerThresholdItemCount()"
34
33
  [attr.upper-threshold-item-count]="upperThresholdItemCount()"
35
34
  [class]="listClass()"
@@ -63,9 +62,16 @@ export class UiList {
63
62
 
64
63
  protected readonly listClass = computed(() => cn('w-full', this.userClass()));
65
64
 
66
- // Explicit height is required for list to scroll in Lynx
67
- protected readonly listStyle = computed(() => {
68
- const h = this.height();
69
- return h ? `height: ${h};` : '';
70
- });
65
+ // Explicit height is required for list to scroll in Lynx. The gaps go here
66
+ // too: they're CSS properties that Lynx's list reads from its style, and as
67
+ // attributes they did nothing.
68
+ protected readonly listStyle = computed(() =>
69
+ [
70
+ this.height() && `height: ${this.height()};`,
71
+ this.mainAxisGap() && `list-main-axis-gap: ${this.mainAxisGap()};`,
72
+ this.crossAxisGap() && `list-cross-axis-gap: ${this.crossAxisGap()};`,
73
+ ]
74
+ .filter(Boolean)
75
+ .join(' '),
76
+ );
71
77
  }
@@ -2,7 +2,9 @@ import { Component, signal } from '@angular/core';
2
2
  import { render, waitForUpdate } from '@blotch/angular-lynx-testing-library';
3
3
  import { LYNX_ELEMENTS } from '@blotch/angular-lynx';
4
4
  import { describe, expect, it, vi } from 'vitest';
5
+ import { DURATION } from '../../utils/animate';
5
6
  import { setInputSignal } from '../../../test-utils/set-input-signal';
7
+ import { UI_ICON_COLOR } from '../icon/icon-theme';
6
8
  import {
7
9
  UiNavDrawer,
8
10
  UiNavDrawerContent,
@@ -13,7 +15,6 @@ import {
13
15
  } from './nav-drawer';
14
16
 
15
17
  @Component({
16
- standalone: true,
17
18
  imports: [
18
19
  UiNavDrawer,
19
20
  UiNavDrawerHeader,
@@ -39,6 +40,47 @@ class NavDrawerHost {
39
40
  }
40
41
 
41
42
  describe('UiNavDrawer', () => {
43
+ it('stops its close timers when it is destroyed while closing', async () => {
44
+ const warn = vi.spyOn(console, 'warn');
45
+ const { componentRef, unmount } = await render(UiNavDrawer);
46
+ const inst = componentRef.instance as UiNavDrawer;
47
+ inst.open.set(true);
48
+ for (let i = 0; i < 5; i++) await waitForUpdate();
49
+ inst.open.set(false);
50
+ await waitForUpdate();
51
+ // Destroyed during the close animation, as an `@if` around the drawer
52
+ // would do. Its close timer (DURATION.normal + 20) used to fire anyway
53
+ // and emit `closed` from the destroyed output.
54
+ unmount();
55
+ await new Promise((r) => setTimeout(r, DURATION.normal + 50));
56
+ const warnings = warn.mock.calls.map(([message]) => String(message));
57
+ warn.mockRestore();
58
+ expect(warnings.filter((message) => message.includes('NG0953'))).toEqual(
59
+ [],
60
+ );
61
+ });
62
+
63
+ it('moves a screen reader into the panel and keeps it there while open', async () => {
64
+ const { componentRef, container } = await render(UiNavDrawer);
65
+ const inst = componentRef.instance as UiNavDrawer;
66
+ const panel = container.querySelector(
67
+ '[accessibility-exclusive-focus]',
68
+ ) as Element & { invoke?: unknown };
69
+ expect(panel.getAttribute('accessibility-exclusive-focus')).toBe('false');
70
+ const invoke = vi.fn();
71
+ panel.invoke = invoke;
72
+ (inst as unknown as { backdropRef: unknown }).backdropRef = signal({
73
+ nativeElement: panel,
74
+ });
75
+ (inst as unknown as { panelRef: unknown }).panelRef = signal({
76
+ nativeElement: panel,
77
+ });
78
+ inst.open.set(true);
79
+ for (let i = 0; i < 5; i++) await waitForUpdate();
80
+ expect(invoke).toHaveBeenCalledWith('requestAccessibilityFocus');
81
+ expect(panel.getAttribute('accessibility-exclusive-focus')).toBe('true');
82
+ });
83
+
42
84
  const patchRefs = (inst: UiNavDrawer, view: Element) => {
43
85
  (inst as unknown as { backdropRef: unknown }).backdropRef = signal({
44
86
  nativeElement: view,
@@ -118,12 +160,18 @@ describe('UiNavDrawer', () => {
118
160
  it('open effect bails when backdrop/panel refs are undefined', async () => {
119
161
  const { componentRef } = await render(UiNavDrawer);
120
162
  const inst = componentRef.instance as UiNavDrawer;
163
+ const closed = vi.fn();
164
+ inst.closed.subscribe(closed);
121
165
  inst.open.set(true);
122
166
  await waitForUpdate();
123
167
  await waitForUpdate();
124
168
  inst.open.set(false);
125
169
  await waitForUpdate();
170
+ // Wait out the close sequence, so #animateOut runs (and bails) while the
171
+ // component is still alive. Destroying it clears the sequence's timers.
172
+ await new Promise((r) => setTimeout(r, DURATION.normal + 50));
126
173
  expect(inst.open()).toBe(false);
174
+ expect(closed).toHaveBeenCalledOnce();
127
175
  });
128
176
  });
129
177
 
@@ -216,6 +264,25 @@ describe('UiNavDrawerItem', () => {
216
264
  expect(() => inst.onPressCancel()).not.toThrow();
217
265
  });
218
266
 
267
+ it("destructive points the content's foreground at the destructive color", async () => {
268
+ const { item, itemResult } = await renderItem();
269
+ const view = () => itemResult.container.querySelector('view');
270
+ expect(view()?.getAttribute('class')).not.toContain('--foreground');
271
+ setInputSignal(item.variant, 'destructive');
272
+ await waitForUpdate();
273
+ expect(view()?.getAttribute('class')).toContain(
274
+ '[--foreground:var(--destructive)]',
275
+ );
276
+ });
277
+
278
+ it('gives projected icons the destructive color', async () => {
279
+ const { item, itemResult } = await renderItem();
280
+ const iconColor = itemResult.componentRef.injector.get(UI_ICON_COLOR);
281
+ expect(iconColor()).toBeUndefined();
282
+ setInputSignal(item.variant, 'destructive');
283
+ expect(iconColor()).toBe('var(--destructive)');
284
+ });
285
+
219
286
  it('active item carries the accent background', async () => {
220
287
  const { item, itemResult } = await renderItem();
221
288
  setInputSignal(item.active, true);
@@ -242,6 +309,18 @@ describe('UiNavDrawerItem', () => {
242
309
  });
243
310
 
244
311
  describe('UiNavDrawerTrigger', () => {
312
+ it('is a labeled button for screen readers', async () => {
313
+ // Only the first render is checked: under JIT, the nested <ui-icon>'s
314
+ // static name="menu" never reaches its signal input, so its change
315
+ // detection throws NG0950 and later updates don't land.
316
+ const { container } = await render(UiNavDrawerTrigger);
317
+ const trigger = container.querySelector('[accessibility-element]');
318
+ expect(trigger?.getAttribute('accessibility-traits')).toBe('button');
319
+ expect(trigger?.getAttribute('accessibility-label')).toBe(
320
+ 'Open navigation menu',
321
+ );
322
+ });
323
+
245
324
  it('onTap emits pressed', async () => {
246
325
  const { componentRef } = await render(UiNavDrawerTrigger);
247
326
  const inst = componentRef.instance as unknown as UiNavDrawerTrigger & {