'use client' import type { Editor } from '@tiptap/react' import { useCallback, useEffect, useState } from 'react' type Orientation = 'horizontal' | 'vertical' | 'both' type SelectedIndexUpdater = number | ((currentIndex: number) => number) interface SelectionState { autoSelectFirstItem: boolean query: string | undefined selectedIndex: number } interface MenuNavigationOptions { editor?: Editor | null containerRef?: React.RefObject query?: string items: T[] onSelect?: (item: T) => void onClose?: () => void orientation?: Orientation autoSelectFirstItem?: boolean } export function useMenuNavigation({ editor, containerRef, query, items, onSelect, onClose, orientation = 'vertical', autoSelectFirstItem = true }: MenuNavigationOptions) { const initialSelectedIndex = autoSelectFirstItem ? 0 : -1 const [selection, setSelection] = useState(() => ({ autoSelectFirstItem, query, selectedIndex: initialSelectedIndex })) const didTrackedInputChange = selection.query !== query || selection.autoSelectFirstItem !== autoSelectFirstItem const nextSelectedIndex = query ? initialSelectedIndex : selection.selectedIndex if (didTrackedInputChange) { // Restart render immediately so query changes reset before paint without an effect. setSelection({ autoSelectFirstItem, query, selectedIndex: nextSelectedIndex }) } const selectedIndex = didTrackedInputChange ? nextSelectedIndex : selection.selectedIndex const setSelectedIndex = useCallback( (value: SelectedIndexUpdater) => { setSelection((currentSelection) => { const didCurrentInputChange = currentSelection.query !== query || currentSelection.autoSelectFirstItem !== autoSelectFirstItem const currentSelectedIndex = didCurrentInputChange && query ? initialSelectedIndex : currentSelection.selectedIndex const nextSelectedIndex = typeof value === 'function' ? value(currentSelectedIndex) : value return { autoSelectFirstItem, query, selectedIndex: nextSelectedIndex } }) }, [autoSelectFirstItem, initialSelectedIndex, query] ) useEffect(() => { const handleKeyboardNavigation = (event: KeyboardEvent) => { if (!items.length) return false const moveNext = () => setSelectedIndex((currentIndex) => { if (currentIndex === -1) return 0 return (currentIndex + 1) % items.length }) const movePrev = () => setSelectedIndex((currentIndex) => { if (currentIndex === -1) return items.length - 1 return (currentIndex - 1 + items.length) % items.length }) switch (event.key) { case 'ArrowUp': { if (orientation === 'horizontal') return false event.preventDefault() movePrev() return true } case 'ArrowDown': { if (orientation === 'horizontal') return false event.preventDefault() moveNext() return true } case 'ArrowLeft': { if (orientation === 'vertical') return false event.preventDefault() movePrev() return true } case 'ArrowRight': { if (orientation === 'vertical') return false event.preventDefault() moveNext() return true } case 'Tab': { event.preventDefault() if (event.shiftKey) { movePrev() } else { moveNext() } return true } case 'Home': { event.preventDefault() setSelectedIndex(0) return true } case 'End': { event.preventDefault() setSelectedIndex(items.length - 1) return true } case 'Enter': { if (event.isComposing) return false event.preventDefault() if (selectedIndex !== -1 && items[selectedIndex]) { onSelect?.(items[selectedIndex]) } return true } case 'Escape': { event.preventDefault() onClose?.() return true } default: return false } } let targetElement: HTMLElement | null = null if (editor) { targetElement = editor.view.dom } else if (containerRef?.current) { targetElement = containerRef.current } if (targetElement) { targetElement.addEventListener('keydown', handleKeyboardNavigation, true) return () => { targetElement?.removeEventListener('keydown', handleKeyboardNavigation, true) } } return undefined }, [editor, containerRef, items, selectedIndex, setSelectedIndex, onSelect, onClose, orientation]) return { selectedIndex: items.length ? selectedIndex : undefined, setSelectedIndex } }