@neo4j-nvl/interaction-handlers 1.1.0 → 1.2.0-090ec876

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/CHANGELOG.md CHANGED
@@ -2,6 +2,31 @@
2
2
 
3
3
  All notable changes to NVL will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
4
4
 
5
+ ## [1.2.0] - 2026-05-27
6
+
7
+ This 1.2.0 release introduces a new keyboard interaction handler and SVG support for the static image wrapper React component. It also includes several performance improvements for clustering and text/icon rendering, as well as several bug fixes.
8
+
9
+ ### Added
10
+ * SVG format support in the static image wrapper.
11
+ * Keyboard interaction handlers.
12
+
13
+ ### Changed
14
+ * Boost performance of graph clustering for force directed layouts.
15
+ * Optimize Canvas rendering performance by caching expensive operations.
16
+ * Deprecate Cytoscape support.
17
+
18
+ ### Fixed
19
+ * Fix Arabic text rendering issues to ensure characters stay connected.
20
+ * Prevent arrow ends from bouncing by removing position rounding.
21
+ * Fix issue where D3 layout would occasionally fail to display.
22
+ * Fix node position loss during a restart when the device pixel ratio changes.
23
+ * Add missing support for relationship overlay icons within the SVG renderer.
24
+ * Fix bug where reactive zoom values were not consistently applied.
25
+ * Correct the pinch-to-zoom speed scaling on the minimap.
26
+ * Add safety guards against non-finite values to prevent runtime rendering errors.
27
+ * Ensure fit targets respect `maxZoom` constraints even when omitted from `zoomOptions`.
28
+ * Force canvas repaint when icon images finish asynchronous loading.
29
+
5
30
  ## [1.1.0] - 2026-02-16
6
31
 
7
32
  This 1.1.0 release introduces new rendering capabilities with the addition of a new SVG export method, alongside a new Circular Layout algorithm and touchpad gestures for zooming. It also includes performance improvements for the renderer and physics engine, as well as various fixes for the React wrappers and Minimap.
@@ -50,14 +50,15 @@ describe('BoxSelectInteraction', () => {
50
50
  resolve();
51
51
  });
52
52
  });
