Game UI
Menu tabs
A menu’s sections on Q / E, the white block gliding between them.
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-tabsExamples
Default
API
<MenuTabs />A menu's sections, switched with Q and E. The white block glides to the new tab, and the content under it should slide the same way — pass the direction to `MenuView`. The tabs take no focus: the keyboard's cursor stays in the list, where the work is.
| Prop | Type | Default | Notes |
|---|---|---|---|
| items* | MenuTab[] | — | |
| onValueChange* | (value: string, direction: 1 | -1) => void | — | `direction` is +1 for a step right, −1 for left — hand it to `MenuView`. |
| value* | string | — | |
| className | string | — | |
| keys | boolean | true | Turn the key listening off — when two tab rows share a screen, only one owns Q / E. |
| nextKey | string | E | |
| prevKey | string | Q | The bumpers. They work: the keys are listened to on the window while the tabs are mounted. |
Dependencies
npm
- motion@^12.40.0
Registry
- @fastrp/key-hint
- @fastrp/ui-sounds
- @fastrp/utils
Source
components/ds/menu-tabs.tsxShowHide
'use client';
import { motion } from 'motion/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]
export type MenuTab = { value: string; label: React.ReactNode };
export type MenuTabsProps = {
items: MenuTab[];
value: string;
/** `direction` is +1 for a step right, −1 for left — hand it to `MenuView`. */
onValueChange: (value: string, direction: 1 | -1) => void;
/** The bumpers. They work: the keys are listened to on the window while the tabs are mounted. */
prevKey?: string;
nextKey?: string;
/** Turn the key listening off — when two tab rows share a screen, only one owns Q / E. */
keys?: boolean;
className?: string;
};
/** A key press in a field is typing, not navigation. */
function isTyping(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) return false;
return target.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(target.tagName);
}
/**
* A menu's sections, switched with Q and E. The white block glides to the new tab, and the content
* under it should slide the same way — pass the direction to `MenuView`. The tabs take no focus:
* the keyboard's cursor stays in the list, where the work is.
*/
function MenuTabs({
items,
value,
onValueChange,
prevKey = 'Q',
nextKey = 'E',
keys = true,
className,
}: MenuTabsProps) {
const id = React.useId();
const sound = useUiSound();
const index = Math.max(
0,
items.findIndex((item) => item.value === value),
);
const go = React.useCallback(
(step: 1 | -1) => {
const next = items[(index + step + items.length) % items.length];
if (!next || next.value === value) return;
sound('tab');
onValueChange(next.value, step);
},
[index, items, onValueChange, sound, value],
);
React.useEffect(() => {
if (!keys) return;
const onKey = (event: KeyboardEvent) => {
if (event.repeat || event.ctrlKey || event.altKey || event.metaKey) return;
if (isTyping(event.target)) return;
const key = event.key.toLowerCase();
if (key === prevKey.toLowerCase()) {
event.preventDefault();
go(-1);
} else if (key === nextKey.toLowerCase()) {
event.preventDefault();
go(1);
}
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [go, keys, nextKey, prevKey]);
const bumper = (label: string, step: 1 | -1) => (
<button
type="button"
tabIndex={-1}
aria-label={step < 0 ? 'Önceki sekme' : 'Sonraki sekme'}
onClick={() => go(step)}
className="menu-block flex h-11 w-11 shrink-0 items-center justify-center transition-[background-color,transform] duration-fast hover:bg-[hsl(var(--menu-block)/0.92)] active:scale-95"
>
<Kbd variant="solid" size="lg">
{label}
</Kbd>
</button>
);
return (
<div
data-slot="menu-tabs"
className={cn('flex select-none items-center gap-menu-gap', className)}
>
{bumper(prevKey, -1)}
<div role="tablist" className="flex items-center gap-menu-gap">
{items.map((item, itemIndex) => {
const selected = item.value === value;
return (
<button
key={item.value}
type="button"
role="tab"
tabIndex={-1}
aria-selected={selected}
onClick={() => {
if (selected) return;
sound('tab');
onValueChange(item.value, itemIndex > index ? 1 : -1);
}}
className={cn(
'menu-block relative isolate h-11 px-5 font-menu text-menu-tab text-foreground text-hud transition-colors duration-fast hover:bg-[hsl(var(--menu-block)/0.92)]',
selected && 'text-background [text-shadow:none]',
)}
>
{selected && (
<motion.span
layoutId={`${id}-tab`}
aria-hidden
className="absolute inset-0 -z-10 rounded-menu bg-foreground"
transition={{ type: 'spring', stiffness: 700, damping: 48, mass: 0.6 }}
/>
)}
{item.label}
</button>
);
})}
</div>
{bumper(nextKey, 1)}
</div>
);
}
export { MenuTabs };