Skip to content

Keyboard Navigation: Arrow Keys and Selection Shortcuts #38

Closed as not planned

Description

@malibio

Keyboard Navigation: Arrow Keys and Selection Shortcuts

Implement comprehensive keyboard navigation for hierarchical node selection, including arrow key navigation, selection shortcuts, and accessibility-compliant keyboard interactions.

Dependencies

Related Issues

  • Part of multi-node selection epic (keyboard interaction component)

Requirements

Arrow Key Navigation

  • Up/Down Arrows: Navigate between sibling blocks and across hierarchy levels
  • Left/Right Arrows: Navigate into/out of hierarchy levels (expand/collapse navigation)
  • Home/End Keys: Jump to first/last block in current hierarchy level
  • Page Up/Down: Navigate by screen sections in large hierarchies
  • File Location: Create src/lib/utils/keyboardNavigation.ts for navigation logic

Selection Keyboard Shortcuts

  • Shift+Arrow Keys: Extend range selection in navigation direction
  • Ctrl+Arrow Keys: Navigate without changing selection (future: multi-select cursor)
  • Ctrl+A: Select all blocks in current context/hierarchy
  • Ctrl+Shift+A: Select all child blocks of currently focused block
  • Space: Toggle selection of currently focused block
  • File Location: Extend src/lib/stores/selectionStore.ts with keyboard selection methods

Focus Management

  • Visible Focus Indicators: Clear visual indication of currently focused block
  • Focus Persistence: Maintain focus across selection operations and hierarchy changes
  • Focus Restoration: Return focus to appropriate block after operations
  • Scroll Behavior: Auto-scroll to keep focused block visible
  • File Location: Enhance src/lib/design/components/BaseNode.svelte with focus management

Technical Implementation Notes

Keyboard Navigation Controller

// src/lib/utils/keyboardNavigation.ts
export interface NavigationContext {
  currentFocus: string | null;
  hierarchyContainer: HTMLElement;
  selectionStore: SelectionStore;
}

export class KeyboardNavigationController {
  private context: NavigationContext;
  
  constructor(context: NavigationContext) {
    this.context = context;
  }
  
  /**
   * Handle keyboard navigation events
   */
  handleKeydown(event: KeyboardEvent): boolean {
    const { key, shiftKey, ctrlKey, metaKey } = event;
    
    switch (key) {
      case 'ArrowUp':
        return this.handleArrowUp(shiftKey, ctrlKey);
      case 'ArrowDown':
        return this.handleArrowDown(shiftKey, ctrlKey);
      case 'ArrowLeft':
        return this.handleArrowLeft(shiftKey);
      case 'ArrowRight':
        return this.handleArrowRight(shiftKey);
      case 'Home':
        return this.handleHome(shiftKey);
      case 'End':
        return this.handleEnd(shiftKey);
      case ' ':
        return this.handleSpace(shiftKey, ctrlKey);
      case 'a':
        if (ctrlKey || metaKey) {
          return this.handleSelectAll(shiftKey);
        }
        break;
      default:
        return false;
    }
    
    return false;
  }
  
  private handleArrowUp(shiftKey: boolean, ctrlKey: boolean): boolean {
    const prevBlock = this.findPreviousBlock();
    if (\!prevBlock) return false;
    
    if (shiftKey) {
      this.context.selectionStore.extendRange(prevBlock.id);
    } else if (\!ctrlKey) {
      this.context.selectionStore.selectBlock(prevBlock.id);
    }
    
    this.moveFocusTo(prevBlock.element);
    return true;
  }
  
  private handleArrowDown(shiftKey: boolean, ctrlKey: boolean): boolean {
    const nextBlock = this.findNextBlock();
    if (\!nextBlock) return false;
    
    if (shiftKey) {
      this.context.selectionStore.extendRange(nextBlock.id);
    } else if (\!ctrlKey) {
      this.context.selectionStore.selectBlock(nextBlock.id);
    }
    
    this.moveFocusTo(nextBlock.element);
    return true;
  }
  
  private handleSpace(shiftKey: boolean, ctrlKey: boolean): boolean {
    const focusedBlock = this.getFocusedBlock();
    if (\!focusedBlock) return false;
    
    if (shiftKey) {
      // Range selection from last anchor
      this.context.selectionStore.extendRange(focusedBlock.id);
    } else if (ctrlKey) {
      // Multi-select toggle
      this.context.selectionStore.toggleBlock(focusedBlock.id);
    } else {
      // Single selection
      this.context.selectionStore.selectBlock(focusedBlock.id);
    }
    
    return true;
  }
  
