Game UI

Key hint

A keycap and what it does: "E Etkileşim", "ESC Geri".

stablev0.3.2 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/key-hint

Examples

Default

Open alone
EEtkileşimGKapıyı kilitleSHIFTEArabayı çalıştır
ESCGeriENTERSeçKullanAyrıntıQESekme
F1TABSPACEE

API

<Kbd />

One key: `E`, `ESC`, `SHIFT`, or a mouse input as `LMB`, `RMB`, `MMB` / `WHEEL`, `MOUSE`.

PropTypeDefaultNotes
size"sm" | "md" | "lg"—
variant"solid" | "cap" | "bare"—
<KeyHint />

A key and what it does — "E Etkileşim", "ESC Çık" — the prompt every game menu speaks in. Put a row of them in a `KeyHintBar`.

PropTypeDefaultNotes
keys*string | string[]—One key, or a chord: `['SHIFT', 'E']`.
label*ReactNode—
size"sm" | "md" | "lg"—
variant"solid" | "cap" | "bare"—
voice"menu" | "ui"ui`ui`: upright, for panels and the web. `menu`: italic, solid keys — the in-game menu's own hint, as `MenuScreen` uses it.
<KeyHintBar />

A row of key hints, spaced as a menu footer.

PropTypeDefaultNotes
separatedbooleanfalseA slash between hints, as the in-game menu sets them.
<MouseGlyph />

A mouse with the button to press filled in — the glyph game menus use instead of the word. Drawn in `currentColor`, sized by the text around it.

PropTypeDefaultNotes
button*MouseButton—

Dependencies

npm

  • class-variance-authority@^0.7.1

Registry

  • @fastrp/utils

Source

components/ds/key-hint.tsxShow
import { cva, type VariantProps } from 'class-variance-authority';
import * as React from 'react';
import { cn } from '@/lib/utils';

// @fast-ds [email protected]

const kbdVariants = cva(
  'inline-flex shrink-0 select-none items-center justify-center font-sans font-bold not-italic leading-none tracking-[0.04em] [&_svg]:h-[1.15em] [&_svg]:w-auto',
  {
    variants: {
      variant: {
        /** A keycap: a lit edge and a darker lip, as the key itself. */
        cap: 'rounded-[0.3125rem] border border-foreground/25 bg-foreground/10 text-foreground shadow-[inset_0_-2px_0_rgb(0_0_0/0.35)]',
        /** Solid white, dark letter — the in-game menu's key. */
        solid: 'rounded-menu bg-foreground text-background',
        /** Just the glyph, on a busy ground. */
        bare: 'text-foreground',
      },
      size: {
        sm: 'h-5 min-w-5 px-1 text-[0.6875rem]',
        md: 'h-6 min-w-6 px-1.5 text-xs',
        lg: 'h-8 min-w-8 px-2 text-sm',
      },
    },
    defaultVariants: { variant: 'cap', size: 'md' },
  },
);

type MouseButton = 'left' | 'right' | 'middle' | 'move';

/** Mouse inputs, by the name a hint uses for them. */
const MOUSE: Record<string, { button: MouseButton; label: string }> = {
  LMB: { button: 'left', label: 'Sol tık' },
  RMB: { button: 'right', label: 'Sağ tık' },
  MMB: { button: 'middle', label: 'Tekerlek' },
  WHEEL: { button: 'middle', label: 'Tekerlek' },
  MOUSE: { button: 'move', label: 'Fare' },
};

/**
 * A mouse with the button to press filled in — the glyph game menus use instead of the word. Drawn
 * in `currentColor`, sized by the text around it.
 */
