Skip to content

Refactor focus system to external FocusStore#44

Merged
zion-off merged 29 commits intomainfrom
input-bugs
Feb 24, 2026
Merged

Refactor focus system to external FocusStore#44
zion-off merged 29 commits intomainfrom
input-bugs

Conversation

@zion-off
Copy link
Owner

Summary

  • Replaces the React context-based focus tree with a plain TypeScript FocusStore class, subscribed via useSyncExternalStore
  • Introduces useFocusScope / <FocusScope> / useFocusNode as the new public API, replacing FocusGroup, useFocus, and the old FocusHandle type
  • Moves the keybinding registry and input dispatch into FocusStore, slimming InputRouter to a thin bridge
  • Adds passive scope support: calling escape() from keybindings yields input back to the parent scope without unmounting, enabling same-key navigation at multiple nesting levels
  • Migrates all UI components (Select, MultiSelect, Autocomplete, TextInput, Confirm) and the router's ScreenEntry to the new API; removes all legacy focus files
  • Updates public API docs (focus.mdx, input.mdx, core-concepts.mdx) and all live demo components to reflect the new API

Test plan

  • Run pnpm dev and verify the playground renders correctly and keyboard navigation works
  • Verify npx tsc --noEmit passes with zero errors
  • Check live docs demos: navigable menu, nested navigation (passive scopes), file tree, file manager search, capture mode form, focus trap modal
  • Confirm focus restores correctly when navigating between router screens

- Changed to Map<nodeId, Map<registrationId, BindingRegistration>>
- Merge all registrations for a node with later keys overriding earlier
- Only activate bindings from capture-mode registrations when capture is enabled
- This allows different handlers for the same key in different modes
- Use useId() to generate unique registration IDs
- Register bindings in component body instead of useEffect
- Only cleanup in useEffect to prevent re-registration issues
- Enables multiple useKeybindings calls to compose properly
- Check capture mode BEFORE checking named bindings
- Keys not in passthrough go to onKeypress immediately
- Keys in passthrough continue to named bindings
- Makes text input more intuitive - capture intercepts first
- Three text input fields with navigation and edit modes
- Shows different bindings for same keys in different modes
- enter: starts editing in nav mode, moves to next field in edit mode
- Demonstrates clean multi-registration pattern
file-list.tsx:
- Expanded from 3 to 7 files with metadata
- Added search mode with live filtering
- Added delete and open file operations
- Shows multi-registration pattern with search capture mode

focus-trap.tsx:
- Enhanced with confirmation dialogs
- Added action tracking and visual feedback
- Added black background to modal for better visibility
- Better demonstrates input isolation
input.mdx:
- Document multi-registration behavior with unique IDs
- Clarify that capture-mode bindings only activate when capture is enabled
- Update capture mode priority explanation
- Add new capture mode contact form example

core-concepts.mdx:
- Update input routing priority order (capture first)
- Remove unnecessary internal implementation details
Pure TypeScript store with no React dependency. Implements the full
focus tree: registerNode (with reverse-scan orphan adoption and
pending focusFirstChild fulfillment), unregisterNode (with ancestor
refocus via persistent parentMap), focusNode (with passive scope
transitions), focusFirstChild (with deferred queue), navigateSibling
(with shallow parameter), isFocused (ancestor walk), isPassive,
makePassive, getActiveBranchPath, setTrap/clearTrap, subscribe/notify.
…ocusScope)

Implements the React integration for the external focus store:

- StoreContext: React context for the FocusStore instance, plus ScopeIdContext
  for implicit parent discovery and a useStore() accessor
- useFocusScope: hook that registers a scope node in the store and returns
  a reactive handle (hasFocus, isPassive) via useSyncExternalStore
- FocusScope: component that sets ScopeIdContext so child components can
  discover their parent scope without explicit prop threading
- GigglesProvider: creates a FocusStore instance (once, via useRef) and
  provides it via StoreContext alongside the existing legacy providers

The old FocusContext/FocusProvider remains in place; UI components will be
migrated in a later chunk.
- FocusStore gains registerKeybindings, unregisterKeybindings,
  getNodeBindings, and getAllBindings. The two-level nodeId → registrationId
  map and the per-registration merge logic are ported from InputContext.

- useKeybindings now calls store.registerKeybindings directly, synchronously
  during render, so handlers always reflect the latest closed-over values.

- InputRouter and useKeybindingRegistry read node bindings, trap ID, and
  all bindings from the store. The active branch path still comes from
  FocusContext until the focus tree itself moves in the next chunk.

