Command-палитра для Vue 3, часть 2: вторичные действия, режимы, превью и ещё дюжина фич

30.05.2026
Command-палитра для Vue 3, часть 2: вторичные действия, режимы, превью и ещё дюжина фич

В первом посте я показал базу @macrulez/vue-command-palette: fuzzy-поиск, вложенные палитры, глобальные хоткеи — всё на голом Vue 3, без зависимостей. Тогда это был аккуратный, но довольно базовый кирпич: открыть, ввести пару букв, нажать Enter.

С тех пор я не останавливался. Пакет дорос до уровня, на котором его уже не стыдно сравнивать с Raycast: вторичные действия по Tab, префиксные режимы, боковая панель предпросмотра, мультивыбор, ранжирование по частоте использования, типобезопасные данные, несколько независимых палитр на странице. А заодно я выловил и починил с десяток багов — ровно тех, на которые наступает любая палитра, когда вырастает из демки в инструмент.

Этот пост — подробно обо всём, что появилось во втором заходе. Тесты, кстати, выросли с 30 до 121 — и это не для красивой цифры, а чтобы можно было уверенно добавлять фичи, не ломая старое: каждая правка прогоняется через lint, typecheck, тесты и сборку, и всё это держится зелёным.

Поиск, который стал умнее и честнее

Базовое ранжирование было и в первой версии: точное совпадение получает 100 баллов, префикс — 80, подстрока — 60, нечёткое (когда буквы идут по порядку, но с разрывами) — от 1 до 40 с штрафом за разрывы. Нормализация диакритики тоже была: cafe находит Café, потому что строки приводятся к форме NFD и комбинирующие знаки отбрасываются. Что изменилось за второй заход.

Поиск теперь смотрит не только в название. Раньше совпадение искалось только по label. Теперь в игру включены description, keywords и aliases — причём у каждого поля свой смысл. keywords — это синонимы и термины, по которым команду логично искать, но которых нет в названии. aliases — альтернативные имена. А description подключён осторожно, и об этом отдельно ниже.

Но тут вылезает проблема UX: пользователь вводит csv, видит в выдаче команду «Download Report», в названии которой нет ни одной из введённых букв, и не понимает, почему она здесь. Поэтому, если команда нашлась не по названию, под ней показывается подсказка — по какому именно слову совпало:

ts Copy
{
  id: 'export',
  label: 'Download Report',
  keywords: ['save', 'csv', 'excel'],   // найдётся по "csv"…
  aliases: ['Get data'],
  perform: () => exportReport(),
}
// в выдаче под «Download Report» появится приписка: csv

Технически это поле matchedField ('label' | 'description' | 'keyword' | 'alias') и matchedText в результате поиска — подсказка показывается, только когда совпадение пришло не из названия и в самом названии подсветить нечего.

description ищется только по подстроке. Это прямое следствие бага, который я поймал (о нём ниже в разделе исправлений). Если позволить нечёткое совпадение по длинным описаниям, оно начинает находить подпоследовательности там, где их «на глаз» нет: буквы e-r-r-o-r находятся в фразе «Charts, metrics and reports» как подпоследовательность, и команда «Go to Analytics» всплывает по запросу error. Поэтому описание учитывается, только если запрос входит в него подстрокой (или точнее) — это отсекает мусор, но оставляет осмысленные совпадения.

Скоринг можно заменить целиком. Если встроенного мало — например, нужна нечёткость уровня Fuse.js или своя логика весов — подставляешь свою функцию через опцию search. Сигнатура простая: запрос плюс массив команд на вход, ранжированные результаты на выход. Группу к результату палитра проставит сама уже после:

ts Copy
import Fuse from 'fuse.js'

app.use(VCommandPalettePlugin, {
  search: (query, commands) => {
    const fuse = new Fuse(commands, {
      keys: ['label', 'description', 'keywords'],
      includeScore: true,
    })
    return fuse.search(query).map(r => ({
      command: r.item,
      score: 1 - (r.score ?? 0),
      matches: [],
    }))
  },
})

Подсветку вынес наружу — getMatchRanges. Когда результаты приходят с сервера (асинхронный поиск, о котором ниже), они не проходят через встроенный скоринг, и подсвечивать в них совпавшие буквы нечем. Раньше такие результаты показывались без подсветки. Теперь есть экспортируемая функция, которая считает диапазоны совпадений для произвольной пары «запрос — текст»:

ts Copy
import { getMatchRanges } from '@macrulez/vue-command-palette'

getMatchRanges('al', 'Alan Turing') // → [[0, 1]]  (подсветит "Al")

Внутри палитра прогоняет через неё все асинхронные результаты, так что подсветка работает везде одинаково — и в локальном поиске, и в серверном.

Вложенные команды теперь ищутся

Любая команда может иметь subCommands — это было и раньше: при выборе открывается вложенная палитра с хлебными крошками, Backspace или Esc возвращают назад. Что добавилось.

Раньше, чтобы добраться до команды внутри подменю, нужно было знать, что она там, и сначала провалиться в родителя. Это нелогично: я ввожу light, я хочу команду «Light», а не «открой меню Change Theme, а там уже ищи». Теперь вложенные команды по умолчанию участвуют в поиске с верхнего уровня (опция searchNested, включена по умолчанию). Ввёл light — в выдаче появляется именно команда «Light» с хлебной крошкой контекста Change Theme › Light, чтобы было видно, откуда она. А команды, которые сами открывают подменю, помечены шевроном в зарезервированной колонке справа.

ts Copy
{
  id: 'theme', label: 'Change Theme', icon: '🎨', perform: () => {},
  subCommands: [
    { id: 'light', label: 'Light', icon: '☀️', perform: () => setTheme('light') },
    { id: 'dark',  label: 'Dark',  icon: '🌙', perform: () => setTheme('dark') },
  ],
}

Вкладывать можно на любую глубину: Git → Branches → конкретная ветка. Это, кстати, оказалось нетривиально: внутреннее дерево хранит только команды верхнего уровня, а сабкоманды живут внутри своих родителей. Чтобы глубокая навигация и контекст хлебных крошек работали, добавился рекурсивный поиск команды по всему дереву по её id. Без него Git → Branches падало обратно в корень (этот баг тоже разобран ниже).

Команды-страницы с собственным асинхронным вводом

subCommands — это статический список, который ты задаёшь заранее. Но часто нужно другое: открыть «страницу» со своим плейсхолдером и поиском, который ходит на бэкенд прямо по мере ввода. Выбор исполнителя из тысячи пользователей, поиск по тикетам, файл из удалённого хранилища — статическим списком тут не обойтись.

Для этого появилось поле page. Команда с page при выборе открывает вложенный уровень, но вместо статичного списка у него свой placeholder и асинхронный onSearch, который вызывается на каждый ввод с дебаунсом 200 мс:

ts Copy
{
  id: 'assign', label: 'Assign to user…', icon: '👤', perform: () => {},
  page: {
    placeholder: 'Search users…',
    onSearch: async (query) => {
      const users = await api.searchUsers(query)
      return users.map(u => ({
        id: u.id, label: u.name, description: u.email,
        perform: () => assign(u.id),
      }))
    },
  },
}

Пока запрос летит, уже показанные результаты не пропадают, а в углу поля крутится спиннер (раньше тут была другая, неприятная история — см. исправления). Если у page задать ещё и статический items, он покажется при пустом запросе — как «недавние» или «популярные» до того, как пользователь начал печатать.

Режимы-скоупы по префиксу

Паттерн прямиком из VS Code, где > переключает на команды, а @ — на символы. Когда в приложении несколько разнородных источников — страницы, люди, файлы, действия — пихать их в одну плоскую выдачу неправильно: они конкурируют за релевантность и мешают друг другу. Режимы решают это явным разделением.

Задаёшь набор режимов, каждый со своим префиксом. Как только запрос начинается с этого префикса, палитра отрезает его, меняет плейсхолдер, показывает слева чип-индикатор и направляет поиск в onSearch этого режима (а если onSearch не задан — просто ищет по обычным командам, но уже без префикса):

vue Copy
<CommandPalette :modes="[
  { prefix: '>', label: 'Commands', placeholder: 'Run a command…' },
  { prefix: '@', label: 'People',   placeholder: 'Find a person…', onSearch: searchPeople },
  { prefix: '#', label: 'Headings', placeholder: 'Jump to a heading…', onSearch: searchHeadings },
]" />

Ввёл @ada — активировался режим People, чип «People», поиск идёт через searchPeople('ada'). Удалил префикс через Backspace — вернулся к обычному поиску. Никакого отдельного «режимного» состояния, которое нужно сбрасывать руками: режим целиком выводится из текста запроса, так что он всегда консистентен с тем, что в поле.

