@neo4j-nvl/interaction-handlers 1.1.0 → 1.2.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/CHANGELOG.md +25 -0
- package/lib/__tests__/keyboard-interaction.test.d.ts +1 -0
- package/lib/__tests__/keyboard-interaction.test.js +322 -0
- package/lib/__tests__/pan-interaction.test.js +24 -21
- package/lib/index.d.ts +4 -2
- package/lib/index.js +2 -1
- package/lib/interaction-handlers/base.d.ts +5 -5
- package/lib/interaction-handlers/box-select-interaction.d.ts +1 -1
- package/lib/interaction-handlers/click-interaction.d.ts +1 -1
- package/lib/interaction-handlers/drag-node-interaction.d.ts +1 -1
- package/lib/interaction-handlers/draw-interaction.d.ts +1 -1
- package/lib/interaction-handlers/hover-interaction.d.ts +1 -1
- package/lib/interaction-handlers/keyboard-interaction.d.ts +128 -0
- package/lib/interaction-handlers/keyboard-interaction.js +189 -0
- package/lib/interaction-handlers/lasso-interaction.d.ts +1 -1
- package/lib/interaction-handlers/pan-interaction.d.ts +1 -1
- package/lib/interaction-handlers/zoom-interaction.d.ts +1 -1
- package/package.json +3 -3
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.
|
|
@@ -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
|
+
});
|
|
@@ -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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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();
|
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<
|
|
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<
|
|
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:
|
|
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:
|
|
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:
|
|
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.
|
|
@@ -35,7 +35,7 @@ export type BoxSelectInteractionCallbacks = {
|
|
|
35
35
|
*
|
|
36
36
|
* For examples, head to the {@link https://neo4j.com/docs/nvl/current/interaction-handlers/#_boxselectinteraction Box Select Interaction documentation page}.
|
|
37
37
|
*/
|
|
38
|
-
export declare class BoxSelectInteraction extends BaseInteraction<
|
|
38
|
+
export declare class BoxSelectInteraction extends BaseInteraction<BoxSelectInteractionOptions> {
|
|
39
39
|
private mousePosition;
|
|
40
40
|
private startWorldPosition;
|
|
41
41
|
private overlayRenderer;
|
|
@@ -78,7 +78,7 @@ export type ClickInteractionCallbacks = {
|
|
|
78
78
|
*
|
|
79
79
|
* For examples, head to the {@link https://neo4j.com/docs/nvl/current/interaction-handlers/#_clickinteraction Click Interaction documentation page}.
|
|
80
80
|
*/
|
|
81
|
-
export declare class ClickInteraction extends BaseInteraction<
|
|
81
|
+
export declare class ClickInteraction extends BaseInteraction<ClickInteractionOptions> {
|
|
82
82
|
private moved;
|
|
83
83
|
private mousePosition;
|
|
84
84
|
/**
|
|
@@ -30,7 +30,7 @@ export type DragNodeInteractionCallbacks = {
|
|
|
30
30
|
*
|
|
31
31
|
* For examples, head to the {@link https://neo4j.com/docs/nvl/current/interaction-handlers/#_dragnodeinteraction Drag Node Interaction documentation page}.
|
|
32
32
|
*/
|
|
33
|
-
export declare class DragNodeInteraction extends BaseInteraction<
|
|
33
|
+
export declare class DragNodeInteraction extends BaseInteraction<Record<string, never>> {
|
|
34
34
|
private mousePosition;
|
|
35
35
|
private mouseDownNode;
|
|
36
36
|
private isDragging;
|
|
@@ -25,7 +25,7 @@ export type DrawInteractionOptions = {
|
|
|
25
25
|
* @internal
|
|
26
26
|
* @hidden
|
|
27
27
|
*/
|
|
28
|
-
export declare class DrawInteraction extends BaseInteraction<
|
|
28
|
+
export declare class DrawInteraction extends BaseInteraction<DrawInteractionOptions> {
|
|
29
29
|
private isMoved;
|
|
30
30
|
private isDrawing;
|
|
31
31
|
private isDraggingNode;
|
|
@@ -29,7 +29,7 @@ export type HoverInteractionCallbacks = {
|
|
|
29
29
|
*
|
|
30
30
|
* For examples, head to the {@link https://neo4j.com/docs/nvl/current/interaction-handlers/#_hoverinteraction Hover Interaction documentation page}.
|
|
31
31
|
*/
|
|
32
|
-
export declare class HoverInteraction extends BaseInteraction<
|
|
32
|
+
export declare class HoverInteraction extends BaseInteraction<HoverInteractionOptions> {
|
|
33
33
|
private currentHoveredElementId;
|
|
34
34
|
private currentHoveredElementIsNode;
|
|
35
35
|
private readonly updates;
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import type { NVL, Node, Relationship } from '@neo4j-nvl/base';
|
|
2
|
+
import { BaseInteraction } from './base';
|
|
3
|
+
/**
|
|
4
|
+
* Options for the keyboard interaction handler.
|
|
5
|
+
* Allows configuring which keys trigger specific navigation actions.
|
|
6
|
+
* Each key combo is an array of strings: the last element is the primary key (`event.key`),
|
|
7
|
+
* and any preceding elements are modifier keys (`'Shift'`, `'Ctrl'`, `'Alt'`, `'Meta'`).
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* ```ts
|
|
11
|
+
* import { KeyboardInteractionOptions } from '@neo4j-nvl/interaction-handlers'
|
|
12
|
+
*
|
|
13
|
+
* const options: KeyboardInteractionOptions = {
|
|
14
|
+
* navigateForwardKey: ['Tab'],
|
|
15
|
+
* contextMenuKey: ['Shift', 'F10']
|
|
16
|
+
* }
|
|
17
|
+
* ```
|
|
18
|
+
*/
|
|
19
|
+
export type KeyboardInteractionOptions = {
|
|
20
|
+
/** Key combo for entering the graph and focusing the first element. Default: `['Enter']` */
|
|
21
|
+
enterGraphKey?: string[];
|
|
22
|
+
/** Key combo for navigating focus to the next element. Default: `['Tab']` */
|
|
23
|
+
navigateForwardKey?: string[];
|
|
24
|
+
/** Key combo for navigating focus to the previous element. Default: `['Shift', 'Tab']` */
|
|
25
|
+
navigateBackwardKey?: string[];
|
|
26
|
+
/** Key combo for exiting the graph (blur focused element and container). Default: `['Escape']` */
|
|
27
|
+
exitGraphKey?: string[];
|
|
28
|
+
/** Key combo for opening the context menu on the focused element. Default: `['Shift', 'F10']` */
|
|
29
|
+
contextMenuKey?: string[];
|
|
30
|
+
};
|
|
31
|
+
/**
|
|
32
|
+
* Callbacks for the keyboard interaction handler.
|
|
33
|
+
*/
|
|
34
|
+
export type KeyboardInteractionCallbacks = {
|
|
35
|
+
/**
|
|
36
|
+
* Called when a key is pressed while the NVL container is focused.
|
|
37
|
+
* Fires for all key events, including those handled by the built-in navigation.
|
|
38
|
+
* @param event - The original keyboard event.
|
|
39
|
+
* @param focusedElement - The currently focused node or relationship, if any.
|
|
40
|
+
*/
|
|
41
|
+
onKeyDown?: ((event: KeyboardEvent, focusedElement?: Node | Relationship) => void) | boolean;
|
|
42
|
+
/**
|
|
43
|
+
* Called when a key is released while the NVL container is focused
|
|
44
|
+
* @param event - The original keyboard event.
|
|
45
|
+
* @param focusedElement - The currently focused node or relationship, if any.
|
|
46
|
+
*/
|
|
47
|
+
onKeyUp?: ((event: KeyboardEvent, focusedElement?: Node | Relationship) => void) | boolean;
|
|
48
|
+
/**
|
|
49
|
+
* Called when a node receives focus (via keyboard navigation)
|
|
50
|
+
* @param node - The node that received focus.
|
|
51
|
+
* @param event - The original keyboard event that triggered the focus change.
|
|
52
|
+
*/
|
|
53
|
+
onNodeFocus?: ((node: Node, event: KeyboardEvent) => void) | boolean;
|
|
54
|
+
/**
|
|
55
|
+
* Called when a node loses focus (via keyboard navigation)
|
|
56
|
+
* @param node - The node that lost focus.
|
|
57
|
+
* @param event - The original keyboard event that triggered the focus change.
|
|
58
|
+
*/
|
|
59
|
+
onNodeBlur?: ((node: Node, event: KeyboardEvent) => void) | boolean;
|
|
60
|
+
/**
|
|
61
|
+
* Called when a relationship receives focus (via keyboard navigation)
|
|
62
|
+
* @param relationship - The relationship that received focus.
|
|
63
|
+
* @param event - The original keyboard event that triggered the focus change.
|
|
64
|
+
*/
|
|
65
|
+
onRelationshipFocus?: ((relationship: Relationship, event: KeyboardEvent) => void) | boolean;
|
|
66
|
+
/**
|
|
67
|
+
* Called when a relationship loses focus (via keyboard navigation)
|
|
68
|
+
* @param relationship - The relationship that lost focus.
|
|
69
|
+
* @param event - The original keyboard event that triggered the focus change.
|
|
70
|
+
*/
|
|
71
|
+
onRelationshipBlur?: ((relationship: Relationship, event: KeyboardEvent) => void) | boolean;
|
|
72
|
+
/**
|
|
73
|
+
* Called when the NVL canvas container receives DOM focus
|
|
74
|
+
* @param event - The original focus event.
|
|
75
|
+
*/
|
|
76
|
+
onCanvasFocus?: ((event: FocusEvent) => void) | boolean;
|
|
77
|
+
/**
|
|
78
|
+
* Called when the NVL canvas container loses DOM focus
|
|
79
|
+
* @param event - The original focus event.
|
|
80
|
+
*/
|
|
81
|
+
onCanvasBlur?: ((event: FocusEvent) => void) | boolean;
|
|
82
|
+
/**
|
|
83
|
+
* Called when the context menu key is pressed while the NVL container is focused.
|
|
84
|
+
* @param element - The focused node or relationship, or undefined if the canvas itself is focused.
|
|
85
|
+
* @param event - The original keyboard event.
|
|
86
|
+
*/
|
|
87
|
+
onContextMenu?: ((element: Node | Relationship | undefined, event: KeyboardEvent) => void) | boolean;
|
|
88
|
+
};
|
|
89
|
+
/**
|
|
90
|
+
* Opinionated interaction handler for keyboard-driven focus navigation
|
|
91
|
+
* through graph elements (nodes and relationships).
|
|
92
|
+
*
|
|
93
|
+
* By default, Tab / Shift+Tab cycle focus through elements, Escape exits the
|
|
94
|
+
* graph, and Shift+F10 call the `onContextMenu` callback with the focused element.
|
|
95
|
+
* All keys can be reconfigured via {@link KeyboardInteractionOptions}.
|
|
96
|
+
*
|
|
97
|
+
* Callbacks are fired for focus/blur transitions and context-menu activation,
|
|
98
|
+
* following the same one-way data-flow pattern as the other interaction handlers.
|
|
99
|
+
*/
|
|
100
|
+
export declare class KeyboardInteraction extends BaseInteraction<KeyboardInteractionOptions> {
|
|
101
|
+
private focusedElementId;
|
|
102
|
+
private focusedElementIsNode;
|
|
103
|
+
private focusableElements;
|
|
104
|
+
private addedTabindex;
|
|
105
|
+
private resolvedOptions;
|
|
106
|
+
/**
|
|
107
|
+
* Creates a new keyboard interaction handler.
|
|
108
|
+
* @param nvl - The NVL instance to attach the interaction handler to.
|
|
109
|
+
* @param options - Options for the keyboard interaction handler.
|
|
110
|
+
*/
|
|
111
|
+
constructor(nvl: NVL, options?: KeyboardInteractionOptions);
|
|
112
|
+
private buildFocusableElements;
|
|
113
|
+
/**
|
|
114
|
+
* Returns the currently focused element, if any.
|
|
115
|
+
*/
|
|
116
|
+
getFocused(): Node | Relationship | undefined;
|
|
117
|
+
private handleFocusIn;
|
|
118
|
+
private handleFocusOut;
|
|
119
|
+
private handleKeyDown;
|
|
120
|
+
private handleKeyUp;
|
|
121
|
+
private navigateFocus;
|
|
122
|
+
private setFocus;
|
|
123
|
+
private clearFocus;
|
|
124
|
+
/**
|
|
125
|
+
* Removes all related event listeners from the container.
|
|
126
|
+
*/
|
|
127
|
+
destroy(): void;
|
|
128
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { BaseInteraction } from './base';
|
|
2
|
+
const DEFAULT_OPTIONS = {
|
|
3
|
+
enterGraphKey: ['Enter'],
|
|
4
|
+
navigateForwardKey: ['Tab'],
|
|
5
|
+
navigateBackwardKey: ['Shift', 'Tab'],
|
|
6
|
+
exitGraphKey: ['Escape'],
|
|
7
|
+
contextMenuKey: ['Shift', 'F10']
|
|
8
|
+
};
|
|
9
|
+
/**
|
|
10
|
+
* Checks whether a keyboard event matches a key combo array.
|
|
11
|
+
* The last element is the primary key (`event.key`), preceding elements are modifiers.
|
|
12
|
+
* Examples: `['Tab']`, `['Shift', 'Tab']`, `['Shift', 'F10']`
|
|
13
|
+
*/
|
|
14
|
+
const matchesKeyCombo = (event, keyCombo) => {
|
|
15
|
+
const key = keyCombo[keyCombo.length - 1];
|
|
16
|
+
const requireShift = keyCombo.includes('Shift');
|
|
17
|
+
const requireCtrl = keyCombo.includes('Ctrl');
|
|
18
|
+
const requireAlt = keyCombo.includes('Alt');
|
|
19
|
+
const requireMeta = keyCombo.includes('Meta');
|
|
20
|
+
return (event.key === key &&
|
|
21
|
+
event.shiftKey === requireShift &&
|
|
22
|
+
event.ctrlKey === requireCtrl &&
|
|
23
|
+
event.altKey === requireAlt &&
|
|
24
|
+
event.metaKey === requireMeta);
|
|
25
|
+
};
|
|
26
|
+
/**
|
|
27
|
+
* Opinionated interaction handler for keyboard-driven focus navigation
|
|
28
|
+
* through graph elements (nodes and relationships).
|
|
29
|
+
*
|
|
30
|
+
* By default, Tab / Shift+Tab cycle focus through elements, Escape exits the
|
|
31
|
+
* graph, and Shift+F10 call the `onContextMenu` callback with the focused element.
|
|
32
|
+
* All keys can be reconfigured via {@link KeyboardInteractionOptions}.
|
|
33
|
+
*
|
|
34
|
+
* Callbacks are fired for focus/blur transitions and context-menu activation,
|
|
35
|
+
* following the same one-way data-flow pattern as the other interaction handlers.
|
|
36
|
+
*/
|
|
37
|
+
export class KeyboardInteraction extends BaseInteraction {
|
|
38
|
+
focusedElementId = undefined;
|
|
39
|
+
focusedElementIsNode = undefined;
|
|
40
|
+
focusableElements = [];
|
|
41
|
+
addedTabindex = false;
|
|
42
|
+
resolvedOptions;
|
|
43
|
+
/**
|
|
44
|
+
* Creates a new keyboard interaction handler.
|
|
45
|
+
* @param nvl - The NVL instance to attach the interaction handler to.
|
|
46
|
+
* @param options - Options for the keyboard interaction handler.
|
|
47
|
+
*/
|
|
48
|
+
constructor(nvl, options = {}) {
|
|
49
|
+
super(nvl, options);
|
|
50
|
+
this.resolvedOptions = { ...DEFAULT_OPTIONS, ...options };
|
|
51
|
+
// Ensure the container is focusable for keyboard events
|
|
52
|
+
const tabindex = this.containerInstance.getAttribute('tabindex');
|
|
53
|
+
if (tabindex === null || tabindex === '') {
|
|
54
|
+
this.containerInstance.setAttribute('tabindex', '0');
|
|
55
|
+
this.addedTabindex = true;
|
|
56
|
+
}
|
|
57
|
+
this.addEventListener('keydown', this.handleKeyDown);
|
|
58
|
+
this.addEventListener('keyup', this.handleKeyUp);
|
|
59
|
+
this.addEventListener('focusin', this.handleFocusIn);
|
|
60
|
+
this.addEventListener('focusout', this.handleFocusOut);
|
|
61
|
+
}
|
|
62
|
+
buildFocusableElements() {
|
|
63
|
+
const nodes = this.nvlInstance.getNodes();
|
|
64
|
+
const relationships = this.nvlInstance.getRelationships();
|
|
65
|
+
this.focusableElements = [
|
|
66
|
+
...nodes.map((node) => ({ id: node.id, isNode: true })),
|
|
67
|
+
...relationships.map((rel) => ({ id: rel.id, isNode: false }))
|
|
68
|
+
];
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Returns the currently focused element, if any.
|
|
72
|
+
*/
|
|
73
|
+
getFocused() {
|
|
74
|
+
if (this.focusedElementId === undefined) {
|
|
75
|
+
return undefined;
|
|
76
|
+
}
|
|
77
|
+
if (this.focusedElementIsNode === true) {
|
|
78
|
+
return this.nvlInstance.getNodes().find((n) => n.id === this.focusedElementId);
|
|
79
|
+
}
|
|
80
|
+
return this.nvlInstance.getRelationships().find((r) => r.id === this.focusedElementId);
|
|
81
|
+
}
|
|
82
|
+
handleFocusIn = (event) => {
|
|
83
|
+
this.callCallbackIfRegistered('onCanvasFocus', event);
|
|
84
|
+
};
|
|
85
|
+
handleFocusOut = (event) => {
|
|
86
|
+
this.callCallbackIfRegistered('onCanvasBlur', event);
|
|
87
|
+
};
|
|
88
|
+
handleKeyDown = (event) => {
|
|
89
|
+
const focusedElement = this.getFocused();
|
|
90
|
+
this.callCallbackIfRegistered('onKeyDown', event, focusedElement);
|
|
91
|
+
const { enterGraphKey, navigateForwardKey, navigateBackwardKey, exitGraphKey, contextMenuKey } = this.resolvedOptions;
|
|
92
|
+
if (matchesKeyCombo(event, enterGraphKey)) {
|
|
93
|
+
if (focusedElement === undefined) {
|
|
94
|
+
event.preventDefault();
|
|
95
|
+
this.navigateFocus(1, event);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
else if (matchesKeyCombo(event, navigateForwardKey)) {
|
|
99
|
+
if (focusedElement !== undefined) {
|
|
100
|
+
event.preventDefault();
|
|
101
|
+
this.navigateFocus(1, event);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
else if (matchesKeyCombo(event, navigateBackwardKey)) {
|
|
105
|
+
if (focusedElement !== undefined) {
|
|
106
|
+
event.preventDefault();
|
|
107
|
+
this.navigateFocus(-1, event);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
else if (matchesKeyCombo(event, exitGraphKey)) {
|
|
111
|
+
if (focusedElement !== undefined) {
|
|
112
|
+
event.preventDefault();
|
|
113
|
+
this.clearFocus(event);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
else if (matchesKeyCombo(event, contextMenuKey)) {
|
|
117
|
+
event.preventDefault();
|
|
118
|
+
this.callCallbackIfRegistered('onContextMenu', focusedElement, event);
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
handleKeyUp = (event) => {
|
|
122
|
+
const focusedElement = this.getFocused();
|
|
123
|
+
this.callCallbackIfRegistered('onKeyUp', event, focusedElement);
|
|
124
|
+
};
|
|
125
|
+
navigateFocus(direction, event) {
|
|
126
|
+
this.buildFocusableElements();
|
|
127
|
+
if (this.focusableElements.length === 0) {
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
const currentIndex = this.focusedElementId !== undefined
|
|
131
|
+
? this.focusableElements.findIndex((el) => el.id === this.focusedElementId && el.isNode === this.focusedElementIsNode)
|
|
132
|
+
: -1;
|
|
133
|
+
let nextIndex;
|
|
134
|
+
if (currentIndex === -1) {
|
|
135
|
+
nextIndex = direction === 1 ? 0 : this.focusableElements.length - 1;
|
|
136
|
+
}
|
|
137
|
+
else {
|
|
138
|
+
nextIndex = (currentIndex + direction + this.focusableElements.length) % this.focusableElements.length;
|
|
139
|
+
}
|
|
140
|
+
const nextElement = this.focusableElements[nextIndex];
|
|
141
|
+
if (nextElement) {
|
|
142
|
+
this.setFocus(nextElement.id, nextElement.isNode, event);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
setFocus(elementId, isNode, event) {
|
|
146
|
+
this.clearFocus(event);
|
|
147
|
+
// Update focus state
|
|
148
|
+
this.focusedElementId = elementId;
|
|
149
|
+
this.focusedElementIsNode = isNode;
|
|
150
|
+
// Notify about the newly focused element
|
|
151
|
+
const newElement = this.getFocused();
|
|
152
|
+
if (newElement !== undefined) {
|
|
153
|
+
if (isNode) {
|
|
154
|
+
this.callCallbackIfRegistered('onNodeFocus', newElement, event);
|
|
155
|
+
}
|
|
156
|
+
else {
|
|
157
|
+
this.callCallbackIfRegistered('onRelationshipFocus', newElement, event);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
clearFocus(event) {
|
|
162
|
+
const previousElement = this.getFocused();
|
|
163
|
+
if (previousElement !== undefined) {
|
|
164
|
+
if (this.focusedElementIsNode === true) {
|
|
165
|
+
this.callCallbackIfRegistered('onNodeBlur', previousElement, event);
|
|
166
|
+
}
|
|
167
|
+
else {
|
|
168
|
+
this.callCallbackIfRegistered('onRelationshipBlur', previousElement, event);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
this.focusedElementId = undefined;
|
|
172
|
+
this.focusedElementIsNode = undefined;
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Removes all related event listeners from the container.
|
|
176
|
+
*/
|
|
177
|
+
destroy() {
|
|
178
|
+
this.removeEventListener('keydown', this.handleKeyDown);
|
|
179
|
+
this.removeEventListener('keyup', this.handleKeyUp);
|
|
180
|
+
this.removeEventListener('focusin', this.handleFocusIn);
|
|
181
|
+
this.removeEventListener('focusout', this.handleFocusOut);
|
|
182
|
+
if (this.addedTabindex) {
|
|
183
|
+
this.containerInstance.removeAttribute('tabindex');
|
|
184
|
+
}
|
|
185
|
+
this.focusedElementId = undefined;
|
|
186
|
+
this.focusedElementIsNode = undefined;
|
|
187
|
+
this.focusableElements = [];
|
|
188
|
+
}
|
|
189
|
+
}
|
|
@@ -58,7 +58,7 @@ export declare const checkPointInside: (x: number, y: number, vs: Point[]) => bo
|
|
|
58
58
|
*
|
|
59
59
|
* For examples, head to the {@link https://neo4j.com/docs/nvl/current/interaction-handlers/#_lassointeraction Lasso Interaction documentation page}.
|
|
60
60
|
*/
|
|
61
|
-
export declare class LassoInteraction extends BaseInteraction<
|
|
61
|
+
export declare class LassoInteraction extends BaseInteraction<LassoInteractionOptions> {
|
|
62
62
|
private active;
|
|
63
63
|
private points;
|
|
64
64
|
private overlayRenderer;
|
|
@@ -36,7 +36,7 @@ export type PanInteractionCallbacks = {
|
|
|
36
36
|
*
|
|
37
37
|
* For examples, head to the {@link https://neo4j.com/docs/nvl/current/interaction-handlers/#_paninteraction Pan Interaction documentation page}.
|
|
38
38
|
*/
|
|
39
|
-
export declare class PanInteraction extends BaseInteraction<
|
|
39
|
+
export declare class PanInteraction extends BaseInteraction<PanInteractionOptions> {
|
|
40
40
|
private initialMousePosition;
|
|
41
41
|
private initialPan;
|
|
42
42
|
private targets;
|
|
@@ -39,7 +39,7 @@ export type ZoomInteractionCallbacks = {
|
|
|
39
39
|
*
|
|
40
40
|
* For examples, head to the {@link https://neo4j.com/docs/nvl/current/interaction-handlers/#_zoominteraction Zoom Interaction documentation page}.
|
|
41
41
|
*/
|
|
42
|
-
export declare class ZoomInteraction extends BaseInteraction<
|
|
42
|
+
export declare class ZoomInteraction extends BaseInteraction<ZoomInteractionOptions> {
|
|
43
43
|
private zoomLimits;
|
|
44
44
|
/**
|
|
45
45
|
* Creates a new instance of the zoom interaction handler.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@neo4j-nvl/interaction-handlers",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"license": "SEE LICENSE IN 'LICENSE.txt'",
|
|
5
5
|
"homepage": "https://neo4j.com/docs/nvl/current/",
|
|
6
6
|
"description": "Interaction handlers for the Neo4j Visualization Library",
|
|
@@ -27,9 +27,9 @@
|
|
|
27
27
|
"eslint": "yarn global:eslint ./src/"
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
|
-
"@neo4j-nvl/base": "1.
|
|
30
|
+
"@neo4j-nvl/base": "1.2.0",
|
|
31
31
|
"concaveman": "1.2.1",
|
|
32
|
-
"lodash": "4.
|
|
32
|
+
"lodash": "4.18.1"
|
|
33
33
|
},
|
|
34
34
|
"devDependencies": {
|
|
35
35
|
"@testing-library/jest-dom": "5.17.0",
|