@opendata-ai/openchart-vanilla 6.3.0 → 6.4.1
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/index.d.ts +44 -3
- package/dist/index.js +580 -4
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
- package/src/__tests__/selection-events.test.ts +869 -0
- package/src/index.ts +3 -1
- package/src/mount.ts +607 -3
- package/src/svg-renderer.ts +3 -0
- package/src/text-edit-overlay.ts +255 -0
|
@@ -0,0 +1,869 @@
|
|
|
1
|
+
import type { ChartSpec, ElementEdit, ElementRef } from '@opendata-ai/openchart-core';
|
|
2
|
+
import { elementRef } from '@opendata-ai/openchart-core';
|
|
3
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
4
|
+
import { createContainer } from '../__test-fixtures__/dom';
|
|
5
|
+
import { barSpec, lineSpec } from '../__test-fixtures__/specs';
|
|
6
|
+
import { createChart } from '../mount';
|
|
7
|
+
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
// Shared specs
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Line chart with chrome, annotations, multi-series (produces legend + series labels).
|
|
14
|
+
* Useful for testing selection across different element types.
|
|
15
|
+
*/
|
|
16
|
+
const selectionSpec: ChartSpec = {
|
|
17
|
+
...lineSpec,
|
|
18
|
+
labels: { show: true },
|
|
19
|
+
annotations: [
|
|
20
|
+
{
|
|
21
|
+
type: 'text',
|
|
22
|
+
x: '2020-01-01',
|
|
23
|
+
y: 10,
|
|
24
|
+
text: 'Peak',
|
|
25
|
+
offset: { dx: 10, dy: -20 },
|
|
26
|
+
},
|
|
27
|
+
],
|
|
28
|
+
chrome: {
|
|
29
|
+
title: 'GDP Growth',
|
|
30
|
+
subtitle: 'US vs UK over time',
|
|
31
|
+
source: 'World Bank',
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/** Simple bar chart with chrome for focused chrome selection tests. */
|
|
36
|
+
const chromeOnlySpec: ChartSpec = {
|
|
37
|
+
...barSpec,
|
|
38
|
+
chrome: {
|
|
39
|
+
title: 'Simple Chart',
|
|
40
|
+
subtitle: 'With subtitle',
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
// Helpers
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Dispatch a click event on an element.
|
|
50
|
+
*/
|
|
51
|
+
function simulateClick(el: Element): void {
|
|
52
|
+
el.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Dispatch a double-click event on an element.
|
|
57
|
+
*/
|
|
58
|
+
function simulateDblClick(el: Element): void {
|
|
59
|
+
el.dispatchEvent(new MouseEvent('dblclick', { bubbles: true }));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Dispatch a keydown event on an element.
|
|
64
|
+
*/
|
|
65
|
+
function simulateKeyDown(el: Element, key: string, opts?: { shiftKey?: boolean }): void {
|
|
66
|
+
el.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, ...opts }));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Find the SVG element inside a container.
|
|
71
|
+
*/
|
|
72
|
+
function getSvg(container: HTMLDivElement): SVGElement {
|
|
73
|
+
return container.querySelector('svg') as SVGElement;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
// Tests
|
|
78
|
+
// ---------------------------------------------------------------------------
|
|
79
|
+
|
|
80
|
+
describe('selection events', () => {
|
|
81
|
+
let container: HTMLDivElement;
|
|
82
|
+
|
|
83
|
+
beforeEach(() => {
|
|
84
|
+
container = createContainer();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
afterEach(() => {
|
|
88
|
+
document.body.innerHTML = '';
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// =========================================================================
|
|
92
|
+
// 1. Click selection
|
|
93
|
+
// =========================================================================
|
|
94
|
+
describe('click selection', () => {
|
|
95
|
+
it('click on an annotation fires onSelect with correct ElementRef', () => {
|
|
96
|
+
const onSelect = vi.fn();
|
|
97
|
+
const chart = createChart(container, selectionSpec, { onSelect });
|
|
98
|
+
|
|
99
|
+
const annotation = container.querySelector('[data-annotation-index]') as SVGElement | null;
|
|
100
|
+
if (!annotation) {
|
|
101
|
+
chart.destroy();
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
simulateClick(annotation);
|
|
106
|
+
|
|
107
|
+
expect(onSelect).toHaveBeenCalledTimes(1);
|
|
108
|
+
const ref: ElementRef = onSelect.mock.calls[0][0];
|
|
109
|
+
expect(ref.type).toBe('annotation');
|
|
110
|
+
if (ref.type === 'annotation') {
|
|
111
|
+
expect(ref.index).toBe(0);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
chart.destroy();
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('click on a chrome element fires onSelect with correct ElementRef', () => {
|
|
118
|
+
const onSelect = vi.fn();
|
|
119
|
+
const chart = createChart(container, selectionSpec, { onSelect });
|
|
120
|
+
|
|
121
|
+
const titleEl = container.querySelector('[data-chrome-key="title"]') as SVGElement | null;
|
|
122
|
+
if (!titleEl) {
|
|
123
|
+
chart.destroy();
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
simulateClick(titleEl);
|
|
128
|
+
|
|
129
|
+
expect(onSelect).toHaveBeenCalledTimes(1);
|
|
130
|
+
const ref: ElementRef = onSelect.mock.calls[0][0];
|
|
131
|
+
expect(ref.type).toBe('chrome');
|
|
132
|
+
if (ref.type === 'chrome') {
|
|
133
|
+
expect(ref.key).toBe('title');
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
chart.destroy();
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('click on empty SVG area fires onDeselect for the previously selected element', () => {
|
|
140
|
+
const onSelect = vi.fn();
|
|
141
|
+
const onDeselect = vi.fn();
|
|
142
|
+
const chart = createChart(container, selectionSpec, { onSelect, onDeselect });
|
|
143
|
+
|
|
144
|
+
const titleEl = container.querySelector('[data-chrome-key="title"]') as SVGElement | null;
|
|
145
|
+
if (!titleEl) {
|
|
146
|
+
chart.destroy();
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// First select the title
|
|
151
|
+
simulateClick(titleEl);
|
|
152
|
+
expect(onSelect).toHaveBeenCalledTimes(1);
|
|
153
|
+
|
|
154
|
+
// Then click on empty SVG area (the SVG root itself)
|
|
155
|
+
const svg = getSvg(container);
|
|
156
|
+
svg.dispatchEvent(new MouseEvent('click', { bubbles: false }));
|
|
157
|
+
|
|
158
|
+
expect(onDeselect).toHaveBeenCalledTimes(1);
|
|
159
|
+
const ref: ElementRef = onDeselect.mock.calls[0][0];
|
|
160
|
+
expect(ref.type).toBe('chrome');
|
|
161
|
+
if (ref.type === 'chrome') {
|
|
162
|
+
expect(ref.key).toBe('title');
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
chart.destroy();
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('clicking a new element when one is already selected fires onDeselect then onSelect', () => {
|
|
169
|
+
const onSelect = vi.fn();
|
|
170
|
+
const onDeselect = vi.fn();
|
|
171
|
+
const chart = createChart(container, selectionSpec, { onSelect, onDeselect });
|
|
172
|
+
|
|
173
|
+
const titleEl = container.querySelector('[data-chrome-key="title"]') as SVGElement | null;
|
|
174
|
+
const subtitleEl = container.querySelector(
|
|
175
|
+
'[data-chrome-key="subtitle"]',
|
|
176
|
+
) as SVGElement | null;
|
|
177
|
+
if (!titleEl || !subtitleEl) {
|
|
178
|
+
chart.destroy();
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Select title
|
|
183
|
+
simulateClick(titleEl);
|
|
184
|
+
expect(onSelect).toHaveBeenCalledTimes(1);
|
|
185
|
+
|
|
186
|
+
// Select subtitle (should deselect title first)
|
|
187
|
+
simulateClick(subtitleEl);
|
|
188
|
+
|
|
189
|
+
expect(onDeselect).toHaveBeenCalledTimes(1);
|
|
190
|
+
const deselectedRef: ElementRef = onDeselect.mock.calls[0][0];
|
|
191
|
+
expect(deselectedRef.type).toBe('chrome');
|
|
192
|
+
if (deselectedRef.type === 'chrome') {
|
|
193
|
+
expect(deselectedRef.key).toBe('title');
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
expect(onSelect).toHaveBeenCalledTimes(2);
|
|
197
|
+
const selectedRef: ElementRef = onSelect.mock.calls[1][0];
|
|
198
|
+
expect(selectedRef.type).toBe('chrome');
|
|
199
|
+
if (selectedRef.type === 'chrome') {
|
|
200
|
+
expect(selectedRef.key).toBe('subtitle');
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
chart.destroy();
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it('onSelect is NOT fired when onEdit is provided but onSelect is not', () => {
|
|
207
|
+
const onEdit = vi.fn();
|
|
208
|
+
// Only providing onEdit, not onSelect
|
|
209
|
+
const chart = createChart(container, selectionSpec, { onEdit });
|
|
210
|
+
|
|
211
|
+
const titleEl = container.querySelector('[data-chrome-key="title"]') as SVGElement | null;
|
|
212
|
+
if (!titleEl) {
|
|
213
|
+
chart.destroy();
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
simulateClick(titleEl);
|
|
218
|
+
|
|
219
|
+
// onEdit should not be called from a simple click (only from drag/delete/text-edit)
|
|
220
|
+
// The key point: no onSelect callback was provided, so none should fire
|
|
221
|
+
// The selection still happens internally, but the callback doesn't fire since it wasn't provided
|
|
222
|
+
expect(onEdit).not.toHaveBeenCalled();
|
|
223
|
+
|
|
224
|
+
chart.destroy();
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
// =========================================================================
|
|
229
|
+
// 2. Programmatic selection API
|
|
230
|
+
// =========================================================================
|
|
231
|
+
describe('programmatic selection API', () => {
|
|
232
|
+
it('getSelectedElement() returns null initially', () => {
|
|
233
|
+
const chart = createChart(container, selectionSpec, { onSelect: vi.fn() });
|
|
234
|
+
|
|
235
|
+
expect(chart.getSelectedElement()).toBeNull();
|
|
236
|
+
|
|
237
|
+
chart.destroy();
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it('getSelectedElement() returns the correct ElementRef after clicking an element', () => {
|
|
241
|
+
const onSelect = vi.fn();
|
|
242
|
+
const chart = createChart(container, selectionSpec, { onSelect });
|
|
243
|
+
|
|
244
|
+
const titleEl = container.querySelector('[data-chrome-key="title"]') as SVGElement | null;
|
|
245
|
+
if (!titleEl) {
|
|
246
|
+
chart.destroy();
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
simulateClick(titleEl);
|
|
251
|
+
|
|
252
|
+
const selected = chart.getSelectedElement();
|
|
253
|
+
expect(selected).not.toBeNull();
|
|
254
|
+
expect(selected?.type).toBe('chrome');
|
|
255
|
+
if (selected?.type === 'chrome') {
|
|
256
|
+
expect(selected.key).toBe('title');
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
chart.destroy();
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it('chart.select(ref) programmatically selects an element and fires onSelect', () => {
|
|
263
|
+
const onSelect = vi.fn();
|
|
264
|
+
const chart = createChart(container, selectionSpec, { onSelect });
|
|
265
|
+
|
|
266
|
+
const titleEl = container.querySelector('[data-chrome-key="title"]') as SVGElement | null;
|
|
267
|
+
if (!titleEl) {
|
|
268
|
+
chart.destroy();
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const ref = elementRef.chrome('title');
|
|
273
|
+
chart.select(ref);
|
|
274
|
+
|
|
275
|
+
expect(onSelect).toHaveBeenCalledTimes(1);
|
|
276
|
+
expect(onSelect.mock.calls[0][0]).toEqual(ref);
|
|
277
|
+
expect(chart.getSelectedElement()).toEqual(ref);
|
|
278
|
+
|
|
279
|
+
chart.destroy();
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it('chart.deselect() programmatically deselects and fires onDeselect', () => {
|
|
283
|
+
const onSelect = vi.fn();
|
|
284
|
+
const onDeselect = vi.fn();
|
|
285
|
+
const chart = createChart(container, selectionSpec, { onSelect, onDeselect });
|
|
286
|
+
|
|
287
|
+
const titleEl = container.querySelector('[data-chrome-key="title"]') as SVGElement | null;
|
|
288
|
+
if (!titleEl) {
|
|
289
|
+
chart.destroy();
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
chart.select(elementRef.chrome('title'));
|
|
294
|
+
chart.deselect();
|
|
295
|
+
|
|
296
|
+
expect(onDeselect).toHaveBeenCalledTimes(1);
|
|
297
|
+
expect(chart.getSelectedElement()).toBeNull();
|
|
298
|
+
|
|
299
|
+
chart.destroy();
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
it('chart.select(ref) with an invalid ref is a silent no-op', () => {
|
|
303
|
+
const onSelect = vi.fn();
|
|
304
|
+
const chart = createChart(container, selectionSpec, { onSelect });
|
|
305
|
+
|
|
306
|
+
// Select an element that doesn't exist in the chart
|
|
307
|
+
const invalidRef = elementRef.chrome('footer');
|
|
308
|
+
chart.select(invalidRef);
|
|
309
|
+
|
|
310
|
+
// Should not fire onSelect since the element wasn't found in the DOM
|
|
311
|
+
expect(onSelect).not.toHaveBeenCalled();
|
|
312
|
+
expect(chart.getSelectedElement()).toBeNull();
|
|
313
|
+
|
|
314
|
+
chart.destroy();
|
|
315
|
+
});
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
// =========================================================================
|
|
319
|
+
// 3. Selection overlay
|
|
320
|
+
// =========================================================================
|
|
321
|
+
describe('selection overlay', () => {
|
|
322
|
+
it('viz-selection-overlay group appears after selection', () => {
|
|
323
|
+
const onSelect = vi.fn();
|
|
324
|
+
const chart = createChart(container, selectionSpec, { onSelect });
|
|
325
|
+
|
|
326
|
+
const titleEl = container.querySelector('[data-chrome-key="title"]') as SVGElement | null;
|
|
327
|
+
if (!titleEl) {
|
|
328
|
+
chart.destroy();
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// No overlay initially
|
|
333
|
+
expect(container.querySelector('.viz-selection-overlay')).toBeNull();
|
|
334
|
+
|
|
335
|
+
simulateClick(titleEl);
|
|
336
|
+
|
|
337
|
+
// Overlay should now exist
|
|
338
|
+
expect(container.querySelector('.viz-selection-overlay')).not.toBeNull();
|
|
339
|
+
|
|
340
|
+
chart.destroy();
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
it('selection overlay disappears after deselection', () => {
|
|
344
|
+
const onSelect = vi.fn();
|
|
345
|
+
const onDeselect = vi.fn();
|
|
346
|
+
const chart = createChart(container, selectionSpec, { onSelect, onDeselect });
|
|
347
|
+
|
|
348
|
+
const titleEl = container.querySelector('[data-chrome-key="title"]') as SVGElement | null;
|
|
349
|
+
if (!titleEl) {
|
|
350
|
+
chart.destroy();
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// Select
|
|
355
|
+
simulateClick(titleEl);
|
|
356
|
+
expect(container.querySelector('.viz-selection-overlay')).not.toBeNull();
|
|
357
|
+
|
|
358
|
+
// Deselect by clicking empty area
|
|
359
|
+
const svg = getSvg(container);
|
|
360
|
+
svg.dispatchEvent(new MouseEvent('click', { bubbles: false }));
|
|
361
|
+
|
|
362
|
+
expect(container.querySelector('.viz-selection-overlay')).toBeNull();
|
|
363
|
+
|
|
364
|
+
chart.destroy();
|
|
365
|
+
});
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
// =========================================================================
|
|
369
|
+
// 4. Deletion via keyboard
|
|
370
|
+
// =========================================================================
|
|
371
|
+
describe('deletion via keyboard', () => {
|
|
372
|
+
it('Delete key fires onEdit({ type: "delete" }) when an element is selected', () => {
|
|
373
|
+
const onSelect = vi.fn();
|
|
374
|
+
const onEdit = vi.fn();
|
|
375
|
+
const chart = createChart(container, selectionSpec, { onSelect, onEdit });
|
|
376
|
+
|
|
377
|
+
const titleEl = container.querySelector('[data-chrome-key="title"]') as SVGElement | null;
|
|
378
|
+
if (!titleEl) {
|
|
379
|
+
chart.destroy();
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
simulateClick(titleEl);
|
|
384
|
+
simulateKeyDown(getSvg(container), 'Delete');
|
|
385
|
+
|
|
386
|
+
expect(onEdit).toHaveBeenCalledTimes(1);
|
|
387
|
+
const edit: ElementEdit = onEdit.mock.calls[0][0];
|
|
388
|
+
expect(edit.type).toBe('delete');
|
|
389
|
+
if (edit.type === 'delete') {
|
|
390
|
+
expect(edit.element.type).toBe('chrome');
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
chart.destroy();
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
it('Backspace key fires onEdit({ type: "delete" }) when an element is selected', () => {
|
|
397
|
+
const onSelect = vi.fn();
|
|
398
|
+
const onEdit = vi.fn();
|
|
399
|
+
const chart = createChart(container, selectionSpec, { onSelect, onEdit });
|
|
400
|
+
|
|
401
|
+
const titleEl = container.querySelector('[data-chrome-key="title"]') as SVGElement | null;
|
|
402
|
+
if (!titleEl) {
|
|
403
|
+
chart.destroy();
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
simulateClick(titleEl);
|
|
408
|
+
simulateKeyDown(getSvg(container), 'Backspace');
|
|
409
|
+
|
|
410
|
+
expect(onEdit).toHaveBeenCalledTimes(1);
|
|
411
|
+
const edit: ElementEdit = onEdit.mock.calls[0][0];
|
|
412
|
+
expect(edit.type).toBe('delete');
|
|
413
|
+
|
|
414
|
+
chart.destroy();
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
it('Delete key does nothing when no element is selected', () => {
|
|
418
|
+
const onEdit = vi.fn();
|
|
419
|
+
const chart = createChart(container, selectionSpec, { onSelect: vi.fn(), onEdit });
|
|
420
|
+
|
|
421
|
+
simulateKeyDown(getSvg(container), 'Delete');
|
|
422
|
+
|
|
423
|
+
expect(onEdit).not.toHaveBeenCalled();
|
|
424
|
+
|
|
425
|
+
chart.destroy();
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
it('Delete key does nothing when text editing is active', () => {
|
|
429
|
+
const onSelect = vi.fn();
|
|
430
|
+
const onEdit = vi.fn();
|
|
431
|
+
const chart = createChart(container, selectionSpec, { onSelect, onEdit });
|
|
432
|
+
|
|
433
|
+
const titleEl = container.querySelector('[data-chrome-key="title"]') as SVGElement | null;
|
|
434
|
+
if (!titleEl) {
|
|
435
|
+
chart.destroy();
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// Select and enter text editing via double-click
|
|
440
|
+
simulateDblClick(titleEl);
|
|
441
|
+
|
|
442
|
+
// Now press Delete: should NOT fire onEdit for deletion since text editing is active
|
|
443
|
+
simulateKeyDown(getSvg(container), 'Delete');
|
|
444
|
+
|
|
445
|
+
// onEdit should not have been called with type 'delete'
|
|
446
|
+
const deleteCalls = onEdit.mock.calls.filter(
|
|
447
|
+
(call: [ElementEdit]) => call[0].type === 'delete',
|
|
448
|
+
);
|
|
449
|
+
expect(deleteCalls).toHaveLength(0);
|
|
450
|
+
|
|
451
|
+
chart.destroy();
|
|
452
|
+
});
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
// =========================================================================
|
|
456
|
+
// 5. Keyboard events
|
|
457
|
+
// =========================================================================
|
|
458
|
+
describe('keyboard events', () => {
|
|
459
|
+
it('Escape key deselects the selected element and fires onDeselect', () => {
|
|
460
|
+
const onSelect = vi.fn();
|
|
461
|
+
const onDeselect = vi.fn();
|
|
462
|
+
const chart = createChart(container, selectionSpec, { onSelect, onDeselect });
|
|
463
|
+
|
|
464
|
+
const titleEl = container.querySelector('[data-chrome-key="title"]') as SVGElement | null;
|
|
465
|
+
if (!titleEl) {
|
|
466
|
+
chart.destroy();
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
simulateClick(titleEl);
|
|
471
|
+
expect(chart.getSelectedElement()).not.toBeNull();
|
|
472
|
+
|
|
473
|
+
simulateKeyDown(getSvg(container), 'Escape');
|
|
474
|
+
|
|
475
|
+
expect(onDeselect).toHaveBeenCalledTimes(1);
|
|
476
|
+
expect(chart.getSelectedElement()).toBeNull();
|
|
477
|
+
|
|
478
|
+
chart.destroy();
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
it('Escape key does nothing when nothing is selected', () => {
|
|
482
|
+
const onDeselect = vi.fn();
|
|
483
|
+
const chart = createChart(container, selectionSpec, { onSelect: vi.fn(), onDeselect });
|
|
484
|
+
|
|
485
|
+
simulateKeyDown(getSvg(container), 'Escape');
|
|
486
|
+
|
|
487
|
+
expect(onDeselect).not.toHaveBeenCalled();
|
|
488
|
+
|
|
489
|
+
chart.destroy();
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
it('Arrow keys cycle through editable elements when one is selected', () => {
|
|
493
|
+
const onSelect = vi.fn();
|
|
494
|
+
const onDeselect = vi.fn();
|
|
495
|
+
const chart = createChart(container, chromeOnlySpec, { onSelect, onDeselect });
|
|
496
|
+
|
|
497
|
+
// First select the title by clicking it
|
|
498
|
+
const titleEl = getSvg(container).querySelector('[data-chrome-key="title"]');
|
|
499
|
+
if (!titleEl) {
|
|
500
|
+
chart.destroy();
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
titleEl.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
|
504
|
+
expect(onSelect).toHaveBeenCalledTimes(1);
|
|
505
|
+
const firstRef: ElementRef = onSelect.mock.calls[0][0];
|
|
506
|
+
expect(firstRef.type).toBe('chrome');
|
|
507
|
+
if (firstRef.type === 'chrome') {
|
|
508
|
+
expect(firstRef.key).toBe('title');
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
// ArrowDown should cycle to the next element (subtitle), deselecting title
|
|
512
|
+
simulateKeyDown(getSvg(container), 'ArrowDown');
|
|
513
|
+
|
|
514
|
+
expect(onDeselect).toHaveBeenCalledTimes(1);
|
|
515
|
+
expect(onSelect).toHaveBeenCalledTimes(2);
|
|
516
|
+
const secondRef: ElementRef = onSelect.mock.calls[1][0];
|
|
517
|
+
expect(secondRef.type).toBe('chrome');
|
|
518
|
+
if (secondRef.type === 'chrome') {
|
|
519
|
+
expect(secondRef.key).toBe('subtitle');
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
chart.destroy();
|
|
523
|
+
});
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
// =========================================================================
|
|
527
|
+
// 6. Selection persistence across updates
|
|
528
|
+
// =========================================================================
|
|
529
|
+
describe('selection persistence across updates', () => {
|
|
530
|
+
it('selection persists across chart.update() -- overlay is recreated', () => {
|
|
531
|
+
const onSelect = vi.fn();
|
|
532
|
+
const chart = createChart(container, selectionSpec, { onSelect });
|
|
533
|
+
|
|
534
|
+
const titleEl = container.querySelector('[data-chrome-key="title"]') as SVGElement | null;
|
|
535
|
+
if (!titleEl) {
|
|
536
|
+
chart.destroy();
|
|
537
|
+
return;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
simulateClick(titleEl);
|
|
541
|
+
expect(container.querySelector('.viz-selection-overlay')).not.toBeNull();
|
|
542
|
+
|
|
543
|
+
// Update with same spec (should re-render but preserve selection)
|
|
544
|
+
chart.update(selectionSpec);
|
|
545
|
+
|
|
546
|
+
// Selection overlay should still be present
|
|
547
|
+
expect(container.querySelector('.viz-selection-overlay')).not.toBeNull();
|
|
548
|
+
expect(chart.getSelectedElement()?.type).toBe('chrome');
|
|
549
|
+
|
|
550
|
+
chart.destroy();
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
it('selection clears when the selected element no longer exists after update', () => {
|
|
554
|
+
const onSelect = vi.fn();
|
|
555
|
+
const chart = createChart(container, selectionSpec, { onSelect });
|
|
556
|
+
|
|
557
|
+
// Select the source chrome element
|
|
558
|
+
const sourceEl = container.querySelector('[data-chrome-key="source"]') as SVGElement | null;
|
|
559
|
+
if (!sourceEl) {
|
|
560
|
+
chart.destroy();
|
|
561
|
+
return;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
simulateClick(sourceEl);
|
|
565
|
+
expect(chart.getSelectedElement()).not.toBeNull();
|
|
566
|
+
|
|
567
|
+
// Update with a spec that has no source chrome
|
|
568
|
+
const noSourceSpec: ChartSpec = {
|
|
569
|
+
...selectionSpec,
|
|
570
|
+
chrome: { title: 'GDP Growth' },
|
|
571
|
+
};
|
|
572
|
+
chart.update(noSourceSpec);
|
|
573
|
+
|
|
574
|
+
// Selection should be cleared because source no longer exists
|
|
575
|
+
expect(chart.getSelectedElement()).toBeNull();
|
|
576
|
+
expect(container.querySelector('.viz-selection-overlay')).toBeNull();
|
|
577
|
+
|
|
578
|
+
chart.destroy();
|
|
579
|
+
});
|
|
580
|
+
|
|
581
|
+
it('chart.update(spec, { selectedElement: ref }) overrides selection', () => {
|
|
582
|
+
const onSelect = vi.fn();
|
|
583
|
+
const chart = createChart(container, selectionSpec, { onSelect });
|
|
584
|
+
|
|
585
|
+
// Select title via click
|
|
586
|
+
const titleEl = container.querySelector('[data-chrome-key="title"]') as SVGElement | null;
|
|
587
|
+
if (!titleEl) {
|
|
588
|
+
chart.destroy();
|
|
589
|
+
return;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
simulateClick(titleEl);
|
|
593
|
+
expect(chart.getSelectedElement()?.type).toBe('chrome');
|
|
594
|
+
if (chart.getSelectedElement()?.type === 'chrome') {
|
|
595
|
+
expect((chart.getSelectedElement() as { type: 'chrome'; key: string }).key).toBe('title');
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
// Update with selectedElement override to subtitle
|
|
599
|
+
chart.update(selectionSpec, { selectedElement: elementRef.chrome('subtitle') });
|
|
600
|
+
|
|
601
|
+
// After re-render, the subtitle should be selected (overlay should exist)
|
|
602
|
+
const selected = chart.getSelectedElement();
|
|
603
|
+
expect(selected).not.toBeNull();
|
|
604
|
+
expect(selected?.type).toBe('chrome');
|
|
605
|
+
if (selected?.type === 'chrome') {
|
|
606
|
+
expect(selected.key).toBe('subtitle');
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
chart.destroy();
|
|
610
|
+
});
|
|
611
|
+
});
|
|
612
|
+
|
|
613
|
+
// =========================================================================
|
|
614
|
+
// 7. Hover feedback
|
|
615
|
+
// =========================================================================
|
|
616
|
+
describe('hover feedback', () => {
|
|
617
|
+
it('mouse enter on editable element adds viz-editable-hover class', () => {
|
|
618
|
+
const onSelect = vi.fn();
|
|
619
|
+
const chart = createChart(container, selectionSpec, { onSelect });
|
|
620
|
+
|
|
621
|
+
const titleEl = container.querySelector('[data-chrome-key="title"]') as SVGElement | null;
|
|
622
|
+
if (!titleEl) {
|
|
623
|
+
chart.destroy();
|
|
624
|
+
return;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
// Trigger mouseenter (uses capture so dispatch on the target itself)
|
|
628
|
+
titleEl.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true }));
|
|
629
|
+
|
|
630
|
+
expect(titleEl.classList.contains('viz-editable-hover')).toBe(true);
|
|
631
|
+
|
|
632
|
+
chart.destroy();
|
|
633
|
+
});
|
|
634
|
+
|
|
635
|
+
it('mouse leave removes viz-editable-hover class', () => {
|
|
636
|
+
const onSelect = vi.fn();
|
|
637
|
+
const chart = createChart(container, selectionSpec, { onSelect });
|
|
638
|
+
|
|
639
|
+
const titleEl = container.querySelector('[data-chrome-key="title"]') as SVGElement | null;
|
|
640
|
+
if (!titleEl) {
|
|
641
|
+
chart.destroy();
|
|
642
|
+
return;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
// Add hover class first
|
|
646
|
+
titleEl.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true }));
|
|
647
|
+
expect(titleEl.classList.contains('viz-editable-hover')).toBe(true);
|
|
648
|
+
|
|
649
|
+
// Remove hover class
|
|
650
|
+
titleEl.dispatchEvent(new MouseEvent('mouseleave', { bubbles: true }));
|
|
651
|
+
expect(titleEl.classList.contains('viz-editable-hover')).toBe(false);
|
|
652
|
+
|
|
653
|
+
chart.destroy();
|
|
654
|
+
});
|
|
655
|
+
});
|
|
656
|
+
|
|
657
|
+
// =========================================================================
|
|
658
|
+
// 8. Text editing via double-click
|
|
659
|
+
// =========================================================================
|
|
660
|
+
describe('text editing', () => {
|
|
661
|
+
it('double-click on chrome text creates a text editing overlay', () => {
|
|
662
|
+
const onSelect = vi.fn();
|
|
663
|
+
const chart = createChart(container, selectionSpec, { onSelect });
|
|
664
|
+
|
|
665
|
+
const titleEl = container.querySelector('[data-chrome-key="title"]') as SVGElement | null;
|
|
666
|
+
if (!titleEl) {
|
|
667
|
+
chart.destroy();
|
|
668
|
+
return;
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
simulateDblClick(titleEl);
|
|
672
|
+
|
|
673
|
+
// A textarea should appear in the container
|
|
674
|
+
const textarea = container.querySelector('textarea');
|
|
675
|
+
expect(textarea).not.toBeNull();
|
|
676
|
+
expect(chart.isEditing).toBe(true);
|
|
677
|
+
|
|
678
|
+
chart.destroy();
|
|
679
|
+
});
|
|
680
|
+
|
|
681
|
+
it('text editing overlay contains the current text', () => {
|
|
682
|
+
const onSelect = vi.fn();
|
|
683
|
+
const chart = createChart(container, selectionSpec, { onSelect });
|
|
684
|
+
|
|
685
|
+
const titleEl = container.querySelector('[data-chrome-key="title"]') as SVGElement | null;
|
|
686
|
+
if (!titleEl) {
|
|
687
|
+
chart.destroy();
|
|
688
|
+
return;
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
simulateDblClick(titleEl);
|
|
692
|
+
|
|
693
|
+
const textarea = container.querySelector('textarea');
|
|
694
|
+
expect(textarea).not.toBeNull();
|
|
695
|
+
expect(textarea?.value).toBe('GDP Growth');
|
|
696
|
+
|
|
697
|
+
chart.destroy();
|
|
698
|
+
});
|
|
699
|
+
|
|
700
|
+
it('pressing Enter in the overlay fires onEdit and onTextEdit when text changed', () => {
|
|
701
|
+
const onSelect = vi.fn();
|
|
702
|
+
const onEdit = vi.fn();
|
|
703
|
+
const onTextEdit = vi.fn();
|
|
704
|
+
const chart = createChart(container, selectionSpec, { onSelect, onEdit, onTextEdit });
|
|
705
|
+
|
|
706
|
+
const titleEl = container.querySelector('[data-chrome-key="title"]') as SVGElement | null;
|
|
707
|
+
if (!titleEl) {
|
|
708
|
+
chart.destroy();
|
|
709
|
+
return;
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
simulateDblClick(titleEl);
|
|
713
|
+
|
|
714
|
+
const textarea = container.querySelector('textarea');
|
|
715
|
+
if (!textarea) {
|
|
716
|
+
chart.destroy();
|
|
717
|
+
return;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
// Change the text
|
|
721
|
+
textarea.value = 'New Title';
|
|
722
|
+
|
|
723
|
+
// Press Enter to commit
|
|
724
|
+
textarea.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
|
|
725
|
+
|
|
726
|
+
expect(onTextEdit).toHaveBeenCalledTimes(1);
|
|
727
|
+
expect(onTextEdit.mock.calls[0][1]).toBe('GDP Growth'); // oldText
|
|
728
|
+
expect(onTextEdit.mock.calls[0][2]).toBe('New Title'); // newText
|
|
729
|
+
|
|
730
|
+
expect(onEdit).toHaveBeenCalledTimes(1);
|
|
731
|
+
const edit: ElementEdit = onEdit.mock.calls[0][0];
|
|
732
|
+
expect(edit.type).toBe('text-edit');
|
|
733
|
+
if (edit.type === 'text-edit') {
|
|
734
|
+
expect(edit.oldText).toBe('GDP Growth');
|
|
735
|
+
expect(edit.newText).toBe('New Title');
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
chart.destroy();
|
|
739
|
+
});
|
|
740
|
+
|
|
741
|
+
it('pressing Escape in the overlay cancels without firing callbacks', () => {
|
|
742
|
+
const onSelect = vi.fn();
|
|
743
|
+
const onEdit = vi.fn();
|
|
744
|
+
const onTextEdit = vi.fn();
|
|
745
|
+
const chart = createChart(container, selectionSpec, { onSelect, onEdit, onTextEdit });
|
|
746
|
+
|
|
747
|
+
const titleEl = container.querySelector('[data-chrome-key="title"]') as SVGElement | null;
|
|
748
|
+
if (!titleEl) {
|
|
749
|
+
chart.destroy();
|
|
750
|
+
return;
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
simulateDblClick(titleEl);
|
|
754
|
+
|
|
755
|
+
const textarea = container.querySelector('textarea');
|
|
756
|
+
if (!textarea) {
|
|
757
|
+
chart.destroy();
|
|
758
|
+
return;
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
// Change the text
|
|
762
|
+
textarea.value = 'Changed Text';
|
|
763
|
+
|
|
764
|
+
// Press Escape to cancel
|
|
765
|
+
textarea.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
|
|
766
|
+
|
|
767
|
+
// Neither onTextEdit nor onEdit should be called for text-edit type
|
|
768
|
+
expect(onTextEdit).not.toHaveBeenCalled();
|
|
769
|
+
const textEditCalls = onEdit.mock.calls.filter(
|
|
770
|
+
(call: [ElementEdit]) => call[0].type === 'text-edit',
|
|
771
|
+
);
|
|
772
|
+
expect(textEditCalls).toHaveLength(0);
|
|
773
|
+
|
|
774
|
+
// Textarea should be removed
|
|
775
|
+
expect(container.querySelector('textarea')).toBeNull();
|
|
776
|
+
|
|
777
|
+
// isEditing should be false again
|
|
778
|
+
expect(chart.isEditing).toBe(false);
|
|
779
|
+
|
|
780
|
+
chart.destroy();
|
|
781
|
+
});
|
|
782
|
+
|
|
783
|
+
it('text edit does not fire when text has not changed', () => {
|
|
784
|
+
const onSelect = vi.fn();
|
|
785
|
+
const onEdit = vi.fn();
|
|
786
|
+
const onTextEdit = vi.fn();
|
|
787
|
+
const chart = createChart(container, selectionSpec, { onSelect, onEdit, onTextEdit });
|
|
788
|
+
|
|
789
|
+
const titleEl = container.querySelector('[data-chrome-key="title"]') as SVGElement | null;
|
|
790
|
+
if (!titleEl) {
|
|
791
|
+
chart.destroy();
|
|
792
|
+
return;
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
simulateDblClick(titleEl);
|
|
796
|
+
|
|
797
|
+
const textarea = container.querySelector('textarea');
|
|
798
|
+
if (!textarea) {
|
|
799
|
+
chart.destroy();
|
|
800
|
+
return;
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
// Don't change the text, just press Enter
|
|
804
|
+
textarea.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
|
|
805
|
+
|
|
806
|
+
// Neither callback should fire since text is unchanged
|
|
807
|
+
expect(onTextEdit).not.toHaveBeenCalled();
|
|
808
|
+
const textEditCalls = onEdit.mock.calls.filter(
|
|
809
|
+
(call: [ElementEdit]) => call[0].type === 'text-edit',
|
|
810
|
+
);
|
|
811
|
+
expect(textEditCalls).toHaveLength(0);
|
|
812
|
+
|
|
813
|
+
chart.destroy();
|
|
814
|
+
});
|
|
815
|
+
|
|
816
|
+
it('isEditing is false initially', () => {
|
|
817
|
+
const chart = createChart(container, selectionSpec, { onSelect: vi.fn() });
|
|
818
|
+
|
|
819
|
+
expect(chart.isEditing).toBe(false);
|
|
820
|
+
|
|
821
|
+
chart.destroy();
|
|
822
|
+
});
|
|
823
|
+
});
|
|
824
|
+
|
|
825
|
+
// =========================================================================
|
|
826
|
+
// 9. Initial selected element
|
|
827
|
+
// =========================================================================
|
|
828
|
+
describe('initial selectedElement option', () => {
|
|
829
|
+
it('passing selectedElement in options selects the element on mount', () => {
|
|
830
|
+
const onSelect = vi.fn();
|
|
831
|
+
const chart = createChart(container, selectionSpec, {
|
|
832
|
+
onSelect,
|
|
833
|
+
selectedElement: elementRef.chrome('title'),
|
|
834
|
+
});
|
|
835
|
+
|
|
836
|
+
// The overlay should be rendered
|
|
837
|
+
expect(container.querySelector('.viz-selection-overlay')).not.toBeNull();
|
|
838
|
+
expect(chart.getSelectedElement()?.type).toBe('chrome');
|
|
839
|
+
|
|
840
|
+
chart.destroy();
|
|
841
|
+
});
|
|
842
|
+
});
|
|
843
|
+
|
|
844
|
+
// =========================================================================
|
|
845
|
+
// 10. Destroy cleanup
|
|
846
|
+
// =========================================================================
|
|
847
|
+
describe('destroy cleanup', () => {
|
|
848
|
+
it('after destroy, clicking elements does not fire callbacks', () => {
|
|
849
|
+
const onSelect = vi.fn();
|
|
850
|
+
const chart = createChart(container, selectionSpec, { onSelect });
|
|
851
|
+
chart.destroy();
|
|
852
|
+
|
|
853
|
+
// The SVG is removed, so we can't click. But verify no errors.
|
|
854
|
+
expect(onSelect).not.toHaveBeenCalled();
|
|
855
|
+
});
|
|
856
|
+
|
|
857
|
+
it('getSelectedElement returns null after destroy clears selection', () => {
|
|
858
|
+
const onSelect = vi.fn();
|
|
859
|
+
const chart = createChart(container, selectionSpec, {
|
|
860
|
+
onSelect,
|
|
861
|
+
selectedElement: elementRef.chrome('title'),
|
|
862
|
+
});
|
|
863
|
+
|
|
864
|
+
chart.destroy();
|
|
865
|
+
|
|
866
|
+
expect(chart.getSelectedElement()).toBeNull();
|
|
867
|
+
});
|
|
868
|
+
});
|
|
869
|
+
});
|