@flighthq/interaction 0.2.0 → 0.2.1-next.410.a23f189

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.
Files changed (74) hide show
  1. package/dist/cursorBackend.d.ts +12 -0
  2. package/dist/cursorBackend.d.ts.map +1 -0
  3. package/dist/cursorBackend.js +17 -0
  4. package/dist/cursorBackend.js.map +1 -0
  5. package/dist/displayHitTests.d.ts +10 -11
  6. package/dist/displayHitTests.d.ts.map +1 -1
  7. package/dist/displayHitTests.js +12 -14
  8. package/dist/displayHitTests.js.map +1 -1
  9. package/dist/enableInteractionGuards.d.ts +23 -0
  10. package/dist/enableInteractionGuards.d.ts.map +1 -0
  11. package/dist/enableInteractionGuards.js +75 -0
  12. package/dist/enableInteractionGuards.js.map +1 -0
  13. package/dist/focusManager.d.ts +46 -0
  14. package/dist/focusManager.d.ts.map +1 -0
  15. package/dist/focusManager.js +243 -0
  16. package/dist/focusManager.js.map +1 -0
  17. package/dist/hitTests.d.ts +45 -24
  18. package/dist/hitTests.d.ts.map +1 -1
  19. package/dist/hitTests.js +164 -47
  20. package/dist/hitTests.js.map +1 -1
  21. package/dist/index.d.ts +9 -1
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +9 -1
  24. package/dist/index.js.map +1 -1
  25. package/dist/interactionManager.d.ts +10 -2
  26. package/dist/interactionManager.d.ts.map +1 -1
  27. package/dist/interactionManager.js +58 -6
  28. package/dist/interactionManager.js.map +1 -1
  29. package/dist/interactionSpatialIndex.d.ts +17 -0
  30. package/dist/interactionSpatialIndex.d.ts.map +1 -0
  31. package/dist/interactionSpatialIndex.js +77 -0
  32. package/dist/interactionSpatialIndex.js.map +1 -0
  33. package/dist/nodeInteractionState.d.ts +39 -0
  34. package/dist/nodeInteractionState.d.ts.map +1 -0
  35. package/dist/nodeInteractionState.js +72 -0
  36. package/dist/nodeInteractionState.js.map +1 -0
  37. package/dist/registerBitmapHitTest.d.ts +11 -0
  38. package/dist/registerBitmapHitTest.d.ts.map +1 -0
  39. package/dist/registerBitmapHitTest.js +55 -0
  40. package/dist/registerBitmapHitTest.js.map +1 -0
  41. package/dist/registerDefaultHitTests.d.ts +2 -0
  42. package/dist/registerDefaultHitTests.d.ts.map +1 -0
  43. package/dist/registerDefaultHitTests.js +20 -0
  44. package/dist/registerDefaultHitTests.js.map +1 -0
  45. package/dist/registerShapeHitTest.d.ts +10 -0
  46. package/dist/registerShapeHitTest.d.ts.map +1 -0
  47. package/dist/registerShapeHitTest.js +32 -0
  48. package/dist/registerShapeHitTest.js.map +1 -0
  49. package/dist/registerTextHitTest.d.ts +12 -0
  50. package/dist/registerTextHitTest.d.ts.map +1 -0
  51. package/dist/registerTextHitTest.js +32 -0
  52. package/dist/registerTextHitTest.js.map +1 -0
  53. package/dist/spriteHitTests.d.ts +3 -3
  54. package/dist/spriteHitTests.d.ts.map +1 -1
  55. package/dist/spriteHitTests.js +5 -5
  56. package/dist/spriteHitTests.js.map +1 -1
  57. package/package.json +13 -7
  58. package/src/cursorBackend.test.ts +24 -0
  59. package/src/displayHitTests.test.ts +39 -47
  60. package/src/enableInteractionGuards.test.ts +102 -0
  61. package/src/focusManager.test.ts +307 -0
  62. package/src/hitTests.test.ts +235 -98
  63. package/src/interactionManager.test.ts +59 -8
  64. package/src/interactionSpatialIndex.test.ts +103 -0
  65. package/src/nodeInteractionState.test.ts +141 -0
  66. package/src/registerBitmapHitTest.test.ts +22 -0
  67. package/src/{registerDefaultHitTestPoints.test.ts → registerDefaultHitTests.test.ts} +10 -7
  68. package/src/registerShapeHitTest.test.ts +27 -0
  69. package/src/registerTextHitTest.test.ts +54 -0
  70. package/src/spriteHitTests.test.ts +15 -15
  71. package/dist/registerDefaultHitTestPoints.d.ts +0 -2
  72. package/dist/registerDefaultHitTestPoints.d.ts.map +0 -1
  73. package/dist/registerDefaultHitTestPoints.js +0 -21
  74. package/dist/registerDefaultHitTestPoints.js.map +0 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flighthq/interaction",