function MouseGlyph({ button }: { button: MouseButton }) {
  return (
    <svg viewBox="0 0 16 22" fill="none" aria-hidden className="h-[1.15em] w-auto">
      <rect
        x="1.25"
        y="1.25"
        width="13.5"
        height="19.5"
        rx="6.75"
        stroke="currentColor"
        strokeWidth="1.5"
      />
      <path d="M8 1.5V9M1.5 9H14.5" stroke="currentColor" strokeWidth="1.5" />
      {button === 'left' && (
        <path d="M8 1.5C4.4 1.5 1.5 4.1 1.5 7.3V9H8V1.5Z" fill="currentColor" />
      )}
      {button === 'right' && (
        <path d="M8 1.5C11.6 1.5 14.5 4.1 14.5 7.3V9H8V1.5Z" fill="currentColor" />
      )}
      {button === 'middle' && (
        <rect x="6.5" y="3" width="3" height="5" rx="1.5" fill="currentColor" />
      )}
      {button === 'move' && (
        <path
          d="M8 12.5V17.5M5.5 15H10.5"
          stroke="currentColor"
          strokeWidth="1.5"
          strokeLinecap="round"
        />
      )}
    </svg>
  );
}

export type KbdProps = React.ComponentProps<'kbd'> & VariantProps<typeof kbdVariants>;

/** One key: `E`, `ESC`, `SHIFT`, or a mouse input as `LMB`, `RMB`, `MMB` / `WHEEL`, `MOUSE`. */
function Kbd({ variant, size, className, children, ...props }: KbdProps) {
  const mouse = typeof children === 'string' ? MOUSE[children] : undefined;
  return (
    <kbd
      data-slot="kbd"
      // A mouse glyph needs no cap around it: it is its own shape.
      className={cn(kbdVariants({ variant: mouse ? 'bare' : variant, size }), className)}
      aria-label={mouse?.label}
      {...props}
    >
      {mouse ? <MouseGlyph button={mouse.button} /> : children}
    </kbd>
  );
}

export type KeyHintProps = Omit<React.ComponentProps<'span'>, 'children'> & {
  /** One key, or a chord: `['SHIFT', 'E']`. */
  keys: string | string[];
  label: React.ReactNode;
  variant?: KbdProps['variant'];
  size?: KbdProps['size'];
  /**
   * `ui`: upright, for panels and the web. `menu`: italic, solid keys — the in-game menu's own
   * hint, as `MenuScreen` uses it.
   */
  voice?: 'ui' | 'menu';
};

/**
 * A key and what it does — "E Etkileşim", "ESC Çık" — the prompt every game menu speaks in. Put
 * a row of them in a `KeyHintBar`.
 */
function KeyHint({ keys, label, variant, size, voice = 'ui', className, ...props }: KeyHintProps) {
  const list = Array.isArray(keys) ? keys : [keys];
  return (
    <span
      data-slot="key-hint"
      className={cn(
        'inline-flex items-center gap-2 text-foreground/90',
        voice === 'ui' ? 'font-display text-label uppercase' : 'font-menu text-menu-hint',
        className,
      )}
      {...props}
    >
      <span className="inline-flex items-center gap-1">
        {list.map((key) => (
          <Kbd key={key} variant={variant ?? (voice === 'menu' ? 'solid' : 'cap')} size={size}>
            {key}
          </Kbd>
        ))}
      </span>
      {label}
    </span>
  );
}

export type KeyHintBarProps = React.ComponentProps<'div'> & {
  /** A slash between hints, as the in-game menu sets them. */
  separated?: boolean;
};

/** A row of key hints, spaced as a menu footer. */
function KeyHintBar({ separated = false, className, children, ...props }: KeyHintBarProps) {
  const items = React.Children.toArray(children);
  return (
    <div
      data-slot="key-hint-bar"
      className={cn(
        'flex flex-wrap items-center gap-y-2',
        separated ? 'gap-x-3' : 'gap-x-5',
        className,
      )}
      {...props}
    >
      {separated
        ? items.map((item, index) => (
            <React.Fragment key={(item as React.ReactElement).key ?? index}>
              {index > 0 && (
                <span aria-hidden className="font-menu text-menu-hint text-foreground/40">
                  /
                </span>
              )}
              {item}
            </React.Fragment>
          ))
        : items}
    </div>
  );
}

export { Kbd, KeyHint, KeyHintBar, kbdVariants, MouseGlyph };