Вторичные действия: Tab открывает меню

Это та фича Raycast, которой мне не хватало больше всего. У команды редко бывает ровно одно осмысленное действие. «Экспорт» хочется и скачать, и скопировать в буфер, и отправить на почту. «Пользователь» — открыть профиль, написать, заблокировать. Складывать всё это отдельными командами в список — он раздувается; прятать в подменю — теряется основное действие.

Решение — вторичные действия. У команды есть основное (perform, по Enter) и набор actions. На активном элементе Tab открывает меню этих действий — стрелки навигируют, Enter запускает, Esc или Tab закрывают:

ts Copy
{
  id: 'export', label: 'Export Data',
  perform: () => download(),                 // Enter — скачать
  actions: [                                  // Tab — меню
    { id: 'copy',  label: 'Copy as JSON', perform: () => copyJson() },
    { id: 'email', label: 'Email export', shortcut: ['$mod', 'm'], perform: () => email() },
  ],
}

Почему именно Tab, а не Cmd+K как в Raycast: Cmd+K у меня занят открытием самой палитры, вешать на него ещё и второй смысл — путаница. Tab внутри палитры всё равно делать особо нечего (фокус и так заперт в диалоге), так что он логично уходит под действия, а если у команды действий нет — отрабатывает обычный focus-trap. Команды с действиями помечены значком справа, чтобы было видно, что там есть что нажать.

Боковая панель предпросмотра

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

Наполняется панель из двух источников, в таком порядке. Первый — слот #preview, полный контроль над вёрсткой:

vue Copy
<CommandPalette preview>
  <template #preview="{ command }">
    <article v-if="command">
      <h3>{{ command.label }}</h3>
      <p>{{ command.description }}</p>
      <img v-if="command.data?.thumbnail" :src="command.data.thumbnail" />
    </article>
  </template>
</CommandPalette>

Второй — поле info у самой команды (текст или HTML). Это для случаев, когда городить слот избыточно: достаточно прицепить кусок разметки к команде, и он отрисуется в панели после слота:

ts Copy
{
  id: 'analytics', label: 'Open Analytics',
  info: '<p>Traffic, conversions and revenue charts.</p><ul><li>Real-time</li><li>Funnels</li></ul>',
  perform: () => {},
}

info рендерится через v-html, так что туда нельзя пихать непроверенный пользовательский ввод — в доке это отдельно оговорено.

Панель сворачивается и разворачивается — иконкой рядом с переключателем темы или хоткеем (по умолчанию ⌘/Ctrl + I, меняется пропом previewHotkey). И тут была отдельная возня с анимацией, которую пришлось переделывать. В первой версии диалог расширялся через max-width, а панель уезжала через flex-basis — и из-за того, что менялись два разных размера одновременно и неконгруэнтно, ширина списка прыгала немонотонно: сначала схлопывалась, потом панель резко исчезала, потом список расширялся обратно. Выглядело дёргано.

Переделал так: панель — это внешний враппер с overflow: hidden, у которого анимируется только ширина (от нуля до --vcp-preview-width), а внутри — контент фиксированной ширины со своим overflow-y: auto, чтобы длинный текст скроллился, а не плющился во время анимации. Ширину диалога при этом меняю синхронно и ровно на ширину панели, так что список остаётся постоянной ширины — визуально просто правый край выезжает и задвигается, без рывков. Диалог становится широким, только когда панель реально раскрыта; свернул — он вернулся к обычной ширине, без пустого места справа. На узких экранах панель скрывается автоматически.

Для асинхронных превью (подгрузить детали по выбранному элементу) есть парный коллбэк onHighlight(command) — он зовётся, когда меняется активный элемент, в любом режиме навигации.

Реальные хоткеи, pinned, мультивыбор, история запросов

Четыре вещи, которые превращают палитру из «списка с поиском» в рабочий инструмент.

Привязка шорткатов. Раньше shortcut у команды был чисто декоративным — подсказка справа, и всё. С опцией bindShortcuts: true он становится настоящим глобальным хоткеем: нажал Cmd + S где угодно в приложении — выполнилась команда «Save», по тому же пути, что и через палитру. Реализовано так, что хоткеи регистрируются и снимаются реактивно по мере появления и удаления команд — добавил команду с shortcut, и он сразу работает, убрал команду — перестал.

