GrKbd

Package: @feugene/granularitycoreGroup: data

Displays a keyboard key or shortcut in a `<kbd>` element.

Machine-translated from the Russian original, not yet reviewed. Read the original

When to take it

  • a key combination is shown — in a hint, in the help, in a menu item;
  • the combination depends on the platformplatform prints on macOS and Ctrl on the rest;
  • there are several keyskeys assembles the combination with a separator, with no markup by hand;
  • native semantics is needed — it renders as a <kbd> tag rather than as a styled <span>.

When to take something else

NeedTake
A status or a tag is shownGrBadge
A hint about the purpose is neededGrTooltip
A fragment of code is shownthe markup of the application: there are no code blocks in the package
The combination opens the command paletteGrCommandPalette

A single key and a combination

<GrKbd>
Esc
</GrKbd>

<GrKbd keys="mod+K" />

<GrKbd :keys="['mod', 'shift', 'P']" />

The slot remains usable for a single key. The keys prop accepts both a string ("mod+shift+K") and an array of tokens — assembling ⌘ + K from two GrKbd and a <span>+</span> is no longer necessary.

A combination is marked up with nested <kbd> — a device from the HTML specification: that way it stays keyboard input as a whole rather than a set of neighbouring elements. Only which of them carries the border depends on the variant.

The variants

variantThe lookWhen
merged (the default)a single plate: ⌘K, Ctrl+Ka hint next to a button or in a field — that is how the systems themselves write a combination
splita plate per key: Kwhen it is exactly the set of keys that has to be shown, in a keyboard-shortcuts reference for instance
sequenceG then Ia chord: the keys are pressed one after another rather than together
<GrKbd keys="mod+K" />                      <!-- ⌘K -->

<GrKbd keys="mod+K" variant="split" />      <!-- ⌘ K -->

<GrKbd :keys="['G', 'I']" variant="sequence" />

The separator is computed by itself

separator is not set by default — that is auto:

  • in merged the separator is put only after a word: ⌘K and ⌘⇧K on macOS, Ctrl+K and Ctrl+Shift+K on the rest. Within one platform the set is uniform (macOS gives symbols, the rest give words), so the rule “a symbol on the left — glue them” means “write it the way the system does”;
  • in split it is +, in sequence a word from the locale (gr.kbd.then).

An explicit separator is stronger than the auto one: separator="" will glue anything, separator="+" will put a plus even between symbols. The separator is decorative (aria-hidden).

The dictionary of keys

Tokens set more than the modifiers:

Token (and aliases)macOSThe rest
modCtrl
ctrl, alt, shift, , Ctrl, Alt, Shift
enter / returnEnter
esc, space, home, endas a wordas a word
tabTab
backspaceBackspace
delete / delDel
pageup / pgup, pagedown / pgdn, PgUp, PgDn
up, down, left, right↑ ↓ ← →↑ ↓ ← →

The case does not matter, and a glyph is accepted as a token too (, ). Writing an arrow as a literal in the markup is neither necessary nor advisable: a token brings a readable name with it, while a bare glyph is pronounced by a screen reader as a sign.

The catalogue is not a table in the docs but data: GR_KBD_TOKENS is exported by the package, the showcase page is built from it, and it feeds the formatter as well. A copy of the list would diverge from the behaviour at the very first new key.

import { GR_KBD_TOKENS, findKbdToken } from '@feugene/granularity'

const navigation = GR_KBD_TOKENS.filter(spec => spec.group === 'navigation')
const command = findKbdToken('⌘') // the same token as `meta`

The platform

The mod token is Cmd on macOS and Ctrl on the other platforms; ctrl, alt and shift are shown as symbols on Apple too (, , ) and as words on the rest.

<GrKbd keys="mod+K" />                    <!-- detected automatically -->

<GrKbd keys="mod+K" platform="apple" />   <!-- always ⌘ -->

<GrKbd keys="mod+K" platform="other" />   <!-- always Ctrl -->