3
- "version": "0.2.0",
3
+ "version": "0.2.1-next.410.a23f189",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/flighthq/flight.git",
@@ -32,14 +32,20 @@
32
32
  "clean:dist": "tsx ../../scripts/clean-package-dist.ts"
33
33
  },
34
34
  "dependencies": {
35
- "@flighthq/geometry": "0.2.0",
36
- "@flighthq/node": "0.2.0",
37
- "@flighthq/displayobject": "0.2.0",
38
- "@flighthq/signals": "0.2.0",
39
- "@flighthq/types": "0.2.0"
35
+ "@flighthq/geometry": "0.2.1-next.410.a23f189",
36
+ "@flighthq/node": "0.2.1-next.410.a23f189",
37
+ "@flighthq/displayobject": "0.2.1-next.410.a23f189",
38
+ "@flighthq/log": "0.2.1-next.410.a23f189",
39
+ "@flighthq/path": "0.2.1-next.410.a23f189",
40
+ "@flighthq/shape": "0.2.1-next.410.a23f189",
41
+ "@flighthq/spatial": "0.2.1-next.410.a23f189",
42
+ "@flighthq/signals": "0.2.1-next.410.a23f189",
43
+ "@flighthq/surface": "0.2.1-next.410.a23f189",
44
+ "@flighthq/text": "0.2.1-next.410.a23f189",
45
+ "@flighthq/textlayout": "0.2.1-next.410.a23f189",
46
+ "@flighthq/types": "0.2.1-next.410.a23f189"
40
47
  },
41
48
  "devDependencies": {
42
- "@flighthq/shape": "*",
43
49
  "@flighthq/sprite": "*",
44
50
  "typescript": "^5.3.0"
45
51
  },
@@ -0,0 +1,24 @@
1
+ import type { Cursor } from '@flighthq/types';
2
+
3
+ import { createWebCursorBackend } from './cursorBackend';
4
+
5
+ describe('createWebCursorBackend', () => {
6
+ function fakeElement(): { style: { cursor: string } } {
7
+ return { style: { cursor: 'init' } };
8
+ }
9
+
10
+ it('writes the cursor value to element.style.cursor', () => {
11
+ const el = fakeElement();
12
+ const backend = createWebCursorBackend(el as unknown as HTMLElement);
13
+ backend.setCursor('pointer' as Cursor);
14
+ expect(el.style.cursor).toBe('pointer');
15
+ });
16
+
17
+ it('clears to the empty string when given null', () => {
18
+ const el = fakeElement();
19
+ const backend = createWebCursorBackend(el as unknown as HTMLElement);
20
+ backend.setCursor('grab' as Cursor);
21
+ backend.setCursor(null);
22
+ expect(el.style.cursor).toBe('');
23
+ });
24
+ });
@@ -3,17 +3,16 @@ import { setRectangle } from '@flighthq/geometry';
3
3
  import { getNodeLocalBoundsRectangle } from '@flighthq/node';
4
4
 
5
5
  import {
6
- defaultBitmapHitTestPointHandler,
7
- defaultDisplayObjectHitTestPointHandler,
8
- defaultHtmlViewHitTestPointHandler,
9
- defaultMovieClipHitTestPointHandler,
10
- defaultRenderViewHitTestPointHandler,
11
- defaultRichTextHitTestPointHandler,
12
- defaultShapeHitTestPointHandler,
13
- defaultStageHitTestPointHandler,
14
- defaultTextHitTestPointHandler,
15
- defaultTextInputHitTestPointHandler,
16
- defaultVideoHitTestPointHandler,
6
+ defaultBitmapHitTestHandler,
7
+ defaultDisplayObjectHitTestHandler,
8
+ defaultHtmlViewHitTestHandler,
9
+ defaultMovieClipHitTestHandler,
10
+ defaultRenderViewHitTestHandler,
11
+ defaultRichTextHitTestHandler,
12
+ defaultShapeHitTestHandler,
13
+ defaultTextHitTestHandler,
14
+ defaultTextInputHitTestHandler,
15
+ defaultVideoHitTestHandler,
17
16
  } from './displayHitTests';
