Files
wr.do/hooks/use-menu-navigation.ts
2025-10-20 11:34:05 +08:00

197 lines
4.5 KiB
TypeScript

"use client"
import * as React from "react"
import type { Editor } from "@tiptap/react"
type Orientation = "horizontal" | "vertical" | "both"
interface MenuNavigationOptions<T> {
/**
* The Tiptap editor instance, if using with a Tiptap editor.
*/
editor?: Editor | null
/**
* Reference to the container element for handling keyboard events.
*/
containerRef?: React.RefObject<HTMLElement | null>
/**
* Search query that affects the selected item.
*/
query?: string
/**
* Array of items to navigate through.
*/
items: T[]
/**
* Callback fired when an item is selected.
*/
onSelect?: (item: T) => void
/**
* Callback fired when the menu should close.
*/
onClose?: () => void
/**
* The navigation orientation of the menu.
* @default "vertical"
*/
orientation?: Orientation
/**
* Whether to automatically select the first item when the menu opens.
* @default true
*/
autoSelectFirstItem?: boolean
}
/**
* Hook that implements keyboard navigation for dropdown menus and command palettes.
*
* Handles arrow keys, tab, home/end, enter for selection, and escape to close.
* Works with both Tiptap editors and regular DOM elements.
*
* @param options - Configuration options for the menu navigation
* @returns Object containing the selected index and a setter function
*/
export function useMenuNavigation<T>({
editor,
containerRef,
query,
items,
onSelect,
onClose,
orientation = "vertical",
autoSelectFirstItem = true,
}: MenuNavigationOptions<T>) {
const [selectedIndex, setSelectedIndex] = React.useState<number>(
autoSelectFirstItem ? 0 : -1
)
React.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,
onSelect,
onClose,
orientation,
])
React.useEffect(() => {
if (query) {
setSelectedIndex(autoSelectFirstItem ? 0 : -1)
}
}, [query, autoSelectFirstItem])
return {
selectedIndex: items.length ? selectedIndex : undefined,
setSelectedIndex,
}
}