platform="auto" refines the platform after mounting: navigator in the first render would diverge from the server HTML and break the hydration. The non-Apple variant therefore always comes from the server, and the appears on the client. If the audience is known in advance, platform removes even that repaint.

The same mod token is understood by the v-hotkey directive, so the hint and the binding are written the same way and do not diverge:

<div v-hotkey="{ 'mod+K': openSearch }">
  <GrKbd keys="mod+K" />
</div>

The parsing and the normalisation live in components/shared/hotkey.ts — the same module GrCommandPalette uses to match its own opening combination. A shared module rather than an import from the directory of the palette: otherwise a GrKbd → GrCommandPalette edge would appear in the build, and granular doctor would count it as an undeclared dependency.

The screen reader

Symbol keys get a readable name beside the symbol:

<kbd><span aria-hidden="true">⌘</span><span class="sr-only">Command</span></kbd>

Without that a screen reader pronounces as “place of interest sign”. Words (Ctrl, Shift, Enter) are read on their own — no name is added to them, otherwise it would come out as “Ctrl Control”. The texts of the names are the gr.kbd.* keys.

The sizes

The full scale of the package: xs, sm, md (the default), lg — the height, the minimum width, the padding, the type size and the gap between the keys change. size is read from GrConfigProvider.

min-w keeps a single symbol square: without it “K” would be narrower than “Esc”, and a row of shortcuts would jump.

Playground 3

Loading…

Code
<GrKbd />

Install

npm i @feugene/granularity

Import

import { GrKbd } from '@feugene/granularity/components/GrKbd'

API

Props

PropTypedefaultDescription
variant"split" | "merged" | "sequence" | undefined"merged"`merged` (the default) — the combination as a single plate, the way the systems themselves write it: `⌘K` on macOS, `Ctrl+K` on the rest. `split` — a plate per key. `sequence` — the chord "G then I": the keys are pressed one after another.
keysstring | string[] | undefinedundefinedThe combination: as a string (`"mod+shift+K"`) or as a set of tokens (`['mod', 'K']`). The `mod` token is Cmd on macOS and Ctrl on the rest.
size"xs" | "sm" | "md" | "lg" | undefinedundefined
separatorstring | undefinedundefinedThe separator between the keys. Unset — auto: in a merged plate the symbols are glued (`⌘K`) and the words are separated with a plus (`Ctrl+K`); in `split` it is a plus, in `sequence` a word from the locale. An empty string leaves only a gap.
platformGrKbdPlatform | undefined"auto"

Slots

SlotTypeDescription
defaultanyA key or a combination instead of the `keys` prop.

Examples 5

Basic

CtrlKCtrlShiftPCtrlSEsc
CtrlKCtrlKCtrlKCtrlK

Basic
<script setup lang="ts">
import { GrKbd } from '@feugene/granularity'
</script>

<template>
  <div class="grid gap-4 text-sm">
    <div class="flex flex-wrap items-center gap-5">
      <!-- `mod` сам превращается в Cmd на macOS и в Ctrl на остальных платформах. -->
      <GrKbd keys="mod+K" />
      <GrKbd keys="mod+shift+P" />
      <GrKbd :keys="['ctrl', 'S']" />
      <GrKbd>Esc</GrKbd>
    </div>

    <div class="flex flex-wrap items-center gap-5">
      <GrKbd keys="mod+K" size="xs" />
      <GrKbd keys="mod+K" size="sm" />
      <GrKbd keys="mod+K" size="md" />
      <GrKbd keys="mod+K" size="lg" />
    </div>
  </div>
</template>

Hotkey Hints

  • НайтиCtrlK
  • СохранитьCtrlS
  • Палитра командCtrlShiftP
  • ОтменитьCtrlZ
  • ЗакрытьEscape
Токен `mod` пишется один раз, а показывается по платформе. Символы (`⌘`, `⇧`) снабжены скрытым читаемым именем — иначе диктор произносит их как значки.

Hotkey Hints
<script setup lang="ts">
import { ref } from 'vue'

import type { GrKbdPlatform } from '@feugene/granularity'
import { GrKbd, GrSegmented } from '@feugene/granularity'

