Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
161 changes: 78 additions & 83 deletions documentation/content/docs/core/input.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -52,26 +52,19 @@ Registers keybindings that only fire when the component is focused.

You can call `useKeybindings` multiple times in the same component — bindings from all calls are merged together. Later calls override earlier ones for duplicate keys.

**Capture mode:** When `capture: true` is active, named bindings are checked first. If a key has no named binding, it falls through to `onKeypress`. Keys in `passthrough` skip `onKeypress` and bubble to parent scopes instead. This makes text input intuitive:
For text input scenarios, pass a `fallback` handler to catch keys that don't match any named binding. Use `bubble` to let specific keys (like tab or escape) propagate to parent scopes instead:

```tsx
// These navigation bindings work normally
useKeybindings(focus, {
j: moveDown,
k: moveUp,
enter: select,
escape: clearInput
});

// When searchMode is true, capture intercepts j/k for text input
// but escape/enter pass through to named bindings above
useKeybindings(
focus,
{ enter: confirmSearch }, // Override enter when searching
{ capture: searchMode, passthrough: ['escape', 'enter'] }
);
...(!searchMode && { j: moveDown, k: moveUp, enter: select }),
escape: clearInput,
}, searchMode ? { fallback: handleSearchInput, bubble: ['escape'] } : undefined);
```

<Callout type="info">
Named bindings always take priority over the fallback, across all `useKeybindings` calls on the same component. If a key like `j` is registered as a named binding in one call and the fallback should own it in another mode, disable it while the fallback is active using conditional spreading: `...(!mode && { j: handler })`.
</Callout>

```ts title="Type Signature"
function useKeybindings(
focus: { id: string },
Expand All @@ -95,7 +88,7 @@ function useKeybindings(
required: true,
},
options: {
description: 'Optional configuration for capture mode',
description: 'Optional fallback and bubble configuration for text input scenarios',
type: 'KeybindingOptions',
},
}}
Expand Down Expand Up @@ -157,31 +150,23 @@ Use these strings as keys in the bindings object:

#### KeybindingOptions

Capture mode is designed for text input — it routes unhandled keypresses to `onKeypress` so printable characters reach your handler. Named bindings always take priority and fire before capture mode is considered.

**Dispatch order:**
Use these options to build text inputs. Named bindings are always checked first — the `fallback` only fires for keys that don't match any binding.

1. **Named bindings** — always checked first, capture mode or not
2. **`onKeypress`** — called if no named binding matched and the key is not in `passthrough`
3. **Parent scope** — key bubbles up if it's in `passthrough` and has no named binding

<Callout type="warn">
Named bindings registered with capture mode options (i.e., when `onKeypress` is provided) are only active when `capture: true`. When `capture: false`, those bindings are inactive, allowing other registrations to handle those keys. This lets you register the same key (like `enter`) with different behaviors for navigation mode vs. input mode.
</Callout>
| Priority | What happens |
|---|---|
| 1. Named binding matches | Handler fires, key stops |
| 2. Key is in `bubble` | Key propagates to parent scope |
| 3. `fallback` exists | Fallback handler fires |
| 4. Nothing matched | Key propagates to parent scope |

<TypeTable
type={{
capture: {
description: 'When enabled, unmatched keystrokes go to onKeypress instead of being handled by parent components',
type: 'boolean',
default: 'false',
},
onKeypress: {
description: 'Handler for keystrokes in capture mode that don\'t match named bindings',
fallback: {
description: 'Handler for keystrokes that don\'t match named bindings. When provided, unmatched keys are routed here instead of bubbling to parent components.',
type: '(input: string, key: Key) => void',
},
passthrough: {
description: 'Key names that skip onKeypress and bubble to parent components instead. Use for keys like tab and escape that should always reach parent scopes.',
bubble: {
description: 'Key names that skip the fallback and bubble to parent components instead. Use for keys like tab and escape that should always reach parent scopes.',
type: 'string[]',
},
}}
Expand Down Expand Up @@ -292,7 +277,7 @@ function App() {

### File manager with search

This example demonstrates multiple keybindings, capture mode for search input, and dynamic filtering:
Navigate a file list with `j`/`k`, press `/` to search. The search mode uses a `fallback` handler to catch typed characters while letting `escape` and `enter` bubble to the navigation bindings:

```tsx title="File Manager"
import { useFocusNode, useKeybindings } from 'giggles';
Expand Down Expand Up @@ -320,31 +305,35 @@ function FileManager() {
setFiles(files.filter(f => f.name !== fileToDelete.name));
};

// Navigation and actions
// Navigation bindings — disabled during search so typed characters reach the fallback
useKeybindings(focus, {
j: () => setSelected((i) => Math.min(filteredFiles.length - 1, i + 1)),
k: () => setSelected((i) => Math.max(0, i - 1)),
enter: () => console.log('Open', filteredFiles[selected].name),
d: deleteFile,
'/': () => setSearchMode(true),
...(!searchMode && {
j: () => setSelected((i) => Math.min(filteredFiles.length - 1, i + 1)),
k: () => setSelected((i) => Math.max(0, i - 1)),
enter: () => console.log('Open', filteredFiles[selected].name),
d: deleteFile,
'/': () => setSearchMode(true),
}),
escape: () => setSearchMode(false)
});

