Command palette
A search box over a list of actions, opened with a shortcut, for running anything by name.
Install
If this is the first component in the project, set up once. This writes ermine.json and copies the theme stylesheet. The installation guide covers Vue, Nuxt and Astro.
npx ermineui initpnpm dlx ermineui inityarn dlx ermineui initbunx ermineui initThen add the component:
npx ermineui add commandpnpm dlx ermineui add commandyarn dlx ermineui add commandbunx ermineui add commandThat copies 5 files into src/components/ui/command/, 4 helpers into src/lib/ermine/. Files you already have are kept.
Use
import { CommandGroup, CommandItem, CommandPalette } from '@/components/ui/command'<CommandPalette v-model:open="open"> <!-- ⌘K / Ctrl+K toggles it; :shortcut="false" turns that off -->
<CommandGroup heading="Project">
<CommandItem keywords="env secrets" shortcut="⌘E" @select="openEnv">Environment variables</CommandItem>
</CommandGroup>
</CommandPalette>In Nuxt, with the auto-import setting, the tags work in templates without the import. In Astro, use it inside a Vue island.
What gets copied
| File | Where it goes |
|---|---|
CommandGroup.vue | src/components/ui/command/CommandGroup.vue |
CommandItem.vue | src/components/ui/command/CommandItem.vue |
CommandPalette.vue | src/components/ui/command/CommandPalette.vue |
context.ts | src/components/ui/command/context.ts |
index.ts | src/components/ui/command/index.ts |
context.ts helper | src/lib/ermine/context.ts |
layer.ts helper | src/lib/ermine/layer.ts |
primitive.ts helper | src/lib/ermine/primitive.ts |
scroll-lock.ts helper | src/lib/ermine/scroll-lock.ts |
The folders are the suggested ones. ermine.json says where yours are.
Source
This is exactly what the command copies. To install by hand, create each file below and the helpers listed above.
CommandGroup.vue 23 lines
<script setup lang="ts">
import { computed, useId } from 'vue'
import { provideCommandGroup, useCommand } from './context'
defineProps<{
heading: string
}>()
const command = useCommand()
const id = useId()
provideCommandGroup(id)
// A group with nothing left to show takes its heading away with it.
const visible = computed(() => [...command.entries.values()].some((entry) => entry.group === id && command.matches(entry.text)))
</script>
<template>
<div v-show="visible" role="group" :aria-labelledby="`${id}-heading`">
<div :id="`${id}-heading`" class="er-command-heading">{{ heading }}</div>
<slot />
</div>
</template>CommandItem.vue 58 lines
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, onUpdated, ref, useId } from 'vue'
import { useCommand, useCommandGroup } from './context'
const props = defineProps<{
disabled?: boolean
/** Extra words this item can be found by, on top of its own text: "theme appearance dark". */
keywords?: string
/** Key hint shown at the right-hand end, e.g. "⌘S". For show only — bind the key yourself. */
shortcut?: string
}>()
const emit = defineEmits<{
select: []
}>()
const command = useCommand()
const group = useCommandGroup()
const label = ref<HTMLElement | null>(null)
const id = useId()
const visible = computed(() => command.matches(command.entries.get(id)?.text ?? ''))
function register() {
command.entries.set(id, { text: `${label.value?.textContent?.trim() ?? ''} ${props.keywords ?? ''}`, group })
}
onMounted(register)
onUpdated(register)
onBeforeUnmount(() => command.entries.delete(id))
function run() {
if (props.disabled) return
emit('select')
command.ran()
}
</script>
<template>
<!-- Removed from the page while filtered out, so the arrow keys only ever land on what is showing. -->
<div
v-show="visible"
:id="id"
:role="visible ? 'option' : 'none'"
class="er-command-item"
:aria-selected="command.activeId.value === id"
:aria-disabled="disabled || undefined"
:data-disabled="disabled ? '' : undefined"
:data-active="command.activeId.value === id ? '' : undefined"
@click="run"
@pointermove="!disabled && (command.activeId.value = id)"
>
<slot name="icon" />
<span ref="label"><slot /></span>
<span v-if="shortcut" class="er-command-shortcut" aria-hidden="true">{{ shortcut }}</span>
</div>
</template>CommandPalette.vue 300 lines
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, useId, watch } from 'vue'
import { useLayer } from '../../lib/layer'
import { Portal } from '../../lib/primitive'
import { lockScroll } from '../../lib/scroll-lock'
import { provideCommand } from './context'
defineOptions({ inheritAttrs: false })
const props = withDefaults(
defineProps<{
/** Letter that opens the palette together with ⌘ (Mac) or Ctrl. Pass false for no shortcut. */
shortcut?: string | false
placeholder?: string
/** Accessible name of the palette. */
label?: string
/** Close after an item is chosen. */
closeOnSelect?: boolean
}>(),
{ shortcut: 'k', placeholder: 'Type a command or search…', label: 'Command palette', closeOnSelect: true },
)
const open = defineModel<boolean>('open', { default: false })
const content = ref<HTMLElement | null>(null)
const input = ref<HTMLInputElement | null>(null)
const list = ref<HTMLElement | null>(null)
const listId = useId()
const entries = reactive(new Map<string, { text: string; group?: string }>())
const activeId = ref<string>()
const text = ref('')
const query = computed(() => text.value.trim().toLowerCase())
// Every word typed has to appear somewhere, in any order: "dep prod" finds "Promote deployment to production".
const matches = (candidate: string) => {
const haystack = candidate.toLowerCase()
return query.value.split(/\s+/).every((word) => haystack.includes(word))
}
const empty = computed(() => ![...entries.values()].some((entry) => matches(entry.text)))
/** Said aloud while typing: the list changes on screen, but focus stays in the search box. */
const status = computed(() => {
if (!query.value) return ''
const count = [...entries.values()].filter((entry) => matches(entry.text)).length
return count === 0 ? 'No results' : `${count} ${count === 1 ? 'result' : 'results'}`
})
function items(): HTMLElement[] {
return Array.from(list.value?.querySelectorAll<HTMLElement>('[role="option"]:not([data-disabled])') ?? [])
}
function setActive(item: HTMLElement | undefined) {
activeId.value = item?.id
item?.scrollIntoView({ block: 'nearest' })
}
provideCommand({
entries,
matches,
activeId,
ran: () => {
if (props.closeOnSelect) open.value = false
},
})
watch(
open,
async (isOpen, _previous, onCleanup) => {
if (!isOpen) return
onCleanup(lockScroll())
text.value = ''
await nextTick()
setActive(items()[0])
},
{ immediate: true },
)
// Whatever is typed, the first result is ready to run with Enter.
watch(query, async () => {
await nextTick()
setActive(items()[0])
})
function onKeydown(event: KeyboardEvent) {
const all = items()
const current = all.findIndex((item) => item.id === activeId.value)
const last = all.length - 1
if (event.key === 'ArrowDown') setActive(all[current === last ? 0 : current + 1])
else if (event.key === 'ArrowUp') setActive(all[current <= 0 ? last : current - 1])
else if (event.key === 'Home') setActive(all[0])
else if (event.key === 'End') setActive(all[last])
else if (event.key === 'Enter') all[current]?.click()
else return
event.preventDefault()
}
function onShortcut(event: KeyboardEvent) {
if (!props.shortcut || event.key.toLowerCase() !== props.shortcut.toLowerCase()) return
if (!(event.metaKey || event.ctrlKey) || event.altKey || event.shiftKey) return
event.preventDefault()
open.value = !open.value
}
onMounted(() => document.addEventListener('keydown', onShortcut))
onBeforeUnmount(() => document.removeEventListener('keydown', onShortcut))
useLayer(content, open, {
modal: true,
loopFocus: true,
onDismiss: () => (open.value = false),
initialFocus: () => input.value,
})
</script>
<template>
<slot name="trigger" :open="() => (open = true)" />
<Portal>
<Transition name="er-command">
<div v-if="open" class="er-command-overlay">
<div ref="content" v-bind="$attrs" role="dialog" aria-modal="true" tabindex="-1" class="er-command" :aria-label="label">
<div class="er-command-search">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true">
<circle cx="11" cy="11" r="7" />
<path d="m20 20-3.5-3.5" />
</svg>
<input
ref="input"
v-model="text"
type="text"
role="combobox"
autocomplete="off"
spellcheck="false"
aria-autocomplete="list"
aria-expanded="true"
:aria-controls="listId"
:aria-activedescendant="activeId"
:aria-label="label"
:placeholder="placeholder"
@keydown="onKeydown"
/>
</div>
<!-- Pressing an item must not take focus out of the search box. -->
<div :id="listId" ref="list" role="listbox" class="er-command-list" :aria-label="label" @mousedown.prevent>
<slot />
<div v-if="empty" class="er-command-empty"><slot name="empty">No results.</slot></div>
</div>
<span class="er-command-status" role="status">{{ status }}</span>
</div>
</div>
</Transition>
</Portal>
</template>
<style>
@layer components {
.er-command-overlay {
position: fixed;
inset: 0;
z-index: var(--er-z-overlay);
display: grid;
/* Sits in the upper part of the window, so the list can grow downward without the box jumping. */
align-items: start;
justify-items: center;
padding: min(15vh, 8rem) 1rem 1rem;
background: var(--er-overlay);
}
.er-command {
position: relative;
display: flex;
flex-direction: column;
width: 100%;
max-width: 34rem;
max-height: min(26rem, 100%);
border: 1px solid var(--er-border);
border-radius: var(--er-radius-lg);
background: var(--er-popover);
color: var(--er-popover-foreground);
box-shadow: var(--er-shadow-lg);
outline: none;
overflow: hidden;
}
.er-command-search {
display: flex;
flex-shrink: 0;
align-items: center;
gap: 0.625rem;
padding: 0 0.875rem;
border-bottom: 1px solid var(--er-border);
color: var(--er-muted-foreground);
}
.er-command-search svg {
flex-shrink: 0;
width: 1rem;
height: 1rem;
}
.er-command-search input {
flex: 1;
min-width: 0;
height: 2.75rem;
border: 0;
background: transparent;
color: var(--er-foreground);
font-family: inherit;
font-size: 1rem;
outline: none;
}
.er-command-search input::placeholder {
color: var(--er-muted-foreground);
}
.er-command-list {
padding: 0.25rem;
font-size: 0.875rem;
overflow-y: auto;
overscroll-behavior: contain;
}
.er-command-heading {
padding: 0.5rem 0.5rem 0.25rem;
color: var(--er-muted-foreground);
font-size: 0.75rem;
font-weight: var(--er-weight-medium);
}
.er-command-item {
display: flex;
align-items: center;
gap: 0.625rem;
padding: 0.5rem;
border-radius: var(--er-radius-sm);
line-height: 1.25rem;
cursor: default;
user-select: none;
}
.er-command-item[data-active] {
background: var(--er-accent);
color: var(--er-accent-foreground);
}
.er-command-item[data-disabled] {
opacity: 0.5;
pointer-events: none;
}
.er-command-item svg {
flex-shrink: 0;
width: 1rem;
height: 1rem;
color: var(--er-muted-foreground);
}
.er-command-shortcut {
margin-left: auto;
padding-left: 1rem;
color: var(--er-muted-foreground);
font-size: 0.75rem;
letter-spacing: 0.08em;
}
.er-command-status {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.er-command-empty {
padding: 1.5rem 0.5rem;
color: var(--er-muted-foreground);
text-align: center;
}
.er-command-enter-active,
.er-command-leave-active,
.er-command-enter-active .er-command,
.er-command-leave-active .er-command {
transition:
opacity var(--er-duration) var(--er-ease),
transform var(--er-duration) var(--er-ease);
}
.er-command-enter-from,
.er-command-leave-to {
opacity: 0;
}
.er-command-enter-from .er-command,
.er-command-leave-to .er-command {
transform: scale(0.97) translateY(-4px);
}
}
</style>context.ts 21 lines
import { inject, provide, type InjectionKey, type Ref } from 'vue'
import { createContext } from '../../lib/context'
export interface CommandContext {
/** item id → what it is searched by, and the group it sits in (if any). Filled in by each CommandItem. */
entries: Map<string, { text: string; group?: string }>
/** Whether some text fits what has been typed. */
matches: (text: string) => boolean
/** id of the highlighted item. Focus stays in the search box; this is what the arrow keys move. */
activeId: Ref<string | undefined>
/** Called by an item when it is chosen. */
ran: () => void
}
export const [provideCommand, useCommand] = createContext<CommandContext>('CommandPalette')
// Items work with or without a group around them, so this one is allowed to be missing.
const groupKey: InjectionKey<string> = Symbol('CommandGroup')
export const provideCommandGroup = (id: string) => provide(groupKey, id)
export const useCommandGroup = () => inject(groupKey, undefined)index.ts 5 lines
/** Command palette — a search box over a list of actions, opened with a shortcut, for running anything by name. */
export { default as CommandGroup } from './CommandGroup.vue'
export { default as CommandItem } from './CommandItem.vue'
export { default as CommandPalette } from './CommandPalette.vue'