const platform = ref<GrKbdPlatform>('auto')

const commands = [
  { label: 'Найти', keys: 'mod+K' },
  { label: 'Сохранить', keys: 'mod+S' },
  { label: 'Палитра команд', keys: 'mod+shift+P' },
  { label: 'Отменить', keys: ['mod', 'Z'] },
  { label: 'Закрыть', keys: 'Esc' },
]
</script>

<template>
  <div class="grid gap-4">
    <GrSegmented
      v-model="platform"
      size="sm"
      :options="[
        { value: 'auto', label: 'auto' },
        { value: 'apple', label: 'macOS' },
        { value: 'other', label: 'Windows/Linux' },
      ]"
    />

    <ul class="grid gap-1 rounded-2xl border border-[var(--gr-brd)] bg-[var(--gr-card)] p-2">
      <li
        v-for="command in commands"
        :key="command.label"
        class="flex items-center justify-between gap-6 rounded-xl px-3 py-2 text-sm hover:bg-[var(--gr-muted)]"
      >
        <span>{{ command.label }}</span>
        <GrKbd :keys="command.keys" :platform="platform" size="sm" />
      </li>
    </ul>

    <div class="rounded-2xl border border-dashed border-[var(--gr-brd)] p-3 text-sm text-[var(--gr-muted-fg)]">
      Токен `mod` пишется один раз, а показывается по платформе. Символы (`⌘`, `⇧`) снабжены
      скрытым читаемым именем — иначе диктор произносит их как значки.
    </div>
  </div>
</template>

Navbar Search

Navbar Searchdepends on the showcase environment
<script setup lang="ts">
import { ref } from 'vue'

import {
  GrAvatar,
  GrCommandPalette,
  GrKbd,
  GrNavbar,
  vHotkey,
  type GrCommandItem,
} from '@feugene/granularity'

import SearchIcon from '~icons/lucide/search'

const isSearchOpen = ref(false)
const lastPicked = ref<string | null>(null)

/** Токен `mod` один и тот же в привязке и в подсказке: Cmd на macOS, Ctrl на прочих. */
const searchHotkey = {
  scope: 'element' as const,
  handlers: {
    'mod+K': { handler: () => (isSearchOpen.value = true), stopPropagation: true },
  },
}

const items: GrCommandItem[] = [
  { id: 'orders', label: 'Заказы', description: 'Список и статусы', shortcut: ['mod', 'O'] },
  { id: 'customers', label: 'Клиенты', description: 'Карточки и сегменты', shortcut: ['mod', 'U'] },
  { id: 'settings', label: 'Настройки', description: 'Оплата, доставка, роли', shortcut: ['mod', ','] },
  { id: 'logs', label: 'Журнал событий', description: 'Аудит действий' },
]

function onSelect(item: GrCommandItem): void {
  lastPicked.value = item.label
}
</script>

<template>
  <!--
    Хоткей ограничен демо (`scope: 'element'`) и гасит всплытие: у самой витрины
    ⌘K уже занят её поиском, и глобальная привязка открыла бы обе панели сразу.
    В приложении scope не нужен — там сочетание слушает окно.
  -->
  <div
    v-hotkey="searchHotkey"
    tabindex="0"
    class="grid gap-3 rounded-[var(--gr-radius-lg)] outline-none focus-visible:ring-2 focus-visible:ring-[var(--gr-ring)]"
  >
    <GrNavbar title="Консоль">
      <template #center>
        <!--
          Кнопка-поле: подпись даёт имя, а сочетание рядом декоративно —
          диктору его сообщает `aria-keyshortcuts`.
        -->
        <button
          type="button"
          class="inline-flex h-8 items-center gap-2 rounded-[var(--gr-radius-md)] border border-[var(--gr-brd)] bg-[var(--gr-muted)] px-3 text-[length:var(--gr-text-sm)] leading-[var(--gr-leading-tight)] text-[var(--gr-muted-fg)] transition-colors hover:bg-[var(--gr-bg)]"
          aria-haspopup="dialog"
          aria-keyshortcuts="Control+K Meta+K"
          @click="isSearchOpen = true"
        >
          <SearchIcon class="h-4 w-4 shrink-0" aria-hidden="true" />
          <span>Поиск</span>
          <span class="ml-2 inline-flex" aria-hidden="true">
            <GrKbd keys="mod+K" size="sm" />
          </span>
        </button>
      </template>

      <GrAvatar name="Ирина Петрова" size="sm" />
    </GrNavbar>

    <p class="text-[length:var(--gr-text-xs)] text-[var(--gr-muted-fg)]">
      Нажмите <GrKbd keys="mod+K" size="sm" />, когда фокус внутри демо, — или кликните по кнопке.
      <template v-if="lastPicked">
        Выбрано: <strong class="text-[var(--gr-fg)]">{{ lastPicked }}</strong>.
      </template>
    </p>

    <!-- `hotkey: null` — сочетание уже слушает демо, второй слушатель открыл бы панель дважды. -->
    <GrCommandPalette
      v-model="isSearchOpen"
      :items="items"
      :hotkey="null"
      placeholder="Раздел, действие, документ"
      aria-label="Поиск по консоли"
      @select="onSelect"
    />
  </div>
