@bindtty/interaction 0.1.0-alpha.1

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.
@@ -0,0 +1,2 @@
1
+ import type { InteractionController } from "./types.js";
2
+ export declare function createInteractionController(): InteractionController;
@@ -0,0 +1,284 @@
1
+ import { isShiftTabKey, isTabKey } from "./keyboard.js";
2
+ function createEmptyResult(handled = false) {
3
+ return {
4
+ handled,
5
+ dirtyNodes: []
6
+ };
7
+ }
8
+ export function createInteractionController() {
9
+ const focusChangeListeners = new Set();
10
+ const internalIds = new WeakMap();
11
+ let nextInternalId = 1;
12
+ let entries = [];
13
+ let focusState = {
14
+ entry: null,
15
+ previousOrder: null
16
+ };
17
+ let disposed = false;
18
+ function allocateInternalId(node) {
19
+ const existing = internalIds.get(node);
20
+ if (existing) {
21
+ return existing;
22
+ }
23
+ const id = `bindtty-internal-focus-${nextInternalId}`;
24
+ nextInternalId += 1;
25
+ internalIds.set(node, id);
26
+ return id;
27
+ }
28
+ function getEntryId(node) {
29
+ const id = node.props.id;
30
+ if (typeof id === "string" || typeof id === "number") {
31
+ return String(id);
32
+ }
33
+ return allocateInternalId(node);
34
+ }
35
+ function resolveOnKey(value) {
36
+ return value;
37
+ }
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
+ };
47
+ }
48
+ if (typeof onKey === "function") {
49
+ return {
50
+ id: getEntryId(node),
51
+ node,
52
+ order,
53
+ handler: onKey
54
+ };
55
+ }
56
+ return null;
57
+ }
58
+ function collectEntries(root) {
59
+ const nextEntries = [];
60
+ let order = 0;
61
+ function visit(node) {
62
+ if (!node) {
63
+ return;
64
+ }
65
+ switch (node.kind) {
66
+ case "element": {
67
+ const entry = toFocusEntry(node, order);
68
+ order += 1;
69
+ if (entry) {
70
+ nextEntries.push(entry);
71
+ }
72
+ for (const child of node.children) {
73
+ visit(child);
74
+ }
75
+ return;
76
+ }
77
+ case "fragment":
78
+ for (const child of node.children) {
79
+ visit(child);
80
+ }
81
+ return;
82
+ case "show":
83
+ visit(node.activeBranch);
84
+ return;
85
+ case "for":
86
+ for (const item of node.items) {
87
+ visit(item.node);
88
+ }
89
+ return;
90
+ }
91
+ }
92
+ visit(root);
93
+ return nextEntries;
94
+ }
95
+ function toSnapshot(entry) {
96
+ if (!entry) {
97
+ return null;
98
+ }
99
+ return {
100
+ id: entry.id,
101
+ node: entry.node
102
+ };
103
+ }
104
+ function readNodeFocusChange(node) {
105
+ return node.props.onFocusChange;
106
+ }
107
+ function notifyNodeFocusChange(entry, focused, reason) {
108
+ const listener = readNodeFocusChange(entry.node);
109
+ if (typeof listener === "function") {
110
+ listener({
111
+ id: entry.id,
112
+ node: entry.node,
113
+ focused,
114
+ reason
115
+ });
116
+ }
117
+ }
118
+ function buildFocusResult(previous, current, reason, handled) {
119
+ if (previous?.node === current?.node) {
120
+ return createEmptyResult(handled);
121
+ }
122
+ const focusChange = {
123
+ previous: toSnapshot(previous),
124
+ current: toSnapshot(current),
125
+ reason
126
+ };
127
+ const dirtyNodes = [];
128
+ if (previous) {
129
+ dirtyNodes.push(previous.node);
130
+ notifyNodeFocusChange(previous, false, reason);
131
+ }
132
+ if (current) {
133
+ dirtyNodes.push(current.node);
134
+ notifyNodeFocusChange(current, true, reason);
135
+ }
136
+ for (const listener of [...focusChangeListeners]) {
137
+ listener(focusChange);
138
+ }
139
+ return {
140
+ handled,
141
+ dirtyNodes,
142
+ focusChange
143
+ };
144
+ }
145
+ function setFocusedEntry(entry, reason, handled) {
146
+ const previous = focusState.entry;
147
+ focusState = {
148
+ entry,
149
+ previousOrder: entry?.order ?? previous?.order ?? focusState.previousOrder
150
+ };
151
+ return buildFocusResult(previous, entry, reason, handled);
152
+ }
153
+ function findEntryForNode(node) {
154
+ return entries.find((entry) => entry.node === node) ?? null;
155
+ }
156
+ function findEntryForId(id) {
157
+ return entries.find((entry) => entry.id === id) ?? null;
158
+ }
159
+ function findRefreshFallback(previousOrder) {
160
+ if (entries.length === 0) {
161
+ return null;
162
+ }
163
+ if (previousOrder === null) {
164
+ return entries[0];
165
+ }
166
+ return (entries.find((entry) => entry.order >= previousOrder) ??
167
+ entries[entries.length - 1]);
168
+ }
169
+ function moveFocus(step) {
170
+ if (disposed || entries.length === 0) {
171
+ return createEmptyResult(false);
172
+ }
173
+ if (entries.length === 1 && focusState.entry) {
174
+ return createEmptyResult(true);
175
+ }
176
+ let nextIndex;
177
+ if (!focusState.entry) {
178
+ nextIndex = step === 1 ? 0 : entries.length - 1;
179
+ }
180
+ else {
181
+ const currentIndex = entries.findIndex((entry) => entry.node === focusState.entry?.node);
182
+ if (currentIndex === -1) {
183
+ nextIndex = step === 1 ? 0 : entries.length - 1;
184
+ }
185
+ else {
186
+ nextIndex = (currentIndex + step + entries.length) % entries.length;
187
+ }
188
+ }
189
+ return setFocusedEntry(entries[nextIndex], step === 1 ? "next" : "previous", true);
190
+ }
191
+ return {
192
+ refresh(root) {
193
+ if (disposed) {
194
+ return createEmptyResult();
195
+ }
196
+ const previousEntry = focusState.entry;
197
+ const previousOrder = previousEntry?.order ?? focusState.previousOrder;
198
+ entries = collectEntries(root);
199
+ if (previousEntry) {
200
+ const retained = findEntryForNode(previousEntry.node);
201
+ if (retained) {
202
+ focusState = {
203
+ entry: retained,
204
+ previousOrder: retained.order
205
+ };
206
+ return createEmptyResult();
207
+ }
208
+ }
209
+ const nextEntry = findRefreshFallback(previousOrder);
210
+ const reason = previousEntry ? "refresh" : "initial";
211
+ return setFocusedEntry(nextEntry, reason, false);
212
+ },
213
+ handleKey(event) {
214
+ if (disposed) {
215
+ return createEmptyResult(false);
216
+ }
217
+ if (isTabKey(event)) {
218
+ return isShiftTabKey(event) ? moveFocus(-1) : moveFocus(1);
219
+ }
220
+ const focusedEntry = focusState.entry;
221
+ if (!focusedEntry?.handler) {
222
+ return createEmptyResult(false);
223
+ }
224
+ return createEmptyResult(focusedEntry.handler(event, {
225
+ node: focusedEntry.node,
226
+ isFocused: true
227
+ }) === true);
228
+ },
229
+ onFocusChange(listener) {
230
+ if (disposed) {
231
+ return () => { };
232
+ }
233
+ focusChangeListeners.add(listener);
234
+ return () => {
235
+ focusChangeListeners.delete(listener);
236
+ };
237
+ },
238
+ focus(target) {
239
+ if (disposed) {
240
+ return createEmptyResult();
241
+ }
242
+ const entry = typeof target === "string"
243
+ ? findEntryForId(target)
244
+ : findEntryForNode(target);
245
+ if (!entry) {
246
+ return createEmptyResult(false);
247
+ }
248
+ return setFocusedEntry(entry, "programmatic", true);
249
+ },
250
+ focusNext() {
251
+ return moveFocus(1);
252
+ },
253
+ focusPrevious() {
254
+ return moveFocus(-1);
255
+ },
256
+ clearFocus() {
257
+ if (disposed || !focusState.entry) {
258
+ return createEmptyResult(false);
259
+ }
260
+ return setFocusedEntry(null, "clear", true);
261
+ },
262
+ getFocusedId() {
263
+ return focusState.entry?.id ?? null;
264
+ },
265
+ getFocusedNode() {
266
+ return focusState.entry?.node ?? null;
267
+ },
268
+ isFocused(node) {
269
+ return node.kind === "element" && focusState.entry?.node === node;
270
+ },
271
+ dispose() {
272
+ if (disposed) {
273
+ return;
274
+ }
275
+ disposed = true;
276
+ focusChangeListeners.clear();
277
+ entries = [];
278
+ focusState = {
279
+ entry: null,
280
+ previousOrder: null
281
+ };
282
+ }
283
+ };
284
+ }
@@ -0,0 +1,3 @@
1
+ export { createInteractionController } from "./controller.js";
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";
package/dist/index.js ADDED
@@ -0,0 +1,2 @@
1
+ export { createInteractionController } from "./controller.js";
2
+ export { isArrowKey, isEnterKey, isEscapeKey, isShiftTabKey, isTabKey, isTextInputKey } from "./keyboard.js";
@@ -0,0 +1,7 @@
1
+ import type { TerminalKeyEvent } from "@bindtty/terminal";
2
+ export declare function isTabKey(event: TerminalKeyEvent): boolean;
3
+ export declare function isShiftTabKey(event: TerminalKeyEvent): boolean;
4
+ export declare function isEnterKey(event: TerminalKeyEvent): boolean;
5
+ export declare function isEscapeKey(event: TerminalKeyEvent): boolean;
6
+ export declare function isArrowKey(event: TerminalKeyEvent): boolean;
7
+ export declare function isTextInputKey(event: TerminalKeyEvent): boolean;
@@ -0,0 +1,21 @@
1
+ export function isTabKey(event) {
2
+ return event.name === "tab" || event.input === "\t";
3
+ }
4
+ export function isShiftTabKey(event) {
5
+ return isTabKey(event) && event.shift;
6
+ }
7
+ export function isEnterKey(event) {
8
+ return event.name === "return" || event.name === "enter" || event.input === "\r";
9
+ }
10
+ export function isEscapeKey(event) {
11
+ return event.name === "escape" || event.input === "\u001b";
12
+ }
13
+ export function isArrowKey(event) {
14
+ return (event.name === "left" ||
15
+ event.name === "right" ||
16
+ event.name === "up" ||
17
+ event.name === "down");
18
+ }
19
+ export function isTextInputKey(event) {
20
+ return event.input.length > 0 && !event.ctrl && !event.meta && !event.name;
21
+ }
@@ -0,0 +1,54 @@
1
+ import type { TerminalKeyEvent } from "@bindtty/terminal";
2
+ import type { BindingValue, MountedElementNode, MountedNode } from "@bindtty/vnode";
3
+ export type InteractionFocusChangeReason = "initial" | "next" | "previous" | "programmatic" | "clear" | "refresh";
4
+ export interface InteractionFocusSnapshot {
5
+ id: string;
6
+ node: MountedElementNode;
7
+ }
8
+ export interface InteractionFocusChangeEvent {
9
+ previous: InteractionFocusSnapshot | null;
10
+ current: InteractionFocusSnapshot | null;
11
+ reason: InteractionFocusChangeReason;
12
+ }
13
+ export interface InteractionNodeFocusChangeEvent {
14
+ id: string;
15
+ node: MountedElementNode;
16
+ focused: boolean;
17
+ reason: InteractionFocusChangeReason;
18
+ }
19
+ 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;
25
+ export type InteractionKeyBinding = boolean | InteractionKeyHandler | null | undefined;
26
+ export interface IntrinsicInteractionProps {
27
+ id?: BindingValue<string | number>;
28
+ onKey?: BindingValue<InteractionKeyBinding>;
29
+ onFocusChange?: (event: InteractionNodeFocusChangeEvent) => void;
30
+ }
31
+ export interface InteractionResult {
32
+ handled: boolean;
33
+ dirtyNodes: MountedNode[];
34
+ focusChange?: InteractionFocusChangeEvent;
35
+ }
36
+ export interface KeyFocusEntry {
37
+ id: string;
38
+ node: MountedElementNode;
39
+ order: number;
40
+ handler: InteractionKeyHandler | null;
41
+ }
42
+ export interface InteractionController {
43
+ refresh(root: MountedNode | null): InteractionResult;
44
+ handleKey(event: TerminalKeyEvent): InteractionResult;
45
+ onFocusChange(listener: InteractionFocusChangeListener): () => void;
46
+ focus(target: string | MountedElementNode): InteractionResult;
47
+ focusNext(): InteractionResult;
48
+ focusPrevious(): InteractionResult;
49
+ clearFocus(): InteractionResult;
50
+ getFocusedId(): string | null;
51
+ getFocusedNode(): MountedElementNode | null;
52
+ isFocused(node: MountedNode): boolean;
53
+ dispose(): void;
54
+ }
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "@bindtty/interaction",
3
+ "version": "0.1.0-alpha.1",
4
+ "type": "module",
5
+ "description": "Keyboard focus and key dispatch layer for BindTTY.",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js"
12
+ }
13
+ },
14
+ "files": [
15
+ "dist",
16
+ "README.md"
17
+ ],
18
+ "scripts": {
19
+ "build": "tsc -p tsconfig.json",
20
+ "prepublishOnly": "npm run build",
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
+ },
23
+ "dependencies": {
24
+ "@bindtty/terminal": "0.1.0-alpha.1",
25
+ "@bindtty/vnode": "0.1.0-alpha.1"
26
+ },
27
+ "devDependencies": {
28
+ "@types/node": "^22.0.0",
29
+ "typescript": "^5.5.0"
30
+ },
31
+ "engines": {
32
+ "node": ">=18"
33
+ },
34
+ "publishConfig": {
35
+ "access": "public"
36
+ },
37
+ "license": "MIT",
38
+ "repository": {
39
+ "type": "git",
40
+ "url": "git+https://github.com/lithdoo/BindTTY.git",
41
+ "directory": "packages/interaction"
42
+ },
43
+ "bugs": {
44
+ "url": "https://github.com/lithdoo/BindTTY/issues"
45
+ },
46
+ "homepage": "https://github.com/lithdoo/BindTTY/tree/main/packages/interaction#readme"
47
+ }