18
17
 
19
18
  function makeDisplayObject() {
@@ -22,116 +21,109 @@ function makeDisplayObject() {
22
21
  return obj;
23
22
  }
24
23
 
25
- describe('defaultBitmapHitTestPointHandler', () => {
24
+ describe('defaultBitmapHitTestHandler', () => {
26
25
  it('returns true when point is within local bounds', () => {
27
26
  const obj = makeDisplayObject();
28
- expect(defaultBitmapHitTestPointHandler(obj, 50, 50, false)).toBe(true);
27
+ expect(defaultBitmapHitTestHandler(obj, 50, 50)).toBe(true);
29
28
  });
30
29
 
31
30
  it('returns false when point is outside local bounds', () => {
32
31
  const obj = makeDisplayObject();
33
- expect(defaultBitmapHitTestPointHandler(obj, 200, 200, false)).toBe(false);
32
+ expect(defaultBitmapHitTestHandler(obj, 200, 200)).toBe(false);
34
33
  });
35
34
  });
36
35
 
37
- describe('defaultDisplayObjectHitTestPointHandler', () => {
36
+ describe('defaultDisplayObjectHitTestHandler', () => {
38
37
  it('always returns false — plain display objects have no hit geometry', () => {
39
38
  const obj = makeDisplayObject();
40
- expect(defaultDisplayObjectHitTestPointHandler(obj, 50, 50, false)).toBe(false);
41
- expect(defaultDisplayObjectHitTestPointHandler(obj, 0, 0, false)).toBe(false);
42
- expect(defaultDisplayObjectHitTestPointHandler(obj, 200, 200, false)).toBe(false);
39
+ expect(defaultDisplayObjectHitTestHandler(obj, 50, 50)).toBe(false);
40
+ expect(defaultDisplayObjectHitTestHandler(obj, 0, 0)).toBe(false);
41
+ expect(defaultDisplayObjectHitTestHandler(obj, 200, 200)).toBe(false);
43
42
  });
44
43
  });
45
44
 
46
- describe('defaultHtmlViewHitTestPointHandler', () => {
45
+ describe('defaultHtmlViewHitTestHandler', () => {
47
46
  it('always returns false — browser manages HtmlView hit testing', () => {
48
47
  const obj = makeDisplayObject();
49
- expect(defaultHtmlViewHitTestPointHandler(obj, 50, 50, false)).toBe(false);
48
+ expect(defaultHtmlViewHitTestHandler(obj, 50, 50)).toBe(false);
50
49
  });
51
50
  });
52
51
 
53
- describe('defaultMovieClipHitTestPointHandler', () => {
52
+ describe('defaultMovieClipHitTestHandler', () => {
54
53
  it('always returns false — containers have no self hit area', () => {
55
54
  const obj = makeDisplayObject();
56
- expect(defaultMovieClipHitTestPointHandler(obj, 50, 50, false)).toBe(false);
55
+ expect(defaultMovieClipHitTestHandler(obj, 50, 50)).toBe(false);
57
56
  });
58
57
  });
59
58
 
60
- describe('defaultRenderViewHitTestPointHandler', () => {
59
+ describe('defaultRenderViewHitTestHandler', () => {
61
60
  it('returns true when point is within local bounds', () => {
62
61
  const obj = makeDisplayObject();
63
- expect(defaultRenderViewHitTestPointHandler(obj, 50, 50, false)).toBe(true);
62
+ expect(defaultRenderViewHitTestHandler(obj, 50, 50)).toBe(true);
64
63
  });
65
64
 
66
65
  it('returns false when point is outside local bounds', () => {
67
66
  const obj = makeDisplayObject();
68
- expect(defaultRenderViewHitTestPointHandler(obj, 200, 200, false)).toBe(false);
67
+ expect(defaultRenderViewHitTestHandler(obj, 200, 200)).toBe(false);
69
68
  });
70
69
  });
71
70
 
72
- describe('defaultRichTextHitTestPointHandler', () => {
71
+ describe('defaultRichTextHitTestHandler', () => {
73
72
  it('returns true when point is within local bounds', () => {
74
73
  const obj = makeDisplayObject();
75
- expect(defaultRichTextHitTestPointHandler(obj, 50, 50, false)).toBe(true);
74
+ expect(defaultRichTextHitTestHandler(obj, 50, 50)).toBe(true);
76
75
  });
77
76
 
78
77
  it('returns false when point is outside local bounds', () => {
79
78
  const obj = makeDisplayObject();
80
- expect(defaultRichTextHitTestPointHandler(obj, 200, 200, false)).toBe(false);
79
+ expect(defaultRichTextHitTestHandler(obj, 200, 200)).toBe(false);
81
80
  });
82
81
  });
83
82
 
84
- describe('defaultShapeHitTestPointHandler', () => {
83
+ describe('defaultShapeHitTestHandler', () => {
85
84
  it('returns true when point is within local bounds', () => {
86
85
  const obj = makeDisplayObject();
87
- expect(defaultShapeHitTestPointHandler(obj, 50, 50, false)).toBe(true);
86
+ expect(defaultShapeHitTestHandler(obj, 50, 50)).toBe(true);
88
87
  });
89
88
 
90
89
  it('returns false when point is outside local bounds', () => {
91
90
  const obj = makeDisplayObject();
92
- expect(defaultShapeHitTestPointHandler(obj, 200, 200, false)).toBe(false);
91
+ expect(defaultShapeHitTestHandler(obj, 200, 200)).toBe(false);
93
92
  });
94
93
  });
95
94
 
96
- describe('defaultStageHitTestPointHandler', () => {
97
- it('always returns false — containers have no self hit area', () => {
98
- const obj = makeDisplayObject();
99
- expect(defaultStageHitTestPointHandler(obj, 50, 50, false)).toBe(false);
100
- });
101
- });
102
-
103
- describe('defaultTextHitTestPointHandler', () => {
95
+ describe('defaultTextHitTestHandler', () => {
104
96
  it('returns true when point is within local bounds', () => {
105
97
  const obj = makeDisplayObject();
106
- expect(defaultTextHitTestPointHandler(obj, 50, 50, false)).toBe(true);
98
+ expect(defaultTextHitTestHandler(obj, 50, 50)).toBe(true);
107
99
  });
108
100
 
109
101
  it('returns false when point is outside local bounds', () => {
110
102
  const obj = makeDisplayObject();
111
- expect(defaultTextHitTestPointHandler(obj, 200, 200, false)).toBe(false);
103
+ expect(defaultTextHitTestHandler(obj, 200, 200)).toBe(false);
112
104
  });
113
105
  });
114
106
 
115
- describe('defaultTextInputHitTestPointHandler', () => {
107
+ describe('defaultTextInputHitTestHandler', () => {
116
108
  it('returns true when point is within local bounds', () => {
117
109
  const obj = makeDisplayObject();
118
- expect(defaultTextInputHitTestPointHandler(obj, 50, 50, false)).toBe(true);
110
+ expect(defaultTextInputHitTestHandler(obj, 50, 50)).toBe(true);
119
111
  });
120
112
 
121
113
  it('returns false when point is outside local bounds', () => {
122
114
  const obj = makeDisplayObject();
123
- expect(defaultTextInputHitTestPointHandler(obj, 200, 200, false)).toBe(false);
115
+ expect(defaultTextInputHitTestHandler(obj, 200, 200)).toBe(false);
124
116
  });
125
117
  });
126
118
 
127
- describe('defaultVideoHitTestPointHandler', () => {
119
+ describe('defaultVideoHitTestHandler', () => {
128
120
  it('returns true when point is within local bounds', () => {
129
121
  const obj = makeDisplayObject();
130
- expect(defaultVideoHitTestPointHandler(obj, 50, 50, false)).toBe(true);
122
+ expect(defaultVideoHitTestHandler(obj, 50, 50)).toBe(true);
131
123
  });
132
124
 
133
125
  it('returns false when point is outside local bounds', () => {
134
126
  const obj = makeDisplayObject();
135
- expect(defaultVideoHitTestPointHandler(obj, 200, 200, false)).toBe(false);
127
+ expect(defaultVideoHitTestHandler(obj, 200, 200)).toBe(false);
136
128
  });
137
129
  });
@@ -0,0 +1,102 @@
1
+ import { createDisplayObject } from '@flighthq/displayobject';
2
+ import { addLogSink, createMemoryLogSink, getMemoryLogSinkEntries, removeLogSink } from '@flighthq/log';
3
+ import { addNodeChild } from '@flighthq/node';
4
+
5
+ import {
6
+ disableInteractionGuards,
7
+ enableInteractionGuards,
8
+ explainInteractionHitEligibility,
9
+ } from './enableInteractionGuards';
10
+ import { connectInteractionSignal, createInteractionManager } from './interactionManager';
11
+ import { setNodeFocusable, setNodeHitTestEnabled } from './nodeInteractionState';
12
+
13
+ afterEach(() => {
14
+ disableInteractionGuards();
15
+ });
16
+
17
+ describe('disableInteractionGuards', () => {
18
+ it('stops the guard from warning', () => {
19
+ const root = createDisplayObject();
20
+ const manager = createInteractionManager(root);
21
+ enableInteractionGuards();
22
+ disableInteractionGuards();
23
+ const sink = createMemoryLogSink(8);
24
+ addLogSink(sink.sink);
25
+ try {
26
+ connectInteractionSignal(manager, root, 'onPointerDown', () => {});
27
+ expect(getMemoryLogSinkEntries(sink).length).toBe(0);
28
+ } finally {
29
+ removeLogSink(sink.sink);
30
+ }
31
+ });
32
+ });
33
+
34
+ describe('enableInteractionGuards', () => {
35
+ it('does not warn when the target subtree has an opted-in node', () => {
36
+ const root = createDisplayObject();
37
+ const child = createDisplayObject();
38
+ setNodeHitTestEnabled(child, true);
39
+ addNodeChild(root, child);
40
+ const manager = createInteractionManager(root);
41
+ enableInteractionGuards();
42
+ const sink = createMemoryLogSink(8);
43
+ addLogSink(sink.sink);
44
+ try {
45
+ connectInteractionSignal(manager, root, 'onPointerDown', () => {});
46
+ expect(getMemoryLogSinkEntries(sink).length).toBe(0);
47
+ } finally {
48
+ removeLogSink(sink.sink);
49
+ }
50
+ });
51
+
52
+ it('warns once when a listener targets a node with no hit-testable subtree', () => {
53
+ const root = createDisplayObject();
54
+ const manager = createInteractionManager(root);
55
+ enableInteractionGuards();
56
+ const sink = createMemoryLogSink(8);
57
+ addLogSink(sink.sink);
58
+ try {
59
+ connectInteractionSignal(manager, root, 'onPointerDown', () => {});
60
+ const entries = getMemoryLogSinkEntries(sink);
61
+ expect(entries.length).toBe(1);
62
+ expect(String((entries[0].data as Record<string, unknown>).message)).toContain('setNodeHitTestEnabled');
63
+ } finally {
64
+ removeLogSink(sink.sink);
65
+ }
66
+ });
67
+
68
+ it('warns a focus listener toward setNodeFocusable, and stays silent once a focus stop exists', () => {
69
+ const root = createDisplayObject();
70
+ const manager = createInteractionManager(root);
71
+ enableInteractionGuards();
72
+ const sink = createMemoryLogSink(8);
73
+ addLogSink(sink.sink);
74
+ try {
75
+ connectInteractionSignal(manager, root, 'onFocusIn', () => {});
76
+ const entries = getMemoryLogSinkEntries(sink);
77
+ expect(entries.length).toBe(1);
78
+ expect(String((entries[0].data as Record<string, unknown>).message)).toContain('setNodeFocusable');
79
+
80
+ const focusable = createDisplayObject();
81
+ setNodeFocusable(focusable, true);
82
+ addNodeChild(root, focusable);
83
+ connectInteractionSignal(manager, root, 'onFocusOut', () => {});
84
+ expect(getMemoryLogSinkEntries(sink).length).toBe(1);
85
+ } finally {
86
+ removeLogSink(sink.sink);
87
+ }
88
+ });
89
+ });
90
+
91
+ describe('explainInteractionHitEligibility', () => {
92
+ it('reports self eligibility and whether the subtree has a candidate', () => {
93
+ const root = createDisplayObject();
94
+ const child = createDisplayObject();
95
+ addNodeChild(root, child);
96
+ expect(explainInteractionHitEligibility(root)).toEqual({ eligible: false, hasEligibleInSubtree: false });
97
+ setNodeHitTestEnabled(child, true);
98
+ expect(explainInteractionHitEligibility(root)).toEqual({ eligible: false, hasEligibleInSubtree: true });
99
+ setNodeHitTestEnabled(root, true);
100
+ expect(explainInteractionHitEligibility(root)).toEqual({ eligible: true, hasEligibleInSubtree: true });
101
+ });
102
+ });
@@ -0,0 +1,307 @@
1
+ import { createDisplayObject } from '@flighthq/displayobject';
2
+ import { setRectangle } from '@flighthq/geometry';
3
+ import { addNodeChild, getNodeLocalBoundsRectangle, invalidateNodeLocalTransform } from '@flighthq/node';
4
+ import { connectSignal, createSignal, emitSignal } from '@flighthq/signals';
5
+ import type { DisplayObject, FocusEventData, InputKeyboardData } from '@flighthq/types';
6
+
7
+ import {
8
+ clearFocus,
9
+ connectFocusNavigation,
10
+ createFocusManager,
11
+ focusNextNode,
12
+ focusNodeInDirection,
13
+ focusPreviousNode,
14
+ getFocusOrder,
15
+ getFocusedNode,
16
+ isNodeFocused,
17
+ setFocusedNode,
18
+ } from './focusManager';
19
+ import { enableInteractionSignals } from './interactionManager';
20
+ import { setNodeFocusable, setNodeTabIndex } from './nodeInteractionState';
21
+
22
+ function focusable(tabIndex: number = -1): DisplayObject {
23
+ const node = createDisplayObject();
24
+ setNodeFocusable(node, true);
25
+ if (tabIndex !== -1) setNodeTabIndex(node, tabIndex);
26
+ return node;
27
+ }
28
+
29
+ // A focusable node positioned in world space, so directional navigation can compare bounds centers.
30
+ function placed(x: number, y: number): DisplayObject {
31
+ const node = focusable();
32
+ node.x = x;
33
+ node.y = y;
34
+ invalidateNodeLocalTransform(node);
35
+ setRectangle(getNodeLocalBoundsRectangle(node), 0, 0, 20, 20);
36
+ return node;
37
+ }
38
+
39
+ function keyInput() {
40
+ return { onKeyDown: createSignal<(data: Readonly<InputKeyboardData>) => void>() };
41
+ }
42
+
43
+ function keyData(key: string, shiftKey: boolean = false): InputKeyboardData {
44
+ return {
45
+ altKey: false,
46
+ capsLock: false,
47
+ code: key,
48
+ ctrlKey: false,
49
+ key,
50
+ keyCode: 0,
51
+ location: 0,
52
+ metaKey: false,
53
+ modifier: 0,
54
+ numLock: false,
55
+ repeat: false,
56
+ shiftKey,
57
+ timeStamp: 0,
58
+ };
59
+ }
60
+
61
+ function sceneOf(...nodes: readonly DisplayObject[]): DisplayObject {
62
+ const root = createDisplayObject();
63
+ for (const node of nodes) addNodeChild(root, node);
64
+ return root;
65
+ }
66
+
67
+ describe('clearFocus', () => {
68
+ it('drops focus and fires onFocusOut on the previously focused node', () => {
69
+ const a = focusable();
70
+ const manager = createFocusManager(sceneOf(a));
71
+ setFocusedNode(manager, a);
72
+ let out = 0;
73
+ connectSignal(enableInteractionSignals(a).onFocusOut, () => out++);
74
+
75
+ clearFocus(manager);
76
+ expect(getFocusedNode(manager)).toBeNull();
77
+ expect(out).toBe(1);
78
+ });
79
+ });
80
+
81
+ describe('connectFocusNavigation', () => {
82
+ it('advances on Tab and retreats on Shift+Tab, and stops after disconnect', () => {
83
+ const a = focusable();
84
+ const b = focusable();
85
+ const manager = createFocusManager(sceneOf(a, b));
86
+ const input = keyInput();
87
+ const disconnect = connectFocusNavigation(input, manager);
88
+
89
+ emitSignal(input.onKeyDown, keyData('Tab'));
90
+ expect(getFocusedNode(manager)).toBe(a);
91
+ emitSignal(input.onKeyDown, keyData('Tab'));
92
+ expect(getFocusedNode(manager)).toBe(b);
93
+ emitSignal(input.onKeyDown, keyData('Tab', true));
94
+ expect(getFocusedNode(manager)).toBe(a);
95
+
96
+ disconnect();
97
+ emitSignal(input.onKeyDown, keyData('Tab'));
98
+ expect(getFocusedNode(manager)).toBe(a);
99
+ });
100
+
101
+ it('drives directional navigation from the arrow keys only when arrowKeys is set', () => {
102
+ const center = placed(100, 100);
103
+ const right = placed(200, 100);
104
+ const manager = createFocusManager(sceneOf(center, right));
105
+ setFocusedNode(manager, center);
106
+
107
+ const plain = keyInput();
108
+ const disconnectPlain = connectFocusNavigation(plain, manager);
109
+ emitSignal(plain.onKeyDown, keyData('ArrowRight'));
110
+ expect(getFocusedNode(manager)).toBe(center);
111
+ disconnectPlain();
112
+
113
+ connectFocusNavigation(keyInput(), manager, { arrowKeys: true });
114
+ const arrows = keyInput();
115
+ connectFocusNavigation(arrows, manager, { arrowKeys: true });
116
+ emitSignal(arrows.onKeyDown, keyData('ArrowRight'));
117
+ expect(getFocusedNode(manager)).toBe(right);
118
+ });
119
+ });
120
+
121
+ describe('createFocusManager', () => {
122
+ it('starts unfocused with wrap on by default, and honors wrap: false', () => {
123
+ const root = createDisplayObject();
124
+ expect(createFocusManager(root)).toEqual({ focused: null, root, wrap: true });
125
+ expect(createFocusManager(root, { wrap: false }).wrap).toBe(false);
126
+ });
127
+ });
128
+
129
+ describe('focusNextNode', () => {
130
+ it('advances through the order and wraps when enabled', () => {
131
+ const a = focusable();
132
+ const b = focusable();
133
+ const manager = createFocusManager(sceneOf(a, b));
134
+
135
+ expect(focusNextNode(manager)).toBe(a);
136
+ expect(focusNextNode(manager)).toBe(b);
137
+ expect(focusNextNode(manager)).toBe(a);
138
+ });
139
+
140
+ it('returns null at the last stop when wrap is off', () => {
141
+ const a = focusable();
142
+ const b = focusable();
143
+ const manager = createFocusManager(sceneOf(a, b), { wrap: false });
144
+ setFocusedNode(manager, b);
145
+
146
+ expect(focusNextNode(manager)).toBeNull();
147
+ expect(getFocusedNode(manager)).toBe(b);
148
+ });
149
+
150
+ it('returns null when there are no focus stops', () => {
151
+ const manager = createFocusManager(sceneOf(createDisplayObject()));
152
+ expect(focusNextNode(manager)).toBeNull();
153
+ });
154
+ });
155
+
156
+ describe('focusNodeInDirection', () => {
157
+ it('picks the nearest stop on the requested side of the focused node', () => {
158
+ const center = placed(100, 100);
159
+ const right = placed(200, 100);
160
+ const left = placed(0, 100);
161
+ const up = placed(100, 0);
162
+ const down = placed(100, 200);
163
+ const manager = createFocusManager(sceneOf(center, right, left, up, down));
164
+
165
+ setFocusedNode(manager, center);
166
+ expect(focusNodeInDirection(manager, 'right')).toBe(right);
167
+ setFocusedNode(manager, center);
168
+ expect(focusNodeInDirection(manager, 'left')).toBe(left);
169
+ setFocusedNode(manager, center);
170
+ expect(focusNodeInDirection(manager, 'up')).toBe(up);
171
+ setFocusedNode(manager, center);
172
+ expect(focusNodeInDirection(manager, 'down')).toBe(down);
173
+ });
174
+
175
+ it('returns null when nothing is focused or nothing lies that way', () => {
176
+ const center = placed(100, 100);
177
+ const left = placed(0, 100);
178
+ const manager = createFocusManager(sceneOf(center, left));
179
+
180
+ expect(focusNodeInDirection(manager, 'right')).toBeNull();
181
+ setFocusedNode(manager, center);
182
+ expect(focusNodeInDirection(manager, 'right')).toBeNull();
183
+ expect(getFocusedNode(manager)).toBe(center);
184
+ });
185
+ });
186
+
187
+ describe('focusPreviousNode', () => {
188
+ it('retreats through the order and wraps when enabled', () => {
189
+ const a = focusable();
190
+ const b = focusable();
191
+ const manager = createFocusManager(sceneOf(a, b));
192
+ setFocusedNode(manager, a);
193
+
194
+ expect(focusPreviousNode(manager)).toBe(b);
195
+ expect(focusPreviousNode(manager)).toBe(a);
196
+ });
197
+ });
198
+
199
+ describe('getFocusedNode', () => {
200
+ it('reflects the current focus', () => {
201
+ const a = focusable();
202
+ const manager = createFocusManager(sceneOf(a));
203
+ expect(getFocusedNode(manager)).toBeNull();
204
+ setFocusedNode(manager, a);
205
+ expect(getFocusedNode(manager)).toBe(a);
206
+ });
207
+ });
208
+
209
+ describe('getFocusOrder', () => {
210
+ it('collects focusable nodes in tree order, skipping non-focusable ones', () => {
211
+ const a = focusable();
212
+ const decorative = createDisplayObject();
213
+ const b = focusable();
214
+ const manager = createFocusManager(sceneOf(a, decorative, b));
215
+
216
+ expect(getFocusOrder(manager)).toEqual([a, b]);
217
+ });
218
+
219
+ it('orders explicit tabIndex ahead of natural order, ascending', () => {
220
+ const natural1 = focusable();
221
+ const natural2 = focusable();
222
+ const explicit = focusable(1);
223
+ const manager = createFocusManager(sceneOf(natural1, natural2, explicit));
224
+
225
+ expect(getFocusOrder(manager)).toEqual([explicit, natural1, natural2]);
226
+ });
227
+
228
+ it('skips a disabled node and its subtree', () => {
229
+ const a = focusable();
230
+ const disabledParent = createDisplayObject();
231
+ disabledParent.enabled = false;
232
+ const buried = focusable();
233
+ addNodeChild(disabledParent, buried);
234
+ const manager = createFocusManager(sceneOf(a, disabledParent));
235
+
236
+ expect(getFocusOrder(manager)).toEqual([a]);
237
+ });
238
+ });
239
+
240
+ describe('isNodeFocused', () => {
241
+ it('is true only for the focused node', () => {
242
+ const a = focusable();
243
+ const b = focusable();
244
+ const manager = createFocusManager(sceneOf(a, b));
245
+ setFocusedNode(manager, a);
246
+
247
+ expect(isNodeFocused(manager, a)).toBe(true);
248
+ expect(isNodeFocused(manager, b)).toBe(false);
249
+ });
250
+ });
251
+
252
+ describe('setFocusedNode', () => {
253
+ it('rejects a non-focusable node without changing focus', () => {
254
+ const plain = createDisplayObject();
255
+ const manager = createFocusManager(sceneOf(plain));
256
+ expect(setFocusedNode(manager, plain)).toBe(false);
257
+ expect(getFocusedNode(manager)).toBeNull();
258
+ });
259
+
260
+ it('fires onFocusOut then onFocusIn with relatedTarget set to the other node', () => {
261
+ const a = focusable();
262
+ const b = focusable();
263
+ const manager = createFocusManager(sceneOf(a, b));
264
+ setFocusedNode(manager, a);
265
+
266
+ let outRelated: unknown = 'unset';
267
+ let inRelated: unknown = 'unset';
268
+ connectSignal(enableInteractionSignals(a).onFocusOut, (data: Readonly<FocusEventData>) => {
269
+ outRelated = data.relatedTarget;
270
+ });
271
+ connectSignal(enableInteractionSignals(b).onFocusIn, (data: Readonly<FocusEventData>) => {
272
+ inRelated = data.relatedTarget;
273
+ });
274
+
275
+ setFocusedNode(manager, b);
276
+ expect(outRelated).toBe(b);
277
+ expect(inRelated).toBe(a);
278
+ });
279
+
280
+ it('bubbles onFocusIn up to an ancestor listener', () => {
281
+ const child = focusable();
282
+ const root = sceneOf(child);
283
+ const manager = createFocusManager(root);
284
+
285
+ let currentTarget: unknown = null;
286
+ let target: unknown = null;
287
+ connectSignal(enableInteractionSignals(root).onFocusIn, (data: Readonly<FocusEventData>) => {
288
+ currentTarget = data.currentTarget;
289
+ target = data.target;
290
+ });
291
+
292
+ setFocusedNode(manager, child);
293
+ expect(target).toBe(child);
294
+ expect(currentTarget).toBe(root);
295
+ });
296
+
297
+ it('is a no-op that still returns true when refocusing the same node', () => {
298
+ const a = focusable();
299
+ const manager = createFocusManager(sceneOf(a));
300
+ setFocusedNode(manager, a);
301
+ let events = 0;
302
+ connectSignal(enableInteractionSignals(a).onFocusIn, () => events++);
303
+
304
+ expect(setFocusedNode(manager, a)).toBe(true);
305
+ expect(events).toBe(0);
306
+ });
307
+ });