giggles 0.0.3 → 0.1.0
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/README.md +1 -0
- package/dist/index.d.ts +203 -1
- package/dist/index.js +391 -0
- package/package.json +3 -3
package/README.md
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
wip -- see [giggles.zzzzion.com](https://giggles.zzzzion.com)!
|
package/dist/index.d.ts
CHANGED
|
@@ -1,2 +1,204 @@
|
|
|
1
|
+
import * as react_jsx_runtime from 'react/jsx-runtime';
|
|
2
|
+
import * as React$1 from 'react';
|
|
3
|
+
import React__default from 'react';
|
|
4
|
+
import { Key } from 'ink';
|
|
5
|
+
export { Key } from 'ink';
|
|
1
6
|
|
|
2
|
-
|
|
7
|
+
type GigglesProviderProps = {
|
|
8
|
+
children: React__default.ReactNode;
|
|
9
|
+
};
|
|
10
|
+
/**
|
|
11
|
+
* Root provider for Giggles applications. Combines focus and input systems.
|
|
12
|
+
*
|
|
13
|
+
* Wraps your app in the necessary context providers and sets up the input router.
|
|
14
|
+
* All Giggles apps must be wrapped in this provider.
|
|
15
|
+
*
|
|
16
|
+
* @example
|
|
17
|
+
* ```tsx
|
|
18
|
+
* import { render } from 'ink';
|
|
19
|
+
* import { GigglesProvider } from 'giggles';
|
|
20
|
+
* import { App } from './App';
|
|
21
|
+
*
|
|
22
|
+
* render(
|
|
23
|
+
* <GigglesProvider>
|
|
24
|
+
* <App />
|
|
25
|
+
* </GigglesProvider>
|
|
26
|
+
* );
|
|
27
|
+
* ```
|
|
28
|
+
*/
|
|
29
|
+
declare function GigglesProvider({ children }: GigglesProviderProps): react_jsx_runtime.JSX.Element;
|
|
30
|
+
|
|
31
|
+
type FocusGroupProps = {
|
|
32
|
+
children: React__default.ReactNode;
|
|
33
|
+
direction?: 'vertical' | 'horizontal';
|
|
34
|
+
value?: string;
|
|
35
|
+
wrap?: boolean;
|
|
36
|
+
navigable?: boolean;
|
|
37
|
+
};
|
|
38
|
+
declare function FocusGroup({ children, direction, value, wrap, navigable }: FocusGroupProps): react_jsx_runtime.JSX.Element;
|
|
39
|
+
|
|
40
|
+
declare const useFocus: () => {
|
|
41
|
+
id: string;
|
|
42
|
+
focused: boolean;
|
|
43
|
+
focus: () => void;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
declare function useFocusState<T extends string>(initial: T): readonly [T, React$1.Dispatch<React$1.SetStateAction<T>>];
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Handler function for a key binding.
|
|
50
|
+
*
|
|
51
|
+
* If a binding exists for a key, the handler will be called and the input will stop bubbling.
|
|
52
|
+
* Unhandled keys (those without bindings) automatically bubble up to parent components.
|
|
53
|
+
*
|
|
54
|
+
* For conditional handling, either:
|
|
55
|
+
* - Register bindings conditionally based on component state, or
|
|
56
|
+
* - Use internal logic within the handler to decide whether to perform an action
|
|
57
|
+
*
|
|
58
|
+
* @param input - The character string for the key pressed (e.g., 'j', 'q')
|
|
59
|
+
* @param key - Ink's Key object with boolean properties (downArrow, return, escape, etc.)
|
|
60
|
+
*
|
|
61
|
+
* @example
|
|
62
|
+
* ```tsx
|
|
63
|
+
* useKeybindings(focus, {
|
|
64
|
+
* j: () => moveDown(),
|
|
65
|
+
* k: () => moveUp(),
|
|
66
|
+
* return: () => selectItem()
|
|
67
|
+
* });
|
|
68
|
+
* ```
|
|
69
|
+
*/
|
|
70
|
+
type KeyHandler = (input: string, key: Key) => void;
|
|
71
|
+
type SpecialKey = 'up' | 'down' | 'left' | 'right' | 'enter' | 'escape' | 'tab' | 'backspace' | 'delete' | 'pageup' | 'pagedown' | 'home' | 'end';
|
|
72
|
+
type KeyName = SpecialKey | (string & {});
|
|
73
|
+
/**
|
|
74
|
+
* Map of key names to handler functions.
|
|
75
|
+
*
|
|
76
|
+
* Keys are strings that match either:
|
|
77
|
+
* - Special keys (autocompleted): 'enter', 'escape', 'up', 'down', etc.
|
|
78
|
+
* - Character keys: 'j', 'k', 'a', '?', etc.
|
|
79
|
+
* - Modified keys: 'ctrl+c', 'ctrl+q', etc.
|
|
80
|
+
*
|
|
81
|
+
* @example
|
|
82
|
+
* ```tsx
|
|
83
|
+
* const bindings: Keybindings = {
|
|
84
|
+
* j: () => moveDown(),
|
|
85
|
+
* k: () => moveUp(),
|
|
86
|
+
* enter: () => selectItem(),
|
|
87
|
+
* 'ctrl+q': () => quit()
|
|
88
|
+
* };
|
|
89
|
+
* ```
|
|
90
|
+
*/
|
|
91
|
+
type Keybindings = Partial<Record<KeyName, KeyHandler>>;
|
|
92
|
+
/**
|
|
93
|
+
* Options for configuring keybinding behavior.
|
|
94
|
+
*/
|
|
95
|
+
type KeybindingOptions = {
|
|
96
|
+
/**
|
|
97
|
+
* Enable capture mode. When true, explicit bindings are checked first,
|
|
98
|
+
* then all remaining keystrokes go to `onKeypress`. Nothing bubbles.
|
|
99
|
+
*
|
|
100
|
+
* Used for text inputs and components that need to receive all keystrokes.
|
|
101
|
+
*/
|
|
102
|
+
capture?: boolean;
|
|
103
|
+
/**
|
|
104
|
+
* Handler for keystrokes in capture mode that don't match explicit bindings.
|
|
105
|
+
*
|
|
106
|
+
* Only called when `capture` is true.
|
|
107
|
+
*/
|
|
108
|
+
onKeypress?: (input: string, key: Key) => void;
|
|
109
|
+
/**
|
|
110
|
+
* Optional layer identifier for organizing keybindings.
|
|
111
|
+
*
|
|
112
|
+
* Can be used for debugging or building tooling that inspects keybindings.
|
|
113
|
+
* Has no effect on input routing behavior.
|
|
114
|
+
*/
|
|
115
|
+
layer?: string;
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Registers keybindings that only fire when this component is focused.
|
|
120
|
+
*
|
|
121
|
+
* Solves Ink's global `useInput` problem by routing keypresses through the focus tree.
|
|
122
|
+
* Only the focused component receives input. Unhandled keys bubble up to parent components.
|
|
123
|
+
*
|
|
124
|
+
* @param focus - The focus handle from `useFocus()`. Ties bindings to this component's focus node.
|
|
125
|
+
* @param bindings - Map of key names to handler functions. Handlers only fire when focused.
|
|
126
|
+
* @param options - Optional configuration for capture mode, keypress handlers, and layers.
|
|
127
|
+
*
|
|
128
|
+
* @example
|
|
129
|
+
* ```tsx
|
|
130
|
+
* function FileList() {
|
|
131
|
+
* const focus = useFocus();
|
|
132
|
+
*
|
|
133
|
+
* useKeybindings(focus, {
|
|
134
|
+
* j: () => moveDown(),
|
|
135
|
+
* k: () => moveUp(),
|
|
136
|
+
* enter: () => openFile()
|
|
137
|
+
* });
|
|
138
|
+
*
|
|
139
|
+
* return <List focused={focus.focused} items={files} />;
|
|
140
|
+
* }
|
|
141
|
+
* ```
|
|
142
|
+
*
|
|
143
|
+
* @example Capture mode for text input
|
|
144
|
+
* ```tsx
|
|
145
|
+
* function TextInput() {
|
|
146
|
+
* const focus = useFocus();
|
|
147
|
+
* const [value, setValue] = useState('');
|
|
148
|
+
*
|
|
149
|
+
* useKeybindings(
|
|
150
|
+
* focus,
|
|
151
|
+
* {
|
|
152
|
+
* escape: () => blur(),
|
|
153
|
+
* enter: () => submit(value)
|
|
154
|
+
* },
|
|
155
|
+
* {
|
|
156
|
+
* capture: true,
|
|
157
|
+
* onKeypress: (key) => setValue(v => v + key)
|
|
158
|
+
* }
|
|
159
|
+
* );
|
|
160
|
+
*
|
|
161
|
+
* return <Text>{value}</Text>;
|
|
162
|
+
* }
|
|
163
|
+
* ```
|
|
164
|
+
*/
|
|
165
|
+
declare function useKeybindings(focus: {
|
|
166
|
+
id: string;
|
|
167
|
+
}, bindings: Keybindings, options?: KeybindingOptions): void;
|
|
168
|
+
|
|
169
|
+
type FocusTrapProps = {
|
|
170
|
+
children: React.ReactNode;
|
|
171
|
+
};
|
|
172
|
+
/**
|
|
173
|
+
* Stops input bubbling at a boundary. Used for modals and dialogs.
|
|
174
|
+
*
|
|
175
|
+
* When a key is pressed, it bubbles up the focus tree until handled.
|
|
176
|
+
* FocusTrap creates a barrier - if nothing inside the trap handles the key,
|
|
177
|
+
* it stops at the trap boundary instead of bubbling to parent components.
|
|
178
|
+
*
|
|
179
|
+
* @example
|
|
180
|
+
* ```tsx
|
|
181
|
+
* function App() {
|
|
182
|
+
* const [showModal, setShowModal] = useState(false);
|
|
183
|
+
*
|
|
184
|
+
* useKeybindings(focus, {
|
|
185
|
+
* '?': () => setShowModal(true),
|
|
186
|
+
* q: () => exit()
|
|
187
|
+
* });
|
|
188
|
+
*
|
|
189
|
+
* return (
|
|
190
|
+
* <Screen>
|
|
191
|
+
* <MainContent />
|
|
192
|
+
* {showModal && (
|
|
193
|
+
* <FocusTrap>
|
|
194
|
+
* <HelpModal onClose={() => setShowModal(false)} />
|
|
195
|
+
* </FocusTrap>
|
|
196
|
+
* )}
|
|
197
|
+
* </Screen>
|
|
198
|
+
* );
|
|
199
|
+
* }
|
|
200
|
+
* ```
|
|
201
|
+
*/
|
|
202
|
+
declare function FocusTrap({ children }: FocusTrapProps): react_jsx_runtime.JSX.Element;
|
|
203
|
+
|
|
204
|
+
export { FocusGroup, FocusTrap, GigglesProvider, type KeyHandler, type KeybindingOptions, type Keybindings, useFocus, useFocusState, useKeybindings };
|
package/dist/index.js
CHANGED
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
// src/core/focus/FocusContext.tsx
|
|
2
|
+
import { createContext, useCallback, useContext, useRef, useState } from "react";
|
|
3
|
+
import { jsx } from "react/jsx-runtime";
|
|
4
|
+
var FocusContext = createContext(null);
|
|
5
|
+
var FocusProvider = ({ children }) => {
|
|
6
|
+
const nodesRef = useRef(/* @__PURE__ */ new Map());
|
|
7
|
+
const [focusedId, setFocusedId] = useState(null);
|
|
8
|
+
const [activeBranchNodes, setActiveBranchNodes] = useState(/* @__PURE__ */ new Set());
|
|
9
|
+
const [activeBranchPath, setActiveBranchPath] = useState([]);
|
|
10
|
+
const focusNode = useCallback((id) => {
|
|
11
|
+
const nodes = nodesRef.current;
|
|
12
|
+
if (!nodes.has(id)) return;
|
|
13
|
+
setFocusedId((current) => {
|
|
14
|
+
if (current === id) return current;
|
|
15
|
+
const pathSet = /* @__PURE__ */ new Set();
|
|
16
|
+
const pathArray = [];
|
|
17
|
+
let currentNode = id;
|
|
18
|
+
while (currentNode) {
|
|
19
|
+
pathSet.add(currentNode);
|
|
20
|
+
pathArray.push(currentNode);
|
|
21
|
+
const node = nodes.get(currentNode);
|
|
22
|
+
currentNode = (node == null ? void 0 : node.parentId) ?? null;
|
|
23
|
+
}
|
|
24
|
+
setActiveBranchNodes(pathSet);
|
|
25
|
+
setActiveBranchPath(pathArray);
|
|
26
|
+
return id;
|
|
27
|
+
});
|
|
28
|
+
}, []);
|
|
29
|
+
const registerNode = useCallback(
|
|
30
|
+
(id, parentId) => {
|
|
31
|
+
const nodes = nodesRef.current;
|
|
32
|
+
const node = {
|
|
33
|
+
id,
|
|
34
|
+
parentId,
|
|
35
|
+
childrenIds: []
|
|
36
|
+
};
|
|
37
|
+
nodes.set(id, node);
|
|
38
|
+
if (parentId) {
|
|
39
|
+
const parent = nodes.get(parentId);
|
|
40
|
+
if (parent && !parent.childrenIds.includes(id)) {
|
|
41
|
+
parent.childrenIds.push(id);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
nodes.forEach((existingNode) => {
|
|
45
|
+
if (existingNode.parentId === id && !node.childrenIds.includes(existingNode.id)) {
|
|
46
|
+
node.childrenIds.push(existingNode.id);
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
if (nodes.size === 1) {
|
|
50
|
+
focusNode(id);
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
[focusNode]
|
|
54
|
+
);
|
|
55
|
+
const unregisterNode = useCallback((id) => {
|
|
56
|
+
const nodes = nodesRef.current;
|
|
57
|
+
const node = nodes.get(id);
|
|
58
|
+
if (!node) return;
|
|
59
|
+
if (node.parentId) {
|
|
60
|
+
const parent = nodes.get(node.parentId);
|
|
61
|
+
if (parent) {
|
|
62
|
+
parent.childrenIds = parent.childrenIds.filter((childId) => childId !== id);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
nodes.delete(id);
|
|
66
|
+
setFocusedId((current) => {
|
|
67
|
+
if (current !== id) return current;
|
|
68
|
+
if (node.parentId) {
|
|
69
|
+
const pathSet = /* @__PURE__ */ new Set();
|
|
70
|
+
const pathArray = [];
|
|
71
|
+
let currentNode = node.parentId;
|
|
72
|
+
while (currentNode) {
|
|
73
|
+
pathSet.add(currentNode);
|
|
74
|
+
pathArray.push(currentNode);
|
|
75
|
+
const n = nodes.get(currentNode);
|
|
76
|
+
currentNode = (n == null ? void 0 : n.parentId) ?? null;
|
|
77
|
+
}
|
|
78
|
+
setActiveBranchNodes(pathSet);
|
|
79
|
+
setActiveBranchPath(pathArray);
|
|
80
|
+
return node.parentId;
|
|
81
|
+
}
|
|
82
|
+
setActiveBranchNodes(/* @__PURE__ */ new Set());
|
|
83
|
+
setActiveBranchPath([]);
|
|
84
|
+
return null;
|
|
85
|
+
});
|
|
86
|
+
}, []);
|
|
87
|
+
const isFocused = useCallback(
|
|
88
|
+
(id) => {
|
|
89
|
+
return id === focusedId;
|
|
90
|
+
},
|
|
91
|
+
[focusedId]
|
|
92
|
+
);
|
|
93
|
+
const getFocusedId = useCallback(() => {
|
|
94
|
+
return focusedId;
|
|
95
|
+
}, [focusedId]);
|
|
96
|
+
const isInActiveBranch = useCallback(
|
|
97
|
+
(id) => {
|
|
98
|
+
return activeBranchNodes.has(id);
|
|
99
|
+
},
|
|
100
|
+
[activeBranchNodes]
|
|
101
|
+
);
|
|
102
|
+
const getActiveBranchPath = useCallback(() => {
|
|
103
|
+
return activeBranchPath;
|
|
104
|
+
}, [activeBranchPath]);
|
|
105
|
+
const navigateSibling = useCallback(
|
|
106
|
+
(direction, wrap = true) => {
|
|
107
|
+
const currentId = focusedId;
|
|
108
|
+
if (!currentId) return;
|
|
109
|
+
const nodes = nodesRef.current;
|
|
110
|
+
const currentNode = nodes.get(currentId);
|
|
111
|
+
if (!(currentNode == null ? void 0 : currentNode.parentId)) return;
|
|
112
|
+
const parent = nodes.get(currentNode.parentId);
|
|
113
|
+
if (!parent || parent.childrenIds.length === 0) return;
|
|
114
|
+
const siblings = parent.childrenIds;
|
|
115
|
+
const currentIndex = siblings.indexOf(currentId);
|
|
116
|
+
if (currentIndex === -1) return;
|
|
117
|
+
let nextIndex;
|
|
118
|
+
if (wrap) {
|
|
119
|
+
nextIndex = direction === "next" ? (currentIndex + 1) % siblings.length : (currentIndex - 1 + siblings.length) % siblings.length;
|
|
120
|
+
} else {
|
|
121
|
+
nextIndex = direction === "next" ? Math.min(currentIndex + 1, siblings.length - 1) : Math.max(currentIndex - 1, 0);
|
|
122
|
+
}
|
|
123
|
+
focusNode(siblings[nextIndex]);
|
|
124
|
+
},
|
|
125
|
+
[focusedId, focusNode]
|
|
126
|
+
);
|
|
127
|
+
return /* @__PURE__ */ jsx(
|
|
128
|
+
FocusContext.Provider,
|
|
129
|
+
{
|
|
130
|
+
value: {
|
|
131
|
+
registerNode,
|
|
132
|
+
unregisterNode,
|
|
133
|
+
focusNode,
|
|
134
|
+
isFocused,
|
|
135
|
+
getFocusedId,
|
|
136
|
+
isInActiveBranch,
|
|
137
|
+
getActiveBranchPath,
|
|
138
|
+
navigateSibling
|
|
139
|
+
},
|
|
140
|
+
children
|
|
141
|
+
}
|
|
142
|
+
);
|
|
143
|
+
};
|
|
144
|
+
var FocusNodeContext = createContext(null);
|
|
145
|
+
var useFocusContext = () => {
|
|
146
|
+
const context = useContext(FocusContext);
|
|
147
|
+
if (!context) {
|
|
148
|
+
throw new Error("useFocusContext must be used within a FocusProvider");
|
|
149
|
+
}
|
|
150
|
+
return context;
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
// src/core/focus/FocusGroup.tsx
|
|
154
|
+
import { useCallback as useCallback3, useEffect as useEffect4, useMemo, useRef as useRef3 } from "react";
|
|
155
|
+
|
|
156
|
+
// src/core/input/InputContext.tsx
|
|
157
|
+
import { createContext as createContext2, useCallback as useCallback2, useContext as useContext2, useRef as useRef2 } from "react";
|
|
158
|
+
import { jsx as jsx2 } from "react/jsx-runtime";
|
|
159
|
+
var InputContext = createContext2(null);
|
|
160
|
+
var InputProvider = ({ children }) => {
|
|
161
|
+
const bindingsRef = useRef2(/* @__PURE__ */ new Map());
|
|
162
|
+
const trapNodeIdRef = useRef2(null);
|
|
163
|
+
const registerKeybindings = useCallback2((nodeId, bindings, options) => {
|
|
164
|
+
const registration = {
|
|
165
|
+
bindings: new Map(Object.entries(bindings).filter((entry) => entry[1] != null)),
|
|
166
|
+
capture: (options == null ? void 0 : options.capture) ?? false,
|
|
167
|
+
onKeypress: options == null ? void 0 : options.onKeypress,
|
|
168
|
+
layer: options == null ? void 0 : options.layer
|
|
169
|
+
};
|
|
170
|
+
bindingsRef.current.set(nodeId, registration);
|
|
171
|
+
}, []);
|
|
172
|
+
const unregisterKeybindings = useCallback2((nodeId) => {
|
|
173
|
+
bindingsRef.current.delete(nodeId);
|
|
174
|
+
}, []);
|
|
175
|
+
const getNodeBindings = useCallback2((nodeId) => {
|
|
176
|
+
return bindingsRef.current.get(nodeId);
|
|
177
|
+
}, []);
|
|
178
|
+
const setTrap = useCallback2((nodeId) => {
|
|
179
|
+
trapNodeIdRef.current = nodeId;
|
|
180
|
+
}, []);
|
|
181
|
+
const clearTrap = useCallback2((nodeId) => {
|
|
182
|
+
if (trapNodeIdRef.current === nodeId) {
|
|
183
|
+
trapNodeIdRef.current = null;
|
|
184
|
+
}
|
|
185
|
+
}, []);
|
|
186
|
+
const getTrapNodeId = useCallback2(() => {
|
|
187
|
+
return trapNodeIdRef.current;
|
|
188
|
+
}, []);
|
|
189
|
+
const getAllBindings = useCallback2(() => {
|
|
190
|
+
const allBindings = [];
|
|
191
|
+
bindingsRef.current.forEach((nodeBindings, nodeId) => {
|
|
192
|
+
nodeBindings.bindings.forEach((handler, key) => {
|
|
193
|
+
allBindings.push({
|
|
194
|
+
nodeId,
|
|
195
|
+
key,
|
|
196
|
+
handler,
|
|
197
|
+
layer: nodeBindings.layer
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
return allBindings;
|
|
202
|
+
}, []);
|
|
203
|
+
return /* @__PURE__ */ jsx2(
|
|
204
|
+
InputContext.Provider,
|
|
205
|
+
{
|
|
206
|
+
value: {
|
|
207
|
+
registerKeybindings,
|
|
208
|
+
unregisterKeybindings,
|
|
209
|
+
getNodeBindings,
|
|
210
|
+
setTrap,
|
|
211
|
+
clearTrap,
|
|
212
|
+
getTrapNodeId,
|
|
213
|
+
getAllBindings
|
|
214
|
+
},
|
|
215
|
+
children
|
|
216
|
+
}
|
|
217
|
+
);
|
|
218
|
+
};
|
|
219
|
+
function useInputContext() {
|
|
220
|
+
const context = useContext2(InputContext);
|
|
221
|
+
if (!context) {
|
|
222
|
+
throw new Error("useInputContext must be used within an InputProvider");
|
|
223
|
+
}
|
|
224
|
+
return context;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// src/core/input/InputRouter.tsx
|
|
228
|
+
import { useInput } from "ink";
|
|
229
|
+
|
|
230
|
+
// src/core/input/normalizeKey.ts
|
|
231
|
+
function normalizeKey(input, key) {
|
|
232
|
+
if (key.downArrow) return "down";
|
|
233
|
+
if (key.upArrow) return "up";
|
|
234
|
+
if (key.leftArrow) return "left";
|
|
235
|
+
if (key.rightArrow) return "right";
|
|
236
|
+
if (key.return) return "enter";
|
|
237
|
+
if (key.escape) return "escape";
|
|
238
|
+
if (key.tab) return "tab";
|
|
239
|
+
if (key.backspace) return "backspace";
|
|
240
|
+
if (key.delete) return "delete";
|
|
241
|
+
if (key.pageUp) return "pageup";
|
|
242
|
+
if (key.pageDown) return "pagedown";
|
|
243
|
+
if (key.home) return "home";
|
|
244
|
+
if (key.end) return "end";
|
|
245
|
+
return input;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// src/core/input/InputRouter.tsx
|
|
249
|
+
import { Fragment, jsx as jsx3 } from "react/jsx-runtime";
|
|
250
|
+
function InputRouter({ children }) {
|
|
251
|
+
const { getFocusedId, getActiveBranchPath } = useFocusContext();
|
|
252
|
+
const { getNodeBindings, getTrapNodeId } = useInputContext();
|
|
253
|
+
useInput((input, key) => {
|
|
254
|
+
const focusedId = getFocusedId();
|
|
255
|
+
if (!focusedId) return;
|
|
256
|
+
const path = getActiveBranchPath();
|
|
257
|
+
const trapNodeId = getTrapNodeId();
|
|
258
|
+
const keyName = normalizeKey(input, key);
|
|
259
|
+
for (const nodeId of path) {
|
|
260
|
+
const nodeBindings = getNodeBindings(nodeId);
|
|
261
|
+
if (!nodeBindings) continue;
|
|
262
|
+
const handler = nodeBindings.bindings.get(keyName);
|
|
263
|
+
if (handler) {
|
|
264
|
+
handler(input, key);
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
if (nodeBindings.capture && nodeBindings.onKeypress) {
|
|
268
|
+
nodeBindings.onKeypress(input, key);
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
if (nodeId === trapNodeId) {
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
});
|
|
276
|
+
return /* @__PURE__ */ jsx3(Fragment, { children });
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// src/core/input/useKeybindings.tsx
|
|
280
|
+
import { useEffect } from "react";
|
|
281
|
+
function useKeybindings(focus, bindings, options) {
|
|
282
|
+
const { registerKeybindings, unregisterKeybindings } = useInputContext();
|
|
283
|
+
registerKeybindings(focus.id, bindings, options);
|
|
284
|
+
useEffect(() => {
|
|
285
|
+
return () => unregisterKeybindings(focus.id);
|
|
286
|
+
}, [focus.id, unregisterKeybindings]);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// src/core/input/FocusTrap.tsx
|
|
290
|
+
import { useEffect as useEffect2 } from "react";
|
|
291
|
+
import { Fragment as Fragment2, jsx as jsx4 } from "react/jsx-runtime";
|
|
292
|
+
function FocusTrap({ children }) {
|
|
293
|
+
const { id } = useFocus();
|
|
294
|
+
const { setTrap, clearTrap } = useInputContext();
|
|
295
|
+
useEffect2(() => {
|
|
296
|
+
setTrap(id);
|
|
297
|
+
return () => clearTrap(id);
|
|
298
|
+
}, [id, setTrap, clearTrap]);
|
|
299
|
+
return /* @__PURE__ */ jsx4(Fragment2, { children });
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// src/core/focus/FocusBindContext.tsx
|
|
303
|
+
import { createContext as createContext3, useContext as useContext3 } from "react";
|
|
304
|
+
var FocusBindContext = createContext3(null);
|
|
305
|
+
|
|
306
|
+
// src/core/focus/useFocus.ts
|
|
307
|
+
import { useContext as useContext4, useEffect as useEffect3, useId } from "react";
|
|
308
|
+
var useFocus = () => {
|
|
309
|
+
const nodeId = useId();
|
|
310
|
+
const parentId = useContext4(FocusNodeContext);
|
|
311
|
+
const { focusNode, registerNode, unregisterNode, isFocused } = useFocusContext();
|
|
312
|
+
useEffect3(() => {
|
|
313
|
+
registerNode(nodeId, parentId);
|
|
314
|
+
return () => {
|
|
315
|
+
unregisterNode(nodeId);
|
|
316
|
+
};
|
|
317
|
+
}, [nodeId, parentId, registerNode, unregisterNode]);
|
|
318
|
+
return {
|
|
319
|
+
id: nodeId,
|
|
320
|
+
focused: isFocused(nodeId),
|
|
321
|
+
focus: () => focusNode(nodeId)
|
|
322
|
+
};
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
// src/core/focus/FocusGroup.tsx
|
|
326
|
+
import { jsx as jsx5 } from "react/jsx-runtime";
|
|
327
|
+
function FocusGroup({
|
|
328
|
+
children,
|
|
329
|
+
direction = "vertical",
|
|
330
|
+
value,
|
|
331
|
+
wrap = true,
|
|
332
|
+
navigable = true
|
|
333
|
+
}) {
|
|
334
|
+
const { id } = useFocus();
|
|
335
|
+
const { focusNode, navigateSibling } = useFocusContext();
|
|
336
|
+
const bindMapRef = useRef3(/* @__PURE__ */ new Map());
|
|
337
|
+
const register = useCallback3((logicalId, nodeId) => {
|
|
338
|
+
bindMapRef.current.set(logicalId, nodeId);
|
|
339
|
+
}, []);
|
|
340
|
+
const unregister = useCallback3((logicalId) => {
|
|
341
|
+
bindMapRef.current.delete(logicalId);
|
|
342
|
+
}, []);
|
|
343
|
+
useEffect4(() => {
|
|
344
|
+
if (value) {
|
|
345
|
+
const nodeId = bindMapRef.current.get(value);
|
|
346
|
+
if (nodeId) {
|
|
347
|
+
focusNode(nodeId);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
}, [value, focusNode]);
|
|
351
|
+
const bindContextValue = value ? { register, unregister } : null;
|
|
352
|
+
const navigationKeys = useMemo(() => {
|
|
353
|
+
if (!navigable) return {};
|
|
354
|
+
const next = () => navigateSibling("next", wrap);
|
|
355
|
+
const prev = () => navigateSibling("prev", wrap);
|
|
356
|
+
return direction === "vertical" ? {
|
|
357
|
+
j: next,
|
|
358
|
+
k: prev,
|
|
359
|
+
down: next,
|
|
360
|
+
up: prev
|
|
361
|
+
} : {
|
|
362
|
+
l: next,
|
|
363
|
+
h: prev,
|
|
364
|
+
right: next,
|
|
365
|
+
left: prev
|
|
366
|
+
};
|
|
367
|
+
}, [navigable, direction, wrap, navigateSibling]);
|
|
368
|
+
useKeybindings({ id }, navigationKeys);
|
|
369
|
+
return /* @__PURE__ */ jsx5(FocusNodeContext.Provider, { value: id, children: /* @__PURE__ */ jsx5(FocusBindContext.Provider, { value: bindContextValue, children }) });
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// src/core/focus/useFocusState.ts
|
|
373
|
+
import { useState as useState2 } from "react";
|
|
374
|
+
function useFocusState(initial) {
|
|
375
|
+
const [focused, setFocused] = useState2(initial);
|
|
376
|
+
return [focused, setFocused];
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// src/core/GigglesProvider.tsx
|
|
380
|
+
import { jsx as jsx6 } from "react/jsx-runtime";
|
|
381
|
+
function GigglesProvider({ children }) {
|
|
382
|
+
return /* @__PURE__ */ jsx6(FocusProvider, { children: /* @__PURE__ */ jsx6(InputProvider, { children: /* @__PURE__ */ jsx6(InputRouter, { children }) }) });
|
|
383
|
+
}
|
|
384
|
+
export {
|
|
385
|
+
FocusGroup,
|
|
386
|
+
FocusTrap,
|
|
387
|
+
GigglesProvider,
|
|
388
|
+
useFocus,
|
|
389
|
+
useFocusState,
|
|
390
|
+
useKeybindings
|
|
391
|
+
};
|
package/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "giggles",
|
|
3
|
-
"version": "0.0
|
|
3
|
+
"version": "0.1.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
7
|
-
"url": "git+https://github.com/zion-off/
|
|
7
|
+
"url": "git+https://github.com/zion-off/giggles.git"
|
|
8
8
|
},
|
|
9
9
|
"type": "module",
|
|
10
10
|
"main": "dist/index.js",
|
|
@@ -31,13 +31,13 @@
|
|
|
31
31
|
"ink": "^6.0.0",
|
|
32
32
|
"react": "^18.0.0 || ^19.0.0"
|
|
33
33
|
},
|
|
34
|
-
"dependencies": {},
|
|
35
34
|
"devDependencies": {
|
|
36
35
|
"@trivago/prettier-plugin-sort-imports": "^4.3.0",
|
|
37
36
|
"@types/node": "^22.0.0",
|
|
38
37
|
"@types/react": "^19.2.13",
|
|
39
38
|
"eslint": "^9.0.0",
|
|
40
39
|
"eslint-plugin-react": "^7.32.2",
|
|
40
|
+
"eslint-plugin-react-hooks": "^7.0.1",
|
|
41
41
|
"ink": "^6.6.0",
|
|
42
42
|
"nodemon": "^3.1.11",
|
|
43
43
|
"prettier": "^2.8.7",
|