@flighthq/interaction 0.2.0 → 0.2.1-next.423.3350e53
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cursorBackend.d.ts +12 -0
- package/dist/cursorBackend.d.ts.map +1 -0
- package/dist/cursorBackend.js +17 -0
- package/dist/cursorBackend.js.map +1 -0
- package/dist/displayHitTests.d.ts +10 -11
- package/dist/displayHitTests.d.ts.map +1 -1
- package/dist/displayHitTests.js +12 -14
- package/dist/displayHitTests.js.map +1 -1
- package/dist/enableInteractionGuards.d.ts +23 -0
- package/dist/enableInteractionGuards.d.ts.map +1 -0
- package/dist/enableInteractionGuards.js +75 -0
- package/dist/enableInteractionGuards.js.map +1 -0
- package/dist/focusManager.d.ts +46 -0
- package/dist/focusManager.d.ts.map +1 -0
- package/dist/focusManager.js +243 -0
- package/dist/focusManager.js.map +1 -0
- package/dist/hitTests.d.ts +45 -24
- package/dist/hitTests.d.ts.map +1 -1
- package/dist/hitTests.js +164 -47
- package/dist/hitTests.js.map +1 -1
- package/dist/index.d.ts +9 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9 -1
- package/dist/index.js.map +1 -1
- package/dist/interactionManager.d.ts +10 -2
- package/dist/interactionManager.d.ts.map +1 -1
- package/dist/interactionManager.js +58 -6
- package/dist/interactionManager.js.map +1 -1
- package/dist/interactionSpatialIndex.d.ts +17 -0
- package/dist/interactionSpatialIndex.d.ts.map +1 -0
- package/dist/interactionSpatialIndex.js +77 -0
- package/dist/interactionSpatialIndex.js.map +1 -0
- package/dist/nodeInteractionState.d.ts +39 -0
- package/dist/nodeInteractionState.d.ts.map +1 -0
- package/dist/nodeInteractionState.js +72 -0
- package/dist/nodeInteractionState.js.map +1 -0
- package/dist/registerBitmapHitTest.d.ts +11 -0
- package/dist/registerBitmapHitTest.d.ts.map +1 -0
- package/dist/registerBitmapHitTest.js +55 -0
- package/dist/registerBitmapHitTest.js.map +1 -0
- package/dist/registerDefaultHitTests.d.ts +2 -0
- package/dist/registerDefaultHitTests.d.ts.map +1 -0
- package/dist/registerDefaultHitTests.js +20 -0
- package/dist/registerDefaultHitTests.js.map +1 -0
- package/dist/registerShapeHitTest.d.ts +10 -0
- package/dist/registerShapeHitTest.d.ts.map +1 -0
- package/dist/registerShapeHitTest.js +32 -0
- package/dist/registerShapeHitTest.js.map +1 -0
- package/dist/registerTextHitTest.d.ts +12 -0
- package/dist/registerTextHitTest.d.ts.map +1 -0
- package/dist/registerTextHitTest.js +32 -0
- package/dist/registerTextHitTest.js.map +1 -0
- package/dist/spriteHitTests.d.ts +3 -3
- package/dist/spriteHitTests.d.ts.map +1 -1
- package/dist/spriteHitTests.js +5 -5
- package/dist/spriteHitTests.js.map +1 -1
- package/package.json +13 -7
- package/src/cursorBackend.test.ts +24 -0
- package/src/displayHitTests.test.ts +39 -47
- package/src/enableInteractionGuards.test.ts +102 -0
- package/src/focusManager.test.ts +307 -0
- package/src/hitTests.test.ts +235 -98
- package/src/interactionManager.test.ts +59 -8
- package/src/interactionSpatialIndex.test.ts +103 -0
- package/src/nodeInteractionState.test.ts +141 -0
- package/src/registerBitmapHitTest.test.ts +22 -0
- package/src/{registerDefaultHitTestPoints.test.ts → registerDefaultHitTests.test.ts} +10 -7
- package/src/registerShapeHitTest.test.ts +27 -0
- package/src/registerTextHitTest.test.ts +54 -0
- package/src/spriteHitTests.test.ts +15 -15
- package/dist/registerDefaultHitTestPoints.d.ts +0 -2
- package/dist/registerDefaultHitTestPoints.d.ts.map +0 -1
- package/dist/registerDefaultHitTestPoints.js +0 -21
- 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.
|
|
3
|
+
"version": "0.2.1-next.423.3350e53",
|
|
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.
|
|
36
|
-
"@flighthq/node": "0.2.
|
|
37
|
-
"@flighthq/displayobject": "0.2.
|
|
38
|
-
"@flighthq/
|
|
39
|
-
"@flighthq/
|
|
35
|
+
"@flighthq/geometry": "0.2.1-next.423.3350e53",
|
|
36
|
+
"@flighthq/node": "0.2.1-next.423.3350e53",
|
|
37
|
+
"@flighthq/displayobject": "0.2.1-next.423.3350e53",
|
|
38
|
+
"@flighthq/log": "0.2.1-next.423.3350e53",
|
|
39
|
+
"@flighthq/path": "0.2.1-next.423.3350e53",
|
|
40
|
+
"@flighthq/shape": "0.2.1-next.423.3350e53",
|
|
41
|
+
"@flighthq/spatial": "0.2.1-next.423.3350e53",
|
|
42
|
+
"@flighthq/signals": "0.2.1-next.423.3350e53",
|
|
43
|
+
"@flighthq/surface": "0.2.1-next.423.3350e53",
|
|
44
|
+
"@flighthq/text": "0.2.1-next.423.3350e53",
|
|
45
|
+
"@flighthq/textlayout": "0.2.1-next.423.3350e53",
|
|
46
|
+
"@flighthq/types": "0.2.1-next.423.3350e53"
|
|
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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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('
|
|
24
|
+
describe('defaultBitmapHitTestHandler', () => {
|
|
26
25
|
it('returns true when point is within local bounds', () => {
|
|
27
26
|
const obj = makeDisplayObject();
|
|
28
|
-
expect(
|
|
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(
|
|
32
|
+
expect(defaultBitmapHitTestHandler(obj, 200, 200)).toBe(false);
|
|
34
33
|
});
|
|
35
34
|
});
|
|
36
35
|
|
|
37
|
-
describe('
|
|
36
|
+
describe('defaultDisplayObjectHitTestHandler', () => {
|
|
38
37
|
it('always returns false — plain display objects have no hit geometry', () => {
|
|
39
38
|
const obj = makeDisplayObject();
|
|
40
|
-
expect(
|
|
41
|
-
expect(
|
|
42
|
-
expect(
|
|
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('
|
|
45
|
+
describe('defaultHtmlViewHitTestHandler', () => {
|
|
47
46
|
it('always returns false — browser manages HtmlView hit testing', () => {
|
|
48
47
|
const obj = makeDisplayObject();
|
|
49
|
-
expect(
|
|
48
|
+
expect(defaultHtmlViewHitTestHandler(obj, 50, 50)).toBe(false);
|
|
50
49
|
});
|
|
51
50
|
});
|
|
52
51
|
|
|
53
|
-
describe('
|
|
52
|
+
describe('defaultMovieClipHitTestHandler', () => {
|
|
54
53
|
it('always returns false — containers have no self hit area', () => {
|
|
55
54
|
const obj = makeDisplayObject();
|
|
56
|
-
expect(
|
|
55
|
+
expect(defaultMovieClipHitTestHandler(obj, 50, 50)).toBe(false);
|
|
57
56
|
});
|
|
58
57
|
});
|
|
59
58
|
|
|
60
|
-
describe('
|
|
59
|
+
describe('defaultRenderViewHitTestHandler', () => {
|
|
61
60
|
it('returns true when point is within local bounds', () => {
|
|
62
61
|
const obj = makeDisplayObject();
|
|
63
|
-
expect(
|
|
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(
|
|
67
|
+
expect(defaultRenderViewHitTestHandler(obj, 200, 200)).toBe(false);
|
|
69
68
|
});
|
|
70
69
|
});
|
|
71
70
|
|
|
72
|
-
describe('
|
|
71
|
+
describe('defaultRichTextHitTestHandler', () => {
|
|
73
72
|
it('returns true when point is within local bounds', () => {
|
|
74
73
|
const obj = makeDisplayObject();
|
|
75
|
-
expect(
|
|
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(
|
|
79
|
+
expect(defaultRichTextHitTestHandler(obj, 200, 200)).toBe(false);
|
|
81
80
|
});
|
|
82
81
|
});
|
|
83
82
|
|
|
84
|
-
describe('
|
|
83
|
+
describe('defaultShapeHitTestHandler', () => {
|
|
85
84
|
it('returns true when point is within local bounds', () => {
|
|
86
85
|
const obj = makeDisplayObject();
|
|
87
|
-
expect(
|
|
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(
|
|
91
|
+
expect(defaultShapeHitTestHandler(obj, 200, 200)).toBe(false);
|
|
93
92
|
});
|
|
94
93
|
});
|
|
95
94
|
|
|
96
|
-
describe('
|
|
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(
|
|
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(
|
|
103
|
+
expect(defaultTextHitTestHandler(obj, 200, 200)).toBe(false);
|
|
112
104
|
});
|
|
113
105
|
});
|
|
114
106
|
|
|
115
|
-
describe('
|
|
107
|
+
describe('defaultTextInputHitTestHandler', () => {
|
|
116
108
|
it('returns true when point is within local bounds', () => {
|
|
117
109
|
const obj = makeDisplayObject();
|
|
118
|
-
expect(
|
|
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(
|
|
115
|
+
expect(defaultTextInputHitTestHandler(obj, 200, 200)).toBe(false);
|
|
124
116
|
});
|
|
125
117
|
});
|
|
126
118
|
|
|
127
|
-
describe('
|
|
119
|
+
describe('defaultVideoHitTestHandler', () => {
|
|
128
120
|
it('returns true when point is within local bounds', () => {
|
|
129
121
|
const obj = makeDisplayObject();
|
|
130
|
-
expect(
|
|
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(
|
|
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
|
+
});
|