import type { KeyEvent } from "@opentui/core";
import { useCallback, useEffect, useId, useMemo, useState } from "react";
import { useScopedKeyboard } from "./focus-scope.js";
import { createKeyMatcher } from "./keymap.js";
import { displayWidth, formatFixedWidth, type TextOverflowMode } from "./text-layout.js";
import { useTuiTheme } from "./theme.js";
export interface MenuItem {
key: string;
label: string;
description?: string;
disabled?: boolean;
}
export interface MenuProps {
title?: string;
items: MenuItem[];
onSelect?: (key: string) => void;
initialIndex?: number;
scopeId?: string;
keyboardEnabled?: boolean;
maxLineWidth?: number;
overflow?: TextOverflowMode;
boxed?: boolean;
onKeyPress?: (
key: KeyEvent,
context: {
index: number;
items: MenuItem[];
setIndex: (nextIndex: number) => void;
select: (itemKey: string) => void;
},
) => boolean;
}
const menuKeymap = createKeyMatcher({
up: ["up", "k"],
down: ["down", "j"],
select: ["enter"],
});
function isUpKey(key: KeyEvent) {
const name = key.name?.toLowerCase();
return menuKeymap.match("up", key) || name === "arrowup" || key.sequence === "\u001b[A";
}
function isDownKey(key: KeyEvent) {
const name = key.name?.toLowerCase();
return menuKeymap.match("down", key) || name === "arrowdown" || key.sequence === "\u001b[B";
}
function isSelectKey(key: KeyEvent) {
const name = key.name?.toLowerCase();
return (
menuKeymap.match("select", key) ||
name === "return" ||
key.sequence === "\r" ||
key.sequence === "\n"
);
}
export function Menu({
title,
items,
onSelect,
initialIndex = 0,
scopeId,
keyboardEnabled = true,
maxLineWidth,
overflow = "ellipsis",
boxed = true,
onKeyPress,
}: MenuProps) {
const { tokens } = useTuiTheme();
const reactScopeId = useId();
const keyboardScopeId = scopeId ?? `menu:${reactScopeId}`;
const [index, setIndex] = useState(initialIndex);
const lineWidth = useMemo(() => {
const contentLineWidth = Math.max(
8,
...items.map((entry) => {
const entryDisabled = entry.disabled ? " [disabled]" : "";
return displayWidth(
`> ${entry.label}${entryDisabled}${entry.description ? ` - ${entry.description}` : ""}`,
);
}),
);
if (typeof maxLineWidth === "number") {
return Math.max(8, Math.min(maxLineWidth, contentLineWidth));
}
return contentLineWidth;
}, [items, maxLineWidth]);
const clearLineWidth = useMemo(() => {
if (boxed) return lineWidth;
const terminalWidth = process.stdout.columns ?? 80;
return Math.max(lineWidth, terminalWidth - 2);
}, [boxed, lineWidth]);
useEffect(() => {
setIndex((prev) => {
if (items.length === 0) return 0;
const bounded = ((prev % items.length) + items.length) % items.length;
if (!items[bounded]?.disabled) {
return bounded;
}
for (let offset = 1; offset < items.length; offset += 1) {
const next = (bounded + offset) % items.length;
if (!items[next]?.disabled) {
return next;
}
}
return bounded;
});
}, [items]);
const move = useCallback(
(delta: number) => {
if (items.length === 0) return;
setIndex((prev) => {
for (let step = 0; step < items.length; step += 1) {
const next = (prev + delta * (step + 1) + items.length) % items.length;
if (!items[next]?.disabled) {
return next;
}
}
return prev;
});
},
[items],
);
useScopedKeyboard(
keyboardScopeId,
(key) => {
if (
onKeyPress?.(key, {
index,
items,
setIndex: (nextIndex) => setIndex(nextIndex),
select: (itemKey) => onSelect?.(itemKey),
})
) {
return true;
}
if (isUpKey(key)) {
move(-1);
return true;
}
if (isDownKey(key)) {
move(1);
return true;
}
if (isSelectKey(key)) {
const item = items[index];
if (!item || item.disabled) return false;
onSelect?.(item.key);
return true;
}
return false;
},
{ active: keyboardEnabled },
);
const rows = (
<>
{title ? (
) : null}
{items.map((item, itemIndex) => {
const active = itemIndex === index;
const prefix = active ? ">" : " ";
const disabled = item.disabled ? " [disabled]" : "";
const rawLine = `${prefix} ${item.label}${disabled}${item.description ? ` - ${item.description}` : ""}`;
return (
);
})}
>
);
if (!boxed) {
return {rows};
}
return (
{rows}
);
}