Skip to main content

useMenuNavigation

Overview

useMenuNavigation is a generic React hook that implements keyboard navigation for dropdown menus, command palettes, and autocomplete lists. It handles arrow keys, Tab, Home/End, Enter for selection, and Escape to close. The hook supports both Tiptap editor contexts and regular DOM elements, with configurable orientation (horizontal, vertical, or both).

Source: template/hooks/use-menu-navigation.ts

Signature

function useMenuNavigation<T>(options: MenuNavigationOptions<T>): {
selectedIndex: number | undefined;
setSelectedIndex: React.Dispatch<React.SetStateAction<number>>;
}

Parameters

PropertyTypeDefaultDescription
itemsT[]RequiredArray of items to navigate through
editorEditor | nullundefinedTiptap editor instance; keyboard events are attached to editor.view.dom
containerRefReact.RefObject<HTMLElement | null>undefinedReference to the container element for keyboard events (used when editor is not provided)
querystringundefinedSearch query; when it changes, the selected index resets
onSelect(item: T) => voidundefinedCallback fired when the user presses Enter on a selected item
onClose() => voidundefinedCallback fired when the user presses Escape
orientation'horizontal' | 'vertical' | 'both''vertical'Controls which arrow keys are active
autoSelectFirstItembooleantrueWhether to pre-select the first item when the menu opens

Return Values

PropertyTypeDescription
selectedIndexnumber | undefinedThe currently selected item index, or undefined if the items array is empty
setSelectedIndexReact.Dispatch<React.SetStateAction<number>>Manual setter for the selected index

Keyboard Bindings

The following keys are handled based on the orientation setting:

KeyOrientation RestrictionBehavior
ArrowUpNot horizontalMove selection to the previous item (wraps around)
ArrowDownNot horizontalMove selection to the next item (wraps around)
ArrowLeftNot verticalMove selection to the previous item (wraps around)
ArrowRightNot verticalMove selection to the next item (wraps around)
TabAnyMove to next item; Shift+Tab moves to previous item
HomeAnyJump to the first item
EndAnyJump to the last item
EnterAnyCall onSelect with the currently selected item (skipped during IME composition)
EscapeAnyCall onClose

All handled keys call event.preventDefault() to avoid interfering with surrounding page behavior.

Implementation Details

  • Event target resolution: The hook attaches a keydown listener to either editor.view.dom (for Tiptap integration) or containerRef.current (for regular DOM elements). If neither is provided, no listener is attached.
  • Capture phase: The event listener uses the capture phase (true as the third argument) to intercept keyboard events before they reach other handlers.
  • Wrapping navigation: Both moveNext and movePrev use modulo arithmetic to wrap around the items array, so navigating past the last item returns to the first.
  • Query-based reset: When the query prop changes, selectedIndex resets to 0 (or -1 if autoSelectFirstItem is false), ensuring the selection stays relevant to filtered results.
  • IME composition guard: The Enter key handler checks event.isComposing to avoid triggering selection while the user is composing characters in an input method editor.
  • Cleanup: The effect returns a cleanup function that removes the event listener when the component unmounts or dependencies change.

Usage Examples

import { useRef, useState } from 'react';
import { useMenuNavigation } from '@/hooks/use-menu-navigation';

function DropdownMenu({ items, onItemClick, onClose }) {
const containerRef = useRef<HTMLDivElement>(null);

const { selectedIndex } = useMenuNavigation({
items,
containerRef,
onSelect: onItemClick,
onClose,
orientation: 'vertical',
});

return (
<div ref={containerRef} role="listbox" tabIndex={0}>
{items.map((item, index) => (
<div
key={item.id}
role="option"
aria-selected={index === selectedIndex}
className={index === selectedIndex ? 'selected' : ''}
onClick={() => onItemClick(item)}
>
{item.label}
</div>
))}
</div>
);
}

Tiptap editor slash command menu

import { useMenuNavigation } from '@/hooks/use-menu-navigation';

function SlashCommandMenu({ editor, query, commands, onSelect, onClose }) {
const filteredCommands = commands.filter((cmd) =>
cmd.label.toLowerCase().includes(query.toLowerCase())
);

const { selectedIndex } = useMenuNavigation({
editor,
query,
items: filteredCommands,
onSelect,
onClose,
autoSelectFirstItem: true,
});

return (
<div className="slash-command-menu">
{filteredCommands.map((cmd, index) => (
<button
key={cmd.id}
className={index === selectedIndex ? 'active' : ''}
onClick={() => onSelect(cmd)}
>
{cmd.icon} {cmd.label}
</button>
))}
</div>
);
}

Horizontal toolbar navigation

import { useRef } from 'react';
import { useMenuNavigation } from '@/hooks/use-menu-navigation';

function Toolbar({ tools, onToolSelect }) {
const toolbarRef = useRef<HTMLDivElement>(null);

const { selectedIndex } = useMenuNavigation({
items: tools,
containerRef: toolbarRef,
onSelect: onToolSelect,
orientation: 'horizontal',
autoSelectFirstItem: false,
});

return (
<div ref={toolbarRef} role="toolbar" tabIndex={0}>
{tools.map((tool, index) => (
<button
key={tool.id}
aria-pressed={index === selectedIndex}
onClick={() => onToolSelect(tool)}
>
{tool.name}
</button>
))}
</div>
);
}