@opendata-ai/openchart-vanilla 6.2.1 → 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.
@@ -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
+ });