@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.
@@ -1,10 +1,25 @@
1
- import { isShiftTabKey, isTabKey } from "./keyboard.js";
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 toFocusEntry(node, order) {
39
- const onKey = resolveOnKey(node.props.onKey);
40
- if (onKey === true) {
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
- if (typeof onKey === "function") {
49
- return {
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
- const entry = toFocusEntry(node, order);
68
- order += 1;
69
- if (entry) {
70
- nextEntries.push(entry);
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?.handler) {
222
- return createEmptyResult(false);
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
- return createEmptyResult(focusedEntry.handler(event, {
225
- node: focusedEntry.node,
226
- isFocused: true
227
- }) === true);
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;
@@ -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, InteractionKeyContext, InteractionKeyHandler, InteractionNodeFocusChangeEvent, InteractionResult, IntrinsicInteractionProps, KeyFocusEntry } from "./types.js";
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 interface InteractionKeyContext {
21
- node: MountedElementNode;
22
- isFocused: true;
23
- }
24
- export type InteractionKeyHandler = (event: TerminalKeyEvent, context: InteractionKeyContext) => boolean | void;
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.2",
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.2",
25
- "@bindtty/vnode": "0.1.0-alpha.2"
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",