53
- test.failing('will not start when node is below pointer when dragging', () => {
53
+ test('will not start when node is below pointer when dragging', () => {
54
54
  boxSelectInteraction.updateCallback('onBoxStarted', startCallback);
55
55
  boxSelectInteraction.updateCallback('onBoxSelect', selectCallback);
56
56
  expect(myNVL.getSelectedNodes()).toHaveLength(0);
57
57
  const gesture = [
58
58
  createMouseEvent('mousedown', 25, 25),
59
59
  createMouseEvent('mousemove', 50, 50),
60
- createMouseEvent('mouseup', 50, 50)
60
+ createMouseEvent('mousemove', 200, 200),
61
+ createMouseEvent('mouseup', 200, 200)
61
62
  ];
62
63
  const container = myNVL.getContainer();
63
64
  gesture.forEach((e) => container.dispatchEvent(e));
@@ -67,6 +68,31 @@ describe('BoxSelectInteraction', () => {
67
68
  resolve();
68
69
  });
69
70
  });
71
+ test('can start box select when only a relationship is under the pointer', () => {
72
+ boxSelectInteraction.updateCallback('onBoxStarted', startCallback);
73
+ boxSelectInteraction.updateCallback('onBoxSelect', selectCallback);
74
+ const gesture = [
75
+ createMouseEvent('mousedown', 100, 100),
76
+ createMouseEvent('mousemove', 150, 100),
77
+ createMouseEvent('mouseup', 150, 100)
78
+ ];
79
+ const container = myNVL.getContainer();
80
+ gesture.forEach((e) => container.dispatchEvent(e));
81
+ return new Promise((resolve) => {
82
+ expect(startCallback).toHaveBeenCalledTimes(1);
83
+ expect(selectCallback).toHaveBeenCalledTimes(1);
84
+ resolve();
85
+ });
86
+ });
87
+ test('does not fire callbacks when clicking directly on a relationship without dragging', () => {
88
+ boxSelectInteraction.updateCallback('onBoxStarted', startCallback);
89
+ boxSelectInteraction.updateCallback('onBoxSelect', selectCallback);
90
+ const container = myNVL.getContainer();
91
+ container.dispatchEvent(createMouseEvent('mousedown', 112, 112));
92
+ container.dispatchEvent(createMouseEvent('mouseup', 112, 112));
93
+ expect(startCallback).not.toHaveBeenCalled();
94
+ expect(selectCallback).not.toHaveBeenCalled();
95
+ });
70
96
  test('box selection should disable text selection until mouse release', () => {
71
97
  const container = myNVL.getContainer();
72
98
  boxSelectInteraction.updateCallback('onBoxSelect', boxSelectCallbackMock);
@@ -0,0 +1 @@
1
+ import '@testing-library/jest-dom';
@@ -0,0 +1,322 @@
1
+ import NVL from '@neo4j-nvl/base';
2
+ import '@testing-library/jest-dom';
3
+ import { KeyboardInteraction } from '../interaction-handlers/keyboard-interaction';
4
+ jest.mock('@neo4j-nvl/layout-workers');
5
+ describe('KeyboardInteraction', () => {
6
+ let keyboardInteraction;
7
+ let myNVL;
8
+ const callbackMock = jest.fn();
9
+ beforeEach(() => {
10
+ myNVL = new NVL(document.createElement('div'), [
11
+ { id: '0', x: 100, y: 100 },
12
+ { id: '1', x: 200, y: 200 }
13
+ ], [{ id: '10', from: '0', to: '1' }], { disableWebGL: true, initialZoom: 1, layout: 'free', renderer: 'webgl' });
14
+ keyboardInteraction = new KeyboardInteraction(myNVL);
15
+ });
16
+ afterEach(() => {
17
+ keyboardInteraction.destroy();
18
+ myNVL.destroy();
19
+ callbackMock.mockReset();
20
+ });
21
+ test('sets tabindex on the container if not already present', () => {
22
+ expect(myNVL.getContainer().getAttribute('tabindex')).toBe('0');
23
+ });
24
+ test('does not override existing tabindex', () => {
25
+ keyboardInteraction.destroy();
26
+ const container = myNVL.getContainer();
27
+ container.setAttribute('tabindex', '-1');
28
+ keyboardInteraction = new KeyboardInteraction(myNVL);
29
+ expect(container.getAttribute('tabindex')).toBe('-1');
30
+ });
31
+ test('removes tabindex on destroy only if it was added by the interaction', () => {
32
+ keyboardInteraction.destroy();
33
+ expect(myNVL.getContainer().getAttribute('tabindex')).toBeNull();
34
+ });
35
+ test('does not remove tabindex on destroy if it was pre-existing', () => {
36
+ keyboardInteraction.destroy();
37
+ const container = myNVL.getContainer();
38
+ container.setAttribute('tabindex', '-1');
39
+ const interaction = new KeyboardInteraction(myNVL);
40
+ interaction.destroy();
41
+ expect(container.getAttribute('tabindex')).toBe('-1');
42
+ });
43
+ test('keydown event triggers onKeyDown callback', () => {
44
+ keyboardInteraction.updateCallback('onKeyDown', callbackMock);
45
+ const event = new KeyboardEvent('keydown', { key: 'a' });
46
+ myNVL.getContainer().dispatchEvent(event);
47
+ expect(callbackMock).toHaveBeenCalledWith(event, undefined);
48
+ expect(callbackMock).toHaveBeenCalledTimes(1);
49
+ });
50
+ test('keyup event triggers onKeyUp callback', () => {
51
+ keyboardInteraction.updateCallback('onKeyUp', callbackMock);
52
+ const event = new KeyboardEvent('keyup', { key: 'a' });
53
+ myNVL.getContainer().dispatchEvent(event);
54
+ expect(callbackMock).toHaveBeenCalledWith(event, undefined);
55
+ expect(callbackMock).toHaveBeenCalledTimes(1);
56
+ });
57
+ test('focusin event triggers onCanvasFocus callback', () => {
58
+ keyboardInteraction.updateCallback('onCanvasFocus', callbackMock);
59
+ const event = new FocusEvent('focusin');
60
+ myNVL.getContainer().dispatchEvent(event);
61
+ expect(callbackMock).toHaveBeenCalledWith(event);
62
+ expect(callbackMock).toHaveBeenCalledTimes(1);
63
+ });
64
+ test('focusout event triggers onCanvasBlur callback', () => {
65
+ keyboardInteraction.updateCallback('onCanvasBlur', callbackMock);
66
+ const event = new FocusEvent('focusout');
67
+ myNVL.getContainer().dispatchEvent(event);
68
+ expect(callbackMock).toHaveBeenCalledWith(event);
69
+ expect(callbackMock).toHaveBeenCalledTimes(1);
70
+ });
71
+ describe('automatic navigation via key mappings', () => {
72
+ test('Enter key enters the graph and focuses the first element', () => {
73
+ keyboardInteraction.updateCallback('onNodeFocus', callbackMock);
74
+ const event = new KeyboardEvent('keydown', { key: 'Enter', bubbles: true });
75
+ myNVL.getContainer().dispatchEvent(event);
76
+ expect(callbackMock).toHaveBeenCalledTimes(1);
77
+ expect(callbackMock).toHaveBeenCalledWith(expect.objectContaining({ id: '0' }), event);
78
+ });
79
+ test('Enter key does nothing when an element is already focused', () => {
80
+ const nodeFocusMock = jest.fn();
81
+ keyboardInteraction.updateCallback('onNodeFocus', nodeFocusMock);
82
+ const container = myNVL.getContainer();
83
+ // Enter the graph first
84
+ container.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
85
+ expect(nodeFocusMock).toHaveBeenCalledTimes(1);
86
+ // Press Enter again — should not navigate
87
+ nodeFocusMock.mockClear();
88
+ container.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
89
+ expect(nodeFocusMock).not.toHaveBeenCalled();
90
+ });
91
+ test('Tab does nothing when the graph has not been entered', () => {
92
+ keyboardInteraction.updateCallback('onNodeFocus', callbackMock);
93
+ myNVL.getContainer().dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true }));
94
+ expect(callbackMock).not.toHaveBeenCalled();
95
+ });
96
+ test('Tab key navigates focus forward after entering the graph', () => {
97
+ const nodeFocusMock = jest.fn();
98
+ keyboardInteraction.updateCallback('onNodeFocus', nodeFocusMock);
99
+ const container = myNVL.getContainer();
100
+ // Enter the graph first
101
+ container.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
102
+ expect(nodeFocusMock).toHaveBeenLastCalledWith(expect.objectContaining({ id: '0' }), expect.any(KeyboardEvent));
103
+ // Tab to next element
104
+ const event = new KeyboardEvent('keydown', { key: 'Tab', bubbles: true });
105
+ container.dispatchEvent(event);
106
+ expect(nodeFocusMock).toHaveBeenLastCalledWith(expect.objectContaining({ id: '1' }), event);
107
+ });
108
+ test('Shift+Tab does nothing when the graph has not been entered', () => {
109
+ keyboardInteraction.updateCallback('onRelationshipFocus', callbackMock);
110
+ myNVL.getContainer().dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', shiftKey: true, bubbles: true }));
111
+ expect(callbackMock).not.toHaveBeenCalled();
112
+ });
113
+ test('Shift+Tab navigates focus backward after entering the graph', () => {
114
+ const nodeFocusMock = jest.fn();
115
+ const relFocusMock = jest.fn();
116
+ keyboardInteraction.updateCallback('onNodeFocus', nodeFocusMock);
117
+ keyboardInteraction.updateCallback('onRelationshipFocus', relFocusMock);
118
+ const container = myNVL.getContainer();
119
+ // Enter the graph first
120
+ container.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
121
+ // Shift+Tab to go backward
122
+ const event = new KeyboardEvent('keydown', { key: 'Tab', shiftKey: true, bubbles: true });
123
+ container.dispatchEvent(event);
124
+ expect(relFocusMock).toHaveBeenCalledWith(expect.objectContaining({ id: '10' }), event);
125
+ });
126
+ test('Tab cycles through nodes and relationships', () => {
127
+ const nodeFocusMock = jest.fn();
128
+ const relFocusMock = jest.fn();
129
+ keyboardInteraction.updateCallback('onNodeFocus', nodeFocusMock);
130
+ keyboardInteraction.updateCallback('onRelationshipFocus', relFocusMock);
131
+ const container = myNVL.getContainer();
132
+ // Enter the graph: node '0'
133
+ container.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
134
+ expect(nodeFocusMock).toHaveBeenLastCalledWith(expect.objectContaining({ id: '0' }), expect.any(KeyboardEvent));
135
+ // First Tab: node '1'
136
+ container.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true }));
137
+ expect(nodeFocusMock).toHaveBeenLastCalledWith(expect.objectContaining({ id: '1' }), expect.any(KeyboardEvent));
138
+ // Second Tab: relationship '10'
139
+ container.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true }));
140
+ expect(relFocusMock).toHaveBeenCalledWith(expect.objectContaining({ id: '10' }), expect.any(KeyboardEvent));
141
+ });
142
+ test('Tab wraps around from last to first element', () => {
143
+ const nodeFocusMock = jest.fn();
144
+ keyboardInteraction.updateCallback('onNodeFocus', nodeFocusMock);
145
+ keyboardInteraction.updateCallback('onRelationshipFocus', jest.fn());
146
+ const container = myNVL.getContainer();
147
+ // Enter the graph, then navigate to the last element (relationship '10')
148
+ container.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
149
+ container.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true }));
150
+ container.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true }));
151
+ // Wrap around to first element
152
+ container.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true }));
153
+ expect(nodeFocusMock).toHaveBeenLastCalledWith(expect.objectContaining({ id: '0' }), expect.any(KeyboardEvent));
154
+ });
155
+ test('Tab fires blur callback when moving away from a node', () => {
156
+ const nodeBlurMock = jest.fn();
157
+ keyboardInteraction.updateCallback('onNodeFocus', jest.fn());
158
+ keyboardInteraction.updateCallback('onNodeBlur', nodeBlurMock);
159
+ const container = myNVL.getContainer();
160
+ container.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
161
+ container.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true }));
162
+ expect(nodeBlurMock).toHaveBeenCalledWith(expect.objectContaining({ id: '0' }), expect.any(KeyboardEvent));
163
+ });
164
+ test('Tab fires onRelationshipBlur when moving away from a relationship', () => {
165
+ const relBlurMock = jest.fn();
166
+ keyboardInteraction.updateCallback('onNodeFocus', jest.fn());
167
+ keyboardInteraction.updateCallback('onRelationshipFocus', jest.fn());
168
+ keyboardInteraction.updateCallback('onRelationshipBlur', relBlurMock);
169
+ const container = myNVL.getContainer();
170
+ // Enter the graph, then navigate to relationship
171
+ container.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
172
+ container.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true }));
173
+ container.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true }));
174
+ // Move away from relationship
175
+ container.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true }));
176
+ expect(relBlurMock).toHaveBeenCalledWith(expect.objectContaining({ id: '10' }), expect.any(KeyboardEvent));
177
+ });
178
+ test('does nothing when there are no elements', () => {
179
+ keyboardInteraction.destroy();
180
+ myNVL.destroy();
181
+ myNVL = new NVL(document.createElement('div'), [], [], {
182
+ disableWebGL: true,
183
+ initialZoom: 1,
184
+ layout: 'free',
185
+ renderer: 'webgl'
186
+ });
187
+ keyboardInteraction = new KeyboardInteraction(myNVL);
188
+ keyboardInteraction.updateCallback('onNodeFocus', callbackMock);
189
+ myNVL.getContainer().dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
190
+ expect(callbackMock).not.toHaveBeenCalled();
191
+ });
192
+ test('Escape clears element focus but keeps canvas focused', () => {
193
+ const nodeBlurMock = jest.fn();
194
+ keyboardInteraction.updateCallback('onNodeFocus', jest.fn());
195
+ keyboardInteraction.updateCallback('onNodeBlur', nodeBlurMock);
196
+ const container = myNVL.getContainer();
197
+ container.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
198
+ container.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
199
+ expect(nodeBlurMock).toHaveBeenCalledWith(expect.objectContaining({ id: '0' }), expect.any(KeyboardEvent));
200
+ expect(keyboardInteraction.getFocused()).toBeUndefined();
201
+ });
202
+ test('Escape does nothing when nothing is focused', () => {
203
+ keyboardInteraction.updateCallback('onNodeBlur', callbackMock);
204
+ keyboardInteraction.updateCallback('onRelationshipBlur', callbackMock);
205
+ myNVL.getContainer().dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
206
+ expect(callbackMock).not.toHaveBeenCalled();
207
+ });
208
+ test('Shift+F10 fires onContextMenu with focused element', () => {
209
+ const contextMenuMock = jest.fn();
210
+ keyboardInteraction.updateCallback('onNodeFocus', jest.fn());
211
+ keyboardInteraction.updateCallback('onContextMenu', contextMenuMock);
212
+ const container = myNVL.getContainer();
213
+ container.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
214
+ const event = new KeyboardEvent('keydown', { key: 'F10', shiftKey: true, bubbles: true });
215
+ container.dispatchEvent(event);
216
+ expect(contextMenuMock).toHaveBeenCalledWith(expect.objectContaining({ id: '0' }), event);
217
+ });
218
+ test('Shift+F10 fires onContextMenu with undefined when nothing is focused', () => {
219
+ const contextMenuMock = jest.fn();
220
+ keyboardInteraction.updateCallback('onContextMenu', contextMenuMock);
221
+ const event = new KeyboardEvent('keydown', { key: 'F10', shiftKey: true, bubbles: true });
222
+ myNVL.getContainer().dispatchEvent(event);
223
+ expect(contextMenuMock).toHaveBeenCalledWith(undefined, event);
224
+ });
225
+ });
226
+ describe('custom key mappings', () => {
227
+ test('respects custom enterGraphKey', () => {
228
+ keyboardInteraction.destroy();
229
+ keyboardInteraction = new KeyboardInteraction(myNVL, { enterGraphKey: ['Space'] });
230
+ keyboardInteraction.updateCallback('onNodeFocus', callbackMock);
231
+ // Default Enter should not enter the graph
232
+ myNVL.getContainer().dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
233
+ expect(callbackMock).not.toHaveBeenCalled();
234
+ // Custom key should enter the graph
235
+ myNVL.getContainer().dispatchEvent(new KeyboardEvent('keydown', { key: 'Space', bubbles: true }));
236
+ expect(callbackMock).toHaveBeenCalledWith(expect.objectContaining({ id: '0' }), expect.any(KeyboardEvent));
237
+ });
238
+ test('respects custom navigateForwardKey', () => {
239
+ keyboardInteraction.destroy();
240
+ keyboardInteraction = new KeyboardInteraction(myNVL, { navigateForwardKey: ['ArrowDown'] });
241
+ keyboardInteraction.updateCallback('onNodeFocus', callbackMock);
242
+ const container = myNVL.getContainer();
243
+ // Enter the graph first
244
+ container.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
245
+ // Custom forward key should navigate to the next element
246
+ container.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
247
+ expect(callbackMock).toHaveBeenLastCalledWith(expect.objectContaining({ id: '1' }), expect.any(KeyboardEvent));
248
+ });
249
+ test('respects custom navigateBackwardKey', () => {
250
+ keyboardInteraction.destroy();
251
+ keyboardInteraction = new KeyboardInteraction(myNVL, { navigateBackwardKey: ['ArrowUp'] });
252
+ keyboardInteraction.updateCallback('onNodeFocus', jest.fn());
253
+ keyboardInteraction.updateCallback('onRelationshipFocus', callbackMock);
254
+ const container = myNVL.getContainer();
255
+ // Enter the graph first
256
+ container.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
257
+ // Custom backward key should navigate to the previous (last) element
258
+ container.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true }));
259
+ expect(callbackMock).toHaveBeenCalledWith(expect.objectContaining({ id: '10' }), expect.any(KeyboardEvent));
260
+ });
261
+ test('respects custom exitGraphKey', () => {
262
+ keyboardInteraction.destroy();
263
+ keyboardInteraction = new KeyboardInteraction(myNVL, { exitGraphKey: ['q'] });
264
+ keyboardInteraction.updateCallback('onNodeFocus', jest.fn());
265
+ keyboardInteraction.updateCallback('onNodeBlur', callbackMock);
266
+ const container = myNVL.getContainer();
267
+ container.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
268
+ container.dispatchEvent(new KeyboardEvent('keydown', { key: 'q', bubbles: true }));
269
+ expect(callbackMock).toHaveBeenCalledWith(expect.objectContaining({ id: '0' }), expect.any(KeyboardEvent));
270
+ });
271
+ test('respects custom contextMenuKey', () => {
272
+ keyboardInteraction.destroy();
273
+ keyboardInteraction = new KeyboardInteraction(myNVL, { contextMenuKey: ['Ctrl', 'm'] });
274
+ const contextMenuMock = jest.fn();
275
+ keyboardInteraction.updateCallback('onNodeFocus', jest.fn());
276
+ keyboardInteraction.updateCallback('onContextMenu', contextMenuMock);
277
+ const container = myNVL.getContainer();
278
+ container.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
279
+ const event = new KeyboardEvent('keydown', { key: 'm', ctrlKey: true, bubbles: true });
280
+ container.dispatchEvent(event);
281
+ expect(contextMenuMock).toHaveBeenCalledWith(expect.objectContaining({ id: '0' }), event);
282
+ });
283
+ });
284
+ test('getFocused returns the currently focused node', () => {
285
+ keyboardInteraction.updateCallback('onNodeFocus', jest.fn());
286
+ expect(keyboardInteraction.getFocused()).toBeUndefined();
287
+ myNVL.getContainer().dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
288
+ expect(keyboardInteraction.getFocused()).toEqual(expect.objectContaining({ id: '0' }));
289
+ });
290
+ test('getFocused returns the currently focused relationship', () => {
291
+ keyboardInteraction.updateCallback('onNodeFocus', jest.fn());
292
+ keyboardInteraction.updateCallback('onRelationshipFocus', jest.fn());
293
+ const container = myNVL.getContainer();
294
+ container.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
295
+ container.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true }));
296
+ container.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true }));
297
+ expect(keyboardInteraction.getFocused()).toEqual(expect.objectContaining({ id: '10' }));
298
+ });
299
+ test('onKeyDown callback receives the currently focused element', () => {
300
+ keyboardInteraction.updateCallback('onNodeFocus', jest.fn());
301
+ keyboardInteraction.updateCallback('onKeyDown', callbackMock);
302
+ const container = myNVL.getContainer();
303
+ container.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
304
+ const event = new KeyboardEvent('keydown', { key: 'a', bubbles: true });
305
+ container.dispatchEvent(event);
306
+ expect(callbackMock).toHaveBeenLastCalledWith(event, expect.objectContaining({ id: '0' }));
307
+ });
308
+ test('destroy removes event listeners', () => {
309
+ keyboardInteraction.updateCallback('onKeyDown', callbackMock);
310
+ keyboardInteraction.destroy();
311
+ const event = new KeyboardEvent('keydown', { key: 'Tab' });
312
+ myNVL.getContainer().dispatchEvent(event);
313
+ expect(callbackMock).not.toHaveBeenCalled();
314
+ });
315
+ test('destroy clears focus state', () => {
316
+ keyboardInteraction.updateCallback('onNodeFocus', jest.fn());
317
+ myNVL.getContainer().dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
318
+ expect(keyboardInteraction.getFocused()).toBeDefined();
319
+ keyboardInteraction.destroy();
320
+ expect(keyboardInteraction.getFocused()).toBeUndefined();
321
+ });
322
+ });
@@ -78,12 +78,39 @@ describe('LassoInteraction', () => {
78
78
  resolve();
79
79
  });
