import { isTouchDevice } from '@/lib/utils' import { Event } from 'nostr-tools' import { createContext, ReactNode, RefObject, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react' import modalManager from '@/services/modal-manager.service' import { useScreenSize } from './ScreenSizeProvider' import { useUserPreferences } from './UserPreferencesProvider' // ============================================================================ // Abstract Navigation Intent System // ============================================================================ /** * Navigation intents are abstract actions that the control plane emits. * Components decide what each intent means in their context. */ export type NavigationIntent = | 'up' | 'down' | 'left' | 'right' | 'activate' | 'back' | 'cancel' | 'pageUp' | 'nextAction' | 'prevAction' /** * Navigation regions handle intents for a specific area of the UI. * Higher priority regions handle intents first. */ export interface NavigationRegion { id: string priority: number isActive: () => boolean handleIntent: (intent: NavigationIntent) => boolean // returns true if handled } // ============================================================================ // Legacy Types (for backward compatibility during migration) // ============================================================================ export type TNavigationColumn = 0 | 1 | 2 // 0=sidebar, 1=primary, 2=secondary export type TActionType = 'reply' | 'repost' | 'quote' | 'react' | 'zap' export type TActionMode = { active: boolean selectedAction: TActionType | null noteEvent: Event | null } export type TItemMeta = { type: 'sidebar' | 'note' | 'settings' event?: Event onActivate?: () => void } type TRegisteredItem = { ref: RefObject meta?: TItemMeta } // ============================================================================ // Context Types // ============================================================================ type TKeyboardNavigationContext = { // Intent system emitIntent: (intent: NavigationIntent) => void registerRegion: (region: NavigationRegion) => void unregisterRegion: (id: string) => void // Column focus (legacy, components should migrate to regions) activeColumn: TNavigationColumn setActiveColumn: (column: TNavigationColumn) => void // Item selection per column selectedIndex: Record setSelectedIndex: (column: TNavigationColumn, index: number) => void resetPrimarySelection: () => void offsetSelection: (column: TNavigationColumn, offset: number) => void clearColumn: (column: TNavigationColumn) => void // Registered items per column registerItem: ( column: TNavigationColumn, index: number, ref: RefObject, meta?: TItemMeta ) => void unregisterItem: (column: TNavigationColumn, index: number) => void getItemCount: (column: TNavigationColumn) => number // Load more callback per column (for infinite scroll) registerLoadMore: (column: TNavigationColumn, callback: () => void) => void unregisterLoadMore: (column: TNavigationColumn) => void // Action mode actionMode: TActionMode enterActionMode: (noteEvent: Event) => void exitActionMode: () => void cycleAction: (direction?: 1 | -1) => void // Visual state isItemSelected: (column: TNavigationColumn, index: number) => boolean // Settings accordion openAccordionItem: string | null setOpenAccordionItem: (value: string | null) => void // Keyboard nav enabled isEnabled: boolean toggleKeyboardMode: () => void // Scroll utilities scrollToCenter: (element: HTMLElement) => void } const ACTIONS: TActionType[] = ['reply', 'repost', 'quote', 'react', 'zap'] const KeyboardNavigationContext = createContext(undefined) export function useKeyboardNavigation() { const context = useContext(KeyboardNavigationContext) if (!context) { throw new Error('useKeyboardNavigation must be used within KeyboardNavigationProvider') } return context } /** * Hook for components to register as a navigation region. * The region handles intents and decides what they mean. */ export function useNavigationRegion( id: string, priority: number, isActive: () => boolean, handleIntent: (intent: NavigationIntent) => boolean, deps: React.DependencyList = [] ) { const { registerRegion, unregisterRegion } = useKeyboardNavigation() useEffect(() => { const region: NavigationRegion = { id, priority, isActive, handleIntent } registerRegion(region) return () => unregisterRegion(id) // eslint-disable-next-line react-hooks/exhaustive-deps }, [id, priority, registerRegion, unregisterRegion, ...deps]) } // Helper to check if an input element is focused function isInputFocused(): boolean { const activeElement = document.activeElement if (!activeElement) return false const tagName = activeElement.tagName.toLowerCase() return ( tagName === 'input' || tagName === 'textarea' || activeElement.getAttribute('contenteditable') === 'true' ) } export function KeyboardNavigationProvider({ children, secondaryStackLength, sidebarDrawerOpen, onBack, onCloseSecondary }: { children: ReactNode secondaryStackLength: number sidebarDrawerOpen: boolean onBack?: () => void onCloseSecondary?: () => void }) { const { isSmallScreen } = useScreenSize() const { enableSingleColumnLayout } = useUserPreferences() const [activeColumn, setActiveColumnState] = useState(1) const [selectedIndex, setSelectedIndexState] = useState>({ 0: 0, 1: 0, 2: 0 }) const [actionMode, setActionMode] = useState({ active: false, selectedAction: null, noteEvent: null }) const [openAccordionItem, setOpenAccordionItem] = useState(null) const [isEnabled, setIsEnabled] = useState(false) // Track Escape presses for triple-Escape to disable keyboard mode const escapeTimestampsRef = useRef([]) const TRIPLE_ESCAPE_WINDOW = 800 // ms within which 3 escapes must occur // Refs to always have latest values (avoid stale closures) const activeColumnRef = useRef(activeColumn) const selectedIndexRef = useRef>(selectedIndex) // Wrapper to update both state and ref const setActiveColumn = useCallback((column: TNavigationColumn) => { activeColumnRef.current = column setActiveColumnState(column) }, []) // Toggle keyboard navigation mode const toggleKeyboardMode = useCallback(() => { setIsEnabled((prev) => { const newEnabled = !prev if (newEnabled) { // When enabling, initialize selection to first item in active column const items = itemsRef.current[activeColumnRef.current] if (items.size > 0) { const firstIndex = Array.from(items.keys()).sort((a, b) => a - b)[0] if (firstIndex !== undefined) { setSelectedIndexState((prevState) => ({ ...prevState, [activeColumnRef.current]: firstIndex })) } } } return newEnabled }) }, []) // Navigation regions registry const regionsRef = useRef>(new Map()) // Ref to hold the latest handleDefaultIntent function const handleDefaultIntentRef = useRef<(intent: NavigationIntent) => void>(() => {}) // Item registration per column const itemsRef = useRef>>({ 0: new Map(), 1: new Map(), 2: new Map() }) // Load more callbacks per column (for infinite scroll) const loadMoreRef = useRef void) | null>>({ 0: null, 1: null, 2: null }) // ============================================================================ // Intent System // ============================================================================ const registerRegion = useCallback((region: NavigationRegion) => { regionsRef.current.set(region.id, region) }, []) const unregisterRegion = useCallback((id: string) => { regionsRef.current.delete(id) }, []) const emitIntent = useCallback((intent: NavigationIntent) => { // Sort regions by priority (highest first) const regions = Array.from(regionsRef.current.values()) .filter((r) => r.isActive()) .sort((a, b) => b.priority - a.priority) // Let regions handle the intent in priority order for (const region of regions) { if (region.handleIntent(intent)) { return // Intent was handled } } // Fallback to default handling if no region handled it handleDefaultIntentRef.current(intent) }, []) // ============================================================================ // Scroll to Center (or top if near edge) // ============================================================================ const scrollToCenter = useCallback((element: HTMLElement) => { // Find the scrollable container (look for overflow-y: auto/scroll) // Stop at document.body - if we reach it, use window scrolling instead let scrollContainer: HTMLElement | null = element.parentElement while (scrollContainer && scrollContainer !== document.body && scrollContainer !== document.documentElement) { const style = window.getComputedStyle(scrollContainer) const overflowY = style.overflowY if (overflowY === 'auto' || overflowY === 'scroll') { // Verify this container is actually scrollable (has scrollable content) if (scrollContainer.scrollHeight > scrollContainer.clientHeight) { break } } scrollContainer = scrollContainer.parentElement } // If we reached body/documentElement, use window scrolling if (!scrollContainer || scrollContainer === document.body || scrollContainer === document.documentElement) { scrollContainer = null } const headerOffset = 100 // Account for sticky headers if (scrollContainer) { // Scroll within container const containerRect = scrollContainer.getBoundingClientRect() const elementRect = element.getBoundingClientRect() // Position relative to container const elementTopInContainer = elementRect.top - containerRect.top const elementBottomInContainer = elementRect.bottom - containerRect.top const containerHeight = containerRect.height const visibleHeight = containerHeight - headerOffset // If element is taller than visible area, scroll to show its top if (elementRect.height > visibleHeight) { const targetScrollTop = scrollContainer.scrollTop + elementTopInContainer - headerOffset scrollContainer.scrollTo({ top: Math.max(0, targetScrollTop), behavior: 'instant' }) return } // Check if already visible with margin const isVisible = elementTopInContainer >= headerOffset && elementBottomInContainer <= containerHeight - 50 if (!isVisible) { // Calculate target scroll to center the element const elementMiddle = elementTopInContainer + elementRect.height / 2 const containerMiddle = containerHeight / 2 const scrollAdjustment = elementMiddle - containerMiddle const newScrollTop = scrollContainer.scrollTop + scrollAdjustment // Don't scroll past the top scrollContainer.scrollTo({ top: Math.max(0, newScrollTop), behavior: 'instant' }) } } else { // Window scrolling (mobile, single column mode) const rect = element.getBoundingClientRect() const viewportHeight = window.innerHeight const visibleHeight = viewportHeight - headerOffset // If element is taller than visible area, scroll to show its top if (rect.height > visibleHeight) { const targetScrollTop = window.scrollY + rect.top - headerOffset window.scrollTo({ top: Math.max(0, targetScrollTop), behavior: 'instant' }) return } // Check if already visible const isVisible = rect.top >= headerOffset && rect.bottom <= viewportHeight - 50 if (!isVisible) { // Calculate target scroll to center the element const elementMiddle = rect.top + rect.height / 2 const viewportMiddle = viewportHeight / 2 const scrollAdjustment = elementMiddle - viewportMiddle const newScrollTop = window.scrollY + scrollAdjustment window.scrollTo({ top: Math.max(0, newScrollTop), behavior: 'instant' }) } } }, []) // ============================================================================ // Legacy Item Management // ============================================================================ const setSelectedIndex = useCallback((column: TNavigationColumn, index: number) => { selectedIndexRef.current = { ...selectedIndexRef.current, [column]: index } setSelectedIndexState((prev) => ({ ...prev, [column]: index })) }, []) const resetPrimarySelection = useCallback(() => { setSelectedIndex(1, 0) setActiveColumn(1) }, [setSelectedIndex, setActiveColumn]) const offsetSelection = useCallback( (column: TNavigationColumn, offset: number) => { setSelectedIndexState((prev) => { const newState = { ...prev, [column]: Math.max(0, prev[column] + offset) } selectedIndexRef.current = newState return newState }) }, [] ) const clearColumn = useCallback((column: TNavigationColumn) => { itemsRef.current[column].clear() setSelectedIndexState((prev) => ({ ...prev, [column]: 0 })) }, []) const registerItem = useCallback( (column: TNavigationColumn, index: number, ref: RefObject, meta?: TItemMeta) => { itemsRef.current[column].set(index, { ref, meta }) }, [] ) const unregisterItem = useCallback((column: TNavigationColumn, index: number) => { itemsRef.current[column].delete(index) }, []) const getItemCount = useCallback((column: TNavigationColumn) => { return itemsRef.current[column].size }, []) const registerLoadMore = useCallback((column: TNavigationColumn, callback: () => void) => { loadMoreRef.current[column] = callback }, []) const unregisterLoadMore = useCallback((column: TNavigationColumn) => { loadMoreRef.current[column] = null }, []) const isItemSelected = useCallback( (column: TNavigationColumn, index: number) => { return isEnabled && activeColumn === column && selectedIndex[column] === index }, [isEnabled, activeColumn, selectedIndex] ) // ============================================================================ // Column Navigation // ============================================================================ const getAvailableColumns = useCallback((): TNavigationColumn[] => { if (isSmallScreen) { // Mobile: sidebar is in a drawer, only one column visible at a time if (sidebarDrawerOpen) return [0] if (secondaryStackLength > 0) return [2] return [1] } if (enableSingleColumnLayout) { // Single column desktop: sidebar is always visible alongside main content if (secondaryStackLength > 0) return [0, 2] return [0, 1] } // Two column desktop if (secondaryStackLength > 0) return [0, 1, 2] return [0, 1] }, [isSmallScreen, enableSingleColumnLayout, sidebarDrawerOpen, secondaryStackLength]) const moveColumn = useCallback( (direction: 1 | -1) => { const available = getAvailableColumns() const currentColumn = activeColumnRef.current const currentIdx = available.indexOf(currentColumn) if (currentIdx === -1) { setActiveColumn(available[0]) return } const newIdx = Math.max(0, Math.min(available.length - 1, currentIdx + direction)) const newColumn = available[newIdx] if (newColumn === currentColumn) return // Already at edge setActiveColumn(newColumn) // Scroll to currently selected item in the new column const items = itemsRef.current[newColumn] const currentSelectedIdx = selectedIndexRef.current[newColumn] const item = items.get(currentSelectedIdx) if (item?.ref.current) { scrollToCenter(item.ref.current) } else if (items.size > 0) { // If no item at current index, select the first one const firstIndex = Array.from(items.keys()).sort((a, b) => a - b)[0] if (firstIndex !== undefined) { setSelectedIndex(newColumn, firstIndex) const firstItem = items.get(firstIndex) if (firstItem?.ref.current) { scrollToCenter(firstItem.ref.current) } } } }, [getAvailableColumns, scrollToCenter, setSelectedIndex, setActiveColumn] ) // ============================================================================ // Item Navigation with Centered Scrolling // ============================================================================ const moveItem = useCallback( (direction: 1 | -1) => { const currentColumn = activeColumnRef.current const items = itemsRef.current[currentColumn] if (items.size === 0) return const indices = Array.from(items.keys()).sort((a, b) => a - b) if (indices.length === 0) return const currentSelected = selectedIndexRef.current[currentColumn] let currentIdx = indices.indexOf(currentSelected) if (currentIdx === -1) { // Find nearest valid index let nearestIdx = 0 let minDistance = Infinity for (let i = 0; i < indices.length; i++) { const distance = Math.abs(indices[i] - currentSelected) if (distance < minDistance) { minDistance = distance nearestIdx = i } } currentIdx = nearestIdx } // Calculate new index with wrap-around let newIdx = currentIdx + direction // Wrap around behavior if (newIdx < 0) { // At top, going up -> wrap to bottom newIdx = indices.length - 1 } else if (newIdx >= indices.length) { // At bottom, going down -> trigger load more and wrap to top const loadMore = loadMoreRef.current[currentColumn] if (loadMore) { loadMore() } newIdx = 0 } const newItemIndex = indices[newIdx] if (newItemIndex === undefined) return setSelectedIndex(currentColumn, newItemIndex) // Scroll to center const item = items.get(newItemIndex) if (item?.ref.current) { scrollToCenter(item.ref.current) } }, [setSelectedIndex, scrollToCenter] ) const jumpToTop = useCallback(() => { // For primary feed, use column 1; for secondary, use column 2 // Fall back to current column if it has items let targetColumn = activeColumnRef.current // Check if current column has items, if not try column 1 (primary) if (itemsRef.current[targetColumn].size === 0) { if (itemsRef.current[1].size > 0) { targetColumn = 1 } else if (itemsRef.current[2].size > 0) { targetColumn = 2 } } const items = itemsRef.current[targetColumn] if (items.size === 0) return const indices = Array.from(items.keys()).sort((a, b) => a - b) if (indices.length === 0) return const newItemIndex = indices[0] if (newItemIndex === undefined) return // Set active column and selection immediately setActiveColumn(targetColumn) setSelectedIndex(targetColumn, newItemIndex) // Scroll to top of the page/container window.scrollTo({ top: 0, behavior: 'smooth' }) // Also scroll any parent containers to top const item = items.get(newItemIndex) if (item?.ref.current) { let scrollContainer: HTMLElement | null = item.ref.current.parentElement while (scrollContainer && scrollContainer !== document.body) { const style = window.getComputedStyle(scrollContainer) if ((style.overflowY === 'auto' || style.overflowY === 'scroll') && scrollContainer.scrollHeight > scrollContainer.clientHeight) { scrollContainer.scrollTo({ top: 0, behavior: 'smooth' }) break } scrollContainer = scrollContainer.parentElement } } }, [setSelectedIndex, setActiveColumn] ) // ============================================================================ // Action Mode // ============================================================================ const enterActionMode = useCallback((noteEvent: Event) => { setActionMode({ active: true, selectedAction: 'reply', noteEvent }) }, []) const exitActionMode = useCallback(() => { setActionMode({ active: false, selectedAction: null, noteEvent: null }) }, []) const cycleAction = useCallback( (direction: 1 | -1 = 1) => { setActionMode((prev) => { if (!prev.active) { const currentColumn = activeColumnRef.current const currentSelected = selectedIndexRef.current[currentColumn] const item = itemsRef.current[currentColumn].get(currentSelected) if (item?.meta?.type === 'note' && item.meta.event) { return { active: true, selectedAction: 'reply', noteEvent: item.meta.event } } return prev } const currentIdx = prev.selectedAction ? ACTIONS.indexOf(prev.selectedAction) : 0 const newIdx = (currentIdx + direction + ACTIONS.length) % ACTIONS.length return { ...prev, selectedAction: ACTIONS[newIdx] } }) }, [] ) // ============================================================================ // Intent Handlers // ============================================================================ const handleEnter = useCallback(() => { const currentColumn = activeColumnRef.current const currentSelected = selectedIndexRef.current[currentColumn] if (actionMode.active) { const item = itemsRef.current[currentColumn].get(currentSelected) if (item?.ref.current && actionMode.selectedAction) { const stuffStats = item.ref.current.querySelector('[data-stuff-stats]') const actionButton = stuffStats?.querySelector( `[data-action="${actionMode.selectedAction}"]` ) as HTMLButtonElement | null actionButton?.click() exitActionMode() } return } const item = itemsRef.current[currentColumn].get(currentSelected) if (!item) return if (currentColumn === 0 && item.meta?.type === 'sidebar') { setSelectedIndex(1, 0) setActiveColumn(1) } if (item.meta?.onActivate) { item.meta.onActivate() } else if (item.ref.current) { item.ref.current.click() } }, [actionMode, exitActionMode, setSelectedIndex, setActiveColumn]) const handleEscape = useCallback(() => { // Track Escape press for triple-Escape detection const now = Date.now() escapeTimestampsRef.current.push(now) // Keep only presses within the time window escapeTimestampsRef.current = escapeTimestampsRef.current.filter( (t) => now - t < TRIPLE_ESCAPE_WINDOW ) // Check for triple-Escape to disable keyboard mode if (escapeTimestampsRef.current.length >= 3 && isEnabled) { setIsEnabled(false) escapeTimestampsRef.current = [] return } if (actionMode.active) { exitActionMode() return } if (openAccordionItem) { setOpenAccordionItem(null) return } if ((isSmallScreen || enableSingleColumnLayout) && secondaryStackLength > 0) { onBack?.() return } const currentColumn = activeColumnRef.current if (currentColumn === 2 && secondaryStackLength > 0) { onCloseSecondary?.() setActiveColumn(1) return } if (currentColumn !== 0) { setActiveColumn(0) setSelectedIndex(0, 0) } }, [ actionMode.active, exitActionMode, openAccordionItem, isSmallScreen, enableSingleColumnLayout, secondaryStackLength, onBack, onCloseSecondary, setSelectedIndex, setActiveColumn, isEnabled, TRIPLE_ESCAPE_WINDOW ]) // Handle back action - move left through columns or close secondary panel const handleBack = useCallback(() => { const currentColumn = activeColumnRef.current // If focused on secondary column (2), close it and move to primary if (currentColumn === 2) { if (secondaryStackLength > 0) { if (isSmallScreen || enableSingleColumnLayout) { // On mobile/single column, use onBack to pop the stack onBack?.() } else { // On desktop with columns, close secondary and focus primary onCloseSecondary?.() setActiveColumn(1) } } else { // No secondary stack, just move to primary setActiveColumn(1) } return } // If focused on primary column (1), move to sidebar if (currentColumn === 1) { setActiveColumn(0) return } // If focused on sidebar (0), do nothing (already at leftmost) }, [secondaryStackLength, isSmallScreen, enableSingleColumnLayout, onBack, onCloseSecondary, setActiveColumn]) // Default intent handler (fallback when no region handles) const handleDefaultIntent = useCallback( (intent: NavigationIntent) => { switch (intent) { case 'up': moveItem(-1) break case 'down': moveItem(1) break case 'left': moveColumn(-1) break case 'right': moveColumn(1) break case 'pageUp': jumpToTop() break case 'activate': handleEnter() break case 'back': handleBack() break case 'cancel': handleEscape() break case 'nextAction': cycleAction(1) break case 'prevAction': cycleAction(-1) break } }, [moveItem, moveColumn, jumpToTop, handleEnter, handleBack, handleEscape, cycleAction] ) // Keep the ref updated with the latest handleDefaultIntent useEffect(() => { handleDefaultIntentRef.current = handleDefaultIntent }, [handleDefaultIntent]) // ============================================================================ // Keyboard Event Handler // ============================================================================ // Helper to trigger an action on the currently selected note const triggerNoteAction = useCallback((action: TActionType) => { const currentColumn = activeColumnRef.current const currentSelected = selectedIndexRef.current[currentColumn] const item = itemsRef.current[currentColumn].get(currentSelected) if (item?.meta?.type === 'note' && item.ref.current) { const stuffStats = item.ref.current.querySelector('[data-stuff-stats]') const actionButton = stuffStats?.querySelector(`[data-action="${action}"]`) as HTMLButtonElement | null actionButton?.click() } }, []) // Main keyboard handler - translates keys to intents // Also handles enabling keyboard nav on first navigation key press useEffect(() => { if (isTouchDevice()) return const handleKeyDown = (e: KeyboardEvent) => { if (isInputFocused()) return if (modalManager.hasOpenModal?.()) return // Map keys to intents let intent: NavigationIntent | null = null const isNavKey = ['ArrowUp', 'ArrowDown', 'j', 'k', 'Tab'].includes(e.key) switch (e.key) { case 'ArrowUp': case 'k': // Vim-style intent = 'up' break case 'ArrowDown': case 'j': // Vim-style intent = 'down' break case 'ArrowLeft': case 'h': // Vim-style intent = 'back' break case 'ArrowRight': case 'l': // Vim-style intent = 'right' break case 'Enter': intent = 'activate' break case 'PageUp': intent = 'pageUp' break case 'Escape': intent = 'cancel' break case 'Backspace': intent = 'back' break case 'Tab': // Tab switches between columns e.preventDefault() intent = e.shiftKey ? 'left' : 'right' break // Direct note actions case 'r': if (isEnabled) { e.preventDefault() triggerNoteAction('reply') return } break case 'R': if (isEnabled) { e.preventDefault() triggerNoteAction('react') return } break case 'p': if (isEnabled) { e.preventDefault() triggerNoteAction('repost') return } break case 'q': if (isEnabled) { e.preventDefault() triggerNoteAction('quote') return } break case 'z': if (isEnabled) { e.preventDefault() triggerNoteAction('zap') return } break case 'K': // Shift+K toggles keyboard mode if (e.shiftKey) { e.preventDefault() toggleKeyboardMode() return } break case 'M': // Shift+M expands/collapses the currently selected note if (e.shiftKey && isEnabled) { e.preventDefault() const col = activeColumnRef.current const sel = selectedIndexRef.current[col] const selectedItem = itemsRef.current[col].get(sel) if (selectedItem?.ref.current) { const expandBtn = selectedItem.ref.current.querySelector('[data-collapsible-expand]') as HTMLButtonElement | null if (expandBtn) { expandBtn.click() // Re-center after expand animation setTimeout(() => { if (selectedItem.ref.current) scrollToCenter(selectedItem.ref.current) }, 50) } } return } break } // Enable keyboard nav on first navigation key press if (!isEnabled && isNavKey) { setIsEnabled(true) // Initialize selection to first item in active column const available = getAvailableColumns() const currentColumn = activeColumnRef.current const column = available.includes(currentColumn) ? currentColumn : available[0] const items = itemsRef.current[column] if (items.size > 0) { const firstIndex = Array.from(items.keys()).sort((a, b) => a - b)[0] if (firstIndex !== undefined) { setSelectedIndex(column, firstIndex) const item = items.get(firstIndex) if (item?.ref.current) { scrollToCenter(item.ref.current) } } } } if (intent && isEnabled) { e.preventDefault() emitIntent(intent) } else if (intent && isNavKey) { // First keypress enables and processes the intent e.preventDefault() emitIntent(intent) } } window.addEventListener('keydown', handleKeyDown) return () => window.removeEventListener('keydown', handleKeyDown) }, [isEnabled, emitIntent, getAvailableColumns, setSelectedIndex, scrollToCenter, triggerNoteAction, toggleKeyboardMode]) // ============================================================================ // Layout Effects // ============================================================================ useEffect(() => { const available = getAvailableColumns() if (!available.includes(activeColumn)) { // When current column becomes unavailable, find the best fallback: // - If coming from column 2 (secondary), prefer column 1 (primary) over column 0 (sidebar) // - Otherwise use the first available column if (activeColumn === 2 && available.includes(1)) { setActiveColumn(1) } else { setActiveColumn(available[0]) } } }, [getAvailableColumns, activeColumn, setActiveColumn]) // Track secondary panel changes to switch focus const prevSecondaryStackLength = useRef(secondaryStackLength) useEffect(() => { if (secondaryStackLength > prevSecondaryStackLength.current && isEnabled) { // Secondary opened - switch to column 2 immediately // This ensures the user can navigate back with left/Escape even if // the secondary panel doesn't have keyboard-navigable items setActiveColumn(2) setSelectedIndex(2, 0) // If there are items in column 2, scroll to the first one const items = itemsRef.current[2] if (items.size > 0) { const indices = Array.from(items.keys()).sort((a, b) => a - b) const firstIndex = indices[0] if (firstIndex !== undefined) { setSelectedIndex(2, firstIndex) const item = items.get(firstIndex) if (item?.ref.current) { scrollToCenter(item.ref.current) } } } } else if (secondaryStackLength < prevSecondaryStackLength.current && isEnabled) { // Secondary closed - return to primary only if we were in the secondary column // Don't move focus if user is already in sidebar or primary if (activeColumnRef.current === 2) { setActiveColumn(1) } } prevSecondaryStackLength.current = secondaryStackLength }, [secondaryStackLength, isEnabled, setSelectedIndex, scrollToCenter, setActiveColumn]) // ============================================================================ // Context Value // ============================================================================ const value = useMemo( () => ({ // Intent system emitIntent, registerRegion, unregisterRegion, // Legacy activeColumn, setActiveColumn, selectedIndex, setSelectedIndex, resetPrimarySelection, offsetSelection, clearColumn, registerItem, unregisterItem, getItemCount, registerLoadMore, unregisterLoadMore, actionMode, enterActionMode, exitActionMode, cycleAction, isItemSelected, openAccordionItem, setOpenAccordionItem, isEnabled, toggleKeyboardMode, scrollToCenter }), [ emitIntent, registerRegion, unregisterRegion, activeColumn, selectedIndex, setSelectedIndex, resetPrimarySelection, offsetSelection, clearColumn, registerItem, unregisterItem, getItemCount, registerLoadMore, unregisterLoadMore, actionMode, enterActionMode, exitActionMode, cycleAction, isItemSelected, openAccordionItem, isEnabled, toggleKeyboardMode, scrollToCenter ] ) return ( {children} ) }