ts Copy
app.use(VCommandPalettePlugin, { bindShortcuts: true })

useRegisterCommands([
  { id: 'save', label: 'Save', shortcut: ['$mod', 's'], perform: () => save() },
  { id: 'find', label: 'Find', shortcut: ['$mod', 'f'], perform: () => openFind() },
])

Закреплённые команды. Любую команду можно запинить (⌘/Ctrl + P на активном элементе) — она появляется в секции «Pinned» над «Недавними». Пины переживают перезагрузку (хранятся в localStorage). Из кода это pin / unpin / togglePin / isPinned в композабле, так что можно сделать и свою кнопку-звёздочку.

Мультивыбор. С пропом selectable палитра превращается в мульти-пикер: Enter или клик переключают выбор элемента, чекбоксы показывают, что отмечено, а ⌘/Ctrl + Enter подтверждает и отдаёт массив выбранных команд через событие. Здесь была развилка с клавишами: естественным переключателем выбора был бы Space, но он печатается в поле ввода — нельзя. Поэтому переключение повесил на Enter, а подтверждение — на ⌘/Ctrl + Enter:

vue Copy
<CommandPalette selectable @submit-selection="(cmds) => bulkArchive(cmds)" />

История запросов. Alt + ↑ / ↓ прокручивает предыдущие запросы — как в терминале. Чистые ↑ / ↓ тут нельзя занять: в пустом поле они уже навигируют по списку недавних и закреплённых команд, так что под историю ушла комбинация с Alt. Запросы запоминаются на момент выполнения команды, дедуплицируются, хранятся последние 25.

Несколько независимых палитр на одной странице

Иногда одной палитры мало. Глобальная командная строка на Cmd + K плюс отдельный быстрый поиск в сайдбаре на своём хоткее — у каждой свой набор команд, своё состояние, свой плейсхолдер. Наивно тут наступаешь на грабли Vue: app.use(plugin) дедуплицирует плагин по ссылке, второй вызов с тем же объектом просто игнорируется. Поэтому для дополнительных инстансов есть фабрика createCommandPalette — каждый вызов возвращает свежий объект плагина:

ts Copy
import { VCommandPalettePlugin, createCommandPalette } from '@macrulez/vue-command-palette'

app.use(VCommandPalettePlugin)                                   // дефолтный инстанс
app.use(createCommandPalette({ name: 'sidebar', hotkey: ['$mod', 'j'] }))
vue Copy
<CommandPalette />
<CommandPalette name="sidebar" placeholder="Search the sidebar…" />

Композаблы принимают имя инстанса вторым аргументом, так что команды можно регистрировать в нужную палитру: useRegisterCommands([...], 'sidebar'), а управлять — useCommandPalette('sidebar').

Типобезопасные данные

Команда теперь дженерик — Command<T> — с полем data типа T. К команде можно прицепить произвольную типизированную нагрузку, и тип протянется насквозь: через useRegisterCommands<T>, через SearchResult<T>, через стандартный поиск. По умолчанию T — это unknown, так что весь старый нетипизированный код продолжает работать как раньше, ничего не замечая.

ts Copy
interface UserData { id: number; email: string }

useRegisterCommands<UserData>([
  { id: 'ada', label: 'Ada Lovelace', data: { id: 1, email: 'ada@example.com' }, perform: () => {} },
])

// @ts-expect-error — data проверяется по UserData
const bad: Command<UserData> = { id: 'x', label: 'X', data: { wrong: true }, perform: () => {} }

Полностью сквозным generic делать не стал — внутри реактивного стора лежат команды разных типов, поэтому в слотах command типизирован как Command с data: unknown; там, где нужна нагрузка, её сужают кастом или гардом. Это осознанный размен: типобезопасность на границе регистрации, где она реально полезна, без переусложнения всего реактивного ядра.

