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

Examples

Default

Open alone

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.

PropTypeDefaultNotes
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—
classNamestring—
keysbooleantrueTurn the key listening off — when two tab rows share a screen, only one owns Q / E.
nextKeystringE
prevKeystringQThe bumpers. They work: the keys are listened to on the window while the tabs are mounted.

Dependencies

npm

  • motion@^12.40.0

Source

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