</template>

Variants

merged — по умолчанию — ⌘K на macOS, Ctrl+K на прочих

CtrlKCtrlShiftPCtrlAltDel

split — плашка на каждую клавишу

CtrlKCtrlShiftPCtrlAltDel

sequence — аккорд: клавиши нажимают одну за другой

GIGP

Variants
<script setup lang="ts">
import { ref } from 'vue'

import type { GrKbdPlatform } from '@feugene/granularity'
import { GrKbd, GrSegmented } from '@feugene/granularity'

const platform = ref<GrKbdPlatform>('auto')

const rows = [
  { title: 'merged — по умолчанию', variant: 'merged' as const, hint: '⌘K на macOS, Ctrl+K на прочих' },
  { title: 'split', variant: 'split' as const, hint: 'плашка на каждую клавишу' },
]

const combos = ['mod+K', 'mod+shift+P', 'ctrl+alt+delete']
</script>

<template>
  <div class="grid gap-5 text-sm">
    <GrSegmented
      v-model="platform"
      size="sm"
      :options="[
        { value: 'auto', label: 'auto' },
        { value: 'apple', label: 'macOS' },
        { value: 'other', label: 'Windows/Linux' },
      ]"
    />

    <div v-for="row in rows" :key="row.variant" class="grid gap-2">
      <p class="text-[length:var(--gr-text-xs)] text-[var(--gr-muted-fg)]">
        {{ row.title }} — {{ row.hint }}
      </p>
      <div class="flex flex-wrap items-center gap-5">
        <GrKbd
          v-for="combo in combos"
          :key="combo"
          :keys="combo"
          :variant="row.variant"
          :platform="platform"
        />
      </div>
    </div>

    <div class="grid gap-2">
      <p class="text-[length:var(--gr-text-xs)] text-[var(--gr-muted-fg)]">
        sequence — аккорд: клавиши нажимают одну за другой
      </p>
      <div class="flex flex-wrap items-center gap-5">
        <GrKbd :keys="['G', 'I']" variant="sequence" :platform="platform" />
        <GrKbd :keys="['G', 'P']" variant="sequence" :platform="platform" />
      </div>
    </div>
  </div>
</template>

Tokens

Модификаторы

  • Ctrlmod
  • Metameta то же: cmd, command, ⌘
  • Ctrlctrl то же: control, ⌃
  • Altalt то же: option, ⌥
  • Shiftshift то же: ⇧

Ввод и редактирование

  • Enterenter то же: return, ↵, ↩
  • Escapeesc то же: escape диктор: escape
  • Tabtab то же: ⇥
  • Spacespace
  • Backspacebackspace то же: ⌫
  • Deldelete то же: del, ⌦

Навигация

  • Arrow Upup то же: arrowup, ↑ диктор: arrowUp
  • Arrow Downdown то же: arrowdown, ↓ диктор: arrowDown
  • Arrow Leftleft то же: arrowleft, ← диктор: arrowLeft
  • Arrow Rightright то же: arrowright, → диктор: arrowRight
  • Homehome
  • Endend
  • PgUppageup то же: pgup, ⇞
  • PgDnpagedown то же: pgdn, pgdown, ⇟

Всё остальное компонент показывает как есть: буква приводится к заглавной (K), слово остаётся словом (F5).

Tokens
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'

import type { GrKbdPlatform, GrKbdTokenGroup } from '@feugene/granularity'
import { GR_KBD_TOKENS, GrKbd, GrSegmented } from '@feugene/granularity'

const platform = ref<GrKbdPlatform>('auto')

/**
 * `auto` уточняется после монтирования — тем же способом, что и в самом
 * компоненте (`navigator` в теле setup разошёлся бы с серверным рендером).
 * Без этого колонка «диктор» показывала бы имя не той платформы, чей глиф
 * нарисован рядом.
 */
const detectedApple = ref(false)
onMounted(() => {
  detectedApple.value = /Mac|iPhone|iPad|iPod/.test(navigator.platform || navigator.userAgent)
})

const isApple = computed(() => (platform.value === 'auto' ? detectedApple.value : platform.value === 'apple'))

const groups: { id: GrKbdTokenGroup, title: string }[] = [
  { id: 'modifier', title: 'Модификаторы' },
  { id: 'editing', title: 'Ввод и редактирование' },
  { id: 'navigation', title: 'Навигация' },
]

/**
 * Список приходит из самого пакета (`GR_KBD_TOKENS`), а не переписан руками:
 * своя копия разошлась бы с форматтером на первой же новой клавише.
 */
const byGroup = computed(() => groups.map(group => ({
  ...group,
  tokens: GR_KBD_TOKENS.filter(spec => spec.group === group.id),
})))
</script>

<template>
  <div class="grid gap-5">
    <GrSegmented
      v-model="platform"
      size="sm"
      :options="[
        { value: 'auto', label: 'auto' },
        { value: 'apple', label: 'macOS' },
        { value: 'other', label: 'Windows/Linux' },
      ]"
    />

    <section v-for="group in byGroup" :key="group.id" class="grid gap-2">
      <h4 class="text-[length:var(--gr-text-xs)] font-600 uppercase tracking-wide text-[var(--gr-muted-fg)]">
        {{ group.title }}
      </h4>

      <ul class="grid gap-1 rounded-2xl border border-[var(--gr-brd)] bg-[var(--gr-card)] p-2">
        <li
          v-for="spec in group.tokens"
          :key="spec.token"
          class="flex flex-wrap items-center gap-x-4 gap-y-1 rounded-xl px-3 py-2 text-[length:var(--gr-text-sm)] hover:bg-[var(--gr-muted)]"
        >
          <GrKbd :keys="[spec.token]" :platform="platform" size="sm" />

          <code class="text-[length:var(--gr-text-xs)] text-[var(--gr-fg)]">{{ spec.token }}</code>

          <span
            v-if="spec.aliases.length"
            class="text-[length:var(--gr-text-xs)] text-[var(--gr-muted-fg)]"
          >
            то же: {{ spec.aliases.join(', ') }}
          </span>

          <!-- Имя произносит диктор вместо глифа; у слов его нет — они читаются сами. -->
          <span
            v-if="(isApple ? spec.apple : spec.other).name"
            class="ml-auto text-[length:var(--gr-text-xs)] text-[var(--gr-muted-fg)]"
          >
            диктор: {{ (isApple ? spec.apple : spec.other).name }}
          </span>
        </li>
      </ul>
    </section>

    <p class="text-[length:var(--gr-text-xs)] text-[var(--gr-muted-fg)]">
      Всё остальное компонент показывает как есть: буква приводится к заглавной
      (<GrKbd keys="k" :platform="platform" size="sm" />), слово остаётся словом
      (<GrKbd :keys="['F5']" :platform="platform" size="sm" />).
    </p>
  </div>
</template>

Component documentationAll components