@opendata-ai/openchart-vanilla 2.10.0 → 2.12.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 +2 -1
- package/dist/index.js +98 -64
- package/dist/index.js.map +1 -1
- package/dist/styles.css +2 -0
- package/package.json +4 -3
- package/src/__tests__/table-keyboard.test.ts +361 -0
- package/src/__tests__/tooltip.test.ts +119 -9
- package/src/graph-mount.ts +30 -38
- package/src/mount.ts +11 -2
- package/src/svg-renderer.ts +14 -5
- package/src/tooltip.ts +70 -44
package/dist/styles.css
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@opendata-ai/openchart-vanilla",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.12.0",
|
|
4
4
|
"description": "Vanilla JS renderer for openchart: SVG charts, HTML tables, force-directed graphs",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Riley Hilliard",
|
|
@@ -49,8 +49,9 @@
|
|
|
49
49
|
"typecheck": "tsc --noEmit"
|
|
50
50
|
},
|
|
51
51
|
"dependencies": {
|
|
52
|
-
"@
|
|
53
|
-
"@opendata-ai/openchart-
|
|
52
|
+
"@floating-ui/dom": "^1.7.6",
|
|
53
|
+
"@opendata-ai/openchart-core": "2.12.0",
|
|
54
|
+
"@opendata-ai/openchart-engine": "2.12.0",
|
|
54
55
|
"d3-force": "^3.0.0",
|
|
55
56
|
"d3-quadtree": "^3.0.1"
|
|
56
57
|
},
|
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { attachKeyboardNav } from '../table-keyboard';
|
|
3
|
+
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
// Helpers
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
|
|
8
|
+
function createTableDOM(rows: number, cols: number, opts?: { search?: boolean }): HTMLDivElement {
|
|
9
|
+
const wrapper = document.createElement('div');
|
|
10
|
+
const table = document.createElement('table');
|
|
11
|
+
|
|
12
|
+
// thead
|
|
13
|
+
const thead = document.createElement('thead');
|
|
14
|
+
const headerRow = document.createElement('tr');
|
|
15
|
+
for (let c = 0; c < cols; c++) {
|
|
16
|
+
const th = document.createElement('th');
|
|
17
|
+
th.textContent = `Col${c}`;
|
|
18
|
+
th.setAttribute('data-column', `col${c}`);
|
|
19
|
+
const sortBtn = document.createElement('button');
|
|
20
|
+
sortBtn.setAttribute('data-sort-column', `col${c}`);
|
|
21
|
+
th.appendChild(sortBtn);
|
|
22
|
+
headerRow.appendChild(th);
|
|
23
|
+
}
|
|
24
|
+
thead.appendChild(headerRow);
|
|
25
|
+
table.appendChild(thead);
|
|
26
|
+
|
|
27
|
+
// tbody
|
|
28
|
+
const tbody = document.createElement('tbody');
|
|
29
|
+
for (let r = 0; r < rows; r++) {
|
|
30
|
+
const tr = document.createElement('tr');
|
|
31
|
+
for (let c = 0; c < cols; c++) {
|
|
32
|
+
const td = document.createElement('td');
|
|
33
|
+
td.textContent = `R${r}C${c}`;
|
|
34
|
+
tr.appendChild(td);
|
|
35
|
+
}
|
|
36
|
+
tbody.appendChild(tr);
|
|
37
|
+
}
|
|
38
|
+
table.appendChild(tbody);
|
|
39
|
+
wrapper.appendChild(table);
|
|
40
|
+
|
|
41
|
+
// Search input
|
|
42
|
+
if (opts?.search) {
|
|
43
|
+
const searchDiv = document.createElement('div');
|
|
44
|
+
searchDiv.className = 'viz-table-search';
|
|
45
|
+
const input = document.createElement('input');
|
|
46
|
+
searchDiv.appendChild(input);
|
|
47
|
+
wrapper.appendChild(searchDiv);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return wrapper;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function keydown(el: HTMLElement, key: string): void {
|
|
54
|
+
el.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true }));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function focusTbody(wrapper: HTMLDivElement): HTMLElement {
|
|
58
|
+
const tbody = wrapper.querySelector('tbody')!;
|
|
59
|
+
tbody.dispatchEvent(new Event('focus'));
|
|
60
|
+
return tbody;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
// Tests
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
describe('attachKeyboardNav', () => {
|
|
68
|
+
let wrapper: HTMLDivElement;
|
|
69
|
+
let onSort: ReturnType<typeof vi.fn>;
|
|
70
|
+
let onClearSearch: ReturnType<typeof vi.fn>;
|
|
71
|
+
let onAnnounce: ReturnType<typeof vi.fn>;
|
|
72
|
+
let cleanup: () => void;
|
|
73
|
+
|
|
74
|
+
function attach(w: HTMLDivElement): () => void {
|
|
75
|
+
return attachKeyboardNav({
|
|
76
|
+
wrapper: w,
|
|
77
|
+
onSort,
|
|
78
|
+
onClearSearch,
|
|
79
|
+
onAnnounce,
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
beforeEach(() => {
|
|
84
|
+
onSort = vi.fn();
|
|
85
|
+
onClearSearch = vi.fn();
|
|
86
|
+
onAnnounce = vi.fn();
|
|
87
|
+
wrapper = createTableDOM(5, 3);
|
|
88
|
+
document.body.appendChild(wrapper);
|
|
89
|
+
cleanup = attach(wrapper);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
afterEach(() => {
|
|
93
|
+
cleanup();
|
|
94
|
+
document.body.innerHTML = '';
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('makes tbody focusable', () => {
|
|
98
|
+
const tbody = wrapper.querySelector('tbody')!;
|
|
99
|
+
expect(tbody.getAttribute('tabindex')).toBe('0');
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('makes header cells focusable', () => {
|
|
103
|
+
const headers = wrapper.querySelectorAll('thead th');
|
|
104
|
+
for (const th of headers) {
|
|
105
|
+
expect(th.getAttribute('tabindex')).toBe('0');
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('returns noop cleanup when no table exists', () => {
|
|
110
|
+
const empty = document.createElement('div');
|
|
111
|
+
const c = attachKeyboardNav({
|
|
112
|
+
wrapper: empty,
|
|
113
|
+
onSort,
|
|
114
|
+
onClearSearch,
|
|
115
|
+
onAnnounce,
|
|
116
|
+
});
|
|
117
|
+
expect(c).toBeTypeOf('function');
|
|
118
|
+
c(); // should not throw
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
describe('tbody arrow key navigation', () => {
|
|
123
|
+
let wrapper: HTMLDivElement;
|
|
124
|
+
let onSort: ReturnType<typeof vi.fn>;
|
|
125
|
+
let onClearSearch: ReturnType<typeof vi.fn>;
|
|
126
|
+
let onAnnounce: ReturnType<typeof vi.fn>;
|
|
127
|
+
let cleanup: () => void;
|
|
128
|
+
|
|
129
|
+
beforeEach(() => {
|
|
130
|
+
onSort = vi.fn();
|
|
131
|
+
onClearSearch = vi.fn();
|
|
132
|
+
onAnnounce = vi.fn();
|
|
133
|
+
wrapper = createTableDOM(5, 3);
|
|
134
|
+
document.body.appendChild(wrapper);
|
|
135
|
+
cleanup = attachKeyboardNav({
|
|
136
|
+
wrapper,
|
|
137
|
+
onSort,
|
|
138
|
+
onClearSearch,
|
|
139
|
+
onAnnounce,
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
afterEach(() => {
|
|
144
|
+
cleanup();
|
|
145
|
+
document.body.innerHTML = '';
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('focus highlights first cell and sets aria-activedescendant', () => {
|
|
149
|
+
const tbody = focusTbody(wrapper);
|
|
150
|
+
expect(tbody.getAttribute('aria-activedescendant')).toBe('viz-cell-0-0');
|
|
151
|
+
const cell = wrapper.querySelector('.viz-table-cell-focus');
|
|
152
|
+
expect(cell).not.toBeNull();
|
|
153
|
+
expect(cell?.textContent).toBe('R0C0');
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('ArrowDown moves focus down one row', () => {
|
|
157
|
+
const tbody = focusTbody(wrapper);
|
|
158
|
+
keydown(tbody, 'ArrowDown');
|
|
159
|
+
expect(tbody.getAttribute('aria-activedescendant')).toBe('viz-cell-1-0');
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('ArrowDown does not go past last row', () => {
|
|
163
|
+
const tbody = focusTbody(wrapper);
|
|
164
|
+
for (let i = 0; i < 10; i++) keydown(tbody, 'ArrowDown');
|
|
165
|
+
expect(tbody.getAttribute('aria-activedescendant')).toBe('viz-cell-4-0');
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('ArrowRight moves focus right one column', () => {
|
|
169
|
+
const tbody = focusTbody(wrapper);
|
|
170
|
+
keydown(tbody, 'ArrowRight');
|
|
171
|
+
expect(tbody.getAttribute('aria-activedescendant')).toBe('viz-cell-0-1');
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('ArrowRight does not go past last column', () => {
|
|
175
|
+
const tbody = focusTbody(wrapper);
|
|
176
|
+
for (let i = 0; i < 10; i++) keydown(tbody, 'ArrowRight');
|
|
177
|
+
expect(tbody.getAttribute('aria-activedescendant')).toBe('viz-cell-0-2');
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('ArrowLeft moves focus left', () => {
|
|
181
|
+
const tbody = focusTbody(wrapper);
|
|
182
|
+
keydown(tbody, 'ArrowRight');
|
|
183
|
+
keydown(tbody, 'ArrowRight');
|
|
184
|
+
keydown(tbody, 'ArrowLeft');
|
|
185
|
+
expect(tbody.getAttribute('aria-activedescendant')).toBe('viz-cell-0-1');
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it('ArrowLeft does not go past first column', () => {
|
|
189
|
+
const tbody = focusTbody(wrapper);
|
|
190
|
+
keydown(tbody, 'ArrowLeft');
|
|
191
|
+
expect(tbody.getAttribute('aria-activedescendant')).toBe('viz-cell-0-0');
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it('Home moves to first column in current row', () => {
|
|
195
|
+
const tbody = focusTbody(wrapper);
|
|
196
|
+
keydown(tbody, 'ArrowRight');
|
|
197
|
+
keydown(tbody, 'ArrowRight');
|
|
198
|
+
keydown(tbody, 'Home');
|
|
199
|
+
expect(tbody.getAttribute('aria-activedescendant')).toBe('viz-cell-0-0');
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it('End moves to last column in current row', () => {
|
|
203
|
+
const tbody = focusTbody(wrapper);
|
|
204
|
+
keydown(tbody, 'End');
|
|
205
|
+
expect(tbody.getAttribute('aria-activedescendant')).toBe('viz-cell-0-2');
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it('navigation clears previous focus highlight', () => {
|
|
209
|
+
const tbody = focusTbody(wrapper);
|
|
210
|
+
keydown(tbody, 'ArrowDown');
|
|
211
|
+
const focused = wrapper.querySelectorAll('.viz-table-cell-focus');
|
|
212
|
+
expect(focused.length).toBe(1);
|
|
213
|
+
expect(focused[0].textContent).toBe('R1C0');
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
describe('header keyboard navigation', () => {
|
|
218
|
+
let wrapper: HTMLDivElement;
|
|
219
|
+
let onSort: ReturnType<typeof vi.fn>;
|
|
220
|
+
let onClearSearch: ReturnType<typeof vi.fn>;
|
|
221
|
+
let onAnnounce: ReturnType<typeof vi.fn>;
|
|
222
|
+
let cleanup: () => void;
|
|
223
|
+
|
|
224
|
+
beforeEach(() => {
|
|
225
|
+
onSort = vi.fn();
|
|
226
|
+
onClearSearch = vi.fn();
|
|
227
|
+
onAnnounce = vi.fn();
|
|
228
|
+
wrapper = createTableDOM(3, 3);
|
|
229
|
+
document.body.appendChild(wrapper);
|
|
230
|
+
cleanup = attachKeyboardNav({
|
|
231
|
+
wrapper,
|
|
232
|
+
onSort,
|
|
233
|
+
onClearSearch,
|
|
234
|
+
onAnnounce,
|
|
235
|
+
});
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
afterEach(() => {
|
|
239
|
+
cleanup();
|
|
240
|
+
document.body.innerHTML = '';
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it('ArrowUp from first body row moves focus to header', () => {
|
|
244
|
+
const tbody = focusTbody(wrapper);
|
|
245
|
+
// ArrowUp from row 0 should focus the header
|
|
246
|
+
keydown(tbody, 'ArrowUp');
|
|
247
|
+
// Focus moved to header, so tbody's activedescendant should remain unchanged
|
|
248
|
+
const headers = wrapper.querySelectorAll('thead th');
|
|
249
|
+
// Can't easily check document.activeElement in happy-dom, but header focus() was called
|
|
250
|
+
expect(headers[0]).toBeDefined();
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it('Enter on header triggers sort', () => {
|
|
254
|
+
const headers = wrapper.querySelectorAll('thead th');
|
|
255
|
+
keydown(headers[1] as HTMLElement, 'Enter');
|
|
256
|
+
expect(onSort).toHaveBeenCalledWith('col1');
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it('Space on header triggers sort', () => {
|
|
260
|
+
const headers = wrapper.querySelectorAll('thead th');
|
|
261
|
+
keydown(headers[0] as HTMLElement, ' ');
|
|
262
|
+
expect(onSort).toHaveBeenCalledWith('col0');
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it('ArrowRight on header moves to next header', () => {
|
|
266
|
+
// This tests the focus logic; in happy-dom we just verify no errors
|
|
267
|
+
const headers = wrapper.querySelectorAll('thead th');
|
|
268
|
+
keydown(headers[0] as HTMLElement, 'ArrowRight');
|
|
269
|
+
// Should not throw
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it('ArrowDown from header moves to body at same column', () => {
|
|
273
|
+
const headers = wrapper.querySelectorAll('thead th');
|
|
274
|
+
keydown(headers[1] as HTMLElement, 'ArrowDown');
|
|
275
|
+
const tbody = wrapper.querySelector('tbody')!;
|
|
276
|
+
// The tbody should have activedescendant set to first row, column 1
|
|
277
|
+
expect(tbody.getAttribute('aria-activedescendant')).toBe('viz-cell-0-1');
|
|
278
|
+
});
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
describe('search escape handling', () => {
|
|
282
|
+
let wrapper: HTMLDivElement;
|
|
283
|
+
let onSort: ReturnType<typeof vi.fn>;
|
|
284
|
+
let onClearSearch: ReturnType<typeof vi.fn>;
|
|
285
|
+
let onAnnounce: ReturnType<typeof vi.fn>;
|
|
286
|
+
let cleanup: () => void;
|
|
287
|
+
|
|
288
|
+
beforeEach(() => {
|
|
289
|
+
onSort = vi.fn();
|
|
290
|
+
onClearSearch = vi.fn();
|
|
291
|
+
onAnnounce = vi.fn();
|
|
292
|
+
wrapper = createTableDOM(3, 3, { search: true });
|
|
293
|
+
document.body.appendChild(wrapper);
|
|
294
|
+
cleanup = attachKeyboardNav({
|
|
295
|
+
wrapper,
|
|
296
|
+
onSort,
|
|
297
|
+
onClearSearch,
|
|
298
|
+
onAnnounce,
|
|
299
|
+
});
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
afterEach(() => {
|
|
303
|
+
cleanup();
|
|
304
|
+
document.body.innerHTML = '';
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
it('Escape in search input calls onClearSearch', () => {
|
|
308
|
+
const input = wrapper.querySelector('.viz-table-search input')!;
|
|
309
|
+
keydown(input as HTMLElement, 'Escape');
|
|
310
|
+
expect(onClearSearch).toHaveBeenCalledTimes(1);
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it('Escape in search announces "Search cleared"', () => {
|
|
314
|
+
const input = wrapper.querySelector('.viz-table-search input')!;
|
|
315
|
+
keydown(input as HTMLElement, 'Escape');
|
|
316
|
+
expect(onAnnounce).toHaveBeenCalledWith('Search cleared');
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
it('non-Escape keys in search do not trigger clear', () => {
|
|
320
|
+
const input = wrapper.querySelector('.viz-table-search input')!;
|
|
321
|
+
keydown(input as HTMLElement, 'a');
|
|
322
|
+
keydown(input as HTMLElement, 'Enter');
|
|
323
|
+
expect(onClearSearch).not.toHaveBeenCalled();
|
|
324
|
+
});
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
describe('cleanup', () => {
|
|
328
|
+
it('removes event listeners after cleanup', () => {
|
|
329
|
+
const wrapper = createTableDOM(3, 3, { search: true });
|
|
330
|
+
document.body.appendChild(wrapper);
|
|
331
|
+
|
|
332
|
+
const onSort = vi.fn();
|
|
333
|
+
const onClearSearch = vi.fn();
|
|
334
|
+
const onAnnounce = vi.fn();
|
|
335
|
+
|
|
336
|
+
const cleanup = attachKeyboardNav({
|
|
337
|
+
wrapper,
|
|
338
|
+
onSort,
|
|
339
|
+
onClearSearch,
|
|
340
|
+
onAnnounce,
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
cleanup();
|
|
344
|
+
|
|
345
|
+
// After cleanup, keyboard events should not trigger callbacks
|
|
346
|
+
const tbody = wrapper.querySelector('tbody')!;
|
|
347
|
+
tbody.dispatchEvent(new Event('focus'));
|
|
348
|
+
keydown(tbody, 'ArrowDown');
|
|
349
|
+
|
|
350
|
+
const input = wrapper.querySelector('.viz-table-search input')!;
|
|
351
|
+
keydown(input as HTMLElement, 'Escape');
|
|
352
|
+
|
|
353
|
+
expect(onClearSearch).not.toHaveBeenCalled();
|
|
354
|
+
|
|
355
|
+
// Focus highlight should be cleared
|
|
356
|
+
const focused = wrapper.querySelectorAll('.viz-table-cell-focus');
|
|
357
|
+
expect(focused.length).toBe(0);
|
|
358
|
+
|
|
359
|
+
document.body.innerHTML = '';
|
|
360
|
+
});
|
|
361
|
+
});
|
|
@@ -5,16 +5,46 @@
|
|
|
5
5
|
* and cleanup behavior.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import { afterEach, describe, expect, it } from 'vitest';
|
|
8
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
9
9
|
import { createContainer } from '../__test-fixtures__/dom';
|
|
10
|
-
|
|
10
|
+
|
|
11
|
+
const mockComputePosition = vi.fn();
|
|
12
|
+
|
|
13
|
+
vi.mock('@floating-ui/dom', async (importOriginal) => {
|
|
14
|
+
const actual = await importOriginal<typeof import('@floating-ui/dom')>();
|
|
15
|
+
return {
|
|
16
|
+
...actual,
|
|
17
|
+
computePosition: (...args: unknown[]) => mockComputePosition(...args),
|
|
18
|
+
};
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
// Import after mock so the module picks up the mock
|
|
22
|
+
const { createTooltipManager } = await import('../tooltip');
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Flush the microtask queue so computePosition's .then() resolves.
|
|
26
|
+
*/
|
|
27
|
+
const flushPositioning = () => vi.waitFor(() => Promise.resolve());
|
|
11
28
|
|
|
12
29
|
// ---------------------------------------------------------------------------
|
|
13
30
|
// Cleanup
|
|
14
31
|
// ---------------------------------------------------------------------------
|
|
15
32
|
|
|
33
|
+
const defaultPositionResult = {
|
|
34
|
+
x: 0,
|
|
35
|
+
y: 0,
|
|
36
|
+
placement: 'bottom-start' as const,
|
|
37
|
+
strategy: 'absolute' as const,
|
|
38
|
+
middlewareData: {},
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
beforeEach(() => {
|
|
42
|
+
mockComputePosition.mockResolvedValue(defaultPositionResult);
|
|
43
|
+
});
|
|
44
|
+
|
|
16
45
|
afterEach(() => {
|
|
17
46
|
document.body.innerHTML = '';
|
|
47
|
+
mockComputePosition.mockReset();
|
|
18
48
|
});
|
|
19
49
|
|
|
20
50
|
// ---------------------------------------------------------------------------
|
|
@@ -131,19 +161,99 @@ describe('createTooltipManager lifecycle', () => {
|
|
|
131
161
|
// ---------------------------------------------------------------------------
|
|
132
162
|
|
|
133
163
|
describe('tooltip positioning', () => {
|
|
134
|
-
it('positions tooltip
|
|
164
|
+
it('positions tooltip via computePosition with flip and shift', async () => {
|
|
165
|
+
mockComputePosition.mockResolvedValueOnce({
|
|
166
|
+
x: 112,
|
|
167
|
+
y: 212,
|
|
168
|
+
placement: 'bottom-start',
|
|
169
|
+
strategy: 'absolute',
|
|
170
|
+
middlewareData: {},
|
|
171
|
+
});
|
|
172
|
+
|
|
135
173
|
const container = createContainer();
|
|
136
174
|
const manager = createTooltipManager(container);
|
|
137
175
|
|
|
138
176
|
manager.show({ title: 'Test', fields: [{ label: 'V', value: '1' }] }, 100, 200);
|
|
177
|
+
await flushPositioning();
|
|
178
|
+
|
|
179
|
+
// computePosition should have been called with the tooltip element
|
|
180
|
+
expect(mockComputePosition).toHaveBeenCalledOnce();
|
|
181
|
+
const [ref, tooltipEl, options] = mockComputePosition.mock.calls[0];
|
|
182
|
+
expect(tooltipEl).toBeInstanceOf(HTMLElement);
|
|
183
|
+
expect(options?.placement).toBe('bottom-start');
|
|
184
|
+
// Should include offset, flip, and shift middleware
|
|
185
|
+
expect(options?.middleware).toHaveLength(3);
|
|
186
|
+
|
|
187
|
+
// Virtual reference should return a rect at the mouse position
|
|
188
|
+
const rect = (ref as { getBoundingClientRect: () => DOMRect }).getBoundingClientRect();
|
|
189
|
+
expect(rect.width).toBe(0);
|
|
190
|
+
expect(rect.height).toBe(0);
|
|
191
|
+
|
|
192
|
+
// Position should be applied from computePosition result
|
|
193
|
+
const tooltip = container.querySelector('.viz-tooltip') as HTMLElement;
|
|
194
|
+
expect(tooltip.style.left).toContain('px');
|
|
195
|
+
expect(tooltip.style.top).toContain('px');
|
|
196
|
+
|
|
197
|
+
manager.destroy();
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('discards stale position callbacks on rapid show() calls', async () => {
|
|
201
|
+
type PosResult = {
|
|
202
|
+
x: number;
|
|
203
|
+
y: number;
|
|
204
|
+
placement: string;
|
|
205
|
+
strategy: string;
|
|
206
|
+
middlewareData: Record<string, never>;
|
|
207
|
+
};
|
|
208
|
+
let resolveFirst!: (val: PosResult) => void;
|
|
209
|
+
let resolveSecond!: (val: PosResult) => void;
|
|
210
|
+
|
|
211
|
+
mockComputePosition
|
|
212
|
+
.mockImplementationOnce(
|
|
213
|
+
() =>
|
|
214
|
+
new Promise((r) => {
|
|
215
|
+
resolveFirst = r;
|
|
216
|
+
}),
|
|
217
|
+
)
|
|
218
|
+
.mockImplementationOnce(
|
|
219
|
+
() =>
|
|
220
|
+
new Promise((r) => {
|
|
221
|
+
resolveSecond = r;
|
|
222
|
+
}),
|
|
223
|
+
);
|
|
224
|
+
|
|
225
|
+
const container = createContainer();
|
|
226
|
+
const manager = createTooltipManager(container);
|
|
227
|
+
|
|
228
|
+
// Two rapid show() calls
|
|
229
|
+
manager.show({ title: 'A', fields: [{ label: 'V', value: '1' }] }, 10, 10);
|
|
230
|
+
manager.show({ title: 'B', fields: [{ label: 'V', value: '2' }] }, 200, 200);
|
|
231
|
+
|
|
232
|
+
// Resolve the second (latest) first
|
|
233
|
+
resolveSecond({
|
|
234
|
+
x: 212,
|
|
235
|
+
y: 212,
|
|
236
|
+
placement: 'bottom-start',
|
|
237
|
+
strategy: 'absolute',
|
|
238
|
+
middlewareData: {},
|
|
239
|
+
});
|
|
240
|
+
await flushPositioning();
|
|
139
241
|
|
|
140
242
|
const tooltip = container.querySelector('.viz-tooltip') as HTMLElement;
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
243
|
+
const posAfterSecond = tooltip.style.left;
|
|
244
|
+
|
|
245
|
+
// Now resolve the first (stale) - should be discarded
|
|
246
|
+
resolveFirst({
|
|
247
|
+
x: 22,
|
|
248
|
+
y: 22,
|
|
249
|
+
placement: 'bottom-start',
|
|
250
|
+
strategy: 'absolute',
|
|
251
|
+
middlewareData: {},
|
|
252
|
+
});
|
|
253
|
+
await flushPositioning();
|
|
254
|
+
|
|
255
|
+
// Position should not have changed (stale callback was discarded)
|
|
256
|
+
expect(tooltip.style.left).toBe(posAfterSecond);
|
|
147
257
|
|
|
148
258
|
manager.destroy();
|
|
149
259
|
});
|
package/src/graph-mount.ts
CHANGED
|
@@ -114,6 +114,8 @@ export function createGraph(
|
|
|
114
114
|
let positionedNodes: PositionedNode[] = [];
|
|
115
115
|
let positionedEdges: PositionedEdge[] = [];
|
|
116
116
|
let adjacencyMap = new Map<string, Set<string>>();
|
|
117
|
+
let nodeDataMap = new Map<string, Record<string, unknown>>();
|
|
118
|
+
let edgeDataMap = new Map<string, Record<string, unknown>>();
|
|
117
119
|
let hoveredNodeId: string | null = null;
|
|
118
120
|
let hoveredEdgeId: string | null = null;
|
|
119
121
|
let selectedNodeIds = new Set<string>();
|
|
@@ -121,7 +123,7 @@ export function createGraph(
|
|
|
121
123
|
let needsRender = false;
|
|
122
124
|
let isGesturing = false;
|
|
123
125
|
let gestureTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
124
|
-
let
|
|
126
|
+
let lastEdgeHitTime = 0;
|
|
125
127
|
|
|
126
128
|
// ---------------------------------------------------------------------------
|
|
127
129
|
// Helpers
|
|
@@ -160,6 +162,11 @@ export function createGraph(
|
|
|
160
162
|
return compileGraph(currentSpec, compileOpts);
|
|
161
163
|
}
|
|
162
164
|
|
|
165
|
+
function buildDataMaps(): void {
|
|
166
|
+
nodeDataMap = new Map(compilation.nodes.map((n) => [n.id, n.data ?? {}]));
|
|
167
|
+
edgeDataMap = new Map(compilation.edges.map((e) => [`${e.source}->${e.target}`, e.data ?? {}]));
|
|
168
|
+
}
|
|
169
|
+
|
|
163
170
|
function buildAdjacencyMap(edges: CompiledGraphEdge[]): Map<string, Set<string>> {
|
|
164
171
|
const map = new Map<string, Set<string>>();
|
|
165
172
|
for (const edge of edges) {
|
|
@@ -191,8 +198,7 @@ export function createGraph(
|
|
|
191
198
|
* Falls back to an empty object if not found.
|
|
192
199
|
*/
|
|
193
200
|
function nodeDataById(nodeId: string): Record<string, unknown> {
|
|
194
|
-
|
|
195
|
-
return node?.data ?? {};
|
|
201
|
+
return nodeDataMap.get(nodeId) ?? {};
|
|
196
202
|
}
|
|
197
203
|
|
|
198
204
|
/**
|
|
@@ -245,9 +251,7 @@ export function createGraph(
|
|
|
245
251
|
* Look up edge data by edge id ("source->target").
|
|
246
252
|
*/
|
|
247
253
|
function edgeDataById(edgeId: string): Record<string, unknown> | null {
|
|
248
|
-
|
|
249
|
-
const edge = compilation.edges.find((e) => e.source === source && e.target === target);
|
|
250
|
-
return edge?.data ?? null;
|
|
254
|
+
return edgeDataMap.get(edgeId) ?? null;
|
|
251
255
|
}
|
|
252
256
|
|
|
253
257
|
// ---------------------------------------------------------------------------
|
|
@@ -413,36 +417,8 @@ export function createGraph(
|
|
|
413
417
|
// One final fit after simulation settles
|
|
414
418
|
if (canvas && positionedNodes.length > 0 && interactionManager && renderer) {
|
|
415
419
|
const { width: cw, height: ch } = getCanvasDimensions();
|
|
416
|
-
const { transform: fitTransform
|
|
417
|
-
positionedNodes,
|
|
418
|
-
cw,
|
|
419
|
-
ch,
|
|
420
|
-
);
|
|
420
|
+
const { transform: fitTransform } = ZoomTransform.fitBounds(positionedNodes, cw, ch);
|
|
421
421
|
interactionManager.setTransform(fitTransform);
|
|
422
|
-
|
|
423
|
-
// Shrink canvas + container to actual content height to eliminate dead space.
|
|
424
|
-
// The chrome (title/subtitle) sits above the canvas, so total height includes both.
|
|
425
|
-
const chromeH = chromeEl?.getBoundingClientRect().height || 0;
|
|
426
|
-
const totalContentHeight = Math.ceil(contentHeight) + chromeH;
|
|
427
|
-
const containerH = container.getBoundingClientRect().height;
|
|
428
|
-
|
|
429
|
-
if (totalContentHeight < containerH) {
|
|
430
|
-
selfResizing = true;
|
|
431
|
-
const targetCanvasH = Math.ceil(contentHeight);
|
|
432
|
-
renderer.resize(cw, targetCanvasH);
|
|
433
|
-
// Re-fit with the new canvas height
|
|
434
|
-
const refit = ZoomTransform.fitBounds(positionedNodes, cw, targetCanvasH);
|
|
435
|
-
interactionManager.setTransform(refit.transform);
|
|
436
|
-
// Collapse the container to content height instead of filling the parent.
|
|
437
|
-
// This eliminates dead space below compact graphs in tall containers.
|
|
438
|
-
container.style.height = 'fit-content';
|
|
439
|
-
// Hold selfResizing long enough for the ResizeObserver (debounced ~16ms)
|
|
440
|
-
// to see it and skip the doResize that would clear our height override.
|
|
441
|
-
setTimeout(() => {
|
|
442
|
-
selfResizing = false;
|
|
443
|
-
}, 100);
|
|
444
|
-
}
|
|
445
|
-
|
|
446
422
|
needsRender = true;
|
|
447
423
|
scheduleRender();
|
|
448
424
|
}
|
|
@@ -539,6 +515,21 @@ export function createGraph(
|
|
|
539
515
|
}
|
|
540
516
|
},
|
|
541
517
|
onBackgroundHover(graphX, graphY, screenX, screenY) {
|
|
518
|
+
// Throttle edge hit testing to avoid O(n) scan on every mousemove
|
|
519
|
+
const now = performance.now();
|
|
520
|
+
if (now - lastEdgeHitTime < 32) {
|
|
521
|
+
// When throttled, clear edge hover so hover-off transitions stay snappy
|
|
522
|
+
if (hoveredEdgeId) {
|
|
523
|
+
hoveredEdgeId = null;
|
|
524
|
+
needsRender = true;
|
|
525
|
+
scheduleRender();
|
|
526
|
+
options?.onEdgeHover?.(null);
|
|
527
|
+
tooltipManager?.hide();
|
|
528
|
+
}
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
531
|
+
lastEdgeHitTime = now;
|
|
532
|
+
|
|
542
533
|
// Edge hit testing: check proximity to edge line segments
|
|
543
534
|
const transform = interactionManager?.getTransform();
|
|
544
535
|
const threshold = 5 / (transform?.k ?? 1); // 5px in screen space
|
|
@@ -699,9 +690,7 @@ export function createGraph(
|
|
|
699
690
|
}
|
|
700
691
|
|
|
701
692
|
function doResize(): void {
|
|
702
|
-
if (destroyed || !canvas || !renderer || !wrapper
|
|
703
|
-
// Clear any content-fit height override so we read the parent's actual size
|
|
704
|
-
container.style.height = '';
|
|
693
|
+
if (destroyed || !canvas || !renderer || !wrapper) return;
|
|
705
694
|
const { width, height } = getContainerDimensions();
|
|
706
695
|
const chromeHeight = chromeEl?.getBoundingClientRect().height || 0;
|
|
707
696
|
const canvasHeight = Math.max(height - chromeHeight, 200);
|
|
@@ -720,6 +709,7 @@ export function createGraph(
|
|
|
720
709
|
// Recompile
|
|
721
710
|
compilation = compile();
|
|
722
711
|
adjacencyMap = buildAdjacencyMap(compilation.edges);
|
|
712
|
+
buildDataMaps();
|
|
723
713
|
|
|
724
714
|
// Update DOM chrome/legend
|
|
725
715
|
renderChrome();
|
|
@@ -749,6 +739,7 @@ export function createGraph(
|
|
|
749
739
|
// Recompile with new spec (encoding, chrome, nodeOverrides, etc.)
|
|
750
740
|
compilation = compile();
|
|
751
741
|
adjacencyMap = buildAdjacencyMap(compilation.edges);
|
|
742
|
+
buildDataMaps();
|
|
752
743
|
|
|
753
744
|
// Transfer positions to new compiled nodes
|
|
754
745
|
positionedNodes = compilation.nodes.map((node) => {
|
|
@@ -833,6 +824,7 @@ export function createGraph(
|
|
|
833
824
|
try {
|
|
834
825
|
compilation = compile();
|
|
835
826
|
adjacencyMap = buildAdjacencyMap(compilation.edges);
|
|
827
|
+
buildDataMaps();
|
|
836
828
|
createDOM();
|
|
837
829
|
initSimulation();
|
|
838
830
|
initInteraction();
|