И ещё по мелочи — но мелочи нужные

  • Frecency. С frecency: true палитра запоминает, как часто и как недавно ты запускаешь каждую команду, и добавляет к её скору бонус — частые команды всплывают наверх сами. Бонус сочетает частоту (счётчик запусков) с давностью (затухает примерно за 30 дней) и никогда не перебивает точное совпадение: он только тасует команды сравнимой релевантности. Статистика — в localStorage, без бэкенда.
  • Бейджи. Метка рядом с командой: badge: 'New' или с цветом — badge: { text: 'Pro', color: '#7c3aed' }.
  • Disabled с причиной. disabledReason показывается тултипом на отключённой команде. А опция showDisabled меняет саму стратегию: вместо того чтобы прятать отключённые команды, она оставляет их в выдаче — приглушёнными, некликабельными и опущенными в самый низ. Полезно, когда важно показать, что действие существует, но сейчас недоступно (и почему).
  • Глобальный async-поиск. Кроме onSearch на уровне группы, есть onSearch на уровне плагина — единый источник результатов поверх обычного поиска, удобно подключить один эндпоинт «искать везде».
  • Локализация. Все строки UI — заголовки секций, кнопки подтверждения, ARIA-метки, текст счётчика результатов — переопределяются через проп labels.
  • Группировка недавних. group-recent кластеризует секцию «Недавние» по группам с под-заголовками.
  • Доступность. role="dialog", aria-activedescendant, focus-trap с возвратом фокуса на элемент, с которого открывали, скрытый aria-live со счётчиком результатов для скринридеров, поддержка prefers-reduced-motion.
  • Адаптив и тач. На узких экранах диалог во всю ширину с крупными тач-таргетами, превью прячется, а свайп вправо во вложенном меню возвращает назад.
  • Зарезервированная колонка под шеврон. Мелочь, но из тех, что цепляют глаз: справа у каждой строки теперь фиксированная колонка под шеврон . Сам шеврон рисуется только у команд-групп, но место зарезервировано у всех — поэтому правая кромка списка ровная, и шортткаты выстраиваются в одну вертикаль.

Всё это — одна peer-зависимость (Vue 3) и около 11 КБ gzip.

Что починилось по дороге

Самая честная часть. Пакет рос итерациями, и почти каждая новая фича вытаскивала на свет грабли — ровно те, на которые наступает любая палитра, когда из демки превращается в инструмент.

  • Глобальные хоткеи срабатывали при наборе текста. Клавиши-последовательности (gh) ловились, даже когда пользователь печатал в обычном <input> где-то на странице. Добавил guard на цель события: в полях ввода такие хоткеи игнорируются, а Cmd/Ctrl-комбинации (вроде открытия палитры) продолжают работать.
  • Клавиатура и виртуальный список не дружили. Для больших выдач есть виртуализация. Активный элемент при навигации стрелками мог оказаться вне отрисованного окна — и стрелки «уезжали» в пустоту, потому что прокрутки к активному элементу не происходило. Прокинул индекс активного элемента в виртуальный список и сделал автоскролл к нему.
  • Подсветка ехала на диакритике. Индексы совпавших букв считались в нормализованной строке (где é уже стало e), а резалась исходная — и на словах вроде Café подчёркивались не те символы. Теперь диапазоны корректно переводятся из нормализованного пространства обратно в исходные индексы.
  • Loading затирал весь список. Пока подгружались асинхронные результаты, список целиком заменялся на «Loading…» — и на каждый введённый символ всё мигало: список пропал, спиннер, список вернулся. Переделал: уже найденное остаётся на экране, а в углу поля крутится ненавязчивый спиннер; полноэкранный «Loading…» показывается только когда показать пока вообще нечего.
  • description давал ложные совпадения. Тот самый случай с error, находящимся в «Charts, metrics and reports». Ограничил совпадение по описанию подстрокой — мусор ушёл, осмысленные совпадения остались.
  • Активный элемент не совпадал с превью при группировке. Список рисуется в порядке групп (по приоритету), а массив результатов отсортирован по релевантности — и activeIndex указывал в этот отсортированный массив, то есть на другую команду. Из-за этого боковая панель показывала данные не того элемента, а Enter мог запустить не то. Ввёл отдельный «порядок ровно как на экране» и индексирую активный элемент по нему.
  • Глубокая вложенность выкидывала в корень. Команду для текущего вложенного уровня искал только среди верхнеуровневых — а сабкоманды там не лежат. Git → Branches падало обратно в корневое меню. Заменил на рекурсивный поиск команды по всему дереву.
  • Скролл прыгал наверх при возврате. Backspace из вложенного меню восстанавливал позицию выбора, но не подскролливал к ней — список показывался с самого верха. Теперь возврат прокручивает список к команде, из которой ты заходил.
  • Команды из вложенных групп не попадали в «Недавние». Выполнил, например, «Light» внутри «Change Theme» — а в недавних её нет. Причина та же: недавние резолвились только среди верхнеуровневых команд, а вложенные leaf-команды туда не входят. Перевёл резолвинг недавних и закреплённых на тот же рекурсивный поиск по дереву.
  • Производительность поиска. Поиск гонялся дважды на каждый ввод (компонент и контекст считали одно и то же), а группа результату назначалась за O(результаты × группы). Свёл к одному прогону и предварительному индексу id → группа — теперь O(1) на результат.
  • Фокус не возвращался при закрытии. По стандарту модальных окон после закрытия фокус должен вернуться на элемент, с которого его открыли. Добавил и полноценный focus-trap внутри диалога, и возврат фокуса наружу.

Сценарий 1: админка / SaaS-дашборд

Палитра как основной способ перемещаться и действовать. Разделы — в группы («Navigation», «Settings», «Billing»), frecency: true — чтобы команды, которыми пользуешься каждый день, всплывали первыми сами, без ручной настройки порядка. Дежурные операции — в закреплённые, чтобы всегда были наверху.

У сущностей — вторичные действия (actions): на команде «Project Acme» по Enter открываешь проект, по Tab — меню «продублировать / архивировать / удалить». Боковая панель preview с полем info показывает детали выбранной записи прямо в палитре — статус, владельца, дату — не уводя со страницы. Для опасных операций — confirm, чтобы случайный Enter не снёс данные. Для действий, доступных не всем, — enabled: () => user.isAdmin плюс disabledReason и showDisabled: команда видна, но приглушена, и тултип объясняет, почему сейчас недоступна.

Сценарий 2: документация / база знаний

Палитра как глобальный поиск по всему контенту. Префиксные режимы делят источники: пусто — поиск по страницам, # — по заголовкам внутри страниц, @ — по API-символам. Каждый режим со своим асинхронным onSearch, который ходит в индекс (Algolia, Meilisearch, свой бэкенд — без разницы). Плюс глобальный onSearch на уровне плагина как единый «искать везде» поверх локальных команд навигации.

Команды-страницы (page) — для разделов, где результаты подгружаются по мере ввода: ввёл пару букв, увидел подсказки с сервера. А getMatchRanges подсвечивает совпадения даже в этих серверных результатах, так что выдача выглядит цельно, без «здесь подсвечено, а здесь нет».

Сценарий 3: редактор / дизайн-инструмент

Палитра как «второй мозг» приложения. Глубоко вложенные меню для иерархичных действий: File → Export As → PDF / PNG / SVG, Insert → Shape → …. Реальные хоткеи через bindShortcuts — чтобы Cmd + S, Cmd + B, Cmd + D работали глобально, а в палитре оставались подсказками рядом с командами. Мультивыбор (selectable) для групповых операций: открыл палитру в режиме выбора, отметил Enter’ом несколько слоёв или ассетов, ⌘ + Enter — применил действие ко всем сразу.

И несколько именованных инстансов: основная командная палитра на Cmd + K со всеми действиями редактора и отдельный «go to anything» на Cmd + P — быстрый прыжок к файлу или артборду, со своим набором и своим состоянием.

Итог

За второй заход палитра из «открыть поиск по команде» превратилась в инструмент, на котором можно собрать настоящий центр управления приложением — с вторичными действиями, режимами, предпросмотром и групповыми операциями. И всё это по-прежнему одна peer-зависимость (Vue 3), около 11 КБ gzip, полные типы, 121 тест и внимание к мелочам вроде фокуса, скролла и доступности — то есть к ровно тем местам, на которых обычно и спотыкаешься, когда пишешь такое сам.

Пакет — @macrulez/vue-command-palette, лицензия MIT:

bash Copy
npm install @macrulez/vue-command-palette

Читать далее

28.05.2026

vue-image-kit — полный toolkit для изображений во Vue 3

Один пакет закрывает всё что нужно для работы с изображениями: ленивая загрузка через IntersectionObserver, пять видов плейсхолдеров (ThumbHash / BlurHash / LQIP / dominant color / shimmer), focal point, WebP/AVIF, srcset с width- и density-дескрипторами, art direction, retry с backoff, CDN-адаптеры для 12 провайдеров, build-time импорты через Vite-плагин, кодирование плейсхолдеров прямо в браузере, CLI для обработки на сборке, Nuxt-модуль. Ноль runtime-зависимостей, tree-shakeable.

Метки
vue3imagesperformancenuxttypescript