- FocusTrap calls store.setTrap/clearTrap instead of InputContext.

- InputProvider and InputContext are deleted; GigglesProvider no longer
  wraps children in InputProvider.
FocusStore gains store.dispatch(input, key) — the full dispatch algorithm:
passive-scope skipping (the key new capability), capture mode, trap boundary,
and mounted-binding fallback. normalizeKey is called inside the store.

FocusContext is updated to dual-register every node in the store and to call
store.focusNode whenever it moves focus. This keeps the store's active branch
path and focusedId in sync with FocusContext's tree so that store.dispatch
walks the correct nodes while old UI components still register through
FocusContext. The bridge is removed in chunk #6.

InputRouter is reduced to a single line:
  useInput((input, key) => store.dispatch(input, key))
The inline dispatch loop and its useFocusContext/useStore calls are gone.
useFocusScope now accepts a keybindings option (plain object or callback).
When a callback is provided it receives six navigation helpers:

  next/prev         — navigate siblings, auto-drill into first leaf
  nextShallow/prevShallow — navigate siblings, land on scope node
  escape            — focus own scope node, mark passive
  drillIn           — focus first child of this scope, clears passive

All helpers are stable references (useCallback with stable deps). Bindings
register synchronously during render so closures are always fresh.

useKeybindings now accepts { id: string } instead of FocusHandle, making it
compatible with FocusScopeHandle and any future handle type.

FocusScopeHelpers is exported from the focus package.
useFocusNode is now store-based: registers in FocusStore via useEffect,
reads hasFocus reactively via useSyncExternalStore. The old focused/focus()
shape is gone.

UI components (Select, TextInput, Autocomplete, MultiSelect, Confirm) updated
to use focus.hasFocus. Viewport, Modal, and CommandPalette needed no changes.

FocusTrap now uses the store directly for setTrap/clearTrap/focusFirstChild/
focusNode/getFocusedId. Children are scoped via ScopeIdContext.Provider.

ScreenEntry (router) migrated from FocusContext to useFocusNode + store.

useKeybindingRegistry now reads the active branch path from the store
instead of FocusContext.

GigglesProvider no longer wraps children in FocusProvider — the store is
the only focus system.

Deleted: FocusContext, FocusGroup, useFocus, FocusBindContext, types (FocusHandle),
useFocusState. The dual-registration bridge from chunk #4 is gone.

Public API: FocusGroup/useFocus/FocusHandle/FocusGroupHelpers removed.
New exports: useFocusScope, FocusScope, useFocusNode (new), FocusScopeHandle,
FocusScopeHelpers, FocusScopeOptions, FocusNodeHandle.
Both components now support controlled and uncontrolled modes.
Omitting value/onChange lets the component manage selection state
internally; passing them opts into the controlled pattern.

MultiSelect uses internal state for toggle logic when uncontrolled.
… API docs

Replace hand-rolled PanelItem, FileItem, and FileList components with
Select in the focus examples. Passive mode and shallow navigation remain
the teaching focus; Select handles internal list navigation.

Update Select and MultiSelect API reference to document controlled vs
uncontrolled modes and mark value/onChange as optional.
Add concurrently so both processes share a terminal and are torn down
together on exit. Initial build still runs first to ensure dist/ exists
before Next.js starts.
Spreads BoxProps (excluding height, overflow, flexDirection) onto the
outer container, allowing callers to set borderStyle, borderColor,
padding, width, etc. directly on Viewport.
…rops

Replace artificial line array with paragraph text to demonstrate
automatic wrap-height measurement. Add rounded border using theme
borderColor to the live demo. Remove stale FocusGroup callout.
Add footer and ...boxProps to API reference table.
… editing keys

Add borderStyle to GigglesTheme (optional, defaults to 'round'). CodeBlock,
Panel, and Modal now read border style from the theme instead of hardcoding it.

Fix TextInput keybinding bug where capture mode intercepted backspace, delete,
and cursor movement keys before named handlers could fire. Add these keys to
the passthrough list so they reach their handlers correctly.

Add placeholder to TextInputRenderProps so custom renders can implement
cursor-on-placeholder behaviour. Update default render to show cursor at
position 0 of placeholder text when focused and empty.
…ferences

Add live demo showing a vim-style search box using the render prop, with
cursor-on-placeholder behaviour and theme-aware border style.

Fix FocusGroup reference in Form with Submit example (deleted API).
Update placeholder description to reflect new focused cursor behaviour.
Add placeholder to TextInputRenderProps table.
@zion-off zion-off merged commit 1393578 into main Feb 24, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant