giggles 0.6.0 → 0.6.2

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 CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  # giggles
6
6
 
7
- ![giggles](https://github.com/user-attachments/assets/c5c7ef05-232f-4180-8b85-0160fb0f083a)
7
+ <img src="https://github.com/user-attachments/assets/c5c7ef05-232f-4180-8b85-0160fb0f083a" width="700" alt="giggles">
8
8
 
9
9
  giggles is a batteries-included react framework for building terminal apps. built on ink, it handles focus, input routing, screen navigation, and theming out of the box so you can skip the plumbing and build.
10
10
 
@@ -30,3 +30,83 @@ npx create-giggles-app
30
30
  ```
31
31
 
32
32
  see [giggles.zzzzion.com](https://giggles.zzzzion.com) for API documentation and live demos.
33
+
34
+ ## giggles/ui
35
+
36
+ ### [select](https://giggles.zzzzion.com/ui/select)
37
+
38
+ <img src="https://github.com/user-attachments/assets/8ce13f75-7a7b-4123-a973-f2992193bf84" width="500" alt="select">
39
+
40
+ ### [multi select](https://giggles.zzzzion.com/ui/multi-select)
41
+
42
+ <img src="https://github.com/user-attachments/assets/24f5d625-6e46-4cb1-8d22-42d40eb48f56" width="500" alt="multi-select">
43
+
44
+ ### [markdown](https://giggles.zzzzion.com/ui/markdown)
45
+
46
+ <img src="https://github.com/user-attachments/assets/1cdb6e84-c714-470a-8cf0-b6abf68b78a9" width="500" alt="markdown">
47
+
48
+ ### [text input](https://giggles.zzzzion.com/ui/text-input)
49
+
50
+ <img src="https://github.com/user-attachments/assets/b56056ca-97e2-4dc2-a1eb-2ee3f4d559e7" width="500" alt="text-input">
51
+
52
+ ### [viewport](https://giggles.zzzzion.com/ui/viewport)
53
+
54
+ <img src="https://github.com/user-attachments/assets/56c6cb6b-b2a7-4803-bed4-c6c6c34042c3" width="500" alt="viewport">
55
+
56
+ ### [code block](https://giggles.zzzzion.com/ui/codeblock)
57
+
58
+ <img src="https://github.com/user-attachments/assets/283dbd7e-326c-4acb-b8c0-d3608722952b" width="500" alt="codeblock">
59
+
60
+ ### [confirm](https://giggles.zzzzion.com/ui/confirm)
61
+
62
+ <img src="https://github.com/user-attachments/assets/b887da72-bf02-4084-b846-8b90cc3c3487" width="500" alt="confirm">
63
+
64
+ ### [spinner](https://giggles.zzzzion.com/ui/spinner)
65
+
66
+ <img src="https://github.com/user-attachments/assets/71aef7f8-e53b-4876-864f-b9b1a5100c5d" width="500" alt="spinner">
67
+
68
+ ### [modal](https://giggles.zzzzion.com/ui/modal)
69
+
70
+ <img src="https://github.com/user-attachments/assets/7415c554-927e-4b5c-91d7-7e3b1f4ea0ca" width="500" alt="modal">
71
+
72
+ ### [paginator](https://giggles.zzzzion.com/ui/paginator)
73
+
74
+ <img src="https://github.com/user-attachments/assets/b0780f46-848e-4881-822a-86d7db02d212" width="500" alt="paginator">
75
+
76
+ ### [autocomplete](https://giggles.zzzzion.com/ui/autocomplete)
77
+
78
+ <img src="https://github.com/user-attachments/assets/aa76dd7a-5357-4969-a979-95975b5ec578" width="500" alt="autocomplete">
79
+
80
+ ### [command palette](https://giggles.zzzzion.com/ui/command-palette)
81
+
82
+ <img src="https://github.com/user-attachments/assets/30886cb5-986f-4477-85c1-61cba4b499aa" width="500" alt="command-palette">
83
+
84
+ ### [virtual list](https://giggles.zzzzion.com/ui/virtual-list)
85
+
86
+ <img src="https://github.com/user-attachments/assets/d3ef1d92-813c-4546-8d60-2c38745ddbbc" width="500" alt="virtual-list">
87
+
88
+ ### [badge](https://giggles.zzzzion.com/ui/badge)
89
+
90
+ <img src="https://github.com/user-attachments/assets/b144c3f5-8b3b-4236-abf2-fc239d23f0c6" width="500" alt="badge">
91
+
92
+ ### [panel](https://giggles.zzzzion.com/ui/panel)
93
+
94
+ <img src="https://github.com/user-attachments/assets/9831d73a-baa9-410a-b933-e0dfd9433604" width="500" alt="panel">
95
+
96
+ ## giggles/terminal
97
+
98
+ ### [useShellOut](https://giggles.zzzzion.com/terminal#useshellout)
99
+
100
+ suspend the UI, hand off the terminal to an external program like `vim` or `less`, and resume cleanly when it exits
101
+
102
+ ### [useSpawn](https://giggles.zzzzion.com/terminal#usespawn)
103
+
104
+ spawn a child process and stream its stdout/stderr output into your UI — with support for colored output via a pty
105
+
106
+ ### [useTerminalSize](https://giggles.zzzzion.com/terminal#useterminalsize)
107
+
108
+ reactively track the terminal's current dimensions (rows and columns), updating on resize
109
+
110
+ ### [useTerminalFocus](https://giggles.zzzzion.com/terminal#useterminalfocus)
111
+
112
+ detect when the terminal window gains or loses focus
@@ -36,8 +36,13 @@ function useKeybindings(focus, bindings, options) {
36
36
  }
37
37
 
38
38
  // src/core/input/useKeybindingRegistry.ts
39
+ import { useSyncExternalStore } from "react";
39
40
  function useKeybindingRegistry(focus) {
40
41
  const store = useStore();
42
+ useSyncExternalStore(
43
+ (cb) => store.subscribe(cb),
44
+ () => store.getVersion()
45
+ );
41
46
  const all = store.getAllBindings().filter((b) => b.name != null);
42
47
  const branchPath = store.getActiveBranchPath();
43
48
  const branchSet = new Set(branchPath);
@@ -47,15 +52,24 @@ function useKeybindingRegistry(focus) {
47
52
  const trapIndex = branchPath.indexOf(trapNodeId);
48
53
  return trapIndex >= 0 ? new Set(branchPath.slice(0, trapIndex + 1)) : null;
49
54
  })();
50
- const available = all.filter((b) => {
51
- return (withinTrapSet ?? branchSet).has(b.nodeId);
52
- });
55
+ const availableSet = withinTrapSet ?? branchSet;
56
+ const seenKeys = /* @__PURE__ */ new Set();
57
+ const available = [];
58
+ for (const nodeId of branchPath) {
59
+ if (!availableSet.has(nodeId)) continue;
60
+ for (const b of all) {
61
+ if (b.nodeId === nodeId && !seenKeys.has(b.key)) {
62
+ seenKeys.add(b.key);
63
+ available.push(b);
64
+ }
65
+ }
66
+ }
53
67
  const local = focus ? all.filter((b) => b.nodeId === focus.id) : [];
54
68
  return { all, available, local };
55
69
  }
56
70
 
57
71
  // src/core/focus/useFocusNode.ts
58
- import { useContext as useContext2, useEffect as useEffect2, useId as useId2, useMemo, useSyncExternalStore } from "react";
72
+ import { useContext as useContext2, useEffect as useEffect2, useId as useId2, useMemo, useSyncExternalStore as useSyncExternalStore2 } from "react";
59
73
  function useFocusNode(options) {
60
74
  var _a;
61
75
  const id = useId2();
@@ -69,7 +83,7 @@ function useFocusNode(options) {
69
83
  store.unregisterNode(id);
70
84
  };
71
85
  }, [id, parentId, store]);
72
- const hasFocus = useSyncExternalStore(subscribe, () => store.isFocused(id));
86
+ const hasFocus = useSyncExternalStore2(subscribe, () => store.isFocused(id));
73
87
  return { id, hasFocus };
74
88
  }
75
89
 
@@ -106,7 +120,7 @@ function InputRouter({ children }) {
106
120
  }
107
121
 
108
122
  // src/core/focus/useFocusScope.ts
109
- import { useCallback, useContext as useContext3, useEffect as useEffect4, useId as useId3, useMemo as useMemo2, useSyncExternalStore as useSyncExternalStore2 } from "react";
123
+ import { useCallback, useContext as useContext3, useEffect as useEffect4, useId as useId3, useMemo as useMemo2, useSyncExternalStore as useSyncExternalStore3 } from "react";
110
124
  function useFocusScope(options) {
111
125
  var _a;
112
126
  const id = useId3();
@@ -121,8 +135,8 @@ function useFocusScope(options) {
121
135
  store.unregisterNode(id);
122
136
  };
123
137
  }, [id, parentId, store]);
124
- const hasFocus = useSyncExternalStore2(subscribe, () => store.isFocused(id));
125
- const isPassive = useSyncExternalStore2(subscribe, () => store.isPassive(id));
138
+ const hasFocus = useSyncExternalStore3(subscribe, () => store.isFocused(id));
139
+ const isPassive = useSyncExternalStore3(subscribe, () => store.isPassive(id));
126
140
  const next = useCallback(() => store.navigateSibling("next", true, id), [store, id]);
127
141
  const prev = useCallback(() => store.navigateSibling("prev", true, id), [store, id]);
128
142
  const nextShallow = useCallback(() => store.navigateSibling("next", true, id, true), [store, id]);
@@ -136,12 +150,25 @@ function useFocusScope(options) {
136
150
  store.unregisterKeybindings(id, keybindingRegistrationId);
137
151
  };
138
152
  }, [id, keybindingRegistrationId, store]);
139
- return { id, hasFocus, isPassive };
153
+ useEffect4(() => {
154
+ if (!store.hasFocusScopeComponent(id)) {
155
+ throw new GigglesError(
156
+ "useFocusScope() was called but no <FocusScope handle={scope}> was rendered. Every useFocusScope() call requires a corresponding <FocusScope> in the render output \u2014 without it, child components register under the wrong parent scope and keyboard navigation silently breaks."
157
+ );
158
+ }
159
+ }, [id, store]);
160
+ return { id, hasFocus, isPassive, next, prev, nextShallow, prevShallow, escape, drillIn };
140
161
  }
141
162
 
142
163
  // src/core/focus/FocusScope.tsx
164
+ import { useEffect as useEffect5 } from "react";
143
165
  import { jsx as jsx3 } from "react/jsx-runtime";
144
166
  function FocusScope({ handle, children }) {
167
+ const store = useStore();
168
+ useEffect5(() => {
169
+ store.registerFocusScopeComponent(handle.id);
170
+ return () => store.unregisterFocusScopeComponent(handle.id);
171
+ }, [handle.id, store]);
145
172
  return /* @__PURE__ */ jsx3(ScopeIdContext.Provider, { value: handle.id, children });
146
173
  }
147
174
 
@@ -177,7 +204,9 @@ var FocusStore = class {
177
204
  passiveSet = /* @__PURE__ */ new Set();
178
205
  pendingFocusFirstChild = /* @__PURE__ */ new Set();
179
206
  trapNodeId = null;
207
+ renderedScopes = /* @__PURE__ */ new Set();
180
208
  listeners = /* @__PURE__ */ new Set();
209
+ version = 0;
181
210
  // nodeId → registrationId → BindingRegistration
182
211
  // Keybindings register synchronously during render; nodes register in useEffect.
183
212
  // A keybinding may exist for a node that has not yet appeared in the node tree —
@@ -191,10 +220,14 @@ var FocusStore = class {
191
220
  return () => this.listeners.delete(listener);
192
221
  }
193
222
  notify() {
223
+ this.version++;
194
224
  for (const listener of this.listeners) {
195
225
  listener();
196
226
  }
197
227
  }
228
+ getVersion() {
229
+ return this.version;
230
+ }
198
231
  // ---------------------------------------------------------------------------
199
232
  // Registration
200
233
  // ---------------------------------------------------------------------------
@@ -377,6 +410,15 @@ var FocusStore = class {
377
410
  // ---------------------------------------------------------------------------
378
411
  // Trap
379
412
  // ---------------------------------------------------------------------------
413
+ registerFocusScopeComponent(id) {
414
+ this.renderedScopes.add(id);
415
+ }
416
+ unregisterFocusScopeComponent(id) {
417
+ this.renderedScopes.delete(id);
418
+ }
419
+ hasFocusScopeComponent(id) {
420
+ return this.renderedScopes.has(id);
421
+ }
380
422
  setTrap(nodeId) {
381
423
  this.trapNodeId = nodeId;
382
424
  }
package/dist/index.d.ts CHANGED
@@ -41,8 +41,6 @@ type FocusScopeHandle = {
41
41
  id: string;
42
42
  hasFocus: boolean;
43
43
  isPassive: boolean;
44
- };
45
- type FocusScopeHelpers = {
46
44
  next: () => void;
47
45
  prev: () => void;
48
46
  nextShallow: () => void;
@@ -50,6 +48,7 @@ type FocusScopeHelpers = {
50
48
  escape: () => void;
51
49
  drillIn: () => void;
52
50
  };
51
+ type FocusScopeHelpers = Pick<FocusScopeHandle, 'next' | 'prev' | 'nextShallow' | 'prevShallow' | 'escape' | 'drillIn'>;
53
52
  type FocusScopeOptions = {
54
53
  parent?: FocusScopeHandle;
55
54
  keybindings?: Keybindings | ((helpers: FocusScopeHelpers) => Keybindings);
package/dist/index.js CHANGED
@@ -14,7 +14,7 @@ import {
14
14
  useKeybindingRegistry,
15
15
  useKeybindings,
16
16
  useStore
17
- } from "./chunk-QV7GQTKO.js";
17
+ } from "./chunk-TWXBZE5C.js";
18
18
  import {
19
19
  ThemeProvider,
20
20
  useTheme
@@ -4,6 +4,11 @@ type TerminalSize = {
4
4
  rows: number;
5
5
  columns: number;
6
6
  };
7
+ type ShellOutHandle = {
8
+ run: (command: string) => Promise<{
9
+ exitCode: number;
10
+ }>;
11
+ };
7
12
  type SpawnOptions = SpawnOptionsWithoutStdio & {
8
13
  /**
9
14
  * Inject FORCE_COLOR=1 and TERM=xterm-256color into the child process
@@ -28,12 +33,8 @@ declare function useTerminalSize(): TerminalSize;
28
33
 
29
34
  declare function useTerminalFocus(callback: (focused: boolean) => void): void;
30
35
 
31
- declare function useShellOut(): {
32
- run: (command: string) => Promise<{
33
- exitCode: number;
34
- }>;
35
- };
36
+ declare function useShellOut(): ShellOutHandle;
36
37
 
37
38
  declare function useSpawn(): SpawnHandle;
38
39
 
39
- export { type SpawnHandle, type SpawnOptions, type SpawnOutputLine, type TerminalSize, useShellOut, useSpawn, useTerminalFocus, useTerminalSize };
40
+ export { type ShellOutHandle, type SpawnHandle, type SpawnOptions, type SpawnOutputLine, type TerminalSize, useShellOut, useSpawn, useTerminalFocus, useTerminalSize };
package/dist/ui/index.js CHANGED
@@ -4,7 +4,7 @@ import {
4
4
  useFocusNode,
5
5
  useKeybindingRegistry,
6
6
  useKeybindings
7
- } from "../chunk-QV7GQTKO.js";
7
+ } from "../chunk-TWXBZE5C.js";
8
8
  import {
9
9
  CodeBlock
10
10
  } from "../chunk-SKSDNDQF.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "giggles",
3
- "version": "0.6.0",
3
+ "version": "0.6.2",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -40,7 +40,8 @@
40
40
  "scripts": {
41
41
  "build": "tsup",
42
42
  "build:watch": "nodemon --watch src --ext ts,tsx --exec tsup",
43
- "play": "tsx --watch",
43
+ "play": "f() { tsx --watch playground/examples/$1.tsx; }; f",
44
+ "record": "f() { vhs playground/tapes/$1.tape; }; f",
44
45
  "dev:docs": "pnpm build && concurrently --kill-others \"pnpm build:watch\" \"pnpm --filter documentation dev\"",
45
46
  "lint": "prettier --write . && eslint . --fix"
46
47
  },