GrKbd
Берут, когда показывается сочетание клавиш.
Когда брать
- показывается сочетание клавиш — в подсказке, в справке, в пункте меню;
- сочетание зависит от платформы —
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 | когда нужно показать именно набор клавиш, например в справке по горячим клавишам |
sequence | G затем 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 | Прочие |
|---|---|---|
mod | ⌘ | Ctrl |
ctrl, alt, shift | ⌃, ⌥, ⇧ | Ctrl, Alt, Shift |
enter / return | ↩ | Enter |
esc, space, home, end | словом | словом |
tab | ⇥ | Tab |
backspace | ⌫ | Backspace |
delete / del | ⌦ | Del |
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
| Prop | Type | по умолчанию | Описание |
|---|---|---|---|
variant | "split" | "merged" | "sequence" | undefined | "merged" | `merged` (по умолчанию) — сочетание одной плашкой, как его пишут сами системы: `⌘K` на macOS, `Ctrl+K` на прочих. `split` — по плашке на клавишу. `sequence` — аккорд «G затем I»: клавиши нажимают одну за другой. |
keys | string | string[] | undefined | undefined | Сочетание: строкой (`"mod+shift+K"`) или набором токенов (`['mod', 'K']`). Токен `mod` — Cmd на macOS, Ctrl на остальных. |
size | "xs" | "sm" | "md" | "lg" | undefined | undefined | — |
separator | string | undefined | undefined | Разделитель между клавишами. Не задан — авто: в общей плашке символы склеиваются (`⌘K`), а слова разделяются плюсом (`Ctrl+K`); у `split` это плюс, у `sequence` — слово из локали. Пустая строка — только зазор. |
platform | GrKbdPlatform | undefined | "auto" | — |
Slots
| Slot | Type | Описание |
|---|---|---|
default | any | Клавиша или сочетание вместо пропа `keys`. |
Примеры 5
Клавиши, сочетания и размеры
Сочетание задаётся пропом keys — строкой или набором токенов; слот остаётся для одиночной клавиши. Шкала размеров полная: xs…lg.
<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
<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, поэтому клавиши в разметке декоративны.
<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 на прочих
split — плашка на каждую клавишу
sequence — аккорд: клавиши нажимают одну за другой
<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, какие синонимы принимаются и какое имя получит диктор вместо глифа.
Модификаторы
- Ctrl
mod - Meta
metaто же: cmd, command, ⌘ - Ctrl
ctrlто же: control, ⌃ - Alt
altто же: option, ⌥ - Shift
shiftто же: ⇧
Ввод и редактирование
- Enter
enterто же: return, ↵, ↩ - Escape
escто же: escape диктор: escape - Tab
tabто же: ⇥ - Space
space - Backspace
backspaceто же: ⌫ - Del
deleteто же: del, ⌦
Навигация
- стрелка вверх
upто же: arrowup, ↑ диктор: arrowUp - стрелка вниз
downто же: arrowdown, ↓ диктор: arrowDown - стрелка влево
leftто же: arrowleft, ← диктор: arrowLeft - стрелка вправо
rightто же: arrowright, → диктор: arrowRight - Home
home - End
end - PgUp
pageupто же: pgup, ⇞ - PgDn
pagedownто же: pgdn, pgdown, ⇟
Всё остальное компонент показывает как есть: буква приводится к заглавной (K), слово остаётся словом (F5).
<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>