@blotch/dolan 22.2.3 → 22.2.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/add.js +9 -1
- package/dist/commands/add.js.map +1 -1
- package/dist/commands/add.test.js +36 -3
- package/dist/commands/add.test.js.map +1 -1
- package/dist/commands/doctor.js +187 -7
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/doctor.test.js +280 -7
- package/dist/commands/doctor.test.js.map +1 -1
- package/dist/commands/init.d.ts +2 -2
- package/dist/commands/init.js +38 -18
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/init.test.js +107 -1
- package/dist/commands/init.test.js.map +1 -1
- package/dist/commands/theme.js +1 -1
- package/dist/commands/theme.js.map +1 -1
- package/dist/commands/update.d.ts +5 -0
- package/dist/commands/update.js +126 -45
- package/dist/commands/update.js.map +1 -1
- package/dist/commands/update.test.js +314 -2
- package/dist/commands/update.test.js.map +1 -1
- package/dist/lockfile.d.ts +7 -0
- package/dist/lockfile.js +36 -0
- package/dist/lockfile.js.map +1 -1
- package/dist/lockfile.test.d.ts +1 -0
- package/dist/lockfile.test.js +25 -0
- package/dist/lockfile.test.js.map +1 -0
- package/dist/registry.d.ts +6 -0
- package/dist/registry.js +48 -2
- package/dist/registry.js.map +1 -1
- package/dist/registry.test.js +45 -3
- package/dist/registry.test.js.map +1 -1
- package/dist/test-utils.d.ts +5 -0
- package/dist/test-utils.js +6 -1
- package/dist/test-utils.js.map +1 -1
- package/dist/testing/fire-lynx-event.d.ts +14 -0
- package/dist/testing/fire-lynx-event.js +19 -0
- package/dist/testing/fire-lynx-event.js.map +1 -0
- package/dist/testing/fire-lynx-event.test.d.ts +1 -0
- package/dist/testing/fire-lynx-event.test.js +44 -0
- package/dist/testing/fire-lynx-event.test.js.map +1 -0
- package/dist/testing/lynx-main-thread.d.ts +22 -0
- package/dist/testing/lynx-main-thread.js +11 -0
- package/dist/testing/lynx-main-thread.js.map +1 -0
- package/dist/testing/lynx-main-thread.test.d.ts +1 -0
- package/dist/testing/lynx-main-thread.test.js +24 -0
- package/dist/testing/lynx-main-thread.test.js.map +1 -0
- package/dist/testing/set-input-signal.d.ts +5 -4
- package/dist/testing/set-input-signal.js +5 -4
- package/dist/testing/set-input-signal.js.map +1 -1
- package/dist/ui/components/accordion/accordion.test.ts +158 -144
- package/dist/ui/components/accordion/accordion.ts +19 -14
- package/dist/ui/components/action-sheet/action-sheet.test.ts +272 -187
- package/dist/ui/components/action-sheet/action-sheet.ts +40 -34
- package/dist/ui/components/alert/alert.test.ts +67 -19
- package/dist/ui/components/alert/alert.ts +14 -16
- package/dist/ui/components/alert-dialog/alert-dialog.test.ts +206 -139
- package/dist/ui/components/alert-dialog/alert-dialog.ts +32 -26
- package/dist/ui/components/area-chart/area-chart.test.ts +81 -1
- package/dist/ui/components/avatar/avatar.test.ts +89 -45
- package/dist/ui/components/badge/badge.test.ts +57 -23
- package/dist/ui/components/badge/badge.ts +12 -14
- package/dist/ui/components/bar-chart/bar-chart.test.ts +82 -1
- package/dist/ui/components/bottom-sheet/bottom-sheet.test.ts +373 -297
- package/dist/ui/components/bottom-sheet/bottom-sheet.ts +34 -33
- package/dist/ui/components/button/button.test.ts +611 -2
- package/dist/ui/components/button/button.ts +431 -62
- package/dist/ui/components/button/index.ts +2 -0
- package/dist/ui/components/button-group/button-group.test.ts +484 -2
- package/dist/ui/components/button-group/button-group.ts +380 -68
- package/dist/ui/components/button-group/index.ts +3 -0
- package/dist/ui/components/candlestick-chart/candlestick-chart.test.ts +82 -1
- package/dist/ui/components/card/card.test.ts +82 -62
- package/dist/ui/components/chart/chart-presets.test.ts +197 -1
- package/dist/ui/components/chart/chart-presets.ts +8 -1
- package/dist/ui/components/chart/chart-theme.ts +12 -0
- package/dist/ui/components/chart/chart.test.ts +1017 -0
- package/dist/ui/components/chart/chart.ts +56 -43
- package/dist/ui/components/checkbox/checkbox.test.ts +124 -42
- package/dist/ui/components/checkbox/checkbox.ts +23 -23
- package/dist/ui/components/collapsible/collapsible.test.ts +158 -81
- package/dist/ui/components/collapsible/collapsible.ts +19 -14
- package/dist/ui/components/context-menu/context-menu.test.ts +224 -0
- package/dist/ui/components/context-menu/context-menu.ts +110 -0
- package/dist/ui/components/context-menu/index.ts +1 -0
- package/dist/ui/components/dialog/dialog.test.ts +210 -152
- package/dist/ui/components/dialog/dialog.ts +29 -23
- package/dist/ui/components/dock/dock-scroll.test.ts +130 -0
- package/dist/ui/components/dock/dock-scroll.ts +133 -0
- package/dist/ui/components/dock/dock.test.ts +749 -0
- package/dist/ui/components/dock/dock.ts +960 -0
- package/dist/ui/components/dock/index.ts +2 -0
- package/dist/ui/components/empty-state/empty-state.test.ts +28 -39
- package/dist/ui/components/glass/concentric-radius.test.ts +13 -0
- package/dist/ui/components/glass/concentric-radius.ts +17 -0
- package/dist/ui/components/glass/glass-design.test.ts +78 -0
- package/dist/ui/components/glass/glass-design.ts +119 -0
- package/dist/ui/components/glass/glass.test.ts +338 -0
- package/dist/ui/components/glass/glass.ts +360 -0
- package/dist/ui/components/glass/index.ts +18 -0
- package/dist/ui/components/icon/icon-theme.test.ts +36 -0
- package/dist/ui/components/icon/icon-theme.ts +36 -13
- package/dist/ui/components/icon/icon.test.ts +52 -41
- package/dist/ui/components/input/input.test.ts +178 -101
- package/dist/ui/components/input/input.ts +15 -12
- package/dist/ui/components/label/label.test.ts +11 -24
- package/dist/ui/components/line-chart/line-chart.test.ts +76 -1
- package/dist/ui/components/list/list.test.ts +80 -49
- package/dist/ui/components/menu/index.ts +25 -0
- package/dist/ui/components/menu/menu-placement.test.ts +153 -0
- package/dist/ui/components/menu/menu-placement.ts +159 -0
- package/dist/ui/components/menu/menu-popup.test.ts +632 -0
- package/dist/ui/components/menu/menu-popup.ts +544 -0
- package/dist/ui/components/menu/menu.test.ts +429 -0
- package/dist/ui/components/menu/menu.ts +415 -0
- package/dist/ui/components/nav-drawer/nav-drawer.test.ts +425 -260
- package/dist/ui/components/nav-drawer/nav-drawer.ts +72 -56
- package/dist/ui/components/pagination/pagination.test.ts +136 -86
- package/dist/ui/components/progress/progress.test.ts +102 -46
- package/dist/ui/components/progress/progress.ts +52 -54
- package/dist/ui/components/radio-group/radio-group.test.ts +128 -83
- package/dist/ui/components/radio-group/radio-group.ts +24 -24
- package/dist/ui/components/scatter-chart/scatter-chart.test.ts +74 -1
- package/dist/ui/components/scroll-area/scroll-area.test.ts +72 -36
- package/dist/ui/components/select/select.test.ts +138 -78
- package/dist/ui/components/select/select.ts +17 -11
- package/dist/ui/components/separator/separator.test.ts +18 -17
- package/dist/ui/components/sheet/sheet.test.ts +64 -11
- package/dist/ui/components/sheet/sheet.ts +9 -1
- package/dist/ui/components/skeleton/skeleton.test.ts +24 -7
- package/dist/ui/components/skeleton/skeleton.ts +10 -16
- package/dist/ui/components/spinner/spinner.test.ts +64 -46
- package/dist/ui/components/spinner/spinner.ts +16 -19
- package/dist/ui/components/switch/switch.test.ts +116 -46
- package/dist/ui/components/switch/switch.ts +8 -13
- package/dist/ui/components/tab-bar/index.ts +1 -0
- package/dist/ui/components/tab-bar/tab-bar.test.ts +280 -0
- package/dist/ui/components/tab-bar/tab-bar.ts +230 -0
- package/dist/ui/components/tabs/tabs.test.ts +215 -157
- package/dist/ui/components/tabs/tabs.ts +110 -118
- package/dist/ui/components/textarea/textarea.test.ts +206 -100
- package/dist/ui/components/textarea/textarea.ts +14 -12
- package/dist/ui/components/toast/toast.test.ts +434 -350
- package/dist/ui/components/toast/toast.ts +42 -51
- package/dist/ui/components/toggle/toggle.test.ts +247 -140
- package/dist/ui/theme/chart-colors.test.ts +8 -3
- package/dist/ui/theme/dark.css +38 -2
- package/dist/ui/theme/default.css +55 -7
- package/dist/ui/theme/icon-colors.test.ts +8 -3
- package/dist/ui/theme/rgb-channels.test.ts +84 -0
- package/dist/ui/theme/tailwind-build.test.ts +114 -0
- package/dist/ui/theme/tailwind-css.test.ts +83 -0
- package/dist/ui/theme/tailwind-plugin.test.ts +42 -2
- package/dist/ui/theme/tailwind-plugin.ts +93 -29
- package/dist/ui/theme/tailwind.css +113 -0
- package/dist/ui/utils/animate.test.ts +13 -1
- package/dist/utils/analyze.d.ts +14 -0
- package/dist/utils/analyze.js +15 -0
- package/dist/utils/analyze.js.map +1 -1
- package/dist/utils/analyze.test.js +14 -1
- package/dist/utils/analyze.test.js.map +1 -1
- package/dist/utils/installed.d.ts +12 -0
- package/dist/utils/installed.js +16 -0
- package/dist/utils/installed.js.map +1 -1
- package/dist/utils/installed.test.js +17 -1
- package/dist/utils/installed.test.js.map +1 -1
- package/dist/utils/resolve-paths.d.ts +4 -4
- package/dist/utils/resolve-paths.js +5 -7
- package/dist/utils/resolve-paths.js.map +1 -1
- package/dist/utils/resolve-paths.test.js +9 -5
- package/dist/utils/resolve-paths.test.js.map +1 -1
- package/dist/utils/tailwind.d.ts +25 -0
- package/dist/utils/tailwind.js +51 -0
- package/dist/utils/tailwind.js.map +1 -0
- package/dist/utils/tailwind.test.d.ts +1 -0
- package/dist/utils/tailwind.test.js +71 -0
- package/dist/utils/tailwind.test.js.map +1 -0
- package/package.json +9 -1
- package/dist/ui/components/area-chart/area-chart-render.test.ts +0 -63
- package/dist/ui/components/bar-chart/bar-chart-render.test.ts +0 -58
- package/dist/ui/components/button/button-render.test.ts +0 -132
- package/dist/ui/components/button-group/button-group-render.test.ts +0 -146
- package/dist/ui/components/candlestick-chart/candlestick-chart-render.test.ts +0 -59
- package/dist/ui/components/chart/chart-render.test.ts +0 -730
- package/dist/ui/components/line-chart/line-chart-render.test.ts +0 -83
- package/dist/ui/components/scatter-chart/scatter-chart-render.test.ts +0 -64
|
@@ -0,0 +1,1017 @@
|
|
|
1
|
+
import { Component, type Provider, signal, viewChild } from '@angular/core';
|
|
2
|
+
import { LYNX_ELEMENTS, LynxTheme } from '@blotch/angular-lynx';
|
|
3
|
+
import {
|
|
4
|
+
fireEvent,
|
|
5
|
+
render,
|
|
6
|
+
waitForUpdate,
|
|
7
|
+
within,
|
|
8
|
+
} from '@blotch/angular-lynx-testing-library';
|
|
9
|
+
import { scaleBand } from '@tanstack/charts/scales/band';
|
|
10
|
+
import { scaleLinear } from '@tanstack/charts/scales/linear';
|
|
11
|
+
import type { ChartDefinition, ChartPoint } from '@tanstack/charts/types';
|
|
12
|
+
import {
|
|
13
|
+
barY,
|
|
14
|
+
defineChart,
|
|
15
|
+
dot,
|
|
16
|
+
lineY,
|
|
17
|
+
ruleY,
|
|
18
|
+
} from '@tanstack/charts/universal';
|
|
19
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
20
|
+
import { fireLynxEvent } from '../../../test-utils/fire-lynx-event';
|
|
21
|
+
import { UiChart, UiChartTooltip, tooltip } from './chart';
|
|
22
|
+
import {
|
|
23
|
+
CHART_DARK_COLORS,
|
|
24
|
+
CHART_LIGHT_COLORS,
|
|
25
|
+
type ChartColorScheme,
|
|
26
|
+
provideChartTheme,
|
|
27
|
+
} from './chart-theme';
|
|
28
|
+
import type { ChartZoomAxes } from './chart-zoom';
|
|
29
|
+
|
|
30
|
+
const revenue = [
|
|
31
|
+
{ month: 'Jan', value: 40 },
|
|
32
|
+
{ month: 'Feb', value: 60 },
|
|
33
|
+
{ month: 'Mar', value: 80 },
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
const barChart = (extra: Record<string, unknown> = {}) =>
|
|
37
|
+
defineChart({
|
|
38
|
+
marks: [
|
|
39
|
+
ruleY([0]),
|
|
40
|
+
barY(revenue, { x: 'month', y: 'value', fill: 'var(--primary)' }),
|
|
41
|
+
],
|
|
42
|
+
scales: {
|
|
43
|
+
x: { scale: () => scaleBand().padding(0.2) },
|
|
44
|
+
y: {
|
|
45
|
+
scale: scaleLinear,
|
|
46
|
+
nice: true,
|
|
47
|
+
grid: true,
|
|
48
|
+
axis: { label: 'Revenue' },
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
tooltip,
|
|
52
|
+
...extra,
|
|
53
|
+
}) as ChartDefinition<any, any, any>;
|
|
54
|
+
|
|
55
|
+
const series = [
|
|
56
|
+
{ x: 0, y: 1 },
|
|
57
|
+
{ x: 5, y: 3 },
|
|
58
|
+
{ x: 10, y: 2 },
|
|
59
|
+
];
|
|
60
|
+
|
|
61
|
+
const lineChart = (extra: Record<string, unknown> = {}) =>
|
|
62
|
+
defineChart({
|
|
63
|
+
marks: [lineY(series, { x: 'x', y: 'y' }), dot(series, { x: 'x', y: 'y' })],
|
|
64
|
+
scales: {
|
|
65
|
+
x: { scale: () => scaleLinear().domain([0, 10]) },
|
|
66
|
+
y: { scale: () => scaleLinear().domain([0, 4]) },
|
|
67
|
+
},
|
|
68
|
+
tooltip,
|
|
69
|
+
...extra,
|
|
70
|
+
}) as ChartDefinition<any, any, any>;
|
|
71
|
+
|
|
72
|
+
@Component({
|
|
73
|
+
imports: [UiChart, LYNX_ELEMENTS],
|
|
74
|
+
template: `
|
|
75
|
+
<ui-chart
|
|
76
|
+
[definition]="definition()"
|
|
77
|
+
[width]="width()"
|
|
78
|
+
[height]="height()"
|
|
79
|
+
[colorScheme]="colorScheme()"
|
|
80
|
+
[zoomable]="zoomable()"
|
|
81
|
+
[zoomAxes]="zoomAxes()"
|
|
82
|
+
[showZoomControls]="showZoomControls()"
|
|
83
|
+
(focusChange)="focused.push($event)"
|
|
84
|
+
(pointSelect)="selected.push($event)"
|
|
85
|
+
/>
|
|
86
|
+
`,
|
|
87
|
+
})
|
|
88
|
+
class ChartHost {
|
|
89
|
+
readonly definition = signal(barChart());
|
|
90
|
+
readonly width = signal<number | undefined>(undefined);
|
|
91
|
+
readonly height = signal<number | undefined>(undefined);
|
|
92
|
+
readonly colorScheme = signal<ChartColorScheme | 'system' | undefined>(
|
|
93
|
+
undefined,
|
|
94
|
+
);
|
|
95
|
+
readonly zoomable = signal(false);
|
|
96
|
+
readonly zoomAxes = signal<ChartZoomAxes>('xy');
|
|
97
|
+
readonly showZoomControls = signal(true);
|
|
98
|
+
/** Every `focusChange`, in order. */
|
|
99
|
+
readonly focused: (ChartPoint | null)[] = [];
|
|
100
|
+
/** Every `pointSelect`, in order. */
|
|
101
|
+
readonly selected: (ChartPoint | null)[] = [];
|
|
102
|
+
readonly chart = viewChild.required(UiChart);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Renders the host 300px wide, after `configure` sets its inputs. Without a
|
|
107
|
+
* width the chart waits for its layout (see "measures its layout").
|
|
108
|
+
*/
|
|
109
|
+
const renderChart = async (
|
|
110
|
+
configure: (host: ChartHost) => void = () => {},
|
|
111
|
+
providers: Provider[] = [],
|
|
112
|
+
) => {
|
|
113
|
+
const result = await render(ChartHost, { providers });
|
|
114
|
+
const host = result.componentRef.instance as ChartHost;
|
|
115
|
+
host.width.set(300);
|
|
116
|
+
configure(host);
|
|
117
|
+
await waitForUpdate();
|
|
118
|
+
return { ...result, host, root: chartRoot(result.container) };
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* The chart's own view, which takes every touch.
|
|
123
|
+
*/
|
|
124
|
+
const chartRoot = (container: Element) =>
|
|
125
|
+
container.querySelector('[accessibility-label]')!;
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* The SVG each layer draws: under the marks, the marks, over the marks.
|
|
129
|
+
*/
|
|
130
|
+
const layers = (container: Element) =>
|
|
131
|
+
[...container.querySelectorAll('svg')].map(
|
|
132
|
+
(svg) => svg.getAttribute('content') ?? '',
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
/** What an empty layer draws. */
|
|
136
|
+
const EMPTY_LAYER = '<svg xmlns="http://www.w3.org/2000/svg" width="1"';
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* The tooltip, while the chart shows one.
|
|
140
|
+
*/
|
|
141
|
+
const tooltipBox = (container: Element) =>
|
|
142
|
+
container.querySelector<HTMLElement>('.flex-col.bg-card');
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* The chart's labels: native texts, which ignore touches like every layer
|
|
146
|
+
* (unlike the tooltip's and zoom controls' texts).
|
|
147
|
+
*/
|
|
148
|
+
const labels = (container: Element) => [
|
|
149
|
+
...container.querySelectorAll('text[user-interaction-enabled="false"]'),
|
|
150
|
+
];
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* The text of each of the chart's labels.
|
|
154
|
+
*/
|
|
155
|
+
const labelTexts = (container: Element) =>
|
|
156
|
+
labels(container).map((text) => text.textContent);
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Fires a touch event at chart coordinates, as Lynx reports them.
|
|
160
|
+
*/
|
|
161
|
+
const touchAt = (
|
|
162
|
+
type: 'touchstart' | 'touchmove' | 'touchend',
|
|
163
|
+
root: Element,
|
|
164
|
+
at: { x: number; y: number },
|
|
165
|
+
) => fireEvent[type](root, { touches: [at] });
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* A point's position, nudged so it's clearly a touch near it.
|
|
169
|
+
*/
|
|
170
|
+
const pointAt = (host: ChartHost, index: number) => {
|
|
171
|
+
const point = host.chart().scene()!.points[index]!;
|
|
172
|
+
return { x: point.x + 1, y: point.y + 1 };
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
type Phase = 'onBegin' | 'onStart' | 'onUpdate' | 'onEnd';
|
|
176
|
+
/**
|
|
177
|
+
* Runs a phase of the chart's pan gesture the way Lynx does: the testing
|
|
178
|
+
* environment keeps what the gesture detector registered with
|
|
179
|
+
* `__SetGestureDetector` on the element, and Lynx calls it with the
|
|
180
|
+
* finger's position on the page.
|
|
181
|
+
*/
|
|
182
|
+
const pan = (root: Element, phase: Phase, pageX = 0, pageY = 0) => {
|
|
183
|
+
const { gesture } = root as unknown as {
|
|
184
|
+
gesture: {
|
|
185
|
+
config: {
|
|
186
|
+
callbacks: {
|
|
187
|
+
name: string;
|
|
188
|
+
callback: { _fn: (event: unknown) => void };
|
|
189
|
+
}[];
|
|
190
|
+
};
|
|
191
|
+
};
|
|
192
|
+
};
|
|
193
|
+
gesture.config.callbacks
|
|
194
|
+
.find((entry) => entry.name === phase)!
|
|
195
|
+
.callback._fn({ params: { pageX, pageY } });
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Touches a point and lifts the finger right there: a tap.
|
|
200
|
+
*/
|
|
201
|
+
const tap = async (root: Element, at: { x: number; y: number }) => {
|
|
202
|
+
touchAt('touchstart', root, at);
|
|
203
|
+
touchAt('touchend', root, at);
|
|
204
|
+
await waitForUpdate();
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
describe('UiChart', () => {
|
|
208
|
+
describe('rendering', () => {
|
|
209
|
+
it('draws nothing until it has a size, and ignores input until then', async () => {
|
|
210
|
+
const { container, componentRef } = await render(ChartHost);
|
|
211
|
+
const host = componentRef.instance as ChartHost;
|
|
212
|
+
const root = chartRoot(container);
|
|
213
|
+
expect(container.querySelectorAll('svg')).toHaveLength(0);
|
|
214
|
+
expect(container.querySelectorAll('text')).toHaveLength(0);
|
|
215
|
+
|
|
216
|
+
touchAt('touchstart', root, { x: 1, y: 1 });
|
|
217
|
+
touchAt('touchend', root, { x: 1, y: 1 });
|
|
218
|
+
fireLynxEvent(root, 'accessibilityaction', {
|
|
219
|
+
detail: { name: 'Next point' },
|
|
220
|
+
});
|
|
221
|
+
// Nor do zoom and pan: there's no scene to zoom yet.
|
|
222
|
+
host.chart().zoomIn();
|
|
223
|
+
host.zoomable.set(true);
|
|
224
|
+
await waitForUpdate();
|
|
225
|
+
pan(root, 'onBegin');
|
|
226
|
+
pan(root, 'onUpdate', 10, 0);
|
|
227
|
+
pan(root, 'onEnd', 10, 0);
|
|
228
|
+
await waitForUpdate();
|
|
229
|
+
expect(host.focused).toEqual([]);
|
|
230
|
+
expect(host.selected).toEqual([]);
|
|
231
|
+
expect(root.getAttribute('accessibility-value')).toBe('');
|
|
232
|
+
expect(host.chart().scene()).toBeNull();
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it('draws marks, empty focus layers and native labels', async () => {
|
|
236
|
+
const { container, root } = await renderChart((host) =>
|
|
237
|
+
host.height.set(200),
|
|
238
|
+
);
|
|
239
|
+
const [under, marks, over] = layers(container);
|
|
240
|
+
expect(layers(container)).toHaveLength(3);
|
|
241
|
+
expect(marks).toContain('<rect');
|
|
242
|
+
expect(marks).toContain(`fill="${CHART_LIGHT_COLORS['primary']}"`);
|
|
243
|
+
expect(under).toContain(EMPTY_LAYER);
|
|
244
|
+
expect(over).toContain(EMPTY_LAYER);
|
|
245
|
+
expect(labelTexts(container)).toEqual(
|
|
246
|
+
expect.arrayContaining(['Jan', 'Feb', 'Mar', 'Revenue']),
|
|
247
|
+
);
|
|
248
|
+
expect(root.getAttribute('style')).toContain('width: 300px');
|
|
249
|
+
expect(root.getAttribute('style')).toContain('height: 200px');
|
|
250
|
+
const layerStyle = container.querySelector('svg')!.getAttribute('style');
|
|
251
|
+
expect(layerStyle).toContain('width: 300px');
|
|
252
|
+
expect(layerStyle).toContain('height: 200px');
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it('draws a blank image, rather than none, once a layer empties', async () => {
|
|
256
|
+
// Lynx for Web keeps showing the last image when the content clears.
|
|
257
|
+
const { container, host } = await renderChart();
|
|
258
|
+
expect(layers(container)[1]).not.toContain(EMPTY_LAYER);
|
|
259
|
+
const noData: typeof series = [];
|
|
260
|
+
host.definition.set(
|
|
261
|
+
defineChart({
|
|
262
|
+
marks: [dot(noData, { x: 'x', y: 'y' })],
|
|
263
|
+
scales: {
|
|
264
|
+
x: { scale: () => scaleLinear().domain([0, 1]), axis: false },
|
|
265
|
+
y: { scale: () => scaleLinear().domain([0, 1]), axis: false },
|
|
266
|
+
},
|
|
267
|
+
}) as ChartDefinition<any, any, any>,
|
|
268
|
+
);
|
|
269
|
+
await waitForUpdate();
|
|
270
|
+
expect(layers(container)).toEqual([
|
|
271
|
+
expect.stringContaining(EMPTY_LAYER),
|
|
272
|
+
expect.stringContaining(EMPTY_LAYER),
|
|
273
|
+
expect.stringContaining(EMPTY_LAYER),
|
|
274
|
+
]);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it('labels the chart for screen readers', async () => {
|
|
278
|
+
const { root } = await renderChart();
|
|
279
|
+
expect(root.getAttribute('accessibility-element')).toBe('true');
|
|
280
|
+
expect(root.getAttribute('accessibility-label')).toBe('Chart');
|
|
281
|
+
expect(root.getAttribute('accessibility-actions')).toBe(
|
|
282
|
+
JSON.stringify(['Next point', 'Previous point']),
|
|
283
|
+
);
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
describe('color scheme', () => {
|
|
287
|
+
const darkDevice = {
|
|
288
|
+
provide: LynxTheme,
|
|
289
|
+
useValue: { isDarkMode: () => true },
|
|
290
|
+
};
|
|
291
|
+
/**
|
|
292
|
+
* The color the bars are painted with.
|
|
293
|
+
*/
|
|
294
|
+
const primaryFill = async (
|
|
295
|
+
providers: Provider[],
|
|
296
|
+
scheme?: ChartColorScheme | 'system',
|
|
297
|
+
) => {
|
|
298
|
+
const { container } = await renderChart(
|
|
299
|
+
(host) => host.colorScheme.set(scheme),
|
|
300
|
+
providers,
|
|
301
|
+
);
|
|
302
|
+
return /fill="(rgba\([^)]*\))"/.exec(layers(container)[1]!)?.[1];
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
it('stays light on a dark device unless the app opts in', async () => {
|
|
306
|
+
expect(await primaryFill([darkDevice])).toBe(
|
|
307
|
+
CHART_LIGHT_COLORS['primary'],
|
|
308
|
+
);
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
it('takes the scheme from its input', async () => {
|
|
312
|
+
expect(await primaryFill([], 'dark')).toBe(
|
|
313
|
+
CHART_DARK_COLORS['primary'],
|
|
314
|
+
);
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
it("follows the device with 'system'", async () => {
|
|
318
|
+
expect(await primaryFill([darkDevice], 'system')).toBe(
|
|
319
|
+
CHART_DARK_COLORS['primary'],
|
|
320
|
+
);
|
|
321
|
+
expect(
|
|
322
|
+
await primaryFill(
|
|
323
|
+
[{ provide: LynxTheme, useValue: { isDarkMode: () => false } }],
|
|
324
|
+
'system',
|
|
325
|
+
),
|
|
326
|
+
).toBe(CHART_LIGHT_COLORS['primary']);
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
it('takes the scheme from the chart theme, a function included', async () => {
|
|
330
|
+
expect(
|
|
331
|
+
await primaryFill([
|
|
332
|
+
darkDevice,
|
|
333
|
+
provideChartTheme({ colorScheme: 'system' }),
|
|
334
|
+
]),
|
|
335
|
+
).toBe(CHART_DARK_COLORS['primary']);
|
|
336
|
+
expect(
|
|
337
|
+
await primaryFill([provideChartTheme({ colorScheme: () => 'dark' })]),
|
|
338
|
+
).toBe(CHART_DARK_COLORS['primary']);
|
|
339
|
+
});
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
it('gives labels the foreground token when the theme sets no foreground', async () => {
|
|
343
|
+
const { getByText } = await renderChart(() => {}, [
|
|
344
|
+
provideChartTheme({ chart: { foreground: undefined } }),
|
|
345
|
+
]);
|
|
346
|
+
expect(getByText('Revenue').getAttribute('style')).toContain(
|
|
347
|
+
'color: var(--foreground)',
|
|
348
|
+
);
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
it('exposes a tooltip token that creates nothing itself', () => {
|
|
352
|
+
// TanStack's DOM tooltip creates its element here; <ui-chart> draws its
|
|
353
|
+
// own, so the token is only a marker.
|
|
354
|
+
expect(tooltip.create()).toBeUndefined();
|
|
355
|
+
expect(tooltip.__chartTooltipHost).toBe('lynx');
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
it('measures its layout when no size is given', async () => {
|
|
359
|
+
const { container, componentRef } = await render(ChartHost);
|
|
360
|
+
const host = componentRef.instance as ChartHost;
|
|
361
|
+
const root = chartRoot(container);
|
|
362
|
+
const layerStyle = () =>
|
|
363
|
+
container.querySelector('svg')?.getAttribute('style');
|
|
364
|
+
expect(root.getAttribute('style') ?? '').not.toContain('width');
|
|
365
|
+
|
|
366
|
+
fireEvent.layoutchange(root, { detail: { width: 250, height: 120 } });
|
|
367
|
+
await waitForUpdate();
|
|
368
|
+
expect(layerStyle()).toContain('width: 250px');
|
|
369
|
+
expect(layerStyle()).toContain('height: 120px');
|
|
370
|
+
|
|
371
|
+
// Older Android reports the size in `params`.
|
|
372
|
+
fireEvent.layoutchange(root, { params: { width: 260, height: 130 } });
|
|
373
|
+
await waitForUpdate();
|
|
374
|
+
expect(layerStyle()).toContain('width: 260px');
|
|
375
|
+
|
|
376
|
+
// The same size again and malformed events change nothing.
|
|
377
|
+
const scene = host.chart().scene();
|
|
378
|
+
fireEvent.layoutchange(root, { detail: { width: 260, height: 130 } });
|
|
379
|
+
fireEvent.layoutchange(root, { detail: {} });
|
|
380
|
+
fireEvent.layoutchange(root, {});
|
|
381
|
+
await waitForUpdate();
|
|
382
|
+
expect(host.chart().scene()).toBe(scene);
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
it('uses the default height when the layout has none', async () => {
|
|
386
|
+
const { container, host } = await renderChart((host) =>
|
|
387
|
+
host.width.set(100),
|
|
388
|
+
);
|
|
389
|
+
const layerStyle = container.querySelector('svg')!.getAttribute('style');
|
|
390
|
+
expect(layerStyle).toContain('width: 100px');
|
|
391
|
+
expect(layerStyle).toContain('height: 200px');
|
|
392
|
+
|
|
393
|
+
host.width.set(0);
|
|
394
|
+
await waitForUpdate();
|
|
395
|
+
expect(container.querySelectorAll('svg')).toHaveLength(0);
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
it('measures text with lynx.getTextInfo where the thread has it', async () => {
|
|
399
|
+
// render() switches to the testing environment's main thread, which
|
|
400
|
+
// copies that thread's globals over, so the stub goes there.
|
|
401
|
+
const mainLynx = (
|
|
402
|
+
globalThis as unknown as {
|
|
403
|
+
lynxTestingEnv: {
|
|
404
|
+
mainThread: { globalThis: { lynx: Record<string, unknown> } };
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
).lynxTestingEnv.mainThread.globalThis.lynx;
|
|
408
|
+
const original = mainLynx['getTextInfo'];
|
|
409
|
+
const getTextInfo = vi.fn(
|
|
410
|
+
(_text: string, _info: { fontSize: string }) => ({ width: 12 }),
|
|
411
|
+
);
|
|
412
|
+
mainLynx['getTextInfo'] = getTextInfo;
|
|
413
|
+
try {
|
|
414
|
+
const { container } = await renderChart();
|
|
415
|
+
expect(container.querySelectorAll('svg')).toHaveLength(3);
|
|
416
|
+
expect(getTextInfo).toHaveBeenCalledWith('Jan', expect.anything());
|
|
417
|
+
} finally {
|
|
418
|
+
mainLynx['getTextInfo'] = original;
|
|
419
|
+
}
|
|
420
|
+
});
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
describe('touch', () => {
|
|
424
|
+
it('focuses the touched point and shows the tooltip', async () => {
|
|
425
|
+
const { container, host, root } = await renderChart();
|
|
426
|
+
touchAt('touchstart', root, pointAt(host, 1));
|
|
427
|
+
await waitForUpdate();
|
|
428
|
+
expect(host.focused).toEqual([
|
|
429
|
+
expect.objectContaining({ xValue: 'Feb' }),
|
|
430
|
+
]);
|
|
431
|
+
expect(layers(container)[2]).not.toContain(EMPTY_LAYER);
|
|
432
|
+
expect(root.getAttribute('accessibility-value')).toContain('Feb');
|
|
433
|
+
|
|
434
|
+
// Until it's been laid out, the tooltip's size is unknown: it's placed
|
|
435
|
+
// with a guess and kept invisible.
|
|
436
|
+
const box = tooltipBox(container)!;
|
|
437
|
+
expect(within(box).getByText('60')).toBeTruthy();
|
|
438
|
+
expect(box.getAttribute('style')).toContain('opacity: 0');
|
|
439
|
+
|
|
440
|
+
fireEvent.layoutchange(box, { detail: { width: 80, height: 40 } });
|
|
441
|
+
await waitForUpdate();
|
|
442
|
+
expect(box.getAttribute('style')).not.toContain('opacity');
|
|
443
|
+
|
|
444
|
+
// The same size again and malformed events change nothing.
|
|
445
|
+
const style = box.getAttribute('style');
|
|
446
|
+
fireEvent.layoutchange(box, { detail: { width: 80, height: 40 } });
|
|
447
|
+
fireEvent.layoutchange(box, {});
|
|
448
|
+
await waitForUpdate();
|
|
449
|
+
expect(box.getAttribute('style')).toBe(style);
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
it('scrubs as the touch moves, without repeating focus events', async () => {
|
|
453
|
+
const { host, root } = await renderChart();
|
|
454
|
+
touchAt('touchstart', root, pointAt(host, 0));
|
|
455
|
+
touchAt('touchmove', root, pointAt(host, 0));
|
|
456
|
+
touchAt('touchmove', root, pointAt(host, 2));
|
|
457
|
+
expect(host.focused.map((point) => point?.xValue)).toEqual([
|
|
458
|
+
'Jan',
|
|
459
|
+
'Mar',
|
|
460
|
+
]);
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
it('ends a scrub without selecting, clearing its focus', async () => {
|
|
464
|
+
const { container, host, root } = await renderChart();
|
|
465
|
+
touchAt('touchstart', root, pointAt(host, 0));
|
|
466
|
+
touchAt('touchmove', root, pointAt(host, 2));
|
|
467
|
+
// Lynx reports the lifted finger in changedTouches.
|
|
468
|
+
fireEvent.touchend(root, { changedTouches: [pointAt(host, 2)] });
|
|
469
|
+
await waitForUpdate();
|
|
470
|
+
expect(host.selected).toEqual([]);
|
|
471
|
+
expect(host.focused.at(-1)).toBeNull();
|
|
472
|
+
expect(tooltipBox(container)).toBeNull();
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
it('ignores touches without coordinates', async () => {
|
|
476
|
+
const { container, host, root } = await renderChart();
|
|
477
|
+
fireEvent.touchstart(root, {});
|
|
478
|
+
fireEvent.touchmove(root, { touches: [{}] });
|
|
479
|
+
fireEvent.touchend(root, {});
|
|
480
|
+
await waitForUpdate();
|
|
481
|
+
expect(host.focused).toEqual([]);
|
|
482
|
+
expect(host.selected).toEqual([]);
|
|
483
|
+
expect(tooltipBox(container)).toBeNull();
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
it('pins the tooltip on tap, and unpins on the next tap', async () => {
|
|
487
|
+
const { container, host, root } = await renderChart();
|
|
488
|
+
await tap(root, pointAt(host, 2));
|
|
489
|
+
expect(host.selected).toEqual([
|
|
490
|
+
expect.objectContaining({ xValue: 'Mar' }),
|
|
491
|
+
]);
|
|
492
|
+
// A pinned tooltip takes touches, for a button in a custom one.
|
|
493
|
+
const box = tooltipBox(container)!;
|
|
494
|
+
expect(box.getAttribute('user-interaction-enabled')).toBe('true');
|
|
495
|
+
|
|
496
|
+
// While pinned, touches don't move focus, and a cancel keeps it.
|
|
497
|
+
touchAt('touchstart', root, pointAt(host, 0));
|
|
498
|
+
touchAt('touchmove', root, pointAt(host, 0));
|
|
499
|
+
fireEvent.touchcancel(root);
|
|
500
|
+
await waitForUpdate();
|
|
501
|
+
expect(within(box).getByText('Mar')).toBeTruthy();
|
|
502
|
+
|
|
503
|
+
await tap(root, pointAt(host, 0));
|
|
504
|
+
expect(tooltipBox(container)).toBeNull();
|
|
505
|
+
expect(host.focused.at(-1)).toBeNull();
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
it('keeps a pinned tooltip through a drag', async () => {
|
|
509
|
+
const { container, host, root } = await renderChart();
|
|
510
|
+
const at = pointAt(host, 1);
|
|
511
|
+
await tap(root, at);
|
|
512
|
+
// A scroll that starts on the chart moves the finger well past a tap.
|
|
513
|
+
touchAt('touchstart', root, at);
|
|
514
|
+
touchAt('touchend', root, { x: at.x, y: at.y - 100 });
|
|
515
|
+
await waitForUpdate();
|
|
516
|
+
expect(
|
|
517
|
+
tooltipBox(container)!.getAttribute('user-interaction-enabled'),
|
|
518
|
+
).toBe('true');
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
it('selects nothing for a tap on empty space', async () => {
|
|
522
|
+
const { container, host, root } = await renderChart((host) =>
|
|
523
|
+
host.definition.set(barChart({ maxFocusDistance: 1 })),
|
|
524
|
+
);
|
|
525
|
+
touchAt('touchstart', root, { x: -50, y: -50 });
|
|
526
|
+
// Without a position on the lifted finger, it's where the touch began.
|
|
527
|
+
fireEvent.touchend(root, {});
|
|
528
|
+
await waitForUpdate();
|
|
529
|
+
expect(host.selected).toEqual([null]);
|
|
530
|
+
expect(tooltipBox(container)).toBeNull();
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
it("tells the definition's selection about taps", async () => {
|
|
534
|
+
const change = vi.fn((_point: ChartPoint | null, _source: string) => {});
|
|
535
|
+
const { host, root } = await renderChart((host) =>
|
|
536
|
+
host.definition.set(barChart({ selection: { change } })),
|
|
537
|
+
);
|
|
538
|
+
await tap(root, pointAt(host, 0));
|
|
539
|
+
expect(change).toHaveBeenCalledWith(
|
|
540
|
+
expect.objectContaining({ xValue: 'Jan' }),
|
|
541
|
+
'pointer',
|
|
542
|
+
);
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
it('keeps focus after a tap when the tooltip is not sticky', async () => {
|
|
546
|
+
const { container, host, root } = await renderChart((host) =>
|
|
547
|
+
host.definition.set(
|
|
548
|
+
barChart({ tooltip: { use: tooltip, sticky: false } }),
|
|
549
|
+
),
|
|
550
|
+
);
|
|
551
|
+
await tap(root, pointAt(host, 0));
|
|
552
|
+
const box = tooltipBox(container)!;
|
|
553
|
+
expect(box.getAttribute('user-interaction-enabled')).toBe('false');
|
|
554
|
+
|
|
555
|
+
// A scrolling parent taking the touch over clears it.
|
|
556
|
+
fireEvent.touchcancel(root);
|
|
557
|
+
await waitForUpdate();
|
|
558
|
+
expect(tooltipBox(container)).toBeNull();
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
it('focuses without a tooltip when the definition has none', async () => {
|
|
562
|
+
const { container, host, root } = await renderChart((host) =>
|
|
563
|
+
host.definition.set(barChart({ tooltip: false })),
|
|
564
|
+
);
|
|
565
|
+
touchAt('touchstart', root, pointAt(host, 0));
|
|
566
|
+
await waitForUpdate();
|
|
567
|
+
expect(tooltipBox(container)).toBeNull();
|
|
568
|
+
expect(layers(container)[2]).not.toContain(EMPTY_LAYER);
|
|
569
|
+
|
|
570
|
+
await tap(root, pointAt(host, 0));
|
|
571
|
+
expect(root.getAttribute('accessibility-value')).toContain('Jan');
|
|
572
|
+
});
|
|
573
|
+
|
|
574
|
+
it('shows a pinned-only tooltip once pinned', async () => {
|
|
575
|
+
const { container, host, root } = await renderChart((host) =>
|
|
576
|
+
host.definition.set(
|
|
577
|
+
barChart({ tooltip: { use: tooltip, visibility: 'pinned' } }),
|
|
578
|
+
),
|
|
579
|
+
);
|
|
580
|
+
touchAt('touchstart', root, pointAt(host, 0));
|
|
581
|
+
await waitForUpdate();
|
|
582
|
+
expect(tooltipBox(container)).toBeNull();
|
|
583
|
+
|
|
584
|
+
touchAt('touchend', root, pointAt(host, 0));
|
|
585
|
+
await waitForUpdate();
|
|
586
|
+
expect(tooltipBox(container)).not.toBeNull();
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
describe('tooltip content', () => {
|
|
590
|
+
/**
|
|
591
|
+
* The tooltip's rows (its title first, if any), each as its texts.
|
|
592
|
+
*/
|
|
593
|
+
const rows = (box: Element) =>
|
|
594
|
+
[...box.querySelectorAll(':scope > .flex-row')].map((row) =>
|
|
595
|
+
[...row.querySelectorAll('text')].map((text) => text.textContent),
|
|
596
|
+
);
|
|
597
|
+
/**
|
|
598
|
+
* Each row's color swatch style, or `null` for a row without one.
|
|
599
|
+
*/
|
|
600
|
+
const swatches = (box: Element) =>
|
|
601
|
+
[...box.querySelectorAll(':scope > .flex-row')].map(
|
|
602
|
+
(row) =>
|
|
603
|
+
row.querySelector('.rounded-sm')?.getAttribute('style') ?? null,
|
|
604
|
+
);
|
|
605
|
+
|
|
606
|
+
it('lists the x and y values, labeled by axis', async () => {
|
|
607
|
+
const { container, host, root } = await renderChart();
|
|
608
|
+
touchAt('touchstart', root, pointAt(host, 1));
|
|
609
|
+
await waitForUpdate();
|
|
610
|
+
const box = tooltipBox(container)!;
|
|
611
|
+
expect(rows(box)).toEqual([
|
|
612
|
+
['x', 'Feb'],
|
|
613
|
+
['Revenue', '60'],
|
|
614
|
+
]);
|
|
615
|
+
expect(swatches(box)).toEqual([null, null]);
|
|
616
|
+
});
|
|
617
|
+
|
|
618
|
+
it("shows the definition's own content, with its colors", async () => {
|
|
619
|
+
const { container, host, root } = await renderChart((host) =>
|
|
620
|
+
host.definition.set(
|
|
621
|
+
barChart({
|
|
622
|
+
tooltip: {
|
|
623
|
+
use: tooltip,
|
|
624
|
+
content: () => ({
|
|
625
|
+
title: 'Totals',
|
|
626
|
+
color: 'red',
|
|
627
|
+
rows: [
|
|
628
|
+
{ label: 'Sum', value: '180' },
|
|
629
|
+
{ label: 'Best', value: 'Mar', color: 'blue' },
|
|
630
|
+
],
|
|
631
|
+
}),
|
|
632
|
+
},
|
|
633
|
+
}),
|
|
634
|
+
),
|
|
635
|
+
);
|
|
636
|
+
touchAt('touchstart', root, pointAt(host, 0));
|
|
637
|
+
await waitForUpdate();
|
|
638
|
+
const box = tooltipBox(container)!;
|
|
639
|
+
expect(rows(box)).toEqual([
|
|
640
|
+
['Totals'],
|
|
641
|
+
['Sum', '180'],
|
|
642
|
+
['Best', 'Mar'],
|
|
643
|
+
]);
|
|
644
|
+
expect(swatches(box)).toEqual([
|
|
645
|
+
expect.stringContaining('background-color: red'),
|
|
646
|
+
null,
|
|
647
|
+
expect.stringContaining('background-color: blue'),
|
|
648
|
+
]);
|
|
649
|
+
});
|
|
650
|
+
|
|
651
|
+
it('leaves the title out when the content has none', async () => {
|
|
652
|
+
const { container, host, root } = await renderChart((host) =>
|
|
653
|
+
host.definition.set(
|
|
654
|
+
barChart({
|
|
655
|
+
tooltip: {
|
|
656
|
+
use: tooltip,
|
|
657
|
+
content: () => ({ rows: [{ label: 'Sum', value: '180' }] }),
|
|
658
|
+
},
|
|
659
|
+
}),
|
|
660
|
+
),
|
|
661
|
+
);
|
|
662
|
+
touchAt('touchstart', root, pointAt(host, 0));
|
|
663
|
+
await waitForUpdate();
|
|
664
|
+
expect(rows(tooltipBox(container)!)).toEqual([['Sum', '180']]);
|
|
665
|
+
});
|
|
666
|
+
|
|
667
|
+
it('shows a string tooltip as its title', async () => {
|
|
668
|
+
const { container, host, root } = await renderChart((host) =>
|
|
669
|
+
host.definition.set(
|
|
670
|
+
barChart({ tooltip: { use: tooltip, format: () => 'Custom' } }),
|
|
671
|
+
),
|
|
672
|
+
);
|
|
673
|
+
touchAt('touchstart', root, pointAt(host, 0));
|
|
674
|
+
await waitForUpdate();
|
|
675
|
+
expect(rows(tooltipBox(container)!)).toEqual([['Custom']]);
|
|
676
|
+
});
|
|
677
|
+
});
|
|
678
|
+
});
|
|
679
|
+
|
|
680
|
+
describe('focus across scene changes', () => {
|
|
681
|
+
it('keeps focus on the same datum, and drops it when the datum is gone', async () => {
|
|
682
|
+
const { container, host, root } = await renderChart();
|
|
683
|
+
await tap(root, pointAt(host, 1));
|
|
684
|
+
host.width.set(320);
|
|
685
|
+
await waitForUpdate();
|
|
686
|
+
expect(within(tooltipBox(container)!).getByText('Feb')).toBeTruthy();
|
|
687
|
+
|
|
688
|
+
host.definition.set(
|
|
689
|
+
defineChart({
|
|
690
|
+
marks: [
|
|
691
|
+
barY([{ month: 'Apr', value: 1 }], { x: 'month', y: 'value' }),
|
|
692
|
+
],
|
|
693
|
+
scales: {
|
|
694
|
+
x: { scale: () => scaleBand() },
|
|
695
|
+
y: { scale: scaleLinear },
|
|
696
|
+
},
|
|
697
|
+
tooltip,
|
|
698
|
+
}) as ChartDefinition<any, any, any>,
|
|
699
|
+
);
|
|
700
|
+
await waitForUpdate();
|
|
701
|
+
expect(tooltipBox(container)).toBeNull();
|
|
702
|
+
expect(root.getAttribute('accessibility-value')).toBe('');
|
|
703
|
+
});
|
|
704
|
+
});
|
|
705
|
+
|
|
706
|
+
describe('accessibility', () => {
|
|
707
|
+
const step = (root: Element, name: string) =>
|
|
708
|
+
fireLynxEvent(root, 'accessibilityaction', { detail: { name } });
|
|
709
|
+
|
|
710
|
+
it('steps through points with the screen reader actions', async () => {
|
|
711
|
+
const { host, root } = await renderChart();
|
|
712
|
+
step(root, 'Next point');
|
|
713
|
+
step(root, 'Next point');
|
|
714
|
+
step(root, 'Previous point');
|
|
715
|
+
await waitForUpdate();
|
|
716
|
+
expect(host.focused.map((point) => point?.xValue)).toEqual([
|
|
717
|
+
'Jan',
|
|
718
|
+
'Feb',
|
|
719
|
+
'Jan',
|
|
720
|
+
]);
|
|
721
|
+
expect(root.getAttribute('accessibility-value')).toContain('Jan');
|
|
722
|
+
|
|
723
|
+
// Other actions, and actions without a name, do nothing.
|
|
724
|
+
step(root, 'Activate');
|
|
725
|
+
fireLynxEvent(root, 'accessibilityaction');
|
|
726
|
+
expect(host.focused).toHaveLength(3);
|
|
727
|
+
});
|
|
728
|
+
|
|
729
|
+
it('clears focus when there are no points to step to', async () => {
|
|
730
|
+
const { root } = await renderChart((host) =>
|
|
731
|
+
host.definition.set(barChart({ focus: false })),
|
|
732
|
+
);
|
|
733
|
+
step(root, 'Next point');
|
|
734
|
+
await waitForUpdate();
|
|
735
|
+
expect(root.getAttribute('accessibility-value')).toBe('');
|
|
736
|
+
});
|
|
737
|
+
|
|
738
|
+
it('describes values that are dates', async () => {
|
|
739
|
+
const { root } = await renderChart((host) =>
|
|
740
|
+
host.definition.set(
|
|
741
|
+
defineChart({
|
|
742
|
+
marks: [
|
|
743
|
+
dot([{ t: new Date(Date.UTC(2026, 0, 2)), v: 3 }], {
|
|
744
|
+
x: 't',
|
|
745
|
+
y: 'v',
|
|
746
|
+
}),
|
|
747
|
+
],
|
|
748
|
+
scales: {
|
|
749
|
+
x: { scale: () => scaleBand() },
|
|
750
|
+
y: { scale: scaleLinear },
|
|
751
|
+
},
|
|
752
|
+
}) as ChartDefinition<any, any, any>,
|
|
753
|
+
),
|
|
754
|
+
);
|
|
755
|
+
step(root, 'Next point');
|
|
756
|
+
await waitForUpdate();
|
|
757
|
+
expect(root.getAttribute('accessibility-value')).toContain('2026-01-02');
|
|
758
|
+
});
|
|
759
|
+
});
|
|
760
|
+
|
|
761
|
+
describe('zoom', () => {
|
|
762
|
+
/**
|
|
763
|
+
* The visible window of an axis.
|
|
764
|
+
*/
|
|
765
|
+
const domainOf = (host: ChartHost, axis: 'x' | 'y') => {
|
|
766
|
+
const scale = host.chart().scene()!.scales[axis]!;
|
|
767
|
+
return (scale.viewport?.domain ?? scale.domain) as number[];
|
|
768
|
+
};
|
|
769
|
+
const isZoomed = (host: ChartHost, axis: 'x' | 'y') =>
|
|
770
|
+
host.chart().scene()!.scales[axis]!.viewport !== undefined;
|
|
771
|
+
/**
|
|
772
|
+
* Taps one of the zoom controls by its label.
|
|
773
|
+
*/
|
|
774
|
+
const press = async (
|
|
775
|
+
getByText: (text: string) => HTMLElement,
|
|
776
|
+
label: '+' | '−' | '1:1',
|
|
777
|
+
) => {
|
|
778
|
+
fireEvent.tap(getByText(label).parentElement!);
|
|
779
|
+
await waitForUpdate();
|
|
780
|
+
};
|
|
781
|
+
|
|
782
|
+
const zoomable =
|
|
783
|
+
(definition = lineChart()) =>
|
|
784
|
+
(host: ChartHost) => {
|
|
785
|
+
host.definition.set(definition);
|
|
786
|
+
host.zoomable.set(true);
|
|
787
|
+
};
|
|
788
|
+
|
|
789
|
+
it('zooms in and out with the controls, and resets', async () => {
|
|
790
|
+
const { getByText, host } = await renderChart(zoomable());
|
|
791
|
+
await press(getByText, '+');
|
|
792
|
+
const [lo, hi] = domainOf(host, 'x');
|
|
793
|
+
expect(hi! - lo!).toBeCloseTo(10 / 1.4);
|
|
794
|
+
|
|
795
|
+
// Zoomed all the way out, the chart renders as if never zoomed.
|
|
796
|
+
await press(getByText, '−');
|
|
797
|
+
expect(isZoomed(host, 'x')).toBe(false);
|
|
798
|
+
expect(domainOf(host, 'x')).toEqual([0, 10]);
|
|
799
|
+
|
|
800
|
+
await press(getByText, '+');
|
|
801
|
+
await press(getByText, '1:1');
|
|
802
|
+
expect(isZoomed(host, 'x')).toBe(false);
|
|
803
|
+
});
|
|
804
|
+
|
|
805
|
+
it('shows the controls only when zoomable and asked to', async () => {
|
|
806
|
+
const { host, queryByText } = await renderChart();
|
|
807
|
+
expect(queryByText('1:1')).toBeNull();
|
|
808
|
+
host.zoomable.set(true);
|
|
809
|
+
await waitForUpdate();
|
|
810
|
+
expect(queryByText('1:1')).not.toBeNull();
|
|
811
|
+
host.showZoomControls.set(false);
|
|
812
|
+
await waitForUpdate();
|
|
813
|
+
expect(queryByText('1:1')).toBeNull();
|
|
814
|
+
});
|
|
815
|
+
|
|
816
|
+
it('only zooms the chosen axes, and only continuous ones', async () => {
|
|
817
|
+
const lines = await renderChart((host) => {
|
|
818
|
+
zoomable()(host);
|
|
819
|
+
host.zoomAxes.set('y');
|
|
820
|
+
});
|
|
821
|
+
await press(lines.getByText, '+');
|
|
822
|
+
expect(isZoomed(lines.host, 'x')).toBe(false);
|
|
823
|
+
expect(isZoomed(lines.host, 'y')).toBe(true);
|
|
824
|
+
|
|
825
|
+
// A band axis can't zoom.
|
|
826
|
+
const bars = await renderChart((host) => {
|
|
827
|
+
zoomable(barChart())(host);
|
|
828
|
+
host.zoomAxes.set('x');
|
|
829
|
+
});
|
|
830
|
+
await press(bars.getByText, '+');
|
|
831
|
+
expect(isZoomed(bars.host, 'x')).toBe(false);
|
|
832
|
+
});
|
|
833
|
+
|
|
834
|
+
it('pans by dragging, from the window at touch-down', async () => {
|
|
835
|
+
const { container, getByText, host, root } =
|
|
836
|
+
await renderChart(zoomable());
|
|
837
|
+
await press(getByText, '+');
|
|
838
|
+
await press(getByText, '+');
|
|
839
|
+
const [startX] = domainOf(host, 'x');
|
|
840
|
+
const [startY] = domainOf(host, 'y');
|
|
841
|
+
const span = domainOf(host, 'x')[1]! - startX!;
|
|
842
|
+
|
|
843
|
+
pan(root, 'onBegin', 100, 100);
|
|
844
|
+
pan(root, 'onStart', 100, 100);
|
|
845
|
+
pan(root, 'onUpdate', 110, 105);
|
|
846
|
+
await waitForUpdate();
|
|
847
|
+
// Dragging right moves the window to lower values; the y axis points
|
|
848
|
+
// down the screen, so dragging down moves it to higher ones.
|
|
849
|
+
const firstShift = startX! - domainOf(host, 'x')[0]!;
|
|
850
|
+
expect(firstShift).toBeGreaterThan(0);
|
|
851
|
+
expect(domainOf(host, 'y')[0]!).toBeGreaterThan(startY!);
|
|
852
|
+
|
|
853
|
+
// Each frame pans by the drag since touch-down, not by the frames'
|
|
854
|
+
// sum, and keeps the window's span.
|
|
855
|
+
pan(root, 'onUpdate', 120, 110);
|
|
856
|
+
await waitForUpdate();
|
|
857
|
+
const [x0, x1] = domainOf(host, 'x');
|
|
858
|
+
expect(startX! - x0!).toBeCloseTo(firstShift * 2);
|
|
859
|
+
expect(x1! - x0!).toBeCloseTo(span);
|
|
860
|
+
|
|
861
|
+
// While panning, the label pool keeps its size.
|
|
862
|
+
const count = labels(container).length;
|
|
863
|
+
pan(root, 'onUpdate', 300, 110);
|
|
864
|
+
await waitForUpdate();
|
|
865
|
+
expect(labels(container)).toHaveLength(count);
|
|
866
|
+
|
|
867
|
+
pan(root, 'onEnd', 300, 110);
|
|
868
|
+
await waitForUpdate();
|
|
869
|
+
expect(
|
|
870
|
+
labels(container).every(
|
|
871
|
+
(text) => !text.getAttribute('style')!.includes('display: none'),
|
|
872
|
+
),
|
|
873
|
+
).toBe(true);
|
|
874
|
+
});
|
|
875
|
+
|
|
876
|
+
it('hides the tooltip while panning', async () => {
|
|
877
|
+
const { container, host, root } = await renderChart(zoomable());
|
|
878
|
+
await tap(root, pointAt(host, 1));
|
|
879
|
+
expect(tooltipBox(container)).not.toBeNull();
|
|
880
|
+
|
|
881
|
+
pan(root, 'onBegin', 100, 100);
|
|
882
|
+
await waitForUpdate();
|
|
883
|
+
expect(tooltipBox(container)).toBeNull();
|
|
884
|
+
|
|
885
|
+
pan(root, 'onEnd', 100, 100);
|
|
886
|
+
await waitForUpdate();
|
|
887
|
+
expect(tooltipBox(container)).not.toBeNull();
|
|
888
|
+
});
|
|
889
|
+
|
|
890
|
+
it('anchors a pan that starts without onBegin', async () => {
|
|
891
|
+
const { getByText, host, root } = await renderChart(zoomable());
|
|
892
|
+
await press(getByText, '+');
|
|
893
|
+
const start = domainOf(host, 'x');
|
|
894
|
+
|
|
895
|
+
pan(root, 'onStart', 100, 0);
|
|
896
|
+
pan(root, 'onUpdate', 130, 0);
|
|
897
|
+
pan(root, 'onEnd', 130, 0);
|
|
898
|
+
await waitForUpdate();
|
|
899
|
+
const panned = domainOf(host, 'x');
|
|
900
|
+
expect(panned[0]!).toBeLessThan(start[0]!);
|
|
901
|
+
|
|
902
|
+
// One that skips onStart too begins at its first update, which is where
|
|
903
|
+
// the finger's travel is measured from: the window doesn't jump.
|
|
904
|
+
pan(root, 'onUpdate', 0, 0);
|
|
905
|
+
pan(root, 'onEnd', 0, 0);
|
|
906
|
+
await waitForUpdate();
|
|
907
|
+
expect(domainOf(host, 'x')).toEqual(panned);
|
|
908
|
+
});
|
|
909
|
+
|
|
910
|
+
it('hides spare labels while a pan holds the pool', async () => {
|
|
911
|
+
const { container, host, root } = await renderChart(zoomable());
|
|
912
|
+
const count = labels(container).length;
|
|
913
|
+
const hidden = () =>
|
|
914
|
+
labels(container).filter((text) =>
|
|
915
|
+
text.getAttribute('style')!.includes('display: none'),
|
|
916
|
+
);
|
|
917
|
+
pan(root, 'onBegin');
|
|
918
|
+
// A chart with fewer labels arrives mid-gesture: the spare slots hide
|
|
919
|
+
// rather than go, since removing elements mid-gesture can crash Lynx.
|
|
920
|
+
host.definition.set(
|
|
921
|
+
lineChart({
|
|
922
|
+
scales: {
|
|
923
|
+
x: { scale: () => scaleLinear().domain([0, 10]), axis: false },
|
|
924
|
+
y: { scale: () => scaleLinear().domain([0, 4]), axis: false },
|
|
925
|
+
},
|
|
926
|
+
}),
|
|
927
|
+
);
|
|
928
|
+
await waitForUpdate();
|
|
929
|
+
expect(labels(container)).toHaveLength(count);
|
|
930
|
+
expect(hidden()).toHaveLength(count);
|
|
931
|
+
|
|
932
|
+
pan(root, 'onEnd');
|
|
933
|
+
await waitForUpdate();
|
|
934
|
+
expect(labels(container)).toHaveLength(0);
|
|
935
|
+
});
|
|
936
|
+
|
|
937
|
+
it("doesn't pan an axis that can't zoom", async () => {
|
|
938
|
+
const { host, root } = await renderChart((host) => {
|
|
939
|
+
zoomable(barChart())(host);
|
|
940
|
+
host.zoomAxes.set('x');
|
|
941
|
+
});
|
|
942
|
+
pan(root, 'onBegin', 100, 0);
|
|
943
|
+
pan(root, 'onUpdate', 130, 0);
|
|
944
|
+
pan(root, 'onEnd', 130, 0);
|
|
945
|
+
await waitForUpdate();
|
|
946
|
+
expect(isZoomed(host, 'x')).toBe(false);
|
|
947
|
+
});
|
|
948
|
+
|
|
949
|
+
it('treats only a short touch as a tap', async () => {
|
|
950
|
+
const { container, host, root } = await renderChart(zoomable());
|
|
951
|
+
const at = pointAt(host, 1);
|
|
952
|
+
touchAt('touchstart', root, at);
|
|
953
|
+
touchAt('touchmove', root, { x: at.x + 40, y: at.y });
|
|
954
|
+
await waitForUpdate();
|
|
955
|
+
// A zoomable chart's drag pans: no scrubbing, no selection.
|
|
956
|
+
expect(tooltipBox(container)).toBeNull();
|
|
957
|
+
touchAt('touchend', root, { x: at.x + 40, y: at.y });
|
|
958
|
+
expect(host.selected).toEqual([]);
|
|
959
|
+
|
|
960
|
+
await tap(root, at);
|
|
961
|
+
expect(host.selected).toHaveLength(1);
|
|
962
|
+
});
|
|
963
|
+
});
|
|
964
|
+
|
|
965
|
+
describe('custom tooltip', () => {
|
|
966
|
+
@Component({
|
|
967
|
+
imports: [UiChart, UiChartTooltip, LYNX_ELEMENTS],
|
|
968
|
+
template: `
|
|
969
|
+
<ui-chart [definition]="definition" [width]="300">
|
|
970
|
+
<ng-template uiChartTooltip let-tooltip>
|
|
971
|
+
<text>{{ tooltip.primaryPoint.xValue }}</text>
|
|
972
|
+
<text>{{ tooltip.points.length }} point</text>
|
|
973
|
+
@if (tooltip.pinned) {
|
|
974
|
+
<view class="close" (bindtap)="tooltip.dismiss()">
|
|
975
|
+
<text>Close</text>
|
|
976
|
+
</view>
|
|
977
|
+
}
|
|
978
|
+
</ng-template>
|
|
979
|
+
</ui-chart>
|
|
980
|
+
`,
|
|
981
|
+
})
|
|
982
|
+
class CustomTooltipHost {
|
|
983
|
+
readonly definition = barChart();
|
|
984
|
+
readonly chart = viewChild.required(UiChart);
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
it('renders the template in place of the default rows', async () => {
|
|
988
|
+
const { container, componentRef, getByText } =
|
|
989
|
+
await render(CustomTooltipHost);
|
|
990
|
+
const host = componentRef.instance as CustomTooltipHost;
|
|
991
|
+
const root = chartRoot(container);
|
|
992
|
+
const point = host.chart().scene()!.points[1]!;
|
|
993
|
+
touchAt('touchstart', root, { x: point.x + 1, y: point.y + 1 });
|
|
994
|
+
await waitForUpdate();
|
|
995
|
+
const box = tooltipBox(container)!;
|
|
996
|
+
expect(within(box).getByText('Feb')).toBeTruthy();
|
|
997
|
+
expect(within(box).getByText('1 point')).toBeTruthy();
|
|
998
|
+
expect(within(box).queryByText('60')).toBeNull();
|
|
999
|
+
expect(within(box).queryByText('Close')).toBeNull();
|
|
1000
|
+
|
|
1001
|
+
// Pinned, it can hold a button that dismisses it.
|
|
1002
|
+
touchAt('touchend', root, { x: point.x + 1, y: point.y + 1 });
|
|
1003
|
+
await waitForUpdate();
|
|
1004
|
+
fireEvent.tap(getByText('Close').parentElement!);
|
|
1005
|
+
await waitForUpdate();
|
|
1006
|
+
expect(tooltipBox(container)).toBeNull();
|
|
1007
|
+
});
|
|
1008
|
+
|
|
1009
|
+
it("types the template's context for the template type checker", () => {
|
|
1010
|
+
// Angular's type checker reads this guard to type `let-tooltip`, as in
|
|
1011
|
+
// the template above; at runtime it only has to say yes.
|
|
1012
|
+
expect(UiChartTooltip.ngTemplateContextGuard(null as never, {})).toBe(
|
|
1013
|
+
true,
|
|
1014
|
+
);
|
|
1015
|
+
});
|
|
1016
|
+
});
|
|
1017
|
+
});
|