KeyboardNavigationProvider.tsx raw
1 import { isTouchDevice } from '@/lib/utils'
2 import { Event } from 'nostr-tools'
3 import {
4 createContext,
5 ReactNode,
6 RefObject,
7 useCallback,
8 useContext,
9 useEffect,
10 useMemo,
11 useRef,
12 useState
13 } from 'react'
14 import modalManager from '@/services/modal-manager.service'
15 import { useScreenSize } from './ScreenSizeProvider'
16 import { useUserPreferences } from './UserPreferencesProvider'
17
18 // ============================================================================
19 // Abstract Navigation Intent System
20 // ============================================================================
21
22 /**
23 * Navigation intents are abstract actions that the control plane emits.
24 * Components decide what each intent means in their context.
25 */
26 export type NavigationIntent =
27 | 'up'
28 | 'down'
29 | 'left'
30 | 'right'
31 | 'activate'
32 | 'back'
33 | 'cancel'
34 | 'pageUp'
35 | 'nextAction'
36 | 'prevAction'
37
38 /**
39 * Navigation regions handle intents for a specific area of the UI.
40 * Higher priority regions handle intents first.
41 */
42 export interface NavigationRegion {
43 id: string
44 priority: number
45 isActive: () => boolean
46 handleIntent: (intent: NavigationIntent) => boolean // returns true if handled
47 }
48
49 // ============================================================================
50 // Legacy Types (for backward compatibility during migration)
51 // ============================================================================
52
53 export type TNavigationColumn = 0 | 1 | 2 // 0=sidebar, 1=primary, 2=secondary
54
55 export type TActionType = 'reply' | 'repost' | 'quote' | 'react' | 'zap'
56
57 export type TActionMode = {
58 active: boolean
59 selectedAction: TActionType | null
60 noteEvent: Event | null
61 }
62
63 export type TItemMeta = {
64 type: 'sidebar' | 'note' | 'settings'
65 event?: Event
66 onActivate?: () => void
67 }
68
69 type TRegisteredItem = {
70 ref: RefObject<HTMLElement>
71 meta?: TItemMeta
72 }
73
74 // ============================================================================
75 // Context Types
76 // ============================================================================
77
78 type TKeyboardNavigationContext = {
79 // Intent system
80 emitIntent: (intent: NavigationIntent) => void
81 registerRegion: (region: NavigationRegion) => void
82 unregisterRegion: (id: string) => void
83
84 // Column focus (legacy, components should migrate to regions)
85 activeColumn: TNavigationColumn
86 setActiveColumn: (column: TNavigationColumn) => void
87
88 // Item selection per column
89 selectedIndex: Record<TNavigationColumn, number>
90 setSelectedIndex: (column: TNavigationColumn, index: number) => void
91 resetPrimarySelection: () => void
92 offsetSelection: (column: TNavigationColumn, offset: number) => void
93 clearColumn: (column: TNavigationColumn) => void
94
95 // Registered items per column
96 registerItem: (
97 column: TNavigationColumn,
98 index: number,
99 ref: RefObject<HTMLElement>,
100 meta?: TItemMeta
101 ) => void
102 unregisterItem: (column: TNavigationColumn, index: number) => void
103 getItemCount: (column: TNavigationColumn) => number
104
105 // Load more callback per column (for infinite scroll)
106 registerLoadMore: (column: TNavigationColumn, callback: () => void) => void
107 unregisterLoadMore: (column: TNavigationColumn) => void
108
109 // Action mode
110 actionMode: TActionMode
111 enterActionMode: (noteEvent: Event) => void
112 exitActionMode: () => void
113 cycleAction: (direction?: 1 | -1) => void
114
115 // Visual state
116 isItemSelected: (column: TNavigationColumn, index: number) => boolean
117
118 // Settings accordion
119 openAccordionItem: string | null
120 setOpenAccordionItem: (value: string | null) => void
121
122 // Keyboard nav enabled
123 isEnabled: boolean
124 toggleKeyboardMode: () => void
125
126 // Scroll utilities
127 scrollToCenter: (element: HTMLElement) => void
128 }
129
130 const ACTIONS: TActionType[] = ['reply', 'repost', 'quote', 'react', 'zap']
131
132 const KeyboardNavigationContext = createContext<TKeyboardNavigationContext | undefined>(undefined)
133
134 export function useKeyboardNavigation() {
135 const context = useContext(KeyboardNavigationContext)
136 if (!context) {
137 throw new Error('useKeyboardNavigation must be used within KeyboardNavigationProvider')
138 }
139 return context
140 }
141
142 /**
143 * Hook for components to register as a navigation region.
144 * The region handles intents and decides what they mean.
145 */
146 export function useNavigationRegion(
147 id: string,
148 priority: number,
149 isActive: () => boolean,
150 handleIntent: (intent: NavigationIntent) => boolean,
151 deps: React.DependencyList = []
152 ) {
153 const { registerRegion, unregisterRegion } = useKeyboardNavigation()
154
155 useEffect(() => {
156 const region: NavigationRegion = {
157 id,
158 priority,
159 isActive,
160 handleIntent
161 }
162 registerRegion(region)
163 return () => unregisterRegion(id)
164 // eslint-disable-next-line react-hooks/exhaustive-deps
165 }, [id, priority, registerRegion, unregisterRegion, ...deps])
166 }
167
168 // Helper to check if an input element is focused
169 function isInputFocused(): boolean {
170 const activeElement = document.activeElement
171 if (!activeElement) return false
172 const tagName = activeElement.tagName.toLowerCase()
173 return (
174 tagName === 'input' ||
175 tagName === 'textarea' ||
176 activeElement.getAttribute('contenteditable') === 'true'
177 )
178 }
179
180 export function KeyboardNavigationProvider({
181 children,
182 secondaryStackLength,
183 sidebarDrawerOpen,
184 onBack,
185 onCloseSecondary
186 }: {
187 children: ReactNode
188 secondaryStackLength: number
189 sidebarDrawerOpen: boolean
190 onBack?: () => void
191 onCloseSecondary?: () => void
192 }) {
193 const { isSmallScreen } = useScreenSize()
194 const { enableSingleColumnLayout } = useUserPreferences()
195
196 const [activeColumn, setActiveColumnState] = useState<TNavigationColumn>(1)
197 const [selectedIndex, setSelectedIndexState] = useState<Record<TNavigationColumn, number>>({
198 0: 0,
199 1: 0,
200 2: 0
201 })
202 const [actionMode, setActionMode] = useState<TActionMode>({
203 active: false,
204 selectedAction: null,
205 noteEvent: null
206 })
207 const [openAccordionItem, setOpenAccordionItem] = useState<string | null>(null)
208 const [isEnabled, setIsEnabled] = useState(false)
209
210 // Track Escape presses for triple-Escape to disable keyboard mode
211 const escapeTimestampsRef = useRef<number[]>([])
212 const TRIPLE_ESCAPE_WINDOW = 800 // ms within which 3 escapes must occur
213
214 // Refs to always have latest values (avoid stale closures)
215 const activeColumnRef = useRef<TNavigationColumn>(activeColumn)
216 const selectedIndexRef = useRef<Record<TNavigationColumn, number>>(selectedIndex)
217
218 // Wrapper to update both state and ref
219 const setActiveColumn = useCallback((column: TNavigationColumn) => {
220 activeColumnRef.current = column
221 setActiveColumnState(column)
222 }, [])
223
224 // Toggle keyboard navigation mode
225 const toggleKeyboardMode = useCallback(() => {
226 setIsEnabled((prev) => {
227 const newEnabled = !prev
228 if (newEnabled) {
229 // When enabling, initialize selection to first item in active column
230 const items = itemsRef.current[activeColumnRef.current]
231 if (items.size > 0) {
232 const firstIndex = Array.from(items.keys()).sort((a, b) => a - b)[0]
233 if (firstIndex !== undefined) {
234 setSelectedIndexState((prevState) => ({
235 ...prevState,
236 [activeColumnRef.current]: firstIndex
237 }))
238 }
239 }
240 }
241 return newEnabled
242 })
243 }, [])
244
245 // Navigation regions registry
246 const regionsRef = useRef<Map<string, NavigationRegion>>(new Map())
247
248 // Ref to hold the latest handleDefaultIntent function
249 const handleDefaultIntentRef = useRef<(intent: NavigationIntent) => void>(() => {})
250
251 // Item registration per column
252 const itemsRef = useRef<Record<TNavigationColumn, Map<number, TRegisteredItem>>>({
253 0: new Map(),
254 1: new Map(),
255 2: new Map()
256 })
257
258 // Load more callbacks per column (for infinite scroll)
259 const loadMoreRef = useRef<Record<TNavigationColumn, (() => void) | null>>({
260 0: null,
261 1: null,
262 2: null
263 })
264
265 // ============================================================================
266 // Intent System
267 // ============================================================================
268
269 const registerRegion = useCallback((region: NavigationRegion) => {
270 regionsRef.current.set(region.id, region)
271 }, [])
272
273 const unregisterRegion = useCallback((id: string) => {
274 regionsRef.current.delete(id)
275 }, [])
276
277 const emitIntent = useCallback((intent: NavigationIntent) => {
278 // Sort regions by priority (highest first)
279 const regions = Array.from(regionsRef.current.values())
280 .filter((r) => r.isActive())
281 .sort((a, b) => b.priority - a.priority)
282
283 // Let regions handle the intent in priority order
284 for (const region of regions) {
285 if (region.handleIntent(intent)) {
286 return // Intent was handled
287 }
288 }
289
290 // Fallback to default handling if no region handled it
291 handleDefaultIntentRef.current(intent)
292 }, [])
293
294 // ============================================================================
295 // Scroll to Center (or top if near edge)
296 // ============================================================================
297
298 const scrollToCenter = useCallback((element: HTMLElement) => {
299 // Find the scrollable container (look for overflow-y: auto/scroll)
300 // Stop at document.body - if we reach it, use window scrolling instead
301 let scrollContainer: HTMLElement | null = element.parentElement
302 while (scrollContainer && scrollContainer !== document.body && scrollContainer !== document.documentElement) {
303 const style = window.getComputedStyle(scrollContainer)
304 const overflowY = style.overflowY
305 if (overflowY === 'auto' || overflowY === 'scroll') {
306 // Verify this container is actually scrollable (has scrollable content)
307 if (scrollContainer.scrollHeight > scrollContainer.clientHeight) {
308 break
309 }
310 }
311 scrollContainer = scrollContainer.parentElement
312 }
313
314 // If we reached body/documentElement, use window scrolling
315 if (!scrollContainer || scrollContainer === document.body || scrollContainer === document.documentElement) {
316 scrollContainer = null
317 }
318
319 const headerOffset = 100 // Account for sticky headers
320
321 if (scrollContainer) {
322 // Scroll within container
323 const containerRect = scrollContainer.getBoundingClientRect()
324 const elementRect = element.getBoundingClientRect()
325
326 // Position relative to container
327 const elementTopInContainer = elementRect.top - containerRect.top
328 const elementBottomInContainer = elementRect.bottom - containerRect.top
329 const containerHeight = containerRect.height
330 const visibleHeight = containerHeight - headerOffset
331
332 // If element is taller than visible area, scroll to show its top
333 if (elementRect.height > visibleHeight) {
334 const targetScrollTop = scrollContainer.scrollTop + elementTopInContainer - headerOffset
335 scrollContainer.scrollTo({
336 top: Math.max(0, targetScrollTop),
337 behavior: 'instant'
338 })
339 return
340 }
341
342 // Check if already visible with margin
343 const isVisible = elementTopInContainer >= headerOffset &&
344 elementBottomInContainer <= containerHeight - 50
345
346 if (!isVisible) {
347 // Calculate target scroll to center the element
348 const elementMiddle = elementTopInContainer + elementRect.height / 2
349 const containerMiddle = containerHeight / 2
350 const scrollAdjustment = elementMiddle - containerMiddle
351 const newScrollTop = scrollContainer.scrollTop + scrollAdjustment
352
353 // Don't scroll past the top
354 scrollContainer.scrollTo({
355 top: Math.max(0, newScrollTop),
356 behavior: 'instant'
357 })
358 }
359 } else {
360 // Window scrolling (mobile, single column mode)
361 const rect = element.getBoundingClientRect()
362 const viewportHeight = window.innerHeight
363 const visibleHeight = viewportHeight - headerOffset
364
365 // If element is taller than visible area, scroll to show its top
366 if (rect.height > visibleHeight) {
367 const targetScrollTop = window.scrollY + rect.top - headerOffset
368 window.scrollTo({
369 top: Math.max(0, targetScrollTop),
370 behavior: 'instant'
371 })
372 return
373 }
374
375 // Check if already visible
376 const isVisible = rect.top >= headerOffset && rect.bottom <= viewportHeight - 50
377
378 if (!isVisible) {
379 // Calculate target scroll to center the element
380 const elementMiddle = rect.top + rect.height / 2
381 const viewportMiddle = viewportHeight / 2
382 const scrollAdjustment = elementMiddle - viewportMiddle
383 const newScrollTop = window.scrollY + scrollAdjustment
384
385 window.scrollTo({
386 top: Math.max(0, newScrollTop),
387 behavior: 'instant'
388 })
389 }
390 }
391 }, [])
392
393 // ============================================================================
394 // Legacy Item Management
395 // ============================================================================
396
397 const setSelectedIndex = useCallback((column: TNavigationColumn, index: number) => {
398 selectedIndexRef.current = { ...selectedIndexRef.current, [column]: index }
399 setSelectedIndexState((prev) => ({
400 ...prev,
401 [column]: index
402 }))
403 }, [])
404
405 const resetPrimarySelection = useCallback(() => {
406 setSelectedIndex(1, 0)
407 setActiveColumn(1)
408 }, [setSelectedIndex, setActiveColumn])
409
410 const offsetSelection = useCallback(
411 (column: TNavigationColumn, offset: number) => {
412 setSelectedIndexState((prev) => {
413 const newState = { ...prev, [column]: Math.max(0, prev[column] + offset) }
414 selectedIndexRef.current = newState
415 return newState
416 })
417 },
418 []
419 )
420
421 const clearColumn = useCallback((column: TNavigationColumn) => {
422 itemsRef.current[column].clear()
423 setSelectedIndexState((prev) => ({
424 ...prev,
425 [column]: 0
426 }))
427 }, [])
428
429 const registerItem = useCallback(
430 (column: TNavigationColumn, index: number, ref: RefObject<HTMLElement>, meta?: TItemMeta) => {
431 itemsRef.current[column].set(index, { ref, meta })
432 },
433 []
434 )
435
436 const unregisterItem = useCallback((column: TNavigationColumn, index: number) => {
437 itemsRef.current[column].delete(index)
438 }, [])
439
440 const getItemCount = useCallback((column: TNavigationColumn) => {
441 return itemsRef.current[column].size
442 }, [])
443
444 const registerLoadMore = useCallback((column: TNavigationColumn, callback: () => void) => {
445 loadMoreRef.current[column] = callback
446 }, [])
447
448 const unregisterLoadMore = useCallback((column: TNavigationColumn) => {
449 loadMoreRef.current[column] = null
450 }, [])
451
452 const isItemSelected = useCallback(
453 (column: TNavigationColumn, index: number) => {
454 return isEnabled && activeColumn === column && selectedIndex[column] === index
455 },
456 [isEnabled, activeColumn, selectedIndex]
457 )
458
459 // ============================================================================
460 // Column Navigation
461 // ============================================================================
462
463 const getAvailableColumns = useCallback((): TNavigationColumn[] => {
464 if (isSmallScreen) {
465 // Mobile: sidebar is in a drawer, only one column visible at a time
466 if (sidebarDrawerOpen) return [0]
467 if (secondaryStackLength > 0) return [2]
468 return [1]
469 }
470 if (enableSingleColumnLayout) {
471 // Single column desktop: sidebar is always visible alongside main content
472 if (secondaryStackLength > 0) return [0, 2]
473 return [0, 1]
474 }
475 // Two column desktop
476 if (secondaryStackLength > 0) return [0, 1, 2]
477 return [0, 1]
478 }, [isSmallScreen, enableSingleColumnLayout, sidebarDrawerOpen, secondaryStackLength])
479
480 const moveColumn = useCallback(
481 (direction: 1 | -1) => {
482 const available = getAvailableColumns()
483 const currentColumn = activeColumnRef.current
484 const currentIdx = available.indexOf(currentColumn)
485 if (currentIdx === -1) {
486 setActiveColumn(available[0])
487 return
488 }
489 const newIdx = Math.max(0, Math.min(available.length - 1, currentIdx + direction))
490 const newColumn = available[newIdx]
491 if (newColumn === currentColumn) return // Already at edge
492
493 setActiveColumn(newColumn)
494
495 // Scroll to currently selected item in the new column
496 const items = itemsRef.current[newColumn]
497 const currentSelectedIdx = selectedIndexRef.current[newColumn]
498 const item = items.get(currentSelectedIdx)
499 if (item?.ref.current) {
500 scrollToCenter(item.ref.current)
501 } else if (items.size > 0) {
502 // If no item at current index, select the first one
503 const firstIndex = Array.from(items.keys()).sort((a, b) => a - b)[0]
504 if (firstIndex !== undefined) {
505 setSelectedIndex(newColumn, firstIndex)
506 const firstItem = items.get(firstIndex)
507 if (firstItem?.ref.current) {
508 scrollToCenter(firstItem.ref.current)
509 }
510 }
511 }
512 },
513 [getAvailableColumns, scrollToCenter, setSelectedIndex, setActiveColumn]
514 )
515
516 // ============================================================================
517 // Item Navigation with Centered Scrolling
518 // ============================================================================
519
520 const moveItem = useCallback(
521 (direction: 1 | -1) => {
522 const currentColumn = activeColumnRef.current
523 const items = itemsRef.current[currentColumn]
524 if (items.size === 0) return
525
526 const indices = Array.from(items.keys()).sort((a, b) => a - b)
527 if (indices.length === 0) return
528
529 const currentSelected = selectedIndexRef.current[currentColumn]
530 let currentIdx = indices.indexOf(currentSelected)
531
532 if (currentIdx === -1) {
533 // Find nearest valid index
534 let nearestIdx = 0
535 let minDistance = Infinity
536 for (let i = 0; i < indices.length; i++) {
537 const distance = Math.abs(indices[i] - currentSelected)
538 if (distance < minDistance) {
539 minDistance = distance
540 nearestIdx = i
541 }
542 }
543 currentIdx = nearestIdx
544 }
545
546 // Calculate new index with wrap-around
547 let newIdx = currentIdx + direction
548
549 // Wrap around behavior
550 if (newIdx < 0) {
551 // At top, going up -> wrap to bottom
552 newIdx = indices.length - 1
553 } else if (newIdx >= indices.length) {
554 // At bottom, going down -> trigger load more and wrap to top
555 const loadMore = loadMoreRef.current[currentColumn]
556 if (loadMore) {
557 loadMore()
558 }
559 newIdx = 0
560 }
561
562 const newItemIndex = indices[newIdx]
563 if (newItemIndex === undefined) return
564
565 setSelectedIndex(currentColumn, newItemIndex)
566
567 // Scroll to center
568 const item = items.get(newItemIndex)
569 if (item?.ref.current) {
570 scrollToCenter(item.ref.current)
571 }
572 },
573 [setSelectedIndex, scrollToCenter]
574 )
575
576 const jumpToTop = useCallback(() => {
577 // For primary feed, use column 1; for secondary, use column 2
578 // Fall back to current column if it has items
579 let targetColumn = activeColumnRef.current
580
581 // Check if current column has items, if not try column 1 (primary)
582 if (itemsRef.current[targetColumn].size === 0) {
583 if (itemsRef.current[1].size > 0) {
584 targetColumn = 1
585 } else if (itemsRef.current[2].size > 0) {
586 targetColumn = 2
587 }
588 }
589
590 const items = itemsRef.current[targetColumn]
591 if (items.size === 0) return
592
593 const indices = Array.from(items.keys()).sort((a, b) => a - b)
594 if (indices.length === 0) return
595
596 const newItemIndex = indices[0]
597 if (newItemIndex === undefined) return
598
599 // Set active column and selection immediately
600 setActiveColumn(targetColumn)
601 setSelectedIndex(targetColumn, newItemIndex)
602
603 // Scroll to top of the page/container
604 window.scrollTo({ top: 0, behavior: 'smooth' })
605
606 // Also scroll any parent containers to top
607 const item = items.get(newItemIndex)
608 if (item?.ref.current) {
609 let scrollContainer: HTMLElement | null = item.ref.current.parentElement
610 while (scrollContainer && scrollContainer !== document.body) {
611 const style = window.getComputedStyle(scrollContainer)
612 if ((style.overflowY === 'auto' || style.overflowY === 'scroll') &&
613 scrollContainer.scrollHeight > scrollContainer.clientHeight) {
614 scrollContainer.scrollTo({ top: 0, behavior: 'smooth' })
615 break
616 }
617 scrollContainer = scrollContainer.parentElement
618 }
619 }
620 },
621 [setSelectedIndex, setActiveColumn]
622 )
623
624 // ============================================================================
625 // Action Mode
626 // ============================================================================
627
628 const enterActionMode = useCallback((noteEvent: Event) => {
629 setActionMode({
630 active: true,
631 selectedAction: 'reply',
632 noteEvent
633 })
634 }, [])
635
636 const exitActionMode = useCallback(() => {
637 setActionMode({
638 active: false,
639 selectedAction: null,
640 noteEvent: null
641 })
642 }, [])
643
644 const cycleAction = useCallback(
645 (direction: 1 | -1 = 1) => {
646 setActionMode((prev) => {
647 if (!prev.active) {
648 const currentColumn = activeColumnRef.current
649 const currentSelected = selectedIndexRef.current[currentColumn]
650 const item = itemsRef.current[currentColumn].get(currentSelected)
651 if (item?.meta?.type === 'note' && item.meta.event) {
652 return {
653 active: true,
654 selectedAction: 'reply',
655 noteEvent: item.meta.event
656 }
657 }
658 return prev
659 }
660
661 const currentIdx = prev.selectedAction ? ACTIONS.indexOf(prev.selectedAction) : 0
662 const newIdx = (currentIdx + direction + ACTIONS.length) % ACTIONS.length
663 return {
664 ...prev,
665 selectedAction: ACTIONS[newIdx]
666 }
667 })
668 },
669 []
670 )
671
672 // ============================================================================
673 // Intent Handlers
674 // ============================================================================
675
676 const handleEnter = useCallback(() => {
677 const currentColumn = activeColumnRef.current
678 const currentSelected = selectedIndexRef.current[currentColumn]
679
680 if (actionMode.active) {
681 const item = itemsRef.current[currentColumn].get(currentSelected)
682 if (item?.ref.current && actionMode.selectedAction) {
683 const stuffStats = item.ref.current.querySelector('[data-stuff-stats]')
684 const actionButton = stuffStats?.querySelector(
685 `[data-action="${actionMode.selectedAction}"]`
686 ) as HTMLButtonElement | null
687 actionButton?.click()
688 exitActionMode()
689 }
690 return
691 }
692
693 const item = itemsRef.current[currentColumn].get(currentSelected)
694 if (!item) return
695
696 if (currentColumn === 0 && item.meta?.type === 'sidebar') {
697 setSelectedIndex(1, 0)
698 setActiveColumn(1)
699 }
700
701 if (item.meta?.onActivate) {
702 item.meta.onActivate()
703 } else if (item.ref.current) {
704 item.ref.current.click()
705 }
706 }, [actionMode, exitActionMode, setSelectedIndex, setActiveColumn])
707
708 const handleEscape = useCallback(() => {
709 // Track Escape press for triple-Escape detection
710 const now = Date.now()
711 escapeTimestampsRef.current.push(now)
712 // Keep only presses within the time window
713 escapeTimestampsRef.current = escapeTimestampsRef.current.filter(
714 (t) => now - t < TRIPLE_ESCAPE_WINDOW
715 )
716 // Check for triple-Escape to disable keyboard mode
717 if (escapeTimestampsRef.current.length >= 3 && isEnabled) {
718 setIsEnabled(false)
719 escapeTimestampsRef.current = []
720 return
721 }
722
723 if (actionMode.active) {
724 exitActionMode()
725 return
726 }
727
728 if (openAccordionItem) {
729 setOpenAccordionItem(null)
730 return
731 }
732
733 if ((isSmallScreen || enableSingleColumnLayout) && secondaryStackLength > 0) {
734 onBack?.()
735 return
736 }
737
738 const currentColumn = activeColumnRef.current
739 if (currentColumn === 2 && secondaryStackLength > 0) {
740 onCloseSecondary?.()
741 setActiveColumn(1)
742 return
743 }
744
745 if (currentColumn !== 0) {
746 setActiveColumn(0)
747 setSelectedIndex(0, 0)
748 }
749 }, [
750 actionMode.active,
751 exitActionMode,
752 openAccordionItem,
753 isSmallScreen,
754 enableSingleColumnLayout,
755 secondaryStackLength,
756 onBack,
757 onCloseSecondary,
758 setSelectedIndex,
759 setActiveColumn,
760 isEnabled,
761 TRIPLE_ESCAPE_WINDOW
762 ])
763
764 // Handle back action - move left through columns or close secondary panel
765 const handleBack = useCallback(() => {
766 const currentColumn = activeColumnRef.current
767
768 // If focused on secondary column (2), close it and move to primary
769 if (currentColumn === 2) {
770 if (secondaryStackLength > 0) {
771 if (isSmallScreen || enableSingleColumnLayout) {
772 // On mobile/single column, use onBack to pop the stack
773 onBack?.()
774 } else {
775 // On desktop with columns, close secondary and focus primary
776 onCloseSecondary?.()
777 setActiveColumn(1)
778 }
779 } else {
780 // No secondary stack, just move to primary
781 setActiveColumn(1)
782 }
783 return
784 }
785
786 // If focused on primary column (1), move to sidebar
787 if (currentColumn === 1) {
788 setActiveColumn(0)
789 return
790 }
791
792 // If focused on sidebar (0), do nothing (already at leftmost)
793 }, [secondaryStackLength, isSmallScreen, enableSingleColumnLayout, onBack, onCloseSecondary, setActiveColumn])
794
795 // Default intent handler (fallback when no region handles)
796 const handleDefaultIntent = useCallback(
797 (intent: NavigationIntent) => {
798 switch (intent) {
799 case 'up':
800 moveItem(-1)
801 break
802 case 'down':
803 moveItem(1)
804 break
805 case 'left':
806 moveColumn(-1)
807 break
808 case 'right':
809 moveColumn(1)
810 break
811 case 'pageUp':
812 jumpToTop()
813 break
814 case 'activate':
815 handleEnter()
816 break
817 case 'back':
818 handleBack()
819 break
820 case 'cancel':
821 handleEscape()
822 break
823 case 'nextAction':
824 cycleAction(1)
825 break
826 case 'prevAction':
827 cycleAction(-1)
828 break
829 }
830 },
831 [moveItem, moveColumn, jumpToTop, handleEnter, handleBack, handleEscape, cycleAction]
832 )
833
834 // Keep the ref updated with the latest handleDefaultIntent
835 useEffect(() => {
836 handleDefaultIntentRef.current = handleDefaultIntent
837 }, [handleDefaultIntent])
838
839 // ============================================================================
840 // Keyboard Event Handler
841 // ============================================================================
842
843 // Helper to trigger an action on the currently selected note
844 const triggerNoteAction = useCallback((action: TActionType) => {
845 const currentColumn = activeColumnRef.current
846 const currentSelected = selectedIndexRef.current[currentColumn]
847 const item = itemsRef.current[currentColumn].get(currentSelected)
848 if (item?.meta?.type === 'note' && item.ref.current) {
849 const stuffStats = item.ref.current.querySelector('[data-stuff-stats]')
850 const actionButton = stuffStats?.querySelector(`[data-action="${action}"]`) as HTMLButtonElement | null
851 actionButton?.click()
852 }
853 }, [])
854
855 // Main keyboard handler - translates keys to intents
856 // Also handles enabling keyboard nav on first navigation key press
857 useEffect(() => {
858 if (isTouchDevice()) return
859
860 const handleKeyDown = (e: KeyboardEvent) => {
861 if (isInputFocused()) return
862 if (modalManager.hasOpenModal?.()) return
863
864 // Map keys to intents
865 let intent: NavigationIntent | null = null
866 const isNavKey = ['ArrowUp', 'ArrowDown', 'j', 'k', 'Tab'].includes(e.key)
867
868 switch (e.key) {
869 case 'ArrowUp':
870 case 'k': // Vim-style
871 intent = 'up'
872 break
873 case 'ArrowDown':
874 case 'j': // Vim-style
875 intent = 'down'
876 break
877 case 'ArrowLeft':
878 case 'h': // Vim-style
879 intent = 'back'
880 break
881 case 'ArrowRight':
882 case 'l': // Vim-style
883 intent = 'right'
884 break
885 case 'Enter':
886 intent = 'activate'
887 break
888 case 'PageUp':
889 intent = 'pageUp'
890 break
891 case 'Escape':
892 intent = 'cancel'
893 break
894 case 'Backspace':
895 intent = 'back'
896 break
897 case 'Tab':
898 // Tab switches between columns
899 e.preventDefault()
900 intent = e.shiftKey ? 'left' : 'right'
901 break
902 // Direct note actions
903 case 'r':
904 if (isEnabled) {
905 e.preventDefault()
906 triggerNoteAction('reply')
907 return
908 }
909 break
910 case 'R':
911 if (isEnabled) {
912 e.preventDefault()
913 triggerNoteAction('react')
914 return
915 }
916 break
917 case 'p':
918 if (isEnabled) {
919 e.preventDefault()
920 triggerNoteAction('repost')
921 return
922 }
923 break
924 case 'q':
925 if (isEnabled) {
926 e.preventDefault()
927 triggerNoteAction('quote')
928 return
929 }
930 break
931 case 'z':
932 if (isEnabled) {
933 e.preventDefault()
934 triggerNoteAction('zap')
935 return
936 }
937 break
938 case 'K':
939 // Shift+K toggles keyboard mode
940 if (e.shiftKey) {
941 e.preventDefault()
942 toggleKeyboardMode()
943 return
944 }
945 break
946 case 'M':
947 // Shift+M expands/collapses the currently selected note
948 if (e.shiftKey && isEnabled) {
949 e.preventDefault()
950 const col = activeColumnRef.current
951 const sel = selectedIndexRef.current[col]
952 const selectedItem = itemsRef.current[col].get(sel)
953 if (selectedItem?.ref.current) {
954 const expandBtn = selectedItem.ref.current.querySelector('[data-collapsible-expand]') as HTMLButtonElement | null
955 if (expandBtn) {
956 expandBtn.click()
957 // Re-center after expand animation
958 setTimeout(() => {
959 if (selectedItem.ref.current) scrollToCenter(selectedItem.ref.current)
960 }, 50)
961 }
962 }
963 return
964 }
965 break
966 }
967
968 // Enable keyboard nav on first navigation key press
969 if (!isEnabled && isNavKey) {
970 setIsEnabled(true)
971
972 // Initialize selection to first item in active column
973 const available = getAvailableColumns()
974 const currentColumn = activeColumnRef.current
975 const column = available.includes(currentColumn) ? currentColumn : available[0]
976 const items = itemsRef.current[column]
977 if (items.size > 0) {
978 const firstIndex = Array.from(items.keys()).sort((a, b) => a - b)[0]
979 if (firstIndex !== undefined) {
980 setSelectedIndex(column, firstIndex)
981 const item = items.get(firstIndex)
982 if (item?.ref.current) {
983 scrollToCenter(item.ref.current)
984 }
985 }
986 }
987 }
988
989 if (intent && isEnabled) {
990 e.preventDefault()
991 emitIntent(intent)
992 } else if (intent && isNavKey) {
993 // First keypress enables and processes the intent
994 e.preventDefault()
995 emitIntent(intent)
996 }
997 }
998
999 window.addEventListener('keydown', handleKeyDown)
1000 return () => window.removeEventListener('keydown', handleKeyDown)
1001 }, [isEnabled, emitIntent, getAvailableColumns, setSelectedIndex, scrollToCenter, triggerNoteAction, toggleKeyboardMode])
1002
1003 // ============================================================================
1004 // Layout Effects
1005 // ============================================================================
1006
1007 useEffect(() => {
1008 const available = getAvailableColumns()
1009 if (!available.includes(activeColumn)) {
1010 // When current column becomes unavailable, find the best fallback:
1011 // - If coming from column 2 (secondary), prefer column 1 (primary) over column 0 (sidebar)
1012 // - Otherwise use the first available column
1013 if (activeColumn === 2 && available.includes(1)) {
1014 setActiveColumn(1)
1015 } else {
1016 setActiveColumn(available[0])
1017 }
1018 }
1019 }, [getAvailableColumns, activeColumn, setActiveColumn])
1020
1021 // Track secondary panel changes to switch focus
1022 const prevSecondaryStackLength = useRef(secondaryStackLength)
1023 useEffect(() => {
1024 if (secondaryStackLength > prevSecondaryStackLength.current && isEnabled) {
1025 // Secondary opened - switch to column 2 immediately
1026 // This ensures the user can navigate back with left/Escape even if
1027 // the secondary panel doesn't have keyboard-navigable items
1028 setActiveColumn(2)
1029 setSelectedIndex(2, 0)
1030
1031 // If there are items in column 2, scroll to the first one
1032 const items = itemsRef.current[2]
1033 if (items.size > 0) {
1034 const indices = Array.from(items.keys()).sort((a, b) => a - b)
1035 const firstIndex = indices[0]
1036 if (firstIndex !== undefined) {
1037 setSelectedIndex(2, firstIndex)
1038 const item = items.get(firstIndex)
1039 if (item?.ref.current) {
1040 scrollToCenter(item.ref.current)
1041 }
1042 }
1043 }
1044 } else if (secondaryStackLength < prevSecondaryStackLength.current && isEnabled) {
1045 // Secondary closed - return to primary only if we were in the secondary column
1046 // Don't move focus if user is already in sidebar or primary
1047 if (activeColumnRef.current === 2) {
1048 setActiveColumn(1)
1049 }
1050 }
1051 prevSecondaryStackLength.current = secondaryStackLength
1052 }, [secondaryStackLength, isEnabled, setSelectedIndex, scrollToCenter, setActiveColumn])
1053
1054 // ============================================================================
1055 // Context Value
1056 // ============================================================================
1057
1058 const value = useMemo(
1059 () => ({
1060 // Intent system
1061 emitIntent,
1062 registerRegion,
1063 unregisterRegion,
1064
1065 // Legacy
1066 activeColumn,
1067 setActiveColumn,
1068 selectedIndex,
1069 setSelectedIndex,
1070 resetPrimarySelection,
1071 offsetSelection,
1072 clearColumn,
1073 registerItem,
1074 unregisterItem,
1075 getItemCount,
1076 registerLoadMore,
1077 unregisterLoadMore,
1078 actionMode,
1079 enterActionMode,
1080 exitActionMode,
1081 cycleAction,
1082 isItemSelected,
1083 openAccordionItem,
1084 setOpenAccordionItem,
1085 isEnabled,
1086 toggleKeyboardMode,
1087 scrollToCenter
1088 }),
1089 [
1090 emitIntent,
1091 registerRegion,
1092 unregisterRegion,
1093 activeColumn,
1094 selectedIndex,
1095 setSelectedIndex,
1096 resetPrimarySelection,
1097 offsetSelection,
1098 clearColumn,
1099 registerItem,
1100 unregisterItem,
1101 getItemCount,
1102 registerLoadMore,
1103 unregisterLoadMore,
1104 actionMode,
1105 enterActionMode,
1106 exitActionMode,
1107 cycleAction,
1108 isItemSelected,
1109 openAccordionItem,
1110 isEnabled,
1111 toggleKeyboardMode,
1112 scrollToCenter
1113 ]
1114 )
1115
1116 return (
1117 <KeyboardNavigationContext.Provider value={value}>
1118 {children}
1119 </KeyboardNavigationContext.Provider>
1120 )
1121 }
1122