Game UI

Menu list

The menu’s rows — items, toggles — walked with the arrow keys.

stablev0.5.0 Chromium 103 ui-scale safe

Install

Copies the file into components/ds/, with its registry dependencies and npm packages. See Installation for the one-time setup.
bunx shadcn@3 add @fastrp/menu-list

Examples

Default

Open alone

Kilitli

API

<MenuItem />

One row of a menu list: a category, an action, a choice.

PropTypeDefaultNotes
activebooleanfalseThe category on screen — white even when the cursor is elsewhere.
iconReactNode—A glyph in front of the label.
kbdstring—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.
trailingReactNode—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 wraps

A 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.

PropTypeDefaultNotes
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.

PropTypeDefaultNotes
label*ReactNode—
value*ReactNode—
iconReactNode—
<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.

PropTypeDefaultNotes
checked*boolean—
label*ReactNode—
onCheckedChange*(checked: boolean) => void—

Dependencies

npm

  • lucide-react@^0.469.0

Source

components/ds/menu-list.tsxShow
'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 };