// Search mode - capture intercepts j/k for text input
// Search mode — fallback catches all typed characters;
// escape and enter bubble to the bindings above
useKeybindings(
focus,
{ enter: () => setSearchMode(false) }, // Override enter when searching
{
capture: searchMode,
passthrough: ['escape', 'enter'],
onKeypress: (input, key) => {
if (key.backspace) {
setSearchQuery(q => q.slice(0, -1));
} else if (input) {
setSearchQuery(q => q + input);
searchMode ? { enter: () => setSearchMode(false) } : {},
searchMode
? {
fallback: (input, key) => {
if (key.backspace) {
setSearchQuery(q => q.slice(0, -1));
} else if (input) {
setSearchQuery(q => q + input);
}
},
bubble: ['escape', 'enter'],
}
},
}
: undefined
);

return (
Expand All @@ -366,9 +355,9 @@ function FileManager() {
<FileListExample />
</Terminal>

### Text input with capture mode
### Text input with mode switching

Capture mode is essential for building text inputs. This form demonstrates switching between navigation and editing modes:
A form that switches between navigation (`j`/`k` to move between fields) and editing (press `enter` to type). When editing, the `fallback` handler catches typed characters:

```tsx title="Contact Form"
import { useFocusNode, useKeybindings } from 'giggles';
Expand All @@ -384,40 +373,46 @@ function ContactForm() {
const fields = ['name', 'email', 'message'];
const currentIndex = fields.indexOf(activeField);

// Navigation keybindings
// Navigation keybindings — disabled while editing so typed characters reach the fallback
useKeybindings(focus, {
j: () => setActiveField(fields[Math.min(currentIndex + 1, 2)]),
k: () => setActiveField(fields[Math.max(currentIndex - 1, 0)]),
enter: () => setEditing(true),
...(!editing && {
j: () => setActiveField(fields[Math.min(currentIndex + 1, 2)]),
k: () => setActiveField(fields[Math.max(currentIndex - 1, 0)]),
enter: () => setEditing(true),
}),
escape: () => setEditing(false)
});

// Edit mode - capture intercepts j/k for text input
// Edit mode — fallback catches typed characters;
// escape and enter bubble to the bindings above
useKeybindings(
focus,
{
enter: () => {
setEditing(false);
if (currentIndex < 2) setActiveField(fields[currentIndex + 1]);
},
},
{
capture: editing,
passthrough: ['escape', 'enter'],
onKeypress: (input, key) => {
if (key.backspace) {
setFormData(prev => ({
...prev,
[activeField]: prev[activeField].slice(0, -1)
}));
} else if (input) {
setFormData(prev => ({
...prev,
[activeField]: prev[activeField] + input
}));
editing
? {
enter: () => {
setEditing(false);
if (currentIndex < 2) setActiveField(fields[currentIndex + 1]);
},
}
: {},
editing
? {
fallback: (input, key) => {
if (key.backspace) {
setFormData(prev => ({
...prev,
[activeField]: prev[activeField].slice(0, -1)
}));
} else if (input) {
setFormData(prev => ({
...prev,
[activeField]: prev[activeField] + input
}));
}
},
bubble: ['escape', 'enter'],
}
},
}
: undefined
);

return (
Expand Down
6 changes: 3 additions & 3 deletions documentation/content/docs/framework/agentic-coding.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -104,11 +104,11 @@ Register keybindings independently from focus scope navigation. You can call it
// Base navigation
useKeybindings(focus, { j: moveDown, k: moveUp });

// Search mode — adds capture when active; escape/enter pass through to named bindings
useKeybindings(focus, { escape: exitSearch }, { capture: searchMode, passthrough: ['escape'] });
// Search mode — fallback intercepts unbound keys; escape bubbles to named bindings above
useKeybindings(focus, searchMode ? { escape: exitSearch } : {}, searchMode ? { fallback: handleInput, bubble: ['escape'] } : undefined);
```

**Capture mode:** when `capture: true`, named bindings are always checked first. If no named binding matches, the key goes to `onKeypress` (for printable characters) unless it's listed in `passthrough`, in which case it bubbles to the parent scope. Use this for text input. `TextInput` and `Autocomplete` use capture mode internally.
**Fallback handler:** pass `fallback` to catch keys that don't match any named binding — useful for text input. Keys listed in `bubble` skip the fallback and propagate to parent scopes. `TextInput` and `Autocomplete` use this internally.

**App-wide shortcuts:** register them on the root scope — unhandled keys bubble up naturally, so a binding at the root fires whenever no child consumes the key first.

Expand Down
12 changes: 6 additions & 6 deletions documentation/content/docs/framework/core-concepts.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -105,11 +105,11 @@ When a key is pressed, giggles searches for a matching handler by walking up the
2. **Parent component's bindings** — then grandparent, and so on; unhandled keys bubble up naturally
3. **Passive scopes** — skipped entirely during the walk
4. **Focus trap** — stops the walk; nothing outside the trap is reachable
5. **Capture mode** — fires last, only if no named binding matched anywhere in the path
5. **Fallback handler** — fires last, only if no named binding matched anywhere in the path

This priority order means:
- Named bindings always win — even over capture mode at the same node
- Keys in `passthrough` skip `onKeypress` and bubble to parent scopes
- Named bindings always win — even over the fallback handler at the same node
- Keys in `bubble` skip the fallback and propagate to parent scopes
- Deeply nested components take precedence over their parents
- Passive scopes are transparent to input
- Modals can prevent keys from reaching background components
Expand All @@ -126,11 +126,11 @@ useKeybindings(focus, {
k: moveUp,
});

// Search mode — adds capture when active
// Search mode — fallback intercepts unbound keys when active
useKeybindings(
focus,
{ escape: exitSearch },
{ capture: searchMode, onKeypress: handleInput }
searchMode ? { escape: exitSearch } : {},
searchMode ? { fallback: handleInput } : undefined
);
```

Expand Down
2 changes: 1 addition & 1 deletion documentation/content/docs/ui/command-palette.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import CommandPaletteExample from '@/components/examples/ui/command-palette';

A compact key hints bar that collects all named keybindings registered across the app. In interactive mode, it captures input and lets the user fuzzy-search and execute commands — type to filter, left/right arrow keys to navigate, Enter to execute, Escape to close. In non-interactive mode, it displays available keybindings for the currently focused context.

Components register commands by passing a `name` field to `useKeybindings`.
Components register commands by passing a `name` field to `useKeybindings`. The default UI shows no keybinding hints of its own — use the `render` prop to add them, or display a static `<CommandPalette interactive={false} />` elsewhere in the layout.

```tsx title="Interactive (Search & Execute)"
import { useKeybindings, useFocus } from 'giggles';
Expand Down
4 changes: 2 additions & 2 deletions documentation/content/internal/architecture-focus-store.md
Original file line number Diff line number Diff line change
Expand Up @@ -359,9 +359,9 @@ The dispatch algorithm lives in `store.dispatch(input, key)`. The `InputRouter`
2. Walk the active branch path (focused node up to root). For each node:
a. If node is in the passive set → skip.
b. If node has a matching named keybinding → call handler, stop.
c. If node has capture mode active: if key is in passthrough → continue to next node; otherwise → record `pendingCapture` and continue walking (ancestor named bindings still get a chance).
c. If node has a fallback handler: if key is in bubble list → continue to next node; otherwise → record `pendingFallback` and continue walking (ancestor named bindings still get a chance).
d. If node is the trap node → break.
3. Fire `pendingCapture` if set and no named binding matched.
3. Fire `pendingFallback` if set and no named binding matched.

---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ focusFirstChild(parentId) {
for (const nodeId of path) {
const nodeBindings = getNodeBindings(nodeId);
if (nodeBindings) {
// check bindings, capture mode
// check bindings, fallback handler
}
if (nodeId === trapNodeId) {
return; // always reached, even if node has no bindings
Expand Down
20 changes: 10 additions & 10 deletions documentation/content/internal/phase-1.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,27 +91,27 @@ function App() {
}
```

### Capture Mode
### Fallback Handler

For components like text inputs that need to receive ALL keystrokes (letters, numbers, punctuation) instead of having them interpreted as keybindings.

When `capture: true` is set on the focused node:
When a `fallback` handler is set on the focused node:

- Explicit keybindings (like `escape`, `enter`) are checked first
- Everything else goes to `onKeypress` — never bubbles
- Named keybindings (like `escape`, `enter`) are checked first
- Everything else goes to `fallback` — never bubbles (unless listed in `bubble`)

```tsx
function TextInput({ onSubmit }) {
const [value, setValue] = useState('');

useKeybindings(
focus,
{
escape: () => blur(),
enter: () => onSubmit(value)
},
{
capture: true,
onKeypress: (key) => setValue((v) => v + key)
fallback: (key) => setValue((v) => v + key)
}
);

Expand All @@ -122,14 +122,14 @@ function TextInput({ onSubmit }) {
Flow comparison:

```
Normal mode (capture: false):
Without fallback:
'j' pressed → check focused node bindings → no match → bubble up → parent handles

Capture mode (capture: true):
'j' pressed → check explicit bindings (escape, enter) → no match → onKeypress → types 'j' → STOPS
With fallback:
'j' pressed → check named bindings (escape, enter) → no match → fallback → types 'j' → STOPS
```

This generalizes beyond text inputs. A confirmation dialog captures with only `y`/`n`/`Escape` as explicit bindings and swallows everything else.
This generalizes beyond text inputs. A confirmation dialog uses a fallback with only `y`/`n`/`Escape` as named bindings and swallows everything else.

---

Expand Down
Loading