@bindtty/interaction 0.1.0-alpha.2 → 0.1.0-alpha.3
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/controller.js +75 -44
- package/dist/dispatch.d.ts +8 -0
- package/dist/dispatch.js +47 -0
- package/dist/index.d.ts +1 -1
- package/dist/key-event.d.ts +6 -0
- package/dist/key-event.js +45 -0
- package/dist/types.d.ts +16 -13
- package/package.json +3 -3
package/dist/controller.js
CHANGED
|
@@ -1,10 +1,25 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { createKeyEvent, dispatchTo, runTabFallback } from "./dispatch.js";
|
|
2
2
|
function createEmptyResult(handled = false) {
|
|
3
3
|
return {
|
|
4
4
|
handled,
|
|
5
5
|
dirtyNodes: []
|
|
6
6
|
};
|
|
7
7
|
}
|
|
8
|
+
function uniqueNodes(nodes) {
|
|
9
|
+
const seen = new Set();
|
|
10
|
+
const result = [];
|
|
11
|
+
for (const node of nodes) {
|
|
12
|
+
if (seen.has(node)) {
|
|
13
|
+
continue;
|
|
14
|
+
}
|
|
15
|
+
seen.add(node);
|
|
16
|
+
result.push(node);
|
|
17
|
+
}
|
|
18
|
+
return result;
|
|
19
|
+
}
|
|
20
|
+
function collectFocusDirtyNodes(previousPath, nextPath) {
|
|
21
|
+
return uniqueNodes([...previousPath, ...nextPath]);
|
|
22
|
+
}
|
|
8
23
|
export function createInteractionController() {
|
|
9
24
|
const focusChangeListeners = new Set();
|
|
10
25
|
const internalIds = new WeakMap();
|
|
@@ -12,7 +27,8 @@ export function createInteractionController() {
|
|
|
12
27
|
let entries = [];
|
|
13
28
|
let focusState = {
|
|
14
29
|
entry: null,
|
|
15
|
-
previousOrder: null
|
|
30
|
+
previousOrder: null,
|
|
31
|
+
focusedPath: []
|
|
16
32
|
};
|
|
17
33
|
let disposed = false;
|
|
18
34
|
function allocateInternalId(node) {
|
|
@@ -35,28 +51,17 @@ export function createInteractionController() {
|
|
|
35
51
|
function resolveOnKey(value) {
|
|
36
52
|
return value;
|
|
37
53
|
}
|
|
38
|
-
function
|
|
39
|
-
const
|
|
40
|
-
if (
|
|
41
|
-
return
|
|
42
|
-
id: getEntryId(node),
|
|
43
|
-
node,
|
|
44
|
-
order,
|
|
45
|
-
handler: null
|
|
46
|
-
};
|
|
54
|
+
function isFocusable(node) {
|
|
55
|
+
const explicit = node.props.focusable;
|
|
56
|
+
if (explicit !== undefined) {
|
|
57
|
+
return explicit === true;
|
|
47
58
|
}
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
id: getEntryId(node),
|
|
51
|
-
node,
|
|
52
|
-
order,
|
|
53
|
-
handler: onKey
|
|
54
|
-
};
|
|
55
|
-
}
|
|
56
|
-
return null;
|
|
59
|
+
const onKey = resolveOnKey(node.props.onKey);
|
|
60
|
+
return onKey === true || typeof onKey === "function";
|
|
57
61
|
}
|
|
58
62
|
function collectEntries(root) {
|
|
59
63
|
const nextEntries = [];
|
|
64
|
+
const path = [];
|
|
60
65
|
let order = 0;
|
|
61
66
|
function visit(node) {
|
|
62
67
|
if (!node) {
|
|
@@ -64,14 +69,20 @@ export function createInteractionController() {
|
|
|
64
69
|
}
|
|
65
70
|
switch (node.kind) {
|
|
66
71
|
case "element": {
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
72
|
+
path.push(node);
|
|
73
|
+
if (isFocusable(node)) {
|
|
74
|
+
nextEntries.push({
|
|
75
|
+
id: getEntryId(node),
|
|
76
|
+
node,
|
|
77
|
+
order,
|
|
78
|
+
path: [...path]
|
|
79
|
+
});
|
|
71
80
|
}
|
|
81
|
+
order += 1;
|
|
72
82
|
for (const child of node.children) {
|
|
73
83
|
visit(child);
|
|
74
84
|
}
|
|
85
|
+
path.pop();
|
|
75
86
|
return;
|
|
76
87
|
}
|
|
77
88
|
case "fragment":
|
|
@@ -97,8 +108,7 @@ export function createInteractionController() {
|
|
|
97
108
|
return null;
|
|
98
109
|
}
|
|
99
110
|
return {
|
|
100
|
-
id: entry.id
|
|
101
|
-
node: entry.node
|
|
111
|
+
id: entry.id
|
|
102
112
|
};
|
|
103
113
|
}
|
|
104
114
|
function readNodeFocusChange(node) {
|
|
@@ -109,13 +119,12 @@ export function createInteractionController() {
|
|
|
109
119
|
if (typeof listener === "function") {
|
|
110
120
|
listener({
|
|
111
121
|
id: entry.id,
|
|
112
|
-
node: entry.node,
|
|
113
122
|
focused,
|
|
114
123
|
reason
|
|
115
124
|
});
|
|
116
125
|
}
|
|
117
126
|
}
|
|
118
|
-
function buildFocusResult(previous, current, reason, handled) {
|
|
127
|
+
function buildFocusResult(previous, current, previousPath, reason, handled) {
|
|
119
128
|
if (previous?.node === current?.node) {
|
|
120
129
|
return createEmptyResult(handled);
|
|
121
130
|
}
|
|
@@ -124,13 +133,11 @@ export function createInteractionController() {
|
|
|
124
133
|
current: toSnapshot(current),
|
|
125
134
|
reason
|
|
126
135
|
};
|
|
127
|
-
const dirtyNodes = [];
|
|
136
|
+
const dirtyNodes = collectFocusDirtyNodes(previousPath, current?.path ?? []);
|
|
128
137
|
if (previous) {
|
|
129
|
-
dirtyNodes.push(previous.node);
|
|
130
138
|
notifyNodeFocusChange(previous, false, reason);
|
|
131
139
|
}
|
|
132
140
|
if (current) {
|
|
133
|
-
dirtyNodes.push(current.node);
|
|
134
141
|
notifyNodeFocusChange(current, true, reason);
|
|
135
142
|
}
|
|
136
143
|
for (const listener of [...focusChangeListeners]) {
|
|
@@ -144,11 +151,13 @@ export function createInteractionController() {
|
|
|
144
151
|
}
|
|
145
152
|
function setFocusedEntry(entry, reason, handled) {
|
|
146
153
|
const previous = focusState.entry;
|
|
154
|
+
const previousPath = focusState.focusedPath;
|
|
147
155
|
focusState = {
|
|
148
156
|
entry,
|
|
149
|
-
previousOrder: entry?.order ?? previous?.order ?? focusState.previousOrder
|
|
157
|
+
previousOrder: entry?.order ?? previous?.order ?? focusState.previousOrder,
|
|
158
|
+
focusedPath: entry?.path ?? []
|
|
150
159
|
};
|
|
151
|
-
return buildFocusResult(previous, entry, reason, handled);
|
|
160
|
+
return buildFocusResult(previous, entry, previousPath, reason, handled);
|
|
152
161
|
}
|
|
153
162
|
function findEntryForNode(node) {
|
|
154
163
|
return entries.find((entry) => entry.node === node) ?? null;
|
|
@@ -188,6 +197,9 @@ export function createInteractionController() {
|
|
|
188
197
|
}
|
|
189
198
|
return setFocusedEntry(entries[nextIndex], step === 1 ? "next" : "previous", true);
|
|
190
199
|
}
|
|
200
|
+
function runFallbackKeyAction(raw) {
|
|
201
|
+
return runTabFallback(raw, moveFocus);
|
|
202
|
+
}
|
|
191
203
|
return {
|
|
192
204
|
refresh(root) {
|
|
193
205
|
if (disposed) {
|
|
@@ -201,7 +213,8 @@ export function createInteractionController() {
|
|
|
201
213
|
if (retained) {
|
|
202
214
|
focusState = {
|
|
203
215
|
entry: retained,
|
|
204
|
-
previousOrder: retained.order
|
|
216
|
+
previousOrder: retained.order,
|
|
217
|
+
focusedPath: retained.path
|
|
205
218
|
};
|
|
206
219
|
return createEmptyResult();
|
|
207
220
|
}
|
|
@@ -214,17 +227,34 @@ export function createInteractionController() {
|
|
|
214
227
|
if (disposed) {
|
|
215
228
|
return createEmptyResult(false);
|
|
216
229
|
}
|
|
217
|
-
if (isTabKey(event)) {
|
|
218
|
-
return isShiftTabKey(event) ? moveFocus(-1) : moveFocus(1);
|
|
219
|
-
}
|
|
220
230
|
const focusedEntry = focusState.entry;
|
|
221
|
-
if (!focusedEntry
|
|
222
|
-
return
|
|
231
|
+
if (!focusedEntry) {
|
|
232
|
+
return runFallbackKeyAction(event);
|
|
233
|
+
}
|
|
234
|
+
const keyEvent = createKeyEvent(event);
|
|
235
|
+
let handled = false;
|
|
236
|
+
for (const node of focusedEntry.path.slice(0, -1)) {
|
|
237
|
+
handled = dispatchTo(node, "capture", keyEvent) || handled;
|
|
238
|
+
if (keyEvent.propagationStopped) {
|
|
239
|
+
break;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
if (!keyEvent.propagationStopped) {
|
|
243
|
+
handled =
|
|
244
|
+
dispatchTo(focusedEntry.node, "target", keyEvent) || handled;
|
|
223
245
|
}
|
|
224
|
-
|
|
225
|
-
node
|
|
226
|
-
|
|
227
|
-
|
|
246
|
+
if (!keyEvent.propagationStopped) {
|
|
247
|
+
for (const node of focusedEntry.path.slice(0, -1).reverse()) {
|
|
248
|
+
handled = dispatchTo(node, "bubble", keyEvent) || handled;
|
|
249
|
+
if (keyEvent.propagationStopped) {
|
|
250
|
+
break;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
if (!handled) {
|
|
255
|
+
return runFallbackKeyAction(event);
|
|
256
|
+
}
|
|
257
|
+
return createEmptyResult(true);
|
|
228
258
|
},
|
|
229
259
|
onFocusChange(listener) {
|
|
230
260
|
if (disposed) {
|
|
@@ -277,7 +307,8 @@ export function createInteractionController() {
|
|
|
277
307
|
entries = [];
|
|
278
308
|
focusState = {
|
|
279
309
|
entry: null,
|
|
280
|
-
previousOrder: null
|
|
310
|
+
previousOrder: null,
|
|
311
|
+
focusedPath: []
|
|
281
312
|
};
|
|
282
313
|
}
|
|
283
314
|
};
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { TerminalKeyEvent } from "@bindtty/terminal";
|
|
2
|
+
import type { MountedElementNode } from "@bindtty/vnode";
|
|
3
|
+
import type { BindTTYKeyEvent, InteractionKeyBinding, InteractionKeyListener, InteractionResult, KeyEventPhase } from "./types.js";
|
|
4
|
+
export declare function createKeyEvent(raw: TerminalKeyEvent): BindTTYKeyEvent;
|
|
5
|
+
export declare function resolveKeyCaptureBinding(node: MountedElementNode): InteractionKeyListener;
|
|
6
|
+
export declare function resolveKeyBinding(node: MountedElementNode): InteractionKeyBinding;
|
|
7
|
+
export declare function dispatchTo(node: MountedElementNode, phase: KeyEventPhase, event: BindTTYKeyEvent): boolean;
|
|
8
|
+
export declare function runTabFallback(raw: TerminalKeyEvent, moveFocus: (step: 1 | -1) => InteractionResult): InteractionResult;
|
package/dist/dispatch.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { isShiftTabKey, isTabKey } from "./keyboard.js";
|
|
2
|
+
export function createKeyEvent(raw) {
|
|
3
|
+
const event = {
|
|
4
|
+
input: raw.input,
|
|
5
|
+
name: raw.name,
|
|
6
|
+
ctrl: raw.ctrl,
|
|
7
|
+
meta: raw.meta,
|
|
8
|
+
shift: raw.shift,
|
|
9
|
+
sequence: raw.sequence,
|
|
10
|
+
phase: "target",
|
|
11
|
+
propagationStopped: false,
|
|
12
|
+
stopPropagation() {
|
|
13
|
+
event.propagationStopped = true;
|
|
14
|
+
}
|
|
15
|
+
};
|
|
16
|
+
return event;
|
|
17
|
+
}
|
|
18
|
+
export function resolveKeyCaptureBinding(node) {
|
|
19
|
+
return node.props.onKeyCapture;
|
|
20
|
+
}
|
|
21
|
+
export function resolveKeyBinding(node) {
|
|
22
|
+
return node.props.onKey;
|
|
23
|
+
}
|
|
24
|
+
export function dispatchTo(node, phase, event) {
|
|
25
|
+
event.phase = phase;
|
|
26
|
+
const binding = phase === "capture"
|
|
27
|
+
? resolveKeyCaptureBinding(node)
|
|
28
|
+
: resolveKeyBinding(node);
|
|
29
|
+
if (binding === true || typeof binding !== "function") {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
const handled = binding(event) === true;
|
|
33
|
+
if (handled) {
|
|
34
|
+
event.stopPropagation();
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
export function runTabFallback(raw, moveFocus) {
|
|
40
|
+
if (isTabKey(raw)) {
|
|
41
|
+
return isShiftTabKey(raw) ? moveFocus(-1) : moveFocus(1);
|
|
42
|
+
}
|
|
43
|
+
return {
|
|
44
|
+
handled: false,
|
|
45
|
+
dirtyNodes: []
|
|
46
|
+
};
|
|
47
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
export { createInteractionController } from "./controller.js";
|
|
2
2
|
export { isArrowKey, isEnterKey, isEscapeKey, isShiftTabKey, isTabKey, isTextInputKey } from "./keyboard.js";
|
|
3
|
-
export type { InteractionController, InteractionFocusChangeEvent, InteractionFocusChangeListener, InteractionFocusChangeReason, InteractionFocusSnapshot, InteractionKeyBinding,
|
|
3
|
+
export type { BindTTYKeyEvent, InteractionController, InteractionFocusChangeEvent, InteractionFocusChangeListener, InteractionFocusChangeReason, InteractionFocusSnapshot, InteractionKeyBinding, InteractionKeyHandler, InteractionKeyListener, InteractionNodeFocusChangeEvent, InteractionResult, IntrinsicInteractionProps, KeyEventPhase } from "./types.js";
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { TerminalKeyEvent } from "@bindtty/terminal";
|
|
2
|
+
import type { MountedElementNode } from "@bindtty/vnode";
|
|
3
|
+
import type { BindTTYKeyEvent, InteractionKeyContext, InteractionKeyHandler } from "./types.js";
|
|
4
|
+
export declare function createBindTTYKeyEvent(terminal: TerminalKeyEvent, target: MountedElementNode, currentTarget: MountedElementNode, phase: BindTTYKeyEvent["phase"]): BindTTYKeyEvent;
|
|
5
|
+
export declare function invokeKeyHandler(handler: InteractionKeyHandler, event: BindTTYKeyEvent, context: InteractionKeyContext): void;
|
|
6
|
+
export declare function resolveKeyHandler(props: Record<string, unknown>, propName: "onKey" | "onKeyCapture"): InteractionKeyHandler | null;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { resolveBindingValue } from "@bindtty/vnode";
|
|
2
|
+
export function createBindTTYKeyEvent(terminal, target, currentTarget, phase) {
|
|
3
|
+
let defaultPrevented = false;
|
|
4
|
+
let propagationStopped = false;
|
|
5
|
+
return {
|
|
6
|
+
input: terminal.input,
|
|
7
|
+
name: terminal.name,
|
|
8
|
+
ctrl: terminal.ctrl,
|
|
9
|
+
meta: terminal.meta,
|
|
10
|
+
shift: terminal.shift,
|
|
11
|
+
sequence: terminal.sequence,
|
|
12
|
+
target,
|
|
13
|
+
currentTarget,
|
|
14
|
+
phase,
|
|
15
|
+
get defaultPrevented() {
|
|
16
|
+
return defaultPrevented;
|
|
17
|
+
},
|
|
18
|
+
get propagationStopped() {
|
|
19
|
+
return propagationStopped;
|
|
20
|
+
},
|
|
21
|
+
preventDefault() {
|
|
22
|
+
defaultPrevented = true;
|
|
23
|
+
},
|
|
24
|
+
stopPropagation() {
|
|
25
|
+
propagationStopped = true;
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
export function invokeKeyHandler(handler, event, context) {
|
|
30
|
+
const result = handler(event, context);
|
|
31
|
+
if (result === true) {
|
|
32
|
+
event.preventDefault();
|
|
33
|
+
event.stopPropagation();
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
export function resolveKeyHandler(props, propName) {
|
|
37
|
+
if (!Object.prototype.hasOwnProperty.call(props, propName)) {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
const binding = resolveBindingValue(props[propName]);
|
|
41
|
+
if (typeof binding === "function") {
|
|
42
|
+
return binding;
|
|
43
|
+
}
|
|
44
|
+
return null;
|
|
45
|
+
}
|
package/dist/types.d.ts
CHANGED
|
@@ -3,7 +3,6 @@ import type { BindingValue, MountedElementNode, MountedNode } from "@bindtty/vno
|
|
|
3
3
|
export type InteractionFocusChangeReason = "initial" | "next" | "previous" | "programmatic" | "clear" | "refresh";
|
|
4
4
|
export interface InteractionFocusSnapshot {
|
|
5
5
|
id: string;
|
|
6
|
-
node: MountedElementNode;
|
|
7
6
|
}
|
|
8
7
|
export interface InteractionFocusChangeEvent {
|
|
9
8
|
previous: InteractionFocusSnapshot | null;
|
|
@@ -12,19 +11,29 @@ export interface InteractionFocusChangeEvent {
|
|
|
12
11
|
}
|
|
13
12
|
export interface InteractionNodeFocusChangeEvent {
|
|
14
13
|
id: string;
|
|
15
|
-
node: MountedElementNode;
|
|
16
14
|
focused: boolean;
|
|
17
15
|
reason: InteractionFocusChangeReason;
|
|
18
16
|
}
|
|
19
17
|
export type InteractionFocusChangeListener = (event: InteractionFocusChangeEvent) => void;
|
|
20
|
-
export
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
18
|
+
export type KeyEventPhase = "capture" | "target" | "bubble";
|
|
19
|
+
export interface BindTTYKeyEvent {
|
|
20
|
+
input: string;
|
|
21
|
+
name?: string;
|
|
22
|
+
ctrl: boolean;
|
|
23
|
+
meta: boolean;
|
|
24
|
+
shift: boolean;
|
|
25
|
+
sequence?: string;
|
|
26
|
+
phase: KeyEventPhase;
|
|
27
|
+
propagationStopped: boolean;
|
|
28
|
+
stopPropagation(): void;
|
|
29
|
+
}
|
|
30
|
+
export type InteractionKeyHandler = (event: BindTTYKeyEvent) => boolean | void;
|
|
31
|
+
export type InteractionKeyListener = InteractionKeyHandler | null | undefined;
|
|
25
32
|
export type InteractionKeyBinding = boolean | InteractionKeyHandler | null | undefined;
|
|
26
33
|
export interface IntrinsicInteractionProps {
|
|
27
34
|
id?: BindingValue<string | number>;
|
|
35
|
+
focusable?: BindingValue<boolean>;
|
|
36
|
+
onKeyCapture?: BindingValue<InteractionKeyListener>;
|
|
28
37
|
onKey?: BindingValue<InteractionKeyBinding>;
|
|
29
38
|
onFocusChange?: (event: InteractionNodeFocusChangeEvent) => void;
|
|
30
39
|
}
|
|
@@ -33,12 +42,6 @@ export interface InteractionResult {
|
|
|
33
42
|
dirtyNodes: MountedNode[];
|
|
34
43
|
focusChange?: InteractionFocusChangeEvent;
|
|
35
44
|
}
|
|
36
|
-
export interface KeyFocusEntry {
|
|
37
|
-
id: string;
|
|
38
|
-
node: MountedElementNode;
|
|
39
|
-
order: number;
|
|
40
|
-
handler: InteractionKeyHandler | null;
|
|
41
|
-
}
|
|
42
45
|
export interface InteractionController {
|
|
43
46
|
refresh(root: MountedNode | null): InteractionResult;
|
|
44
47
|
handleKey(event: TerminalKeyEvent): InteractionResult;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bindtty/interaction",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.3",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Keyboard focus and key dispatch layer for BindTTY.",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -21,8 +21,8 @@
|
|
|
21
21
|
"test": "npm run build --workspace @bindtty/vnode && npm run build --workspace @bindtty/terminal && npm run build && tsc -p test/tsconfig.json && node --test test/dist/*.test.js"
|
|
22
22
|
},
|
|
23
23
|
"dependencies": {
|
|
24
|
-
"@bindtty/terminal": "0.1.0-alpha.
|
|
25
|
-
"@bindtty/vnode": "0.1.0-alpha.
|
|
24
|
+
"@bindtty/terminal": "0.1.0-alpha.3",
|
|
25
|
+
"@bindtty/vnode": "0.1.0-alpha.3"
|
|
26
26
|
},
|
|
27
27
|
"devDependencies": {
|
|
28
28
|
"@types/node": "^22.0.0",
|