  private handleSelectAll(shiftKey: boolean): boolean {
    const allBlocks = this.getAllBlocksInContext();
    
    if (shiftKey) {
      // Select all children of focused block
      const focusedBlock = this.getFocusedBlock();
      if (focusedBlock) {
        const childBlocks = this.getChildBlocks(focusedBlock.id);
        childBlocks.forEach(block => {
          this.context.selectionStore.addToSelection(block.id);
        });
      }
    } else {
      // Select all blocks in current context
      allBlocks.forEach(block => {
        this.context.selectionStore.addToSelection(block.id);
      });
    }
    
    return true;
  }
  
  private findPreviousBlock(): { id: string; element: HTMLElement } | null {
    const currentFocus = this.getFocusedBlock();
    if (\!currentFocus) return null;
    
    const allBlocks = this.getAllVisibleBlocks();
    const currentIndex = allBlocks.findIndex(block => block.id === currentFocus.id);
    
    if (currentIndex > 0) {
      return allBlocks[currentIndex - 1];
    }
    
    return null;
  }
  
  private findNextBlock(): { id: string; element: HTMLElement } | null {
    const currentFocus = this.getFocusedBlock();
    if (\!currentFocus) return null;
    
    const allBlocks = this.getAllVisibleBlocks();
    const currentIndex = allBlocks.findIndex(block => block.id === currentFocus.id);
    
    if (currentIndex < allBlocks.length - 1) {
      return allBlocks[currentIndex + 1];
    }
    
    return null;
  }
  
  private moveFocusTo(element: HTMLElement): void {
    element.focus();
    this.scrollIntoViewIfNeeded(element);
  }
  
  private scrollIntoViewIfNeeded(element: HTMLElement): void {
    const rect = element.getBoundingClientRect();
    const containerRect = this.context.hierarchyContainer.getBoundingClientRect();
    
    if (rect.top < containerRect.top || rect.bottom > containerRect.bottom) {
      element.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
    }
  }
  
  private getAllVisibleBlocks(): Array<{ id: string; element: HTMLElement }> {
    const blockElements = this.context.hierarchyContainer.querySelectorAll(
      '[data-block-id]:not([hidden]):not(.collapsed)'
    );
    
    return Array.from(blockElements).map(el => ({
      id: el.getAttribute('data-block-id')\!,
      element: el as HTMLElement
    }));
  }
  
  private getFocusedBlock(): { id: string; element: HTMLElement } | null {
    const activeElement = document.activeElement as HTMLElement;
    const blockId = activeElement?.getAttribute('data-block-id');
    
    if (blockId) {
      return { id: blockId, element: activeElement };
    }
    
    return null;
  }
  
  private getAllBlocksInContext(): Array<{ id: string; element: HTMLElement }> {
    // Implementation depends on hierarchy context definition
    return this.getAllVisibleBlocks();
  }
  
  private getChildBlocks(parentId: string): Array<{ id: string; element: HTMLElement }> {
    // Find all blocks that are children of the specified parent
    const parentElement = this.context.hierarchyContainer.querySelector(
      `[data-block-id="${parentId}"]`
    ) as HTMLElement;
    
    if (\!parentElement) return [];
    
    const childContainer = parentElement.querySelector('.node-children');
    if (\!childContainer) return [];
    
    const childElements = childContainer.querySelectorAll('[data-block-id]');
    
    return Array.from(childElements).map(el => ({
      id: el.getAttribute('data-block-id')\!,
      element: el as HTMLElement
    }));
  }
}

Enhanced Selection Store for Keyboard

// src/lib/stores/selectionStore.ts - Keyboard methods extension
interface SelectionStore extends Readable<SelectionState> {
  // ... existing methods
  
  // Keyboard-specific methods
  selectAll(blockIds: string[]): void;
  selectAllInContext(contextElement: HTMLElement): void;
  extendRangeInDirection(direction: 'up' | 'down'): void;
  moveSelectionFocus(targetId: string, maintainSelection: boolean): void;
}

class SelectionStoreImpl implements SelectionStore {
  // ... existing implementation
  
  selectAll(blockIds: string[]): void {
    this._store.update(state => ({
      ...state,
      selectedBlocks: [...blockIds],
      mode: blockIds.length > 1 ? 'multi' : 'single',
      lastActionType: 'multi-add'
    }));
  }
  
  selectAllInContext(contextElement: HTMLElement): void {
    const blockElements = contextElement.querySelectorAll('[data-block-id]');
    const blockIds = Array.from(blockElements)
      .map(el => el.getAttribute('data-block-id'))
      .filter(id => id \!== null) as string[];
    
    this.selectAll(blockIds);
  }
  
