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 init
pnpm dlx ermineui init
yarn dlx ermineui init
bunx ermineui init

Then add the component:

npx ermineui add command
pnpm dlx ermineui add command
yarn dlx ermineui add command
bunx ermineui add command

That 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

FileWhere it goes
CommandGroup.vuesrc/components/ui/command/CommandGroup.vue
CommandItem.vuesrc/components/ui/command/CommandItem.vue
CommandPalette.vuesrc/components/ui/command/CommandPalette.vue
context.tssrc/components/ui/command/context.ts
index.tssrc/components/ui/command/index.ts
context.ts helpersrc/lib/ermine/context.ts
layer.ts helpersrc/lib/ermine/layer.ts
primitive.ts helpersrc/lib/ermine/primitive.ts
scroll-lock.ts helpersrc/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'