Game UI

Menu choice

One of a few as blocks: Nakit / Banka.

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-choice

Examples

Default

Open alone

Ödeme yöntemi

Kimin adına

API

<MenuChoice />

One of a few, side by side as menu blocks: Nakit / Banka, Kişisel / Fraksiyon. The chosen block is white. Arrow left and right switch it.

PropTypeDefaultNotes
label*string—Read by screen readers: "Ödeme yöntemi".
onChange*(value: string) => void—
options*MenuChoiceOption[]—
value*string—

Dependencies

npm

  • None

Source

components/ds/menu-choice.tsxShow
'use client';

import type * as React from 'react';
import { focusOnHover } from '@/components/ds/menu-list';
import { useUiSound } from '@/components/ds/ui-sounds';
import { cn } from '@/lib/utils';

// @fast-ds [email protected]

export type MenuChoiceOption = {
  value: string;
  label: React.ReactNode;
  icon?: React.ReactNode;
  /** Small, under the label: a balance, a limit. */
  detail?: React.ReactNode;
  disabled?: boolean;
};

export type MenuChoiceProps = Omit<React.ComponentProps<'div'>, 'onChange'> & {
  options: MenuChoiceOption[];
  value: string;
  onChange: (value: string) => void;
  /** Read by screen readers: "Ödeme yöntemi". */
  label: string;
};

/**
 * One of a few, side by side as menu blocks: Nakit / Banka, Kişisel / Fraksiyon. The chosen block
 * is white. Arrow left and right switch it.
 */
function MenuChoice({ options, value, onChange, label, className, ...props }: MenuChoiceProps) {
  const sound = useUiSound();
  const enabled = options.filter((option) => !option.disabled);
  const choose = (next: string) => {
    if (next === value) return;
    sound('adjust');
    onChange(next);
  };

  const onKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
    if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return;
    const index = enabled.findIndex((option) => option.value === value);
    const step = event.key === 'ArrowRight' ? 1 : -1;
    const next = enabled[(index + step + enabled.length) % enabled.length];
    if (!next) return;
    event.preventDefault();
    event.stopPropagation();
    choose(next.value);
  };

  return (
    <div
      data-slot="menu-choice"
      data-menu-row
      data-sfx-hover="navigate"
      role="radiogroup"
      aria-label={label}
      tabIndex={0}
      onKeyDown={onKeyDown}
      onPointerEnter={focusOnHover}
      className={cn(
        'grid select-none gap-menu-gap rounded-menu outline-none transition-[margin] duration-fast ease-brand focus:mr-[-0.625rem] [&:focus_[aria-checked=true]]:shadow-[0_0_0_2px_hsl(var(--selection))]',
        className,
      )}
      style={{ gridTemplateColumns: `repeat(${options.length}, minmax(0, 1fr))` }}
      {...props}
    >
      {options.map((option) => {
        const selected = option.value === value;
        return (
          // biome-ignore lint/a11y/useSemanticElements: a block, not a form radio — no native input in the NUI
          <div
            key={option.value}
            role="radio"
            aria-checked={selected}
            aria-disabled={option.disabled || undefined}
            tabIndex={-1}
            onClick={() => !option.disabled && choose(option.value)}
            onKeyDown={(event) => {
              if ((event.key === 'Enter' || event.key === ' ') && !option.disabled) {
                event.preventDefault();
                choose(option.value);
              }
            }}
            className={cn(
              'menu-block flex h-menu-row cursor-pointer items-center gap-3 px-4 font-menu text-menu-item text-foreground text-hud transition-colors duration-fast hover:bg-[hsl(var(--menu-block)/0.92)] [&_svg]:size-5',
              selected && 'bg-foreground text-background [text-shadow:none] hover:bg-foreground',
              option.disabled && 'cursor-not-allowed opacity-40',
            )}
          >
            {option.icon}
            <span className="flex min-w-0 flex-col leading-tight">
              <span className="truncate">{option.label}</span>
              {option.detail && (
                <span className="truncate font-sans text-xs font-semibold normal-case not-italic opacity-70">
                  {option.detail}
                </span>
              )}
            </span>
          </div>
        );
      })}
    </div>
  );
}

export { MenuChoice };