Command centre
A keyboard-first screen that stays usable without a keyboard, and a palette that runs commands rather than finding pages.
What this proves
Press ⌘K, or N, or click the same action with a mouse. All three do the
same thing, because they call the same function — and that is the point of the
screen.
A keyboard-first interface that hides functionality behind shortcuts is not fast. It is closed: quick for the people who already know, and worse than a plain menu for everyone else. Everything here has both doors.
A palette runs commands; search finds pages
Put forty documents into a command palette and the six commands people opened it for are buried. The empty state says this out loud — commands are actions; pages live in search — because the moment a palette starts returning documents, users stop trusting it for actions.
The portal itself makes the opposite choice on purpose: ⌘K here opens
search, because a documentation site has nothing to command. Same
component, different job, and the difference is not cosmetic.
The shortcut is written where the action is
Every action carries its key next to it. That is where shortcuts get learnt — at the moment someone does the slow thing — and not on a help page they will never open.
Single letters are the dangerous kind
N creates a task, and would create one in the middle of a word if the
handler did not check what is focused first. The screen ignores keys while a
text field or the palette has focus, because the palette is a place where
people type.
E archives and Shift+E exports: a pair on purpose, to show that a modifier
has to be checked rather than ignored. A handler that reads only the letter
turns every capital into a different command.
A closed palette leaves no evidence
It shuts the instant it runs something. Without the log on the right there would be no sign the command happened at all — and for a screen reader, no sign even with the log, which is why the result is announced in a live region as well.
Components used
The list is built from frontmatter and checked against the source: a component added to the code and forgotten in the header fails the gate.
GrCommandPalettenavigationGrKbddataGrButtonactionsGrCarddataGrBadgefeedbackGrAlertfeedbackGrEmptyStatefeedback
Packages:
@feugene/granularity
Entry point
The same file that runs here — examples/command/App.vue. No second copy of the code exists.
<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>