Menu list
The menu’s rows — items, toggles — walked with the arrow keys.
Install
components/ds/, with its registry dependencies and npm packages. See Installation for the one-time setup.bunx shadcn@3 add @fastrp/menu-listExamples
Default
API
<MenuItem />One row of a menu list: a category, an action, a choice.
| Prop | Type | Default | Notes |
|---|---|---|---|
| active | boolean | false | The category on screen — white even when the cursor is elsewhere. |
| icon | ReactNode | — | A glyph in front of the label. |
| kbd | string | — | A key on the left that picks this row: `"1"` … `"9"` for an NPC's options. The list presses the row when that digit is typed. |
| trailing | ReactNode | — | On the right: a price, a value, a "YENİ" tag, a chevron. |
| variant | "default" | "back" | default | `back`: the "‹ parent" row at the top of a drill-down; BACKSPACE presses it. |
<MenuLabel />Takes the props of what it wrapsA caption between rows: the group the rows below belong to — "RENK", "SEÇENEKLER".
<MenuList />The menu's rows, stacked with the menu gap, driven from the keyboard: ↑ ↓ move the cursor, ENTER presses, BACKSPACE steps back. The mouse moves the same cursor — hovering a row selects it — so there is never a second highlight. Each row arrives from the left a beat after the one above. Put `MenuItem`, `MenuToggle`, `OptionStepper`, `MenuSlider`, `MenuChoice`, `ColorGrid` and `MenuLabel` in it — nothing else.
| Prop | Type | Default | Notes |
|---|---|---|---|
| onBack | (() => void) | — | BACKSPACE: the step back. A `back` row in the list is pressed when this is left out. |
<MenuStat />A fact on a menu row, read and not pressed: money, playtime, a licence count. Not focusable — the cursor walks the rows that do something.
| Prop | Type | Default | Notes |
|---|---|---|---|
| label* | ReactNode | — | |
| value* | ReactNode | — | |
| icon | ReactNode | — |
<MenuToggle />An on/off on a menu row: neon, a xenon light, a part fitted or not. ENTER or ← → flip it. A `switch` button — never a checkbox input, which takes the NUI's keyboard.
| Prop | Type | Default | Notes |
|---|---|---|---|
| checked* | boolean | — | |
| label* | ReactNode | — | |
| onCheckedChange* | (checked: boolean) => void | — |
Dependencies
npm
- lucide-react@^0.469.0
Registry
- @fastrp/key-hint
- @fastrp/ui-sounds
- @fastrp/utils
Source
components/ds/menu-list.tsxShowHide
'use client';
import { ChevronLeft } from 'lucide-react';
import * as React from 'react';
import { Kbd } from '@/components/ds/key-hint';
import { useUiSound } from '@/components/ds/ui-sounds';
import { cn } from '@/lib/utils';
// @fast-ds [email protected]
type MenuListNav = {
/** Move the cursor by `step` rows, wrapping. */
move: (from: Element, step: number) => void;
};
const MenuListContext = React.createContext<MenuListNav | null>(null);
/** For a row that owns the arrow keys itself (a slider) to hand ↑ ↓ back to the list. */
function useMenuListNav(): MenuListNav | null {
return React.useContext(MenuListContext);
}
const ROW = '[data-menu-row]:not([aria-disabled="true"]):not(:disabled)';
export type MenuListProps = React.ComponentProps<'div'> & {
/**
* Put the cursor on the chosen row — or the first — as soon as the list appears. The screen's
* main list takes it: a game menu opens with something selected, not with a mouse to find.
*/
autoFocus?: boolean;
/** BACKSPACE: the step back. A `back` row in the list is pressed when this is left out. */
onBack?: () => void;
};
/**
* The menu's rows, stacked with the menu gap, driven from the keyboard: ↑ ↓ move the cursor,
* ENTER presses, BACKSPACE steps back. The mouse moves the same cursor — hovering a row selects
* it — so there is never a second highlight. Each row arrives from the left a beat after the one
* above.
*
* Put `MenuItem`, `MenuToggle`, `OptionStepper`, `MenuSlider`, `MenuChoice`, `ColorGrid` and
* `MenuLabel` in it — nothing else.
*/
function MenuList({
autoFocus = false,
onBack,
className,
children,
onKeyDown,
...props
}: MenuListProps) {
const ref = React.useRef<HTMLDivElement>(null);
const sound = useUiSound();
const nav = React.useMemo<MenuListNav>(
() => ({
move(from, step) {
const list = ref.current;
if (!list) return;
const rows = Array.from(list.querySelectorAll<HTMLElement>(ROW));
if (rows.length === 0) return;
const current = rows.findIndex((row) => row === from || row.contains(from));
const next = rows[(current + step + rows.length) % rows.length];
if (next && next !== rows[current]) {
next.focus({ preventScroll: false });
sound('navigate');
}
},
}),
[sound],
);
React.useEffect(() => {
if (!autoFocus) return;
const list = ref.current;
// A drill-down opens on its first option, not on the way back out.
const target =
list?.querySelector<HTMLElement>(`${ROW}[data-active="true"]`) ??
list?.querySelector<HTMLElement>(`${ROW}:not([data-menu-back])`) ??
list?.querySelector<HTMLElement>(ROW);
// After the entrance has started, so the row is laid out; without scrolling the page.
const frame = requestAnimationFrame(() => target?.focus({ preventScroll: true }));
return () => cancelAnimationFrame(frame);
}, [autoFocus]);
const onKey = (event: React.KeyboardEvent<HTMLDivElement>) => {
onKeyDown?.(event);
if (event.defaultPrevented) return;
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault();
nav.move(document.activeElement ?? event.currentTarget, event.key === 'ArrowDown' ? 1 : -1);
} else if (event.key === 'Backspace') {
event.preventDefault();
if (onBack) {
sound('back');
onBack();
} else {
event.currentTarget.querySelector<HTMLElement>('[data-menu-back]')?.click();
}
} else if (/^[0-9]$/.test(event.key)) {
// A row with a number on it is pressed by that number, wherever the cursor is.
const row = event.currentTarget.querySelector<HTMLElement>(
`${ROW}[data-menu-kbd="${event.key}"]`,
);
if (row) {
event.preventDefault();
row.focus({ preventScroll: true });
row.click();
}
}
};
return (
<MenuListContext.Provider value={nav}>
{/* biome-ignore lint/a11y/noStaticElementInteractions: delegates the keys to the rows, which are the controls */}
<div
ref={ref}
data-slot="menu-list"
className={cn('flex flex-col gap-menu-gap', className)}
onKeyDown={onKey}
{...props}
>
{React.Children.toArray(children).map((child, index) => (
<div
key={(child as React.ReactElement).key ?? index}
className="stagger-item [animation-name:fast-from-left]"
style={{ '--i': index } as React.CSSProperties}
>
{child}
</div>
))}
</div>
</MenuListContext.Provider>
);
}
/**
* The row look every menu row shares: the dark block with a rule on its left. The cursor is a
* white fill that wipes in from the left edge and the row juts out — on focus, which the keys and
* the pointer both move. `isolate` keeps the wipe between the block and the text.
*/
export const menuRowClassName =
'menu-block group/row relative isolate flex h-menu-row select-none w-full items-center gap-4 pl-5 pr-4 font-menu text-menu-item text-foreground text-hud outline-none transition-[color,margin] duration-fast ease-brand before:absolute before:inset-y-0 before:left-0 before:w-[3px] before:rounded-l-menu before:bg-foreground/70 after:absolute after:inset-0 after:-z-10 after:origin-left after:scale-x-0 after:rounded-menu after:bg-foreground after:transition-transform after:duration-fast after:ease-brand focus:mr-[-0.625rem] focus:text-background focus:[text-shadow:none] focus:before:hidden focus:after:scale-x-100';
/** The same look, lit by focus anywhere inside — for a row whose control is a child (a slider). */
export const menuRowWithinClassName =
'focus-within:mr-[-0.625rem] focus-within:text-background focus-within:[text-shadow:none] focus-within:before:hidden focus-within:after:scale-x-100';
/**
* Hovering a row puts the cursor on it — the pointer and the keys share one selection. The rows
* also carry `data-sfx-hover="navigate"`, so the pointer's move ticks as the keys' does.
*/
function focusOnHover(event: React.PointerEvent<HTMLElement>) {
if (event.pointerType === 'mouse' && document.activeElement !== event.currentTarget) {
event.currentTarget.focus({ preventScroll: true });
}
}
export type MenuItemProps = Omit<React.ComponentProps<'button'>, 'children'> & {
/** A glyph in front of the label. */
icon?: React.ReactNode;
children: React.ReactNode;
/** On the right: a price, a value, a "YENİ" tag, a chevron. */
trailing?: React.ReactNode;
/** The category on screen — white even when the cursor is elsewhere. */
active?: boolean;
/** A key on the left that picks this row: `"1"` … `"9"` for an NPC's options. The list presses
* the row when that digit is typed. */
kbd?: string;
/** `back`: the "‹ parent" row at the top of a drill-down; BACKSPACE presses it. */
variant?: 'default' | 'back';
};
/** One row of a menu list: a category, an action, a choice. */
function MenuItem({
icon,
children,
trailing,
active = false,
kbd,
variant = 'default',
disabled,
className,
type = 'button',
onClick,
onPointerEnter,
...props
}: MenuItemProps) {
const sound = useUiSound();
return (
<button
data-slot="menu-item"
data-menu-row
data-menu-back={variant === 'back' || undefined}
data-menu-kbd={kbd}
data-sfx-hover="navigate"
data-active={active}
aria-current={active || undefined}
aria-disabled={disabled || undefined}
disabled={disabled}
type={type}
onPointerEnter={(event) => {
onPointerEnter?.(event);
focusOnHover(event);
}}
onClick={(event) => {
sound(variant === 'back' ? 'back' : 'select');
onClick?.(event);
}}
className={cn(
menuRowClassName,
'text-left disabled:cursor-not-allowed disabled:opacity-40',
'data-[active=true]:mr-[-0.625rem] data-[active=true]:text-background data-[active=true]:[text-shadow:none] data-[active=true]:before:hidden data-[active=true]:after:scale-x-100',
variant === 'back' &&
'h-11 bg-transparent text-menu-hint text-foreground/70 before:hidden focus:mr-0',
className,
)}
{...props}
>
{kbd && (
<Kbd
variant="solid"
size="lg"
className="-ml-1 group-focus/row:bg-background group-focus/row:text-foreground group-data-[active=true]/row:bg-background group-data-[active=true]/row:text-foreground"
>
{kbd}
</Kbd>
)}
{variant === 'back' && <ChevronLeft className="-ml-1 size-5 shrink-0" />}
{icon && (
<span className="flex size-5 shrink-0 items-center justify-center [&_svg]:size-5">
{icon}
</span>
)}
<span className="min-w-0 flex-1 truncate">{children}</span>
{trailing && <span className="shrink-0 text-menu-hint opacity-80">{trailing}</span>}
</button>
);
}
export type MenuToggleProps = Omit<React.ComponentProps<'button'>, 'onChange' | 'children'> & {
label: React.ReactNode;
checked: boolean;
onCheckedChange: (checked: boolean) => void;
};
/**
* An on/off on a menu row: neon, a xenon light, a part fitted or not. ENTER or ← → flip it. A
* `switch` button — never a checkbox input, which takes the NUI's keyboard.
*/
function MenuToggle({
label,
checked,
onCheckedChange,
disabled,
className,
onKeyDown,
onPointerEnter,
...props
}: MenuToggleProps) {
const sound = useUiSound();
const flip = () => {
sound(checked ? 'toggle-off' : 'toggle-on');
onCheckedChange(!checked);
};
return (
<button
data-slot="menu-toggle"
data-menu-row
data-sfx-hover="navigate"
type="button"
role="switch"
aria-checked={checked}
disabled={disabled}
onClick={flip}
onPointerEnter={(event) => {
onPointerEnter?.(event);
focusOnHover(event);
}}
onKeyDown={(event) => {
onKeyDown?.(event);
if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') {
event.preventDefault();
flip();
}
}}
className={cn(menuRowClassName, 'text-left disabled:opacity-40', className)}
{...props}
>
<span className="min-w-0 flex-1 truncate">{label}</span>
<span
aria-hidden
className={cn(
'relative h-5 w-9 shrink-0 rounded-full border border-foreground/30 transition-colors duration-base',
checked ? 'bg-primary' : 'bg-foreground/15 group-focus/row:bg-background/15',
)}
>
<span
className={cn(
'absolute top-0.5 size-3.5 rounded-full bg-foreground shadow transition-[left] duration-base ease-pop group-focus/row:bg-background',
checked ? 'left-[1.125rem] bg-primary-foreground' : 'left-0.5',
)}
/>
</span>
</button>
);
}
/** A caption between rows: the group the rows below belong to — "RENK", "SEÇENEKLER". */
function MenuLabel({ className, ...props }: React.ComponentProps<'p'>) {
return (
<p
data-slot="menu-label"
className={cn(
'px-1 pb-1 pt-4 font-menu text-menu-hint text-foreground/55 text-hud',
className,
)}
{...props}
/>
);
}
export type MenuStatProps = Omit<React.ComponentProps<'div'>, 'children'> & {
label: React.ReactNode;
value: React.ReactNode;
icon?: React.ReactNode;
};
/**
* A fact on a menu row, read and not pressed: money, playtime, a licence count. Not focusable —
* the cursor walks the rows that do something.
*/
function MenuStat({ label, value, icon, className, ...props }: MenuStatProps) {
return (
<div
data-slot="menu-stat"
className={cn(
'menu-block flex h-12 items-center gap-3 px-4 font-menu text-menu-hint text-foreground text-hud',
className,
)}
{...props}
>
{icon && <span className="flex shrink-0 text-foreground/60 [&_svg]:size-4">{icon}</span>}
<span className="min-w-0 flex-1 truncate text-foreground/65">{label}</span>
<span className="shrink-0 text-menu-item tabular-nums">{value}</span>
</div>
);
}
export { focusOnHover, MenuItem, MenuLabel, MenuList, MenuStat, MenuToggle, useMenuListNav };