Repository navigation
Closed as not planned
Description
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
- Foundation: Single-Block Selection with Visual Highlighting #35 (Foundation: Single-Block Selection) - Required for base selection functionality
- Range Selection: Shift+Click Multi-Block Selection #36 (Range Selection: Shift+Click Multi-Block Selection) - Required for keyboard range extension
- Multi-Select: Ctrl+Click Individual Block Toggle #37 (Multi-Select: Ctrl+Click Individual Block Toggle) - Required for keyboard multi-select
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.tsfor 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.tswith 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.sveltewith 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.
Metadata
Metadata
Assignees
Labels
No labels
Activity
Update architecture documentation with advanced UI/UX capabilities
Update architecture documentation with advanced UI/UX capabilities
Update architecture documentation with advanced UI/UX capabilities
malibio commentedon Aug 3, 2026
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.svelteandsrc/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-repodocs/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 (
contextmenuhas zero hits insrc/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).