@opendata-ai/openchart-vanilla 2.9.1 → 2.11.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 +126 -55
- 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/__tests__/zoom.test.ts +12 -4
- package/src/graph/zoom.ts +16 -4
- package/src/graph-mount.ts +31 -8
- package/src/mount.ts +14 -2
- package/src/svg-renderer.ts +28 -13
- 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.11.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.11.0",
|
|
54
|
+
"@opendata-ai/openchart-engine": "2.11.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
|
});
|
|
@@ -141,7 +141,7 @@ describe('ZoomTransform', () => {
|
|
|
141
141
|
|
|
142
142
|
describe('fitBounds', () => {
|
|
143
143
|
it('returns identity for empty node array', () => {
|
|
144
|
-
const t = ZoomTransform.fitBounds([], 800, 600);
|
|
144
|
+
const { transform: t } = ZoomTransform.fitBounds([], 800, 600);
|
|
145
145
|
expect(t.x).toBe(0);
|
|
146
146
|
expect(t.y).toBe(0);
|
|
147
147
|
expect(t.k).toBe(1);
|
|
@@ -149,7 +149,7 @@ describe('ZoomTransform', () => {
|
|
|
149
149
|
|
|
150
150
|
it('centers a single node', () => {
|
|
151
151
|
const nodes = [makeNode('a', 0, 0)];
|
|
152
|
-
const t = ZoomTransform.fitBounds(nodes, 800, 600, 40);
|
|
152
|
+
const { transform: t } = ZoomTransform.fitBounds(nodes, 800, 600, 40);
|
|
153
153
|
// Single node at origin should be centered
|
|
154
154
|
// Transform should put graph origin at screen center
|
|
155
155
|
const screen = t.graphToScreen(0, 0);
|
|
@@ -159,7 +159,7 @@ describe('ZoomTransform', () => {
|
|
|
159
159
|
|
|
160
160
|
it('fits a spread of nodes within the canvas', () => {
|
|
161
161
|
const nodes = [makeNode('a', -200, -100), makeNode('b', 200, 100)];
|
|
162
|
-
const t = ZoomTransform.fitBounds(nodes, 800, 600, 40);
|
|
162
|
+
const { transform: t } = ZoomTransform.fitBounds(nodes, 800, 600, 40);
|
|
163
163
|
|
|
164
164
|
// Both nodes should map to within the canvas bounds (with padding)
|
|
165
165
|
const sa = t.graphToScreen(-200, -100);
|
|
@@ -174,10 +174,18 @@ describe('ZoomTransform', () => {
|
|
|
174
174
|
it('produces correct scale for known graph bounds', () => {
|
|
175
175
|
// Graph spans 400x200, canvas 800x600, padding 0
|
|
176
176
|
const nodes = [makeNode('a', 0, 0, 0), makeNode('b', 400, 200, 0)];
|
|
177
|
-
const t = ZoomTransform.fitBounds(nodes, 800, 600, 0);
|
|
177
|
+
const { transform: t } = ZoomTransform.fitBounds(nodes, 800, 600, 0);
|
|
178
178
|
// Scale should be min(800/400, 600/200) = min(2, 3) = 2
|
|
179
179
|
expect(t.k).toBeCloseTo(2);
|
|
180
180
|
});
|
|
181
|
+
|
|
182
|
+
it('returns contentHeight matching scaled graph bounds plus padding', () => {
|
|
183
|
+
const nodes = [makeNode('a', 0, 0, 0), makeNode('b', 400, 200, 0)];
|
|
184
|
+
const { contentHeight } = ZoomTransform.fitBounds(nodes, 800, 600, 40);
|
|
185
|
+
// k = min(720/400, 520/200) = min(1.8, 2.6) = 1.8
|
|
186
|
+
// contentHeight = 200 * 1.8 + 80 = 440
|
|
187
|
+
expect(contentHeight).toBeCloseTo(440);
|
|
188
|
+
});
|
|
181
189
|
});
|
|
182
190
|
|
|
183
191
|
// -------------------------------------------------------------------------
|
package/src/graph/zoom.ts
CHANGED
|
@@ -52,15 +52,18 @@ export class ZoomTransform {
|
|
|
52
52
|
/**
|
|
53
53
|
* Compute a transform that fits all nodes within the given canvas
|
|
54
54
|
* dimensions with the specified padding.
|
|
55
|
+
*
|
|
56
|
+
* Returns the transform and the ideal content height (in screen pixels)
|
|
57
|
+
* so callers can shrink the canvas to eliminate dead space.
|
|
55
58
|
*/
|
|
56
59
|
static fitBounds(
|
|
57
60
|
nodes: PositionedNode[],
|
|
58
61
|
canvasW: number,
|
|
59
62
|
canvasH: number,
|
|
60
63
|
padding: number = 40,
|
|
61
|
-
): ZoomTransform {
|
|
64
|
+
): { transform: ZoomTransform; contentHeight: number } {
|
|
62
65
|
if (nodes.length === 0) {
|
|
63
|
-
return ZoomTransform.identity();
|
|
66
|
+
return { transform: ZoomTransform.identity(), contentHeight: canvasH };
|
|
64
67
|
}
|
|
65
68
|
|
|
66
69
|
let minX = Infinity;
|
|
@@ -81,7 +84,10 @@ export class ZoomTransform {
|
|
|
81
84
|
|
|
82
85
|
if (graphW === 0 && graphH === 0) {
|
|
83
86
|
// All nodes at the same point; just center
|
|
84
|
-
return
|
|
87
|
+
return {
|
|
88
|
+
transform: new ZoomTransform(canvasW / 2 - minX, canvasH / 2 - minY, 1),
|
|
89
|
+
contentHeight: padding * 2,
|
|
90
|
+
};
|
|
85
91
|
}
|
|
86
92
|
|
|
87
93
|
const availW = canvasW - padding * 2;
|
|
@@ -95,7 +101,13 @@ export class ZoomTransform {
|
|
|
95
101
|
const tx = canvasW / 2 - cx * k;
|
|
96
102
|
const ty = padding - minY * k;
|
|
97
103
|
|
|
98
|
-
|
|
104
|
+
// Content height = scaled graph extent + top and bottom padding
|
|
105
|
+
const contentHeight = graphH * k + padding * 2;
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
transform: new ZoomTransform(tx, ty, k),
|
|
109
|
+
contentHeight,
|
|
110
|
+
};
|
|
99
111
|
}
|
|
100
112
|
|
|
101
113
|
/** Identity transform (no pan, no zoom). */
|