Командный центр

  • Рабочие экраны
  • компонентов: 7
  • пакетов: 1

Экран, где клавиатура — основной способ, но всё работает и без неё, а палитра выполняет команды, а не ищет страницы.

Ревизия 2026-09-06

На этой странице
Во всю ширину

Что он доказывает

Нажмите ⌘K, или N, или ту же команду мышью. Все три пути делают одно и то же, потому что зовут одну функцию, — и в этом весь экран.

Интерфейс, который прячет возможности за сочетаниями, не быстрый. Он закрытый: быстрый для тех, кто уже знает, и хуже обычного меню для всех остальных. Здесь у каждого действия две двери.

Палитра выполняет команды, поиск находит страницы

Положите в палитру сорок документов — и шесть команд, ради которых её открывали, окажутся погребены. Пустое состояние говорит это прямо: здесь команды — действия; страницы живут в поиске. Как только палитра начинает отдавать документы, ей перестают доверять действия.

Сам портал делает обратный выбор нарочно: ⌘K здесь открывает поиск, потому что сайту документации нечем командовать. Компонент один, работа разная, и разница не косметическая.

Сочетание написано там, где действие

У каждого действия рядом стоит его клавиша. Именно там сочетания и запоминаются — в тот момент, когда человек делает медленно, — а не на странице справки, которую он не откроет.

Одиночные буквы — самые опасные

N создаёт задачу и создавала бы её посреди слова, если бы обработчик не проверял, что сейчас в фокусе. Экран не слушает клавиши, пока фокус в поле ввода или в палитре, — в палитре как раз печатают.

E убирает в архив, Shift+E выгружает: пара нарочная, чтобы показать, что модификатор обязан проверяться, а не игнорироваться. Обработчик, читающий одну букву, превращает каждую заглавную в чужую команду.

Закрывшаяся палитра не оставляет следов

Она закрывается в тот же миг, когда что-то запустила. Без журнала справа не осталось бы и признака, что команда была, — а для читалки не осталось бы и с журналом, поэтому результат ещё и объявляется живой областью.

Использованные компоненты

Список строится из frontmatter и сверяется с исходником: компонент, добавленный в код и забытый в шапке, роняет гейт.

Пакеты:

  • @feugene/granularity

Точка входа

Тот же файл, который здесь исполняется, — examples/command/App.vue. Отдельной копии кода не существует.

examples/command/App.vue
<script setup lang="ts">
/**
 * `/examples/command` — командный центр, работа C2.
 *
 * Единственный экран раздела, где клавиатура — основной способ, а не запасной.
 * И ровно поэтому он собран так, чтобы **без клавиатуры работало всё то же
 * самое**: интерфейс, который быстр только для знающих сочетания, не быстрый —
 * он закрытый.
 *
 * Три решения, которые здесь видно:
 *
 * 1. Палитра — не поиск. В ней действия, а не страницы: список из сорока
 *    документов хоронит шесть команд, ради которых её открывали.
 * 2. Сочетание написано там, где действие. Человек учит клавишу в тот момент,
 *    когда делает медленно, — а не на странице справки, куда не заходит.
 * 3. У выполненного есть след. Палитра закрывается в тот же миг, когда что-то
 *    запустила, и без журнала доказательств не остаётся никаких.
 */
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import type { GrCommandItem } from '@feugene/granularity/components/GrCommandPalette'
import { GrCommandPalette } from '@feugene/granularity/components/GrCommandPalette'
import { GrKbd } from '@feugene/granularity/components/GrKbd'
import { GrButton } from '@feugene/granularity/components/GrButton'
import { GrCard } from '@feugene/granularity/components/GrCard'
import { GrBadge } from '@feugene/granularity/components/GrBadge'
import { GrAlert } from '@feugene/granularity/components/GrAlert'
import { GrEmptyState } from '@feugene/granularity/components/GrEmptyState'
import type { CommandData } from './data'

const props = defineProps<{ data: CommandData }>()

const open = ref(false)
const log = ref<Array<{ id: number, text: string }>>([])
const recent = ref<string[]>([])
const announcement = ref('')

let nextId = 1

const items = computed<GrCommandItem[]>(() => props.data.commands.map(command => ({
  id: command.id,
  label: command.label,
  description: command.description,
  shortcut: command.shortcut,
  group: command.group,
})))

/**
 * Выполнение команды — одно на оба пути.
 *
 * Мышь и палитра зовут одну и ту же функцию, и это не экономия строк: две
 * ветки разошлись бы, и половина команд начала бы вести себя по-разному в
 * зависимости от того, как её вызвали.
 */
function run(id: string) {
  const command = props.data.commands.find(item => item.id === id)
  if (!command)
    return

  log.value = [{ id: nextId++, text: command.done }, ...log.value].slice(0, 8)

  // Недавние поднимаются наверх палитры: она без памяти заставляет набирать
  // одно и то же по десять раз в день.
  recent.value = [id, ...recent.value.filter(item => item !== id)].slice(0, 3)

  // След объявляется вслух: закрывшаяся палитра ничего не сообщает читалке.
  announcement.value = `${props.data.announced}: ${command.done}`
}

/**
 * Одиночные клавиши.
 *
 * Показать сочетание и не привязать его — то же самое, что соврать: экран
 * обещает клавиатуру, а работает мышью. Поэтому клавиши настоящие.
 *
 * И поэтому же здесь есть проверка на поле ввода. Одиночная буква — самое
 * удобное сочетание и самое опасное: без неё «N» в середине слова создавала бы
 * задачу вместо буквы. Палитра исключена по той же причине — в ней печатают.
 *
 * `Shift+E` и `E` — разные команды нарочно: пара показывает, что модификатор
 * обязан проверяться, а не игнорироваться.
 */