  extendRangeInDirection(direction: 'up' | 'down'): void {
    // Implementation for keyboard range extension
    this._store.update(state => {
      if (state.selectedBlocks.length === 0) return state;
      
      const lastSelected = state.selectedBlocks[state.selectedBlocks.length - 1];
      // Logic to find next block in specified direction
      // and extend range selection
      
      return {
        ...state,
        direction,
        lastActionType: 'range'
      };
    });
  }
  
  moveSelectionFocus(targetId: string, maintainSelection: boolean): void {
    if (\!maintainSelection) {
      this.selectBlock(targetId);
    }
    // Focus management happens in navigation controller
  }
}

NodeTree Keyboard Integration

<\!-- src/lib/components/NodeTree.svelte - Keyboard event handling -->
<script lang="ts">
  import { KeyboardNavigationController } from '$lib/utils/keyboardNavigation';
  import { selectionStore } from '$lib/stores/selectionStore';
  
  let treeContainer: HTMLElement;
  let navigationController: KeyboardNavigationController;
  
  $: if (treeContainer) {
    navigationController = new KeyboardNavigationController({
      currentFocus: null,
      hierarchyContainer: treeContainer,
      selectionStore
    });
  }
  
  function handleKeydown(event: KeyboardEvent) {
    if (navigationController?.handleKeydown(event)) {
      event.preventDefault();
      event.stopPropagation();
    }
  }
</script>

<div 
  class="node-tree"
  bind:this={treeContainer}
  on:keydown={handleKeydown}
  role="tree"
  aria-label="Hierarchical node tree"
  tabindex="0"
>
  <\!-- existing tree content -->
</div>

<style>
  .node-tree {
    outline: none; /* Custom focus management */
  }
  
  .node-tree:focus-visible {
    outline: 2px solid var(--color-focus-ring);
    outline-offset: 2px;
  }
</style>

Enhanced BaseNode Focus Management

<\!-- src/lib/design/components/BaseNode.svelte - Focus and keyboard handling -->
<script lang="ts">
  import { selectionStore } from '$lib/stores/selectionStore';
  
  export let nodeId: string;
  let nodeElement: HTMLElement;
  
  $: isSelected = $selectionStore.selectedBlocks.includes(nodeId);
  $: isFocused = false; // Will be managed by focus events
  
  function handleFocus(event: FocusEvent) {
    isFocused = true;
  }
  
  function handleBlur(event: FocusEvent) {
    isFocused = false;
  }
  
  function handleKeydown(event: KeyboardEvent) {
    // Node-level keyboard handling (delegated from NodeTree)
    if (event.key === 'Enter') {
      // Future: Open node for editing
      console.log('Enter pressed on node:', nodeId);
      event.preventDefault();
    }
  }
</script>

<div 
  bind:this={nodeElement}
  class="base-node"
  class:selected={isSelected}
  class:focused={isFocused}
  data-block-id={nodeId}
  on:focus={handleFocus}
  on:blur={handleBlur}
  on:keydown={handleKeydown}
  role="treeitem"
  aria-selected={isSelected}
  tabindex="-1"
>
  <\!-- existing node content -->
</div>

<style>
  .base-node {
    transition: var(--selection-transition);
    cursor: pointer;
    outline: none;
  }
  
  .base-node.selected {
    background-color: var(--selection-background);
  }
  
  .base-node.focused {
    outline: 2px solid var(--color-focus-ring);
    outline-offset: 1px;
  }
  
  .base-node.focused.selected {
    outline-color: var(--selection-border);
  }
</style>

Acceptance Criteria

Arrow Key Navigation

  • Up/Down arrows navigate between blocks in document order
  • Left/Right arrows navigate hierarchy levels (collapse/expand)
  • Home/End keys jump to first/last block in current context
  • Navigation wraps appropriately at hierarchy boundaries
  • Arrow navigation works correctly with collapsed/expanded sections

Selection Keyboard Shortcuts

  • Shift+Arrow extends range selection in navigation direction
  • Ctrl+Arrow navigates without changing selection (cursor movement)
  • Space key toggles selection of currently focused block
  • Ctrl+A selects all blocks in current hierarchy context
  • Ctrl+Shift+A selects all children of currently focused block

Focus Management

  • Clear visual focus indicators show currently focused block
  • Focus persists correctly during selection operations
  • Auto-scroll keeps focused block visible during keyboard navigation
  • Focus restoration works after hierarchy changes (collapse/expand)
  • Focus indicators are distinct from selection highlighting

Accessibility Compliance

  • All keyboard navigation follows WCAG 2.1 guidelines
  • Screen readers announce focus and selection changes appropriately
  • ARIA tree widget patterns implemented correctly
  • Keyboard shortcuts documented and discoverable
  • High contrast mode preserves focus visibility

