Keyboard Navigation Implementation in Vanilla-Grid
This document explains how keyboard navigation is implemented in Vanilla-Grid, including event gating, key-to-scroll translation, repeat behavior, momentum guards, and interaction with wheel/custom scrollbar input.
1. Feature Scope
Keyboard navigation supports scrolling by:
ArrowUp/ArrowDownPageUp/PageDownSpace/Shift+SpaceHome/End
Behavior is integrated with virtualization, custom scrollbar state, and momentum rejection safeguards.
2. Event Wiring and Lifetime
Shared listener registry (default). Rather than every grid instance registering its own keydown/keyup (document) and resize/blur (window) listeners, vanilla-grid.js maintains a module-level singleton registry (_vgKeyboardRegistry) that installs exactly one listener of each type, regardless of how many grids are on the page. _initEventListeners() calls _vgKeyboardRegistry.add(this), which:
- lazily attaches the four shared listeners the first time any grid registers;
- on
keydown/keyup, dispatches togrid.handleKeydown(e)/grid.handleKeyup(e)for every registered instance — the per-instance gate below (shouldHandleKeyboardEvent) is unchanged and still decides whether that instance reacts, so hover-without-focus scrolling works exactly as before; - on
resize/blur, dispatches togrid._handleResize()/grid.handleWindowBlur()for every registered instance unconditionally (these were never gated by hover/focus).
destroy() calls _vgKeyboardRegistry.remove(this); the shared listeners are removed once the last registered grid is destroyed.
Escape hatch: set window.VanillaGridKeyboardSingleton = false before constructing grids to restore the previous behaviour — each instance binds and registers its own four listeners (boundKeydown, boundKeyup, boundResize, boundWindowBlur), removed individually in that instance's destroy(). Singleton and legacy-mode instances can coexist on the same page (each instance remembers which path it used via _useKeyboardSingleton, decided once in _initEventListeners()).
Cleanup in destroy() removes listeners (shared or per-instance) and resets key-scrolling state.
On window blur, handleWindowBlur() calls stopKeyScroll() and resets gesture state to avoid stuck interactions.
3. Keyboard State Model
Internal fields:
activeScrollKeyactiveScrollKeyShiftisKeyActivekeyIdleTimerkeyScrollTimer(kept for compatibility; active flow uses idle timeout)- momentum fields:
momentumGuardUntil,lastScrollInputSource,lastProgrammaticScrollAt,lastProgrammaticScrollTop
These fields allow keyboard input to coexist with wheel/drag/programmatic scroll paths.
4. Input Gating (shouldHandleKeyboardEvent)
Keyboard events are ignored when:
- no viewport exists
- target is content-editable
- target is
input,textarea,select, orbutton
Events are handled only when viewport is effectively active:
- active element is viewport
- or active element is inside viewport
- or viewport is hovered (
isViewportHovered)
This avoids hijacking typing interactions elsewhere in the page. The button exemption exists so a focused in-cell <button> — today, only row grouping's caption toggle — keeps its own native Space/Enter activation instead of the grid claiming Space as a page-down scroll. This is a shared-path rule, not grouping-specific: any future in-cell button gets the same native-activation behavior for free. Space still pages the grid normally whenever focus is on the viewport itself, not on a button (regression-tested in tests/playwright/keyboard-navigation.spec.js).
5. Keydown Flow (handleKeydown)
handleKeydown(e) sequence:
- guard checks (
viewport, loading state, ctrl key, keyboard eligibility) - map supported key
preventDefault()to fully own scroll behavior- compute
deltaPixels - dispatch by key type:
Home/End: immediate jump and short momentum guard- repeatable keys (
Arrow*,Page*,Space):startKeyScroll(...) - fallback immediate delta apply
Key mapping:
ArrowUp:-rowHeightArrowDown:+rowHeightPageUp:-viewportHeightPageDown:+viewportHeightSpace: page-down; withShiftpage-upHome: large negative delta (clamped to top)End: large positive delta (clamped to bottom)
6. Keyup and Idle Stop
handleKeyup(e) stops key scrolling when released key matches active key (including space alias handling).
startKeyScroll(key, shiftKey, isRepeat):
- stores active key state
- clears previous idle timer
- computes normalized key step via
getKeyScrollDelta(...) - applies delta through
applyScrollDelta(..., 'key') - schedules idle stop (
scheduleKeyScrollStop())
scheduleKeyScrollStop() clears key-active state after 300ms of inactivity and arms momentum guard.
stopKeyScroll() performs explicit key-state reset and guard arming.
7. Step Sizing and Repeat Behavior
getKeyScrollDelta(key, shiftKey, isRepeat) scales movement:
- Arrow keys:
- first press:
rowHeight * 1 - repeat:
rowHeight * 3
- first press:
- Page/Space keys:
- first press:
viewportHeight * 0.85 - repeat:
viewportHeight * 0.95
- first press:
This produces responsive repeat scrolling while avoiding extreme jumps.
8. Scroll Application Path
All keyboard movement uses applyScrollDelta(deltaPixels, 'key').
applyScrollDelta(...):
- clamps target scroll top to viewport bounds
- records programmatic write metadata
- sets
viewport.scrollTop
Because the same path is used by wheel/scrollbar inputs (with different source labels), downstream guard logic can reason about input origin consistently.
9. Momentum Guard and Scroll Rejection
9.1 Guard arming
armMomentumGuard(duration) sets a future window where uncontrolled follow-up scroll is scrutinized.
Keyboard code arms this after:
- Home/End jump
- key stop / idle expiration
- other stop paths
9.2 Rejection (shouldRejectUncontrolledScroll)
During active guard window, scroll events are rejected when they appear uncontrolled relative to the last programmatic write.
Rejection is bypassed while user is actively interacting via:
- wheel (
isWheelActive) - keyboard (
isKeyActive) - scrollbar drag (
_scrollbarDragging)
Direct follow-up events close in time/position to programmatic writes are allowed.
This reduces momentum run-on and resize/gesture feedback loops.
10. Interaction with Wheel and Scrollbar Input
Keyboard navigation is part of a unified input model:
- wheel path (
handleWheel) normalizes and applies deltas via sameapplyScrollDelta(...) - custom scrollbar drag also sets
lastScrollInputSource stopActiveScroll()halts active input modes and applies guards- window blur cleanup resets key + wheel activity
The result is predictable arbitration across mixed input modalities.
11. Virtualization Coupling
Keyboard updates scrollTop; virtualization then reacts in handleScroll() and renderVisibleRows(...).
Because keyboard uses deterministic pixel deltas and clamp logic:
- row range updates remain stable
- near-bottom detection and prefetch remain coherent
- rendering avoids partial uncontrolled jumps
12. Practical Notes
- Keyboard scrolling is disabled while shimmer loading is active.
Ctrl+keycombinations are ignored by keyboard-scrolling handler.- Global listeners mean no extra setup is needed once grid is instantiated.
- Focus/hover gating keeps keyboard behavior scoped to grid intent.
13. Example Usage
No extra API call is required; keyboard support is active by default.
To ensure good UX:
- keep
viewportfocusable (tabindexis auto-added by grid if missing) - avoid overlay elements that permanently steal focus from viewport
- use reasonable
rowHeightfor predictable arrow-step movement