function onKey(event: KeyboardEvent) {
  if (open.value || event.metaKey || event.ctrlKey || event.altKey)
    return

  const target = event.target as HTMLElement | null
  if (target?.closest('input, textarea, select, [contenteditable="true"]'))
    return

  // Регистр приводится **с обеих сторон**. Первая версия приводила только
  // объявленное сочетание, и `Shift+E` не совпадал сам с собой: одиночные
  // буквы работали, а пара с модификатором молчала.
  const pressed = [...(event.shiftKey ? ['Shift'] : []), event.key].join('+').toUpperCase()
  const command = props.data.commands.find(item => item.shortcut.join('+').toUpperCase() === pressed)

  if (command) {
    event.preventDefault()
    run(command.id)
  }
}

onMounted(() => window.addEventListener('keydown', onKey))
onBeforeUnmount(() => window.removeEventListener('keydown', onKey))
</script>

<template>
  <div class="screen">
    <header class="screen__head">
      <div>
        <p class="screen__brand">{{ data.brand }}</p>
        <h1 class="screen__title">{{ data.title }}</h1>
        <p class="screen__subtitle">{{ data.subtitle }}</p>
      </div>

      <!--
        Кнопка и есть способ узнать о сочетании: подсказка стоит на ней, а не
        в справке. Открыть палитру можно обоими способами.
      -->
      <GrButton variant="outline" @click="open = true">
        <span class="i-lucide-command block h-4 w-4" aria-hidden="true" />
        {{ data.open }}
        <GrKbd :keys="['Mod', 'K']" size="sm" />
      </GrButton>
    </header>

    <p class="screen__hint">{{ data.openHint }}</p>

    <!-- Живая область: без неё выполненная команда остаётся немой. -->
    <GrAlert v-if="announcement" tone="success" live="polite" :title="announcement" />

    <div class="screen__split">
      <GrCard class="screen__panel">
        <h2 class="screen__section">{{ data.quickTitle }}</h2>
        <p class="screen__hint">{{ data.quickLead }}</p>

        <ul class="screen__actions">
          <li v-for="command in data.commands" :key="command.id">
            <GrButton variant="ghost" class="screen__action" @click="run(command.id)">
              <span class="screen__actionText">
                <span>{{ command.label }}</span>
                <span class="screen__hint">{{ command.description }}</span>
              </span>
              <GrKbd :keys="command.shortcut" size="sm" />
            </GrButton>
          </li>
        </ul>
      </GrCard>

      <GrCard class="screen__panel">
        <h2 class="screen__section">{{ data.logTitle }}</h2>
        <p class="screen__hint">{{ data.logLead }}</p>

        <GrEmptyState v-if="!log.length" :title="data.logEmpty" />

        <ul v-else class="screen__log">
          <li v-for="entry in log" :key="entry.id">
            <GrBadge size="sm" tone="success">{{ data.announced }}</GrBadge>
            <span>{{ entry.text }}</span>
          </li>
        </ul>
      </GrCard>
    </div>

    <GrCommandPalette
      v-model="open"
      :items="items"
      :recent-ids="recent"
      :placeholder="data.placeholder"
      :empty-text="data.empty"
      :aria-label="data.open"
      @select="item => run(item.id)"
    />
  </div>
</template>

<style scoped>
.screen {
  display: grid;
  align-content: start;
  gap: var(--gr-space-4);
  min-height: 100dvh;
  padding: var(--gr-space-4);
  background: var(--gr-bg);
  color: var(--gr-fg);
}

.screen__head {
  display: flex;
  flex-wrap: wrap;
  align-items: flex-start;
  justify-content: space-between;
  gap: var(--gr-space-3);
}

.screen__brand {
  margin: 0;
  color: var(--gr-muted-fg);
  font-size: var(--gr-text-sm);
}

.screen__title {
  margin: var(--gr-space-1) 0 0;
  font-size: var(--gr-text-xl);
  font-weight: 600;
}

.screen__subtitle {
  margin: var(--gr-space-1) 0 0;
  color: var(--gr-muted-fg);
  font-size: var(--gr-text-sm);
}

.screen__hint {
  margin: 0;
  color: var(--gr-muted-fg);
  font-size: var(--gr-text-xs);
}

.screen__split {
  display: grid;
  gap: var(--gr-space-3);
}

.screen__panel {
  display: grid;
  align-content: start;
  gap: var(--gr-space-3);
  padding: var(--gr-space-4);
}

.screen__section {
  margin: 0;
  font-size: var(--gr-text-sm);
  font-weight: 600;
}

.screen__actions,
.screen__log {
  display: grid;
  gap: var(--gr-space-2);
  margin: 0;
  padding: 0;
  list-style: none;
}

.screen__action {
  width: 100%;
  justify-content: space-between;
  gap: var(--gr-space-3);
}

.screen__actionText {
  display: grid;
  gap: 0.15em;
  text-align: start;
}

.screen__log li {
  display: flex;
  align-items: center;
  gap: var(--gr-space-3);
  font-size: var(--gr-text-sm);
}

@media (min-width: 768px) {
  .screen {
    padding: var(--gr-space-5);
  }
}

@media (min-width: 1200px) {
  .screen__split {
    grid-template-columns: repeat(2, minmax(0, 1fr));
    align-items: start;
  }
}
</style>

Собрать такое же