Командный центр
Экран, где клавиатура — основной способ, но всё работает и без неё, а палитра выполняет команды, а не ищет страницы.
Что он доказывает
Нажмите ⌘K, или N, или ту же команду мышью. Все три пути делают одно и то
же, потому что зовут одну функцию, — и в этом весь экран.
Интерфейс, который прячет возможности за сочетаниями, не быстрый. Он закрытый: быстрый для тех, кто уже знает, и хуже обычного меню для всех остальных. Здесь у каждого действия две двери.
Палитра выполняет команды, поиск находит страницы
Положите в палитру сорок документов — и шесть команд, ради которых её открывали, окажутся погребены. Пустое состояние говорит это прямо: здесь команды — действия; страницы живут в поиске. Как только палитра начинает отдавать документы, ей перестают доверять действия.
Сам портал делает обратный выбор нарочно: ⌘K здесь открывает поиск,
потому что сайту документации нечем командовать. Компонент один, работа разная,
и разница не косметическая.
Сочетание написано там, где действие
У каждого действия рядом стоит его клавиша. Именно там сочетания и запоминаются — в тот момент, когда человек делает медленно, — а не на странице справки, которую он не откроет.
Одиночные буквы — самые опасные
N создаёт задачу и создавала бы её посреди слова, если бы обработчик не
проверял, что сейчас в фокусе. Экран не слушает клавиши, пока фокус в поле
ввода или в палитре, — в палитре как раз печатают.
E убирает в архив, Shift+E выгружает: пара нарочная, чтобы показать, что
модификатор обязан проверяться, а не игнорироваться. Обработчик, читающий одну
букву, превращает каждую заглавную в чужую команду.
Закрывшаяся палитра не оставляет следов
Она закрывается в тот же миг, когда что-то запустила. Без журнала справа не осталось бы и признака, что команда была, — а для читалки не осталось бы и с журналом, поэтому результат ещё и объявляется живой областью.
Использованные компоненты
Список строится из frontmatter и сверяется с исходником: компонент, добавленный в код и забытый в шапке, роняет гейт.
GrCommandPaletteНавигацияGrKbdДанныеGrButtonДействияGrCardДанныеGrBadgeОбратная связьGrAlertОбратная связьGrEmptyStateОбратная связь
Пакеты:
@feugene/granularity
Точка входа
Тот же файл, который здесь исполняется, — 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>