80
80
  });
81
+ test('does not fire started or select callbacks when movement stays below drag threshold', () => {
82
+ lassoInteraction.updateCallback('onLassoStarted', startCallback);
83
+ lassoInteraction.updateCallback('onLassoSelect', selectCallback);
84
+ const container = myNVL.getContainer();
85
+ container.dispatchEvent(createMouseEvent('mousedown', 100, 100));
86
+ container.dispatchEvent(createMouseEvent('mousemove', 100, 103));
87
+ container.dispatchEvent(createMouseEvent('mouseup', 100, 103));
88
+ expect(startCallback).not.toHaveBeenCalled();
89
+ expect(selectCallback).not.toHaveBeenCalled();
90
+ });
91
+ test('can start lasso when only a relationship is under the pointer', () => {
92
+ lassoInteraction.updateCallback('onLassoStarted', startCallback);
93
+ lassoInteraction.updateCallback('onLassoSelect', selectCallback);
94
+ const gesture = [
95
+ createMouseEvent('mousedown', 100, 100),
96
+ createMouseEvent('mousemove', 150, 100),
97
+ createMouseEvent('mousemove', 50, 150),
98
+ createMouseEvent('mouseup', 100, 100)
99
+ ];
100
+ const container = myNVL.getContainer();
101
+ gesture.forEach((e) => container.dispatchEvent(e));
102
+ return new Promise((resolve) => {
103
+ expect(startCallback).toHaveBeenCalledTimes(1);
104
+ expect(selectCallback).toHaveBeenCalledTimes(1);
105
+ resolve();
106
+ });
107
+ });
81
108
  test('will not start when node is below pointer when dragging', () => {
82
109
  lassoInteraction.updateCallback('onLassoStarted', startCallback);
83
110
  lassoInteraction.updateCallback('onLassoSelect', selectCallback);
84
111
  expect(myNVL.getSelectedNodes()).toHaveLength(0);
85
112
  const gesture1 = [
86
- createMouseEvent('mousedown', 10, 10),
113
+ createMouseEvent('mousedown', 25, 25),
87
114
  createMouseEvent('mousemove', 50, 0),
88
115
  createMouseEvent('mousemove', 20, 50),
89
116
  createMouseEvent('mouseup', 0, 0)
@@ -95,6 +122,15 @@ describe('LassoInteraction', () => {
95
122
  resolve();
96
123
  });
97
124
  });
125
+ test('does not fire callbacks when clicking directly on a relationship without dragging', () => {
126
+ lassoInteraction.updateCallback('onLassoStarted', startCallback);
127
+ lassoInteraction.updateCallback('onLassoSelect', selectCallback);
128
+ const container = myNVL.getContainer();
129
+ container.dispatchEvent(createMouseEvent('mousedown', 112, 112));
130
+ container.dispatchEvent(createMouseEvent('mouseup', 112, 112));
131
+ expect(startCallback).not.toHaveBeenCalled();
132
+ expect(selectCallback).not.toHaveBeenCalled();
133
+ });
98
134
  test('test for line crossing utility function', () => {
99
135
  const res1 = checkLinesCrossing([0, 0], [10, 10], [10, 0], [0, 10]);
100
136
  expect(res1).toBeTruthy();
@@ -2,6 +2,11 @@ import NVL from '@neo4j-nvl/base';
2
2
  import '@testing-library/jest-dom';
3
3
  import { PanInteraction } from '../interaction-handlers/pan-interaction';
4
4
  jest.mock('@neo4j-nvl/layout-workers');
5
+ const waitForAnimationFrame = async () => {
6
+ await new Promise((resolve) => {
7
+ window.requestAnimationFrame(() => resolve());
8
+ });
9
+ };
5
10
  describe('PanInteraction', () => {
6
11
  let panInteraction;
7
12
  let myNVL;
@@ -18,7 +23,7 @@ describe('PanInteraction', () => {
18
23
  myNVL.destroy();
19
24
  panCallbackMock.mockReset();
20
25
  });
21
- test('performing a simple pan operation should invoke the expected callback', () => {
26
+ test('performing a simple pan operation should invoke the expected callback', async () => {
22
27
  panInteraction.updateCallback('onPan', panCallbackMock);
23
28
  const mouseDownEvent = new MouseEvent('mousedown', {
24
29
  clientX: 100,
@@ -36,18 +41,17 @@ describe('PanInteraction', () => {
36
41
  const container = myNVL.getContainer();
37
42
  const initialPan = myNVL.getPan();
38
43
  container.dispatchEvent(mouseDownEvent);
44
+ await waitForAnimationFrame();
39
45
  container.dispatchEvent(mouseMoveEvent);
46
+ await waitForAnimationFrame();
40
47
  container.dispatchEvent(mouseUpEvent);
41
- return new Promise((resolve) => {
42
- expect(panCallbackMock).toHaveBeenCalledTimes(1);
43
- expect(panCallbackMock).toHaveBeenCalledWith({
44
- x: initialPan.x - 20,
45
- y: initialPan.y - 20
46
- }, mouseMoveEvent);
47
- const newPan = myNVL.getPan();
48
- expect(newPan).not.toEqual(initialPan);
49
- resolve();
50
- });
48
+ expect(panCallbackMock).toHaveBeenCalledTimes(1);
49
+ expect(panCallbackMock).toHaveBeenCalledWith({
50
+ x: initialPan.x - 20,
51
+ y: initialPan.y - 20
52
+ }, mouseMoveEvent);
53
+ const newPan = myNVL.getPan();
54
+ expect(newPan).not.toEqual(initialPan);
51
55
  });
52
56
  test('panning should not work when clicking on nodes by default', () => {
53
57
  panInteraction.updateCallback('onPan', panCallbackMock);
@@ -150,7 +154,7 @@ describe('PanInteraction', () => {
150
154
  resolve();
151
155
  });
152
156
  });
153
- test('when controlledPan is false, should update NVL instance normally', () => {
157
+ test('when controlledPan is false, should update NVL instance normally', async () => {
154
158
  panInteraction.destroy();
155
159
  myNVL.destroy();
156
160
  myNVL = new NVL(document.createElement('div'), [
@@ -172,18 +176,17 @@ describe('PanInteraction', () => {
172
176
  const mouseUpEvent = new MouseEvent('mouseup');
173
177
  const container = myNVL.getContainer();
174
178
  container.dispatchEvent(mouseDownEvent);
179
+ await waitForAnimationFrame();
175
180
  container.dispatchEvent(mouseMoveEvent);
181
+ await waitForAnimationFrame();
176
182
  container.dispatchEvent(mouseUpEvent);
177
- return new Promise((resolve) => {
178
- expect(myNVL.getPan()).not.toEqual(initialPan);
179
- expect(myNVL.getPan()).toEqual({
180
- x: initialPan.x - 20,
181
- y: initialPan.y - 20
182
- });
183
- expect(panCallbackMock).toHaveBeenCalledTimes(1);
184
- panInteractionWithUpdate.destroy();
185
- resolve();
183
+ expect(myNVL.getPan()).not.toEqual(initialPan);
184
+ expect(myNVL.getPan()).toEqual({
185
+ x: initialPan.x - 20,
186
+ y: initialPan.y - 20
186
187
  });
188
+ expect(panCallbackMock).toHaveBeenCalledTimes(1);
189
+ panInteractionWithUpdate.destroy();
187
190
  });
188
191
  test('when controlledPan is true, should provide correct callback values for multiple operations', () => {
189
192
  panInteraction.destroy();
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,19 @@
1
+ import { isDraggingDistance, isDraggingMovement } from '../interaction-handlers/utils';
2
+ describe('interaction utils', () => {
3
+ test('returns true when movement reaches threshold on either axis', () => {
4
+ expect(isDraggingDistance({ x: 0, y: 0 }, { x: 10, y: 0 })).toBe(true);
5
+ expect(isDraggingDistance({ x: 0, y: 0 }, { x: 0, y: 10 })).toBe(true);
6
+ });
7
+ test('returns true for diagonal movement whose combined movement reaches threshold', () => {
8
+ expect(isDraggingDistance({ x: 0, y: 0 }, { x: 2, y: 2 })).toBe(false);
9
+ expect(isDraggingDistance({ x: 0, y: 0 }, { x: 7, y: 7 })).toBe(true);
10
+ expect(isDraggingDistance({ x: 0, y: 0 }, { x: 8, y: 8 })).toBe(true);
11
+ });
12
+ test('applies the same threshold logic to mouse events', () => {
13
+ expect(isDraggingMovement(new MouseEvent('mousemove', { clientX: 10, clientY: 0 }), { x: 0, y: 0 })).toBe(true);
14
+ expect(isDraggingMovement(new MouseEvent('mousemove', { clientX: 0, clientY: 10 }), { x: 0, y: 0 })).toBe(true);
15
+ expect(isDraggingMovement(new MouseEvent('mousemove', { clientX: 2, clientY: 2 }), { x: 0, y: 0 })).toBe(false);
16
+ expect(isDraggingMovement(new MouseEvent('mousemove', { clientX: 7, clientY: 7 }), { x: 0, y: 0 })).toBe(true);
17
+ expect(isDraggingMovement(new MouseEvent('mousemove', { clientX: 8, clientY: 8 }), { x: 0, y: 0 })).toBe(true);
18
+ });
19
+ });
package/lib/index.d.ts CHANGED
@@ -8,11 +8,13 @@ import { DrawInteraction } from './interaction-handlers/draw-interaction';
8
8
  import type { DrawInteractionCallbacks, DrawInteractionOptions } from './interaction-handlers/draw-interaction';
9
9
  import type { HoverInteractionCallbacks, HoverInteractionOptions } from './interaction-handlers/hover-interaction';
10
10
  import { HoverInteraction } from './interaction-handlers/hover-interaction';
11
+ import type { KeyboardInteractionCallbacks, KeyboardInteractionOptions } from './interaction-handlers/keyboard-interaction';
12
+ import { KeyboardInteraction } from './interaction-handlers/keyboard-interaction';
11
13
  import { LassoInteraction } from './interaction-handlers/lasso-interaction';
12
14
  import type { LassoInteractionCallbacks, LassoInteractionOptions } from './interaction-handlers/lasso-interaction';
13
15
  import type { PanInteractionCallbacks, PanInteractionOptions } from './interaction-handlers/pan-interaction';
14
16
  import { PanInteraction } from './interaction-handlers/pan-interaction';
15
17
  import type { ZoomInteractionCallbacks, ZoomInteractionOptions } from './interaction-handlers/zoom-interaction';
16
18
  import { ZoomInteraction } from './interaction-handlers/zoom-interaction';
17
- export type { BoxSelectInteractionOptions, BoxSelectInteractionCallbacks, ClickInteractionOptions, HoverInteractionOptions, HoverInteractionCallbacks, DragNodeInteractionCallbacks, ClickInteractionCallbacks, PanInteractionCallbacks, DrawInteractionCallbacks, PanInteractionOptions, ZoomInteractionCallbacks, ZoomInteractionOptions, LassoInteractionOptions, LassoInteractionCallbacks, DrawInteractionOptions };
18
- export { ZoomInteraction, PanInteraction, BoxSelectInteraction, ClickInteraction, HoverInteraction, DragNodeInteraction, DrawInteraction, LassoInteraction };
19
+ export type { BoxSelectInteractionOptions, BoxSelectInteractionCallbacks, ClickInteractionOptions, HoverInteractionOptions, HoverInteractionCallbacks, DragNodeInteractionCallbacks, ClickInteractionCallbacks, KeyboardInteractionCallbacks, KeyboardInteractionOptions, PanInteractionCallbacks, DrawInteractionCallbacks, PanInteractionOptions, ZoomInteractionCallbacks, ZoomInteractionOptions, LassoInteractionOptions, LassoInteractionCallbacks, DrawInteractionOptions };
20
+ export { ZoomInteraction, PanInteraction, BoxSelectInteraction, ClickInteraction, HoverInteraction, DragNodeInteraction, DrawInteraction, LassoInteraction, KeyboardInteraction };
package/lib/index.js CHANGED
@@ -3,7 +3,8 @@ import { ClickInteraction } from './interaction-handlers/click-interaction';
3
3
  import { DragNodeInteraction } from './interaction-handlers/drag-node-interaction';
4
4
  import { DrawInteraction } from './interaction-handlers/draw-interaction';
5
5
  import { HoverInteraction } from './interaction-handlers/hover-interaction';
6
+ import { KeyboardInteraction } from './interaction-handlers/keyboard-interaction';
6
7
  import { LassoInteraction } from './interaction-handlers/lasso-interaction';
7
8
  import { PanInteraction } from './interaction-handlers/pan-interaction';
8
9
  import { ZoomInteraction } from './interaction-handlers/zoom-interaction';
9
- export { ZoomInteraction, PanInteraction, BoxSelectInteraction, ClickInteraction, HoverInteraction, DragNodeInteraction, DrawInteraction, LassoInteraction };
10
+ export { ZoomInteraction, PanInteraction, BoxSelectInteraction, ClickInteraction, HoverInteraction, DragNodeInteraction, DrawInteraction, LassoInteraction, KeyboardInteraction };
@@ -5,7 +5,7 @@ import type { NVL } from '@neo4j-nvl/base';
5
5
  * @internal
6
6
  * @hidden
7
7
  */
8
- declare abstract class BaseInteraction<T extends Record<string, ((...args: unknown[]) => void) | boolean>, P extends Record<string, unknown>> {
8
+ declare abstract class BaseInteraction<P extends Record<string, unknown>> {
9
9
  private readonly nvl;
10
10
  private readonly options;
11
11
  private readonly container;
@@ -13,7 +13,7 @@ declare abstract class BaseInteraction<T extends Record<string, ((...args: unkno
13
13
  * @internal
14
14
  * @hidden
15
15
  */
16
- callbackMap: Map<keyof T, T[keyof T]>;
16
+ callbackMap: Map<string, ((...args: unknown[]) => void) | boolean>;
17
17
  /**
18
18
  * @internal
19
19
  * @hidden
@@ -48,18 +48,18 @@ declare abstract class BaseInteraction<T extends Record<string, ((...args: unkno
48
48
  * @internal
49
49
  * @hidden
50
50
  */
51
- callCallbackIfRegistered: (name: keyof T, ...args: unknown[]) => void;
51
+ callCallbackIfRegistered: (name: string, ...args: unknown[]) => void;
52
52
  /**
53
53
  * Add or update a callback for a given event of type.
54
54
  * @param name - The name of the event.
55
55
  * @param callback - The callback to be called when the event is triggered.
56
56
  */
57
- updateCallback: (name: keyof T | string, callback: T[keyof T]) => void;
57
+ updateCallback: (name: string, callback: ((...args: unknown[]) => void) | boolean) => void;
58
58
  /**
59
59
  * Remove a callback for a given event of type.
60
60
  * @param name - The name of the event.
61
61
  */
62
- removeCallback: (name: keyof T | string) => void;
62
+ removeCallback: (name: string) => void;
63
63
  /**
64
64
  * Enables or disables global text selection during a drag or pan operation.
65
65
  * @param enable - Whether to enable or disable global text selection.