@opendata-ai/openchart-vanilla 2.0.0
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 +327 -0
- package/dist/index.js +4745 -0
- package/dist/index.js.map +1 -0
- package/dist/simulation-worker.js +1196 -0
- package/package.json +58 -0
- package/src/__test-fixtures__/dom.ts +42 -0
- package/src/__test-fixtures__/specs.ts +187 -0
- package/src/__tests__/edit-events.test.ts +747 -0
- package/src/__tests__/events.test.ts +336 -0
- package/src/__tests__/export.test.ts +150 -0
- package/src/__tests__/mount.test.ts +219 -0
- package/src/__tests__/svg-renderer.test.ts +609 -0
- package/src/__tests__/table-mount.test.ts +484 -0
- package/src/__tests__/tooltip.test.ts +201 -0
- package/src/export.ts +105 -0
- package/src/graph/__tests__/canvas-renderer.test.ts +704 -0
- package/src/graph/__tests__/graph-mount.test.ts +213 -0
- package/src/graph/__tests__/interaction.test.ts +205 -0
- package/src/graph/__tests__/keyboard.test.ts +653 -0
- package/src/graph/__tests__/search.test.ts +88 -0
- package/src/graph/__tests__/simulation.test.ts +233 -0
- package/src/graph/__tests__/spatial-index.test.ts +142 -0
- package/src/graph/__tests__/zoom.test.ts +195 -0
- package/src/graph/canvas-renderer.ts +660 -0
- package/src/graph/interaction.ts +359 -0
- package/src/graph/keyboard.ts +208 -0
- package/src/graph/search.ts +50 -0
- package/src/graph/simulation-worker-url.ts +30 -0
- package/src/graph/simulation-worker.ts +265 -0
- package/src/graph/simulation.ts +350 -0
- package/src/graph/spatial-index.ts +121 -0
- package/src/graph/types.ts +44 -0
- package/src/graph/worker-protocol.ts +67 -0
- package/src/graph/zoom.ts +104 -0
- package/src/graph-mount.ts +675 -0
- package/src/index.ts +56 -0
- package/src/mount.ts +1639 -0
- package/src/renderers/table-cells.ts +444 -0
- package/src/resize-observer.ts +46 -0
- package/src/svg-renderer.ts +914 -0
- package/src/table-keyboard.ts +266 -0
- package/src/table-mount.ts +532 -0
- package/src/table-renderer.ts +350 -0
- package/src/tooltip.ts +120 -0
|
@@ -0,0 +1,484 @@
|
|
|
1
|
+
import type { TableSpec } from '@opendata-ai/openchart-engine';
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import { createTable } from '../table-mount';
|
|
4
|
+
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// Test data
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
|
|
9
|
+
function makeSpec(overrides?: Partial<TableSpec>): TableSpec {
|
|
10
|
+
return {
|
|
11
|
+
type: 'table',
|
|
12
|
+
data: [
|
|
13
|
+
{ name: 'Alice', age: 30, city: 'Portland' },
|
|
14
|
+
{ name: 'Bob', age: 25, city: 'Seattle' },
|
|
15
|
+
{ name: 'Charlie', age: 35, city: 'Portland' },
|
|
16
|
+
{ name: 'Diana', age: 28, city: 'Denver' },
|
|
17
|
+
{ name: 'Eve', age: 22, city: 'Seattle' },
|
|
18
|
+
],
|
|
19
|
+
columns: [
|
|
20
|
+
{ key: 'name', label: 'Name' },
|
|
21
|
+
{ key: 'age', label: 'Age' },
|
|
22
|
+
{ key: 'city', label: 'City' },
|
|
23
|
+
],
|
|
24
|
+
chrome: { title: 'People' },
|
|
25
|
+
...overrides,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const paginatedSpec: TableSpec = {
|
|
30
|
+
type: 'table',
|
|
31
|
+
data: Array.from({ length: 50 }, (_, i) => ({
|
|
32
|
+
id: i + 1,
|
|
33
|
+
name: `Person ${i + 1}`,
|
|
34
|
+
value: (i * 17 + 3) % 100,
|
|
35
|
+
})),
|
|
36
|
+
columns: [
|
|
37
|
+
{ key: 'id', label: 'ID' },
|
|
38
|
+
{ key: 'name', label: 'Name' },
|
|
39
|
+
{ key: 'value', label: 'Value' },
|
|
40
|
+
],
|
|
41
|
+
pagination: { pageSize: 10 },
|
|
42
|
+
search: true,
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const heatmapSpec: TableSpec = {
|
|
46
|
+
type: 'table',
|
|
47
|
+
data: [
|
|
48
|
+
{ name: 'A', score: 10 },
|
|
49
|
+
{ name: 'B', score: 50 },
|
|
50
|
+
{ name: 'C', score: 90 },
|
|
51
|
+
],
|
|
52
|
+
columns: [
|
|
53
|
+
{ key: 'name', label: 'Name' },
|
|
54
|
+
{ key: 'score', label: 'Score', heatmap: {} },
|
|
55
|
+
],
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const barSpec: TableSpec = {
|
|
59
|
+
type: 'table',
|
|
60
|
+
data: [
|
|
61
|
+
{ name: 'A', value: 250 },
|
|
62
|
+
{ name: 'B', value: 750 },
|
|
63
|
+
{ name: 'C', value: 500 },
|
|
64
|
+
],
|
|
65
|
+
columns: [
|
|
66
|
+
{ key: 'name', label: 'Name' },
|
|
67
|
+
{ key: 'value', label: 'Value', bar: {} },
|
|
68
|
+
],
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const sparklineSpec: TableSpec = {
|
|
72
|
+
type: 'table',
|
|
73
|
+
data: [
|
|
74
|
+
{ name: 'A', trend: [1, 3, 2, 5, 4] },
|
|
75
|
+
{ name: 'B', trend: [5, 4, 3, 2, 1] },
|
|
76
|
+
],
|
|
77
|
+
columns: [
|
|
78
|
+
{ key: 'name', label: 'Name' },
|
|
79
|
+
{ key: 'trend', label: 'Trend', sparkline: { type: 'line' } },
|
|
80
|
+
],
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const stickySpec: TableSpec = {
|
|
84
|
+
type: 'table',
|
|
85
|
+
data: [{ name: 'A', v1: 1, v2: 2, v3: 3 }],
|
|
86
|
+
columns: [
|
|
87
|
+
{ key: 'name', label: 'Name' },
|
|
88
|
+
{ key: 'v1', label: 'V1' },
|
|
89
|
+
{ key: 'v2', label: 'V2' },
|
|
90
|
+
{ key: 'v3', label: 'V3' },
|
|
91
|
+
],
|
|
92
|
+
stickyFirstColumn: true,
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
// ---------------------------------------------------------------------------
|
|
96
|
+
// Tests
|
|
97
|
+
// ---------------------------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
describe('createTable', () => {
|
|
100
|
+
let container: HTMLDivElement;
|
|
101
|
+
|
|
102
|
+
beforeEach(() => {
|
|
103
|
+
container = document.createElement('div');
|
|
104
|
+
Object.defineProperty(container, 'getBoundingClientRect', {
|
|
105
|
+
value: () => ({
|
|
106
|
+
width: 800,
|
|
107
|
+
height: 600,
|
|
108
|
+
top: 0,
|
|
109
|
+
left: 0,
|
|
110
|
+
right: 800,
|
|
111
|
+
bottom: 600,
|
|
112
|
+
x: 0,
|
|
113
|
+
y: 0,
|
|
114
|
+
toJSON: () => ({}),
|
|
115
|
+
}),
|
|
116
|
+
});
|
|
117
|
+
document.body.appendChild(container);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
afterEach(() => {
|
|
121
|
+
document.body.innerHTML = '';
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('renders a <table> element', () => {
|
|
125
|
+
const table = createTable(container, makeSpec());
|
|
126
|
+
const tableEl = container.querySelector('table');
|
|
127
|
+
expect(tableEl).not.toBeNull();
|
|
128
|
+
table.destroy();
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('renders correct number of rows and columns', () => {
|
|
132
|
+
const spec = makeSpec();
|
|
133
|
+
const table = createTable(container, spec);
|
|
134
|
+
|
|
135
|
+
const headerCells = container.querySelectorAll('thead th');
|
|
136
|
+
expect(headerCells.length).toBe(3);
|
|
137
|
+
|
|
138
|
+
const bodyRows = container.querySelectorAll('tbody tr');
|
|
139
|
+
expect(bodyRows.length).toBe(5);
|
|
140
|
+
|
|
141
|
+
table.destroy();
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('renders column headers with correct labels', () => {
|
|
145
|
+
const table = createTable(container, makeSpec());
|
|
146
|
+
|
|
147
|
+
const headers = container.querySelectorAll('thead th');
|
|
148
|
+
expect(headers[0]?.textContent).toContain('Name');
|
|
149
|
+
expect(headers[1]?.textContent).toContain('Age');
|
|
150
|
+
expect(headers[2]?.textContent).toContain('City');
|
|
151
|
+
|
|
152
|
+
table.destroy();
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('sort click re-renders sorted data', () => {
|
|
156
|
+
const spec = makeSpec();
|
|
157
|
+
const table = createTable(container, spec);
|
|
158
|
+
|
|
159
|
+
// Click sort button for "Age"
|
|
160
|
+
const sortBtns = container.querySelectorAll('[data-sort-column]');
|
|
161
|
+
const ageBtn = Array.from(sortBtns).find(
|
|
162
|
+
(btn) => btn.getAttribute('data-sort-column') === 'age',
|
|
163
|
+
);
|
|
164
|
+
expect(ageBtn).not.toBeNull();
|
|
165
|
+
ageBtn!.dispatchEvent(new Event('click', { bubbles: true }));
|
|
166
|
+
|
|
167
|
+
// After sorting by age ascending, first row should be youngest
|
|
168
|
+
const firstRowCells = container.querySelectorAll('tbody tr:first-child td');
|
|
169
|
+
// Eve (22) should be first
|
|
170
|
+
expect(firstRowCells[0]?.textContent).toBe('Eve');
|
|
171
|
+
|
|
172
|
+
table.destroy();
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('sort cycling: none -> asc -> desc -> none', () => {
|
|
176
|
+
const spec = makeSpec();
|
|
177
|
+
const table = createTable(container, spec);
|
|
178
|
+
|
|
179
|
+
const sortBtn = container.querySelector('[data-sort-column="age"]')!;
|
|
180
|
+
|
|
181
|
+
// Click 1: asc
|
|
182
|
+
sortBtn.dispatchEvent(new Event('click', { bubbles: true }));
|
|
183
|
+
expect(table.getState().sort).toEqual({ column: 'age', direction: 'asc' });
|
|
184
|
+
|
|
185
|
+
// Click 2: desc
|
|
186
|
+
sortBtn.dispatchEvent(new Event('click', { bubbles: true }));
|
|
187
|
+
expect(table.getState().sort).toEqual({ column: 'age', direction: 'desc' });
|
|
188
|
+
|
|
189
|
+
// Click 3: none
|
|
190
|
+
sortBtn.dispatchEvent(new Event('click', { bubbles: true }));
|
|
191
|
+
expect(table.getState().sort).toBeNull();
|
|
192
|
+
|
|
193
|
+
table.destroy();
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it('search input filters rows', () => {
|
|
197
|
+
vi.useFakeTimers();
|
|
198
|
+
try {
|
|
199
|
+
const spec = makeSpec({ search: true });
|
|
200
|
+
const table = createTable(container, spec);
|
|
201
|
+
|
|
202
|
+
const input = container.querySelector('.viz-table-search input') as HTMLInputElement;
|
|
203
|
+
expect(input).not.toBeNull();
|
|
204
|
+
|
|
205
|
+
// Type in a search query
|
|
206
|
+
input.value = 'Portland';
|
|
207
|
+
input.dispatchEvent(new Event('input', { bubbles: true }));
|
|
208
|
+
|
|
209
|
+
// Advance past the 200ms debounce
|
|
210
|
+
vi.advanceTimersByTime(200);
|
|
211
|
+
|
|
212
|
+
// Should show only Portland rows (Alice and Charlie)
|
|
213
|
+
const rows = container.querySelectorAll('tbody tr');
|
|
214
|
+
expect(rows.length).toBe(2);
|
|
215
|
+
|
|
216
|
+
table.destroy();
|
|
217
|
+
} finally {
|
|
218
|
+
vi.useRealTimers();
|
|
219
|
+
}
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it('pagination controls navigate pages', () => {
|
|
223
|
+
const table = createTable(container, paginatedSpec);
|
|
224
|
+
|
|
225
|
+
// Should show page 1 of 5 (10 per page, 50 total)
|
|
226
|
+
const info = container.querySelector('.viz-table-pagination-info');
|
|
227
|
+
expect(info?.textContent).toContain('Showing 1-10 of 50');
|
|
228
|
+
|
|
229
|
+
const rows = container.querySelectorAll('tbody tr');
|
|
230
|
+
expect(rows.length).toBe(10);
|
|
231
|
+
|
|
232
|
+
// Click next page
|
|
233
|
+
const nextBtn = container.querySelector('[data-page-action="next"]') as HTMLButtonElement;
|
|
234
|
+
expect(nextBtn).not.toBeNull();
|
|
235
|
+
nextBtn.dispatchEvent(new Event('click', { bubbles: true }));
|
|
236
|
+
|
|
237
|
+
const infoAfter = container.querySelector('.viz-table-pagination-info');
|
|
238
|
+
expect(infoAfter?.textContent).toContain('Showing 11-20 of 50');
|
|
239
|
+
|
|
240
|
+
// Previous button should be enabled
|
|
241
|
+
const prevBtn = container.querySelector('[data-page-action="prev"]') as HTMLButtonElement;
|
|
242
|
+
expect(prevBtn.disabled).toBe(false);
|
|
243
|
+
|
|
244
|
+
table.destroy();
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it('sticky first column CSS applied', () => {
|
|
248
|
+
const table = createTable(container, stickySpec);
|
|
249
|
+
|
|
250
|
+
const tableEl = container.querySelector('table');
|
|
251
|
+
expect(tableEl?.classList.contains('viz-table--sticky')).toBe(true);
|
|
252
|
+
|
|
253
|
+
table.destroy();
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it('heatmap cells have colored backgrounds', () => {
|
|
257
|
+
const table = createTable(container, heatmapSpec);
|
|
258
|
+
|
|
259
|
+
// The heatmap cells should have background styles
|
|
260
|
+
const bodyRows = container.querySelectorAll('tbody tr');
|
|
261
|
+
const cells = bodyRows[2]?.querySelectorAll('td');
|
|
262
|
+
// Score column (index 1) should have background color
|
|
263
|
+
const scoreCellHigh = cells?.[1];
|
|
264
|
+
expect(
|
|
265
|
+
scoreCellHigh?.style.background !== '' || scoreCellHigh?.style.backgroundColor !== '',
|
|
266
|
+
).toBe(true);
|
|
267
|
+
|
|
268
|
+
table.destroy();
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it('sparkline cells contain inline SVG', () => {
|
|
272
|
+
const table = createTable(container, sparklineSpec);
|
|
273
|
+
|
|
274
|
+
// Sparkline cells should be rendered with the sparkline class
|
|
275
|
+
const sparklineCells = container.querySelectorAll('.viz-table-sparkline');
|
|
276
|
+
expect(sparklineCells.length).toBeGreaterThan(0);
|
|
277
|
+
|
|
278
|
+
const svg = sparklineCells[0]?.querySelector('svg');
|
|
279
|
+
expect(svg).not.toBeNull();
|
|
280
|
+
|
|
281
|
+
// Line sparkline should have a polyline
|
|
282
|
+
const polyline = svg?.querySelector('polyline');
|
|
283
|
+
expect(polyline).not.toBeNull();
|
|
284
|
+
|
|
285
|
+
table.destroy();
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it('bar cells have proportional fill div', () => {
|
|
289
|
+
const table = createTable(container, barSpec);
|
|
290
|
+
|
|
291
|
+
const barFills = container.querySelectorAll('.viz-table-bar-fill');
|
|
292
|
+
expect(barFills.length).toBe(3);
|
|
293
|
+
|
|
294
|
+
// The bar values should have width proportional to their data
|
|
295
|
+
const barValues = container.querySelectorAll('.viz-table-bar-value');
|
|
296
|
+
expect(barValues.length).toBe(3);
|
|
297
|
+
|
|
298
|
+
table.destroy();
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
it('destroy() removes DOM', () => {
|
|
302
|
+
const table = createTable(container, makeSpec());
|
|
303
|
+
|
|
304
|
+
const tableBefore = container.querySelector('table');
|
|
305
|
+
expect(tableBefore).not.toBeNull();
|
|
306
|
+
|
|
307
|
+
table.destroy();
|
|
308
|
+
|
|
309
|
+
const tableAfter = container.querySelector('table');
|
|
310
|
+
expect(tableAfter).toBeNull();
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it('export("csv") returns valid CSV', () => {
|
|
314
|
+
const spec = makeSpec();
|
|
315
|
+
const table = createTable(container, spec);
|
|
316
|
+
|
|
317
|
+
const csv = table.export('csv');
|
|
318
|
+
const lines = csv.split('\n');
|
|
319
|
+
expect(lines.length).toBe(6); // 1 header + 5 data rows
|
|
320
|
+
expect(lines[0]).toContain('Name');
|
|
321
|
+
expect(lines[0]).toContain('Age');
|
|
322
|
+
expect(lines[0]).toContain('City');
|
|
323
|
+
|
|
324
|
+
table.destroy();
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
it('search with no results renders "No results found" message', () => {
|
|
328
|
+
vi.useFakeTimers();
|
|
329
|
+
try {
|
|
330
|
+
const spec = makeSpec({ search: true });
|
|
331
|
+
const table = createTable(container, spec);
|
|
332
|
+
|
|
333
|
+
// Search for something that doesn't match any data
|
|
334
|
+
const input = container.querySelector('.viz-table-search input') as HTMLInputElement;
|
|
335
|
+
input.value = 'zzzznonexistent';
|
|
336
|
+
input.dispatchEvent(new Event('input', { bubbles: true }));
|
|
337
|
+
|
|
338
|
+
// Advance past the 200ms debounce
|
|
339
|
+
vi.advanceTimersByTime(200);
|
|
340
|
+
|
|
341
|
+
const empty = container.querySelector('.viz-table-empty');
|
|
342
|
+
expect(empty).not.toBeNull();
|
|
343
|
+
expect(empty?.textContent).toBe('No results found');
|
|
344
|
+
|
|
345
|
+
table.destroy();
|
|
346
|
+
} finally {
|
|
347
|
+
vi.useRealTimers();
|
|
348
|
+
}
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
it('controlled mode: externalState used, onStateChange fires', () => {
|
|
352
|
+
const onStateChange = vi.fn();
|
|
353
|
+
const spec = makeSpec();
|
|
354
|
+
|
|
355
|
+
const table = createTable(container, spec, {
|
|
356
|
+
externalState: { sort: null, search: '', page: 0 },
|
|
357
|
+
onStateChange,
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
// Click sort
|
|
361
|
+
const sortBtn = container.querySelector('[data-sort-column="age"]')!;
|
|
362
|
+
sortBtn.dispatchEvent(new Event('click', { bubbles: true }));
|
|
363
|
+
|
|
364
|
+
expect(onStateChange).toHaveBeenCalledWith(
|
|
365
|
+
expect.objectContaining({
|
|
366
|
+
sort: { column: 'age', direction: 'asc' },
|
|
367
|
+
}),
|
|
368
|
+
);
|
|
369
|
+
|
|
370
|
+
table.destroy();
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
it('update() re-renders with new spec', () => {
|
|
374
|
+
const spec = makeSpec();
|
|
375
|
+
const table = createTable(container, spec);
|
|
376
|
+
|
|
377
|
+
const headersBefore = container.querySelectorAll('thead th');
|
|
378
|
+
expect(headersBefore.length).toBe(3);
|
|
379
|
+
|
|
380
|
+
// Update with a spec that has 2 columns
|
|
381
|
+
table.update({
|
|
382
|
+
type: 'table',
|
|
383
|
+
data: [{ x: 1, y: 2 }],
|
|
384
|
+
columns: [
|
|
385
|
+
{ key: 'x', label: 'X' },
|
|
386
|
+
{ key: 'y', label: 'Y' },
|
|
387
|
+
],
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
const headersAfter = container.querySelectorAll('thead th');
|
|
391
|
+
expect(headersAfter.length).toBe(2);
|
|
392
|
+
|
|
393
|
+
table.destroy();
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
it('getState returns current state', () => {
|
|
397
|
+
const spec = makeSpec();
|
|
398
|
+
const table = createTable(container, spec);
|
|
399
|
+
|
|
400
|
+
const state = table.getState();
|
|
401
|
+
expect(state.sort).toBeNull();
|
|
402
|
+
expect(state.search).toBe('');
|
|
403
|
+
expect(state.page).toBe(0);
|
|
404
|
+
|
|
405
|
+
table.destroy();
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
it('setState updates table', () => {
|
|
409
|
+
const spec = makeSpec();
|
|
410
|
+
const table = createTable(container, spec);
|
|
411
|
+
|
|
412
|
+
table.setState({ sort: { column: 'age', direction: 'desc' } });
|
|
413
|
+
|
|
414
|
+
const state = table.getState();
|
|
415
|
+
expect(state.sort).toEqual({ column: 'age', direction: 'desc' });
|
|
416
|
+
|
|
417
|
+
// First row should be the oldest (Charlie, 35)
|
|
418
|
+
const firstRowCells = container.querySelectorAll('tbody tr:first-child td');
|
|
419
|
+
expect(firstRowCells[0]?.textContent).toBe('Charlie');
|
|
420
|
+
|
|
421
|
+
table.destroy();
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
it('compact mode applies viz-table--compact class', () => {
|
|
425
|
+
const spec = makeSpec({ compact: true });
|
|
426
|
+
const table = createTable(container, spec);
|
|
427
|
+
|
|
428
|
+
const wrapper = container.querySelector('.viz-table-wrapper');
|
|
429
|
+
expect(wrapper?.classList.contains('viz-table--compact')).toBe(true);
|
|
430
|
+
|
|
431
|
+
table.destroy();
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
it('dark mode applies viz-dark class', () => {
|
|
435
|
+
const spec = makeSpec();
|
|
436
|
+
const table = createTable(container, spec, { darkMode: 'force' });
|
|
437
|
+
|
|
438
|
+
expect(container.classList.contains('viz-dark')).toBe(true);
|
|
439
|
+
|
|
440
|
+
table.destroy();
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
it('onRowClick makes rows clickable', () => {
|
|
444
|
+
const onClick = vi.fn();
|
|
445
|
+
const spec = makeSpec();
|
|
446
|
+
const table = createTable(container, spec, { onRowClick: onClick });
|
|
447
|
+
|
|
448
|
+
const wrapper = container.querySelector('.viz-table-wrapper');
|
|
449
|
+
expect(wrapper?.classList.contains('viz-table--clickable')).toBe(true);
|
|
450
|
+
|
|
451
|
+
// Click first row
|
|
452
|
+
const firstRow = container.querySelector('tbody tr');
|
|
453
|
+
firstRow?.dispatchEvent(new Event('click', { bubbles: true }));
|
|
454
|
+
|
|
455
|
+
expect(onClick).toHaveBeenCalledTimes(1);
|
|
456
|
+
expect(onClick).toHaveBeenCalledWith(expect.objectContaining({ name: expect.any(String) }));
|
|
457
|
+
|
|
458
|
+
table.destroy();
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
it('renders title chrome', () => {
|
|
462
|
+
const spec = makeSpec();
|
|
463
|
+
const table = createTable(container, spec);
|
|
464
|
+
|
|
465
|
+
const title = container.querySelector('.viz-table-title');
|
|
466
|
+
expect(title).not.toBeNull();
|
|
467
|
+
expect(title?.textContent).toBe('People');
|
|
468
|
+
|
|
469
|
+
table.destroy();
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
it('resize() re-renders the table', () => {
|
|
473
|
+
const spec = makeSpec();
|
|
474
|
+
const table = createTable(container, spec);
|
|
475
|
+
|
|
476
|
+
// Should not throw
|
|
477
|
+
table.resize();
|
|
478
|
+
|
|
479
|
+
const tableEl = container.querySelector('table');
|
|
480
|
+
expect(tableEl).not.toBeNull();
|
|
481
|
+
|
|
482
|
+
table.destroy();
|
|
483
|
+
});
|
|
484
|
+
});
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tooltip manager tests.
|
|
3
|
+
*
|
|
4
|
+
* Tests the createTooltipManager() show/hide lifecycle, positioning logic,
|
|
5
|
+
* and cleanup behavior.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
9
|
+
import { createContainer } from '../__test-fixtures__/dom';
|
|
10
|
+
import { createTooltipManager } from '../tooltip';
|
|
11
|
+
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
// Cleanup
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
|
|
16
|
+
afterEach(() => {
|
|
17
|
+
document.body.innerHTML = '';
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// Lifecycle
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
describe('createTooltipManager lifecycle', () => {
|
|
25
|
+
it('appends a tooltip element to the container on creation', () => {
|
|
26
|
+
const container = createContainer();
|
|
27
|
+
createTooltipManager(container);
|
|
28
|
+
|
|
29
|
+
const tooltip = container.querySelector('.viz-tooltip');
|
|
30
|
+
expect(tooltip).not.toBeNull();
|
|
31
|
+
expect(tooltip!.getAttribute('role')).toBe('tooltip');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('show() makes the tooltip visible', () => {
|
|
35
|
+
const container = createContainer();
|
|
36
|
+
const manager = createTooltipManager(container);
|
|
37
|
+
|
|
38
|
+
manager.show({ title: 'Point A', fields: [{ label: 'Value', value: '42' }] }, 100, 100);
|
|
39
|
+
|
|
40
|
+
const tooltip = container.querySelector('.viz-tooltip') as HTMLElement;
|
|
41
|
+
expect(tooltip.style.display).toBe('block');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('show() renders title and field content', () => {
|
|
45
|
+
const container = createContainer();
|
|
46
|
+
const manager = createTooltipManager(container);
|
|
47
|
+
|
|
48
|
+
manager.show(
|
|
49
|
+
{
|
|
50
|
+
title: '2021-Q1',
|
|
51
|
+
fields: [
|
|
52
|
+
{ label: 'Revenue', value: '$1.2M', color: '#3b82f6' },
|
|
53
|
+
{ label: 'Growth', value: '+15%' },
|
|
54
|
+
],
|
|
55
|
+
},
|
|
56
|
+
100,
|
|
57
|
+
100,
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
const tooltip = container.querySelector('.viz-tooltip') as HTMLElement;
|
|
61
|
+
expect(tooltip.innerHTML).toContain('2021-Q1');
|
|
62
|
+
expect(tooltip.innerHTML).toContain('Revenue');
|
|
63
|
+
expect(tooltip.innerHTML).toContain('$1.2M');
|
|
64
|
+
expect(tooltip.innerHTML).toContain('Growth');
|
|
65
|
+
expect(tooltip.innerHTML).toContain('+15%');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('show() renders color dot when field has color', () => {
|
|
69
|
+
const container = createContainer();
|
|
70
|
+
const manager = createTooltipManager(container);
|
|
71
|
+
|
|
72
|
+
manager.show(
|
|
73
|
+
{
|
|
74
|
+
title: 'Test',
|
|
75
|
+
fields: [{ label: 'Value', value: '42', color: '#ff0000' }],
|
|
76
|
+
},
|
|
77
|
+
50,
|
|
78
|
+
50,
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
const dot = container.querySelector('.viz-tooltip-dot') as HTMLElement;
|
|
82
|
+
expect(dot).not.toBeNull();
|
|
83
|
+
expect(dot.style.background).toBe('#ff0000');
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('hide() hides the tooltip', () => {
|
|
87
|
+
const container = createContainer();
|
|
88
|
+
const manager = createTooltipManager(container);
|
|
89
|
+
|
|
90
|
+
manager.show({ title: 'Test', fields: [{ label: 'V', value: '1' }] }, 100, 100);
|
|
91
|
+
manager.hide();
|
|
92
|
+
|
|
93
|
+
const tooltip = container.querySelector('.viz-tooltip') as HTMLElement;
|
|
94
|
+
expect(tooltip.style.display).toBe('none');
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('destroy() removes the tooltip element from the DOM', () => {
|
|
98
|
+
const container = createContainer();
|
|
99
|
+
const manager = createTooltipManager(container);
|
|
100
|
+
|
|
101
|
+
// Verify it exists
|
|
102
|
+
expect(container.querySelector('.viz-tooltip')).not.toBeNull();
|
|
103
|
+
|
|
104
|
+
manager.destroy();
|
|
105
|
+
|
|
106
|
+
expect(container.querySelector('.viz-tooltip')).toBeNull();
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('show() updates content when called again', () => {
|
|
110
|
+
const container = createContainer();
|
|
111
|
+
const manager = createTooltipManager(container);
|
|
112
|
+
|
|
113
|
+
manager.show({ title: 'First', fields: [{ label: 'A', value: '1' }] }, 50, 50);
|
|
114
|
+
|
|
115
|
+
let tooltip = container.querySelector('.viz-tooltip') as HTMLElement;
|
|
116
|
+
expect(tooltip.innerHTML).toContain('First');
|
|
117
|
+
|
|
118
|
+
manager.show({ title: 'Second', fields: [{ label: 'B', value: '2' }] }, 100, 100);
|
|
119
|
+
|
|
120
|
+
tooltip = container.querySelector('.viz-tooltip') as HTMLElement;
|
|
121
|
+
expect(tooltip.innerHTML).toContain('Second');
|
|
122
|
+
// First content should be replaced
|
|
123
|
+
expect(tooltip.innerHTML).not.toContain('First');
|
|
124
|
+
|
|
125
|
+
manager.destroy();
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
// ---------------------------------------------------------------------------
|
|
130
|
+
// Positioning
|
|
131
|
+
// ---------------------------------------------------------------------------
|
|
132
|
+
|
|
133
|
+
describe('tooltip positioning', () => {
|
|
134
|
+
it('positions tooltip at offset from given coordinates', () => {
|
|
135
|
+
const container = createContainer();
|
|
136
|
+
const manager = createTooltipManager(container);
|
|
137
|
+
|
|
138
|
+
manager.show({ title: 'Test', fields: [{ label: 'V', value: '1' }] }, 100, 200);
|
|
139
|
+
|
|
140
|
+
const tooltip = container.querySelector('.viz-tooltip') as HTMLElement;
|
|
141
|
+
// Tooltip should be positioned (the exact offset is TOOLTIP_OFFSET = 12)
|
|
142
|
+
const left = parseInt(tooltip.style.left, 10);
|
|
143
|
+
const top = parseInt(tooltip.style.top, 10);
|
|
144
|
+
// Should be near the given coordinates (offset by 12)
|
|
145
|
+
expect(left).toBeGreaterThanOrEqual(0);
|
|
146
|
+
expect(top).toBeGreaterThanOrEqual(0);
|
|
147
|
+
|
|
148
|
+
manager.destroy();
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('sets container position to relative if not already set', () => {
|
|
152
|
+
const container = createContainer();
|
|
153
|
+
container.style.position = '';
|
|
154
|
+
|
|
155
|
+
createTooltipManager(container);
|
|
156
|
+
|
|
157
|
+
// createTooltipManager sets container.style.position to 'relative'
|
|
158
|
+
// if it was empty
|
|
159
|
+
expect(container.style.position).toBe('relative');
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('preserves existing container position style', () => {
|
|
163
|
+
const container = createContainer();
|
|
164
|
+
container.style.position = 'absolute';
|
|
165
|
+
|
|
166
|
+
createTooltipManager(container);
|
|
167
|
+
|
|
168
|
+
expect(container.style.position).toBe('absolute');
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
// ---------------------------------------------------------------------------
|
|
173
|
+
// HTML escaping
|
|
174
|
+
// ---------------------------------------------------------------------------
|
|
175
|
+
|
|
176
|
+
describe('tooltip content escaping', () => {
|
|
177
|
+
it('escapes HTML special characters in title and fields', () => {
|
|
178
|
+
const container = createContainer();
|
|
179
|
+
const manager = createTooltipManager(container);
|
|
180
|
+
|
|
181
|
+
manager.show(
|
|
182
|
+
{
|
|
183
|
+
title: '<script>alert("xss")</script>',
|
|
184
|
+
fields: [{ label: 'A&B', value: 'val<ue' }],
|
|
185
|
+
},
|
|
186
|
+
50,
|
|
187
|
+
50,
|
|
188
|
+
);
|
|
189
|
+
|
|
190
|
+
const tooltip = container.querySelector('.viz-tooltip') as HTMLElement;
|
|
191
|
+
// Should not contain raw HTML tags - the <script> should be escaped
|
|
192
|
+
expect(tooltip.innerHTML).not.toContain('<script>');
|
|
193
|
+
// Should contain escaped versions of angle brackets and ampersands
|
|
194
|
+
expect(tooltip.innerHTML).toContain('<script>');
|
|
195
|
+
expect(tooltip.innerHTML).toContain('A&B');
|
|
196
|
+
// The value with < should be escaped
|
|
197
|
+
expect(tooltip.innerHTML).toContain('val<ue');
|
|
198
|
+
|
|
199
|
+
manager.destroy();
|
|
200
|
+
});
|
|
201
|
+
});
|