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-choiceExamples
Default
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.
| Prop | Type | Default | Notes |
|---|---|---|---|
| label* | string | — | Read by screen readers: "Ödeme yöntemi". |
| onChange* | (value: string) => void | — | |
| options* | MenuChoiceOption[] | — | |
| value* | string | — |
Dependencies
npm
- None
Registry
- @fastrp/menu-list
- @fastrp/ui-sounds
- @fastrp/utils
Source
components/ds/menu-choice.tsxShowHide
'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 };