@opendata-ai/openchart-vanilla 8.0.0-rc.2 → 8.0.0-rc.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (145) hide show
  1. package/dist/{chunk-W34KN7FD.js → chunk-2NAQRP66.js} +2 -2
  2. package/dist/chunk-DCODCLWI.js +234 -0
  3. package/dist/chunk-DCODCLWI.js.map +1 -0
  4. package/dist/{chunk-5EOV5JQZ.js → chunk-EKAUQAAX.js} +50 -46
  5. package/dist/chunk-EKAUQAAX.js.map +1 -0
  6. package/dist/{chunk-ENXSUXC5.js → chunk-KNT5AZ2O.js} +7309 -5490
  7. package/dist/chunk-KNT5AZ2O.js.map +1 -0
  8. package/dist/chunk-RM5XS6NR.js +113 -0
  9. package/dist/chunk-RM5XS6NR.js.map +1 -0
  10. package/dist/chunk-T32UHMZA.js +282 -0
  11. package/dist/chunk-T32UHMZA.js.map +1 -0
  12. package/dist/export-gif.d.ts +113 -0
  13. package/dist/export-gif.js +21 -0
  14. package/dist/export-gif.js.map +1 -0
  15. package/dist/index.d.ts +283 -24
  16. package/dist/index.js +2012 -763
  17. package/dist/index.js.map +1 -1
  18. package/dist/{mount-CUKQLeqD.d.ts → mount-Dvv8NYIv.d.ts} +117 -3
  19. package/dist/simulation-worker.js +83 -2
  20. package/dist/static.js +7 -69
  21. package/dist/static.js.map +1 -1
  22. package/dist/story/index.d.ts +3 -2
  23. package/dist/story/index.js +5 -3
  24. package/dist/story/index.js.map +1 -1
  25. package/dist/styles.css +1 -1
  26. package/dist/styles.css.map +1 -1
  27. package/package.json +14 -6
  28. package/dist/chunk-5EOV5JQZ.js.map +0 -1
  29. package/dist/chunk-ENXSUXC5.js.map +0 -1
  30. package/src/__test-fixtures__/dom.ts +0 -70
  31. package/src/__test-fixtures__/specs.ts +0 -214
  32. package/src/__tests__/animation.test.ts +0 -358
  33. package/src/__tests__/annotations-render.test.ts +0 -201
  34. package/src/__tests__/compound-labels.test.ts +0 -122
  35. package/src/__tests__/continuous-legend.test.ts +0 -91
  36. package/src/__tests__/crosshair.test.ts +0 -130
  37. package/src/__tests__/edit-events.test.ts +0 -902
  38. package/src/__tests__/events.test.ts +0 -387
  39. package/src/__tests__/export.test.ts +0 -241
  40. package/src/__tests__/font-race.test.ts +0 -199
  41. package/src/__tests__/gradient-ids.test.ts +0 -159
  42. package/src/__tests__/gradient-utils.test.ts +0 -125
  43. package/src/__tests__/known-bugs-render.test.ts +0 -183
  44. package/src/__tests__/map.test.ts +0 -293
  45. package/src/__tests__/mount.test.ts +0 -357
  46. package/src/__tests__/pattern-fills.test.ts +0 -105
  47. package/src/__tests__/resize-timing.test.ts +0 -218
  48. package/src/__tests__/sankey.test.ts +0 -133
  49. package/src/__tests__/selection-events.test.ts +0 -869
  50. package/src/__tests__/series-search.test.ts +0 -311
  51. package/src/__tests__/static.test.ts +0 -141
  52. package/src/__tests__/svg-renderer.test.ts +0 -895
  53. package/src/__tests__/table-keyboard.test.ts +0 -361
  54. package/src/__tests__/table-mount.test.ts +0 -639
  55. package/src/__tests__/tilemap.test.ts +0 -158
  56. package/src/__tests__/tooltip.test.ts +0 -337
  57. package/src/__tests__/transition.test.ts +0 -1777
  58. package/src/__tests__/you-draw-it.test.ts +0 -273
  59. package/src/animation-vars.ts +0 -42
  60. package/src/animation.ts +0 -76
  61. package/src/barlist-mount.ts +0 -383
  62. package/src/barlist-renderer.ts +0 -267
  63. package/src/dom-helpers.ts +0 -17
  64. package/src/export.ts +0 -482
  65. package/src/gradient-utils.ts +0 -138
  66. package/src/graph/__tests__/canvas-renderer.test.ts +0 -713
  67. package/src/graph/__tests__/graph-mount.test.ts +0 -213
  68. package/src/graph/__tests__/interaction.test.ts +0 -205
  69. package/src/graph/__tests__/keyboard.test.ts +0 -653
  70. package/src/graph/__tests__/search.test.ts +0 -88
  71. package/src/graph/__tests__/simulation.test.ts +0 -233
  72. package/src/graph/__tests__/spatial-index.test.ts +0 -142
  73. package/src/graph/__tests__/zoom.test.ts +0 -203
  74. package/src/graph/canvas-renderer.ts +0 -747
  75. package/src/graph/interaction.ts +0 -367
  76. package/src/graph/keyboard.ts +0 -208
  77. package/src/graph/search.ts +0 -50
  78. package/src/graph/simulation-worker-url.ts +0 -12
  79. package/src/graph/simulation-worker.ts +0 -278
  80. package/src/graph/simulation.ts +0 -422
  81. package/src/graph/spatial-index.ts +0 -121
  82. package/src/graph/types.ts +0 -47
  83. package/src/graph/worker-protocol.ts +0 -70
  84. package/src/graph/zoom.ts +0 -134
  85. package/src/graph-mount.ts +0 -888
  86. package/src/index.ts +0 -76
  87. package/src/interactions/chart-events.ts +0 -139
  88. package/src/interactions/crosshair.ts +0 -233
  89. package/src/interactions/drag-handler.ts +0 -175
  90. package/src/interactions/editing-drags.ts +0 -515
  91. package/src/interactions/index.ts +0 -25
  92. package/src/interactions/keyboard-nav.ts +0 -111
  93. package/src/interactions/legend-interaction.ts +0 -46
  94. package/src/interactions/selection.ts +0 -280
  95. package/src/interactions/tooltip-events.ts +0 -72
  96. package/src/map-camera.ts +0 -88
  97. package/src/map-mount.ts +0 -701
  98. package/src/map-renderer.ts +0 -338
  99. package/src/map-transition.ts +0 -83
  100. package/src/measure-text.ts +0 -134
  101. package/src/mount.ts +0 -1382
  102. package/src/pattern-utils.ts +0 -110
  103. package/src/renderers/annotations.ts +0 -388
  104. package/src/renderers/axes.ts +0 -351
  105. package/src/renderers/brand.ts +0 -87
  106. package/src/renderers/chrome.ts +0 -196
  107. package/src/renderers/endpoint-labels.ts +0 -150
  108. package/src/renderers/legend.ts +0 -311
  109. package/src/renderers/marks.ts +0 -561
  110. package/src/renderers/metrics.ts +0 -63
  111. package/src/renderers/svg-dom.ts +0 -40
  112. package/src/renderers/table-cells.ts +0 -446
  113. package/src/resize-observer.ts +0 -46
  114. package/src/sankey-mount.ts +0 -622
  115. package/src/sankey-renderer.ts +0 -614
  116. package/src/series-search.ts +0 -392
  117. package/src/static.ts +0 -251
  118. package/src/story/__tests__/camera-math.test.ts +0 -145
  119. package/src/story/__tests__/create-chart-story.test.ts +0 -317
  120. package/src/story/__tests__/progress-math.test.ts +0 -95
  121. package/src/story/__tests__/scroll-driver.test.ts +0 -130
  122. package/src/story/__tests__/tween.test.ts +0 -159
  123. package/src/story/camera-math.ts +0 -125
  124. package/src/story/create-chart-story.ts +0 -353
  125. package/src/story/crossfade.ts +0 -70
  126. package/src/story/index.ts +0 -39
  127. package/src/story/progress-math.ts +0 -85
  128. package/src/story/resolve-camera-target.ts +0 -119
  129. package/src/story/scroll-driver.ts +0 -206
  130. package/src/story/story-camera.ts +0 -49
  131. package/src/story/tween.ts +0 -112
  132. package/src/story/types.ts +0 -76
  133. package/src/svg-ids.ts +0 -23
  134. package/src/svg-renderer.ts +0 -383
  135. package/src/table-keyboard.ts +0 -266
  136. package/src/table-mount.ts +0 -567
  137. package/src/table-renderer.ts +0 -414
  138. package/src/text-edit-overlay.ts +0 -255
  139. package/src/theme-tokens.ts +0 -51
  140. package/src/tilemap-mount.ts +0 -511
  141. package/src/tilemap-renderer.ts +0 -396
  142. package/src/tooltip.ts +0 -177
  143. package/src/transition.ts +0 -2816
  144. package/src/you-draw-it.ts +0 -480
  145. /package/dist/{chunk-W34KN7FD.js.map → chunk-2NAQRP66.js.map} +0 -0
@@ -1,869 +0,0 @@
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('oc-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('.oc-selection-overlay')).toBeNull();
334
-
335
- simulateClick(titleEl);
336
-
337
- // Overlay should now exist
338
- expect(container.querySelector('.oc-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('.oc-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('.oc-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('.oc-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('.oc-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('.oc-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 oc-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('oc-editable-hover')).toBe(true);
631
-
632
- chart.destroy();
633
- });
634
-
635
- it('mouse leave removes oc-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('oc-editable-hover')).toBe(true);
648
-
649
- // Remove hover class
650
- titleEl.dispatchEvent(new MouseEvent('mouseleave', { bubbles: true }));
651
- expect(titleEl.classList.contains('oc-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('.oc-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
- });