Performance Requirements

  • Keyboard navigation responds within 16ms for smooth interaction
  • Navigation algorithms scale efficiently with large hierarchies (1000+ blocks)
  • Event handling doesn't block main thread during rapid key presses
  • Memory usage remains stable during extended keyboard navigation
  • Focus management works efficiently with deep hierarchy nesting

Integration Requirements

  • Seamlessly integrates with existing mouse-based selection
  • Compatible with all selection modes (single, range, multi)
  • Works correctly with hierarchical expand/collapse operations
  • Maintains existing BaseNode and NodeTree APIs
  • No conflicts with other keyboard shortcuts in the application

Resources & References

Implementation Patterns

  • ARIA Tree Widget: W3C guidelines for keyboard navigation in tree structures
  • Focus Management: Modern web application focus management patterns
  • Keyboard Navigation: Desktop application navigation conventions
  • Selection Patterns: IDE and file manager keyboard selection standards

Accessibility Standards

  • WCAG 2.1: Keyboard accessibility requirements for complex widgets
  • ARIA Practices: Tree widget keyboard interaction patterns
  • Screen Reader Support: Proper announcement patterns for navigation
  • Focus Management: Best practices for programmatic focus control

UX Research References

  • Desktop Applications: File manager and IDE keyboard navigation patterns
  • Web Standards: Modern web application keyboard interaction conventions
  • Logseq Analysis: Keyboard navigation patterns from research
  • Performance Studies: Efficient keyboard navigation in large data structures

Definition of Done

Functionality

  • All keyboard navigation patterns work reliably across different scenarios
  • Focus management is robust and predictable
  • Selection operations integrate seamlessly with keyboard navigation
  • Performance requirements met through optimization and testing
  • Accessibility requirements validated through testing and compliance checking

Code Quality

  • TypeScript compilation passes with strict mode and comprehensive coverage
  • Keyboard navigation controller is well-architected and extensible
  • Event handling follows best practices for performance and maintainability
  • Focus management code is clean and follows accessibility guidelines
  • No memory leaks or performance regressions in navigation operations

Testing

  • Unit tests cover keyboard navigation controller logic thoroughly
  • Integration tests verify navigation across different hierarchy structures
  • Accessibility testing confirms WCAG compliance and screen reader compatibility
  • Performance testing validates responsiveness with large hierarchies
  • Cross-browser testing ensures consistent keyboard behavior

Documentation

  • Keyboard navigation patterns documented with examples
  • Accessibility features documented for compliance verification
  • Integration patterns documented for future developers
  • Performance characteristics and limitations documented
  • Extension points identified for advanced keyboard features

This completes the comprehensive keyboard navigation system, enabling efficient keyboard-only interaction with the hierarchical node selection system.

Activity

  1. malibio commented on Aug 3, 2026

    @malibio
    CollaboratorAuthor

    Closing as superseded by #278 (Copy text across node boundaries).

    This issue is part of the 2025 four-issue chain (#35 → #36 → #37 → #38) specifying a block-level, id-set selection model — a selection store of node ids, driven by click / shift+click / ctrl+click, intended to enable bulk operations on a set of nodes.

    Two reasons for closing:

    1. The specs are not implementable as written. They target a file layout that no longer exists: src/lib/design/components/BaseNode.svelte and src/lib/components/NodeTree.svelte (the codebase is kebab-case — base-node.svelte, node-tree.svelte), src/lib/design/tokens/ as a directory (it is a single file, lib/design/tokens.ts), Svelte 4 stores with $: reactivity (the codebase is on Svelte 5 runes), an in-repo docs/ directory, and LanceDB.

    2. The actual need is text-level, not block-level. The requirement driving this work is copying a passage of text that spans several nodes — a range that can start and end partway through a node. Block selection copies whole nodes and cannot express that. #278 now covers it, scoped to text-level selection and copy.

    Bulk operations on a set of nodes (delete, indent, move) remain unbuilt and untracked. There is currently no UI surface for node operations at all — no context menu (contextmenu has zero hits in src/lib), no toolbar, and deletion exists only as a side effect of Backspace in the editor (base-node-viewer.svelte:809-905). If that becomes wanted, it should be filed fresh against the current architecture rather than revived from these.

    Worth noting what already exists and is not lost by closing these: arrow-key node-to-node navigation (lib/commands/keyboard/navigate-up.command.ts:26, navigate-down.command.ts:26), a keyboard command registry (lib/services/keyboard-command-registry.ts:207-210), and single-node focus management (lib/services/focus-manager.svelte.ts).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions