GrKbd

Пакет: @feugene/granularityядроГруппа: Данные

Берут, когда показывается сочетание клавиш.

Когда брать

  • показывается сочетание клавиш — в подсказке, в справке, в пункте меню;
  • сочетание зависит от платформыplatform печатает на macOS и Ctrl на остальных;
  • клавиш несколькоkeys собирает сочетание с разделителем, без ручной вёрстки;
  • нужна нативная семантика — рендерится тегом <kbd>, а не стилизованным <span>.

Когда взять другое

НужноБерите
Показывается статус или ярлыкGrBadge
Нужна подсказка о назначенииGrTooltip
Показывается фрагмент кодаразметка приложения: блоков кода в пакете нет
Сочетание вызывает палитру командGrCommandPalette

Одна клавиша и сочетание

<GrKbd>
Esc
</GrKbd>

<GrKbd keys="mod+K" />

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

Слот остаётся рабочим для одиночной клавиши. Проп keys принимает и строку ("mod+shift+K"), и массив токенов — собирать ⌘ + K из двух GrKbd и <span>+</span> больше не нужно.

Сочетание размечается вложенными <kbd> — приём из спецификации HTML: так оно остаётся клавиатурным вводом целиком, а не набором соседних элементов. От варианта зависит только то, кто из них носит рамку.

Варианты

variantВидКогда
merged (по умолчанию)одна плашка: ⌘K, Ctrl+Kподсказка рядом с кнопкой или в поле — так сочетание пишут сами системы
splitплашка на каждую клавишу: Kкогда нужно показать именно набор клавиш, например в справке по горячим клавишам
sequenceG затем Iаккорд: клавиши нажимают одну за другой, а не вместе
<GrKbd keys="mod+K" />                      <!-- ⌘K -->

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

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

Разделитель считается сам

separator по умолчанию не задан — это авто:

  • в merged разделитель ставится только после слова: ⌘K и ⌘⇧K на macOS, Ctrl+K и Ctrl+Shift+K на прочих. Внутри одной платформы набор однороден (macOS даёт символы, остальные — слова), поэтому правило «слева символ — склеиваем» и означает «пишем как система»;
  • в split это +, в sequence — слово из локали (gr.kbd.then).

Явный separator сильнее авто: separator="" склеит что угодно, separator="+" поставит плюс даже между символами. Разделитель декоративен (aria-hidden).

Словарь клавиш

Токенами задаются не только модификаторы:

Токен (и алиасы)macOSПрочие
modCtrl
ctrl, alt, shift, , Ctrl, Alt, Shift
enter / returnEnter
esc, space, home, endсловомсловом
tabTab
backspaceBackspace
delete / delDel
pageup / pgup, pagedown / pgdn, PgUp, PgDn
up, down, left, right↑ ↓ ← →↑ ↓ ← →

Регистр не важен, глиф тоже принимается как токен (, ). Писать стрелку литералом в разметке не нужно и не стоит: токен приносит с собой читаемое имя, а голый глиф диктор произносит как значок.

Каталог — не таблица в доке, а данные: GR_KBD_TOKENS экспортируется пакетом, по нему построена страница витрины, и он же кормит форматтер. Копия списка разошлась бы с поведением на первой же новой клавише.

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

const navigation = GR_KBD_TOKENS.filter(spec => spec.group === 'navigation')
const command = findKbdToken('⌘') // тот же токен, что и `meta`

Платформа

Токен mod — Cmd на macOS и Ctrl на остальных платформах; ctrl, alt, shift тоже показываются символами на Apple (, , ) и словами на остальных.

<GrKbd keys="mod+K" />                    <!-- определяется автоматически -->

<GrKbd keys="mod+K" platform="apple" />   <!-- всегда ⌘ -->

<GrKbd keys="mod+K" platform="other" />   <!-- всегда Ctrl -->

platform="auto" уточняет платформу после монтирования: navigator в первом рендере разошёлся бы с серверным HTML и сломал гидрацию. Поэтому с сервера всегда приходит не-Apple вариант, а появляется на клиенте. Если аудитория известна заранее, platform убирает и эту перерисовку.

Тот же токен mod понимает директива v-hotkey, поэтому подсказка и привязка пишутся одинаково и не расходятся:

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

Разбор и нормализация живут в components/shared/hotkey.ts — том же модуле, которым GrCommandPalette матчит своё сочетание открытия. Общий модуль, а не импорт из директории палитры: иначе на сборке возникло бы ребро GrKbd → GrCommandPalette, и granular doctor посчитал бы его незадекларированной зависимостью.

Скринридер

Символьные клавиши получают читаемое имя рядом с символом:

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

Без этого диктор произносит как «знак места интереса». Слова (Ctrl, Shift, Enter) читаются сами — имя к ним не добавляется, иначе вышло бы «Ctrl Control». Тексты имён — ключи gr.kbd.*.

Размеры

Полная шкала пакета: xs, sm, md (по умолчанию), lg — меняются высота, минимальная ширина, padding, кегль и зазор между клавишами. size читается из GrConfigProvider.

min-w держит одиночный символ квадратным: без него «K» была бы у́же, чем «Esc», и ряд хоткеев прыгал бы.

Playground 3

Загружается…

Код
<GrKbd />

Установка

npm i @feugene/granularity

Импорт

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

API

Props

PropTypeпо умолчаниюОписание
variant"split" | "merged" | "sequence" | undefined"merged"`merged` (по умолчанию) — сочетание одной плашкой, как его пишут сами системы: `⌘K` на macOS, `Ctrl+K` на прочих. `split` — по плашке на клавишу. `sequence` — аккорд «G затем I»: клавиши нажимают одну за другой.
keysstring | string[] | undefinedundefinedСочетание: строкой (`"mod+shift+K"`) или набором токенов (`['mod', 'K']`). Токен `mod` — Cmd на macOS, Ctrl на остальных.
size"xs" | "sm" | "md" | "lg" | undefinedundefined
separatorstring | undefinedundefinedРазделитель между клавишами. Не задан — авто: в общей плашке символы склеиваются (`⌘K`), а слова разделяются плюсом (`Ctrl+K`); у `split` это плюс, у `sequence` — слово из локали. Пустая строка — только зазор.
platformGrKbdPlatform | undefined"auto"

Slots

SlotTypeОписание
defaultanyКлавиша или сочетание вместо пропа `keys`.

Примеры 5

Клавиши, сочетания и размеры

Сочетание задаётся пропом keys — строкой или набором токенов; слот остаётся для одиночной клавиши. Шкала размеров полная: xs…lg.

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>

Подсказки хоткеев в меню

Токен mod пишется один раз и показывается по платформе; platform позволяет зафиксировать её вручную.

  • Найти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>

Поиск в шапке приложения

Сочетание рядом с кнопкой — единственный способ узнать про ⌘K, не нажимая его. Кнопка открывает GrCommandPalette, а само сочетание вешает директива v-hotkey; диктору его сообщает aria-keyshortcuts, поэтому клавиши в разметке декоративны.

Navbar Searchзависит от окружения витрины
<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>

Одна плашка, отдельные клавиши, аккорд

По умолчанию сочетание рисуется одной плашкой — так его пишут сами системы: ⌘K на macOS и Ctrl+K на прочих (разделитель ставится сам: символы склеиваются, слова — нет). split возвращает плашку на клавишу, sequence — аккорд «G затем I». Клавиши без букв приходят токенами (up, tab, backspace), и диктор получает имя вместо глифа.

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>

Все клавиши, которые понимает `keys`

Список приходит из самого пакета (GR_KBD_TOKENS), а не переписан в витрине: своя копия разошлась бы с форматтером на первой же новой клавише. Для каждой клавиши видно, что писать в keys, какие синонимы принимаются и какое имя получит диктор вместо глифа.

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

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

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

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

Навигация

  • стрелка вверхup то же: arrowup, ↑ диктор: arrowUp
  • стрелка внизdown то же: arrowdown, ↓ диктор: arrowDown
  • стрелка влевоleft то же: arrowleft, ← диктор: arrowLeft
  • стрелка вправоright то же: 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>

Документация компонентаВсе компоненты