CommandPalette 命令面板

以快捷键唤起的搜索面板,输入后从命令与页面中选择。

<script setup lang="ts">
  import { Bookmark, Home, Library, LogOut, Moon, PenLine, Search, Settings } from '@lucide/vue'
  import { ref } from 'vue'
  import { Button, CommandPalette, Kbd, Stack, Text, type CommandItems } from '@hina-ui/vue'

  const picked = ref('')

  const items: CommandItems = [
    {
      label: '页面',
      items: [
        { id: 'home', label: '首页', icon: Home, keywords: ['home'] },
        { id: 'library', label: '书架', icon: Library, keywords: ['library'] },
        { id: 'bookmarks', label: '收藏', icon: Bookmark, keywords: ['bookmark'] },
        { id: 'settings', label: '设置', icon: Settings, keywords: ['settings'], kbd: ['⌘', ','] },
      ],
    },
    {
      label: '操作',
      items: [
        { id: 'review', label: '新建书评', icon: PenLine, description: '记录一本刚读完的书' },
        { id: 'theme', label: '切换主题', icon: Moon, kbd: ['⌘', 'D'] },
        { id: 'logout', label: '退出登录', icon: LogOut },
      ],
    },
  ]
</script>

<template>
  <Stack gap="md" align="start">
    <CommandPalette :items="items" hotkey="mod+j" @select="item => (picked = item.label)">
      <Button variant="outline" tone="neutral">
        <template #icon><Search /></template>
        搜索
        <Kbd>⌘J</Kbd>
      </Button>
    </CommandPalette>
    <Text v-if="picked" size="sm" tone="muted">已选择:{{ picked }}</Text>
  </Stack>
</template>
vue

用法

import { CommandPalette } from '@hina-ui/vue'
ts

items 是条目列表。每个条目至少有 id 与 label,可以带 description、keywords、icon、kbd 与 onSelect;带有 label 与 items 的对象是一个分组。默认插槽是触发器。选中条目时先调用该条目的 onSelect,再触发 select 事件,然后关闭面板。

<script setup lang="ts">
  import { ref } from 'vue'
  import { Button, CommandPalette, Stack, Text, type CommandItems } from '@hina-ui/vue'

  const picked = ref('')

  const items: CommandItems = [
    { id: 'home', label: '首页' },
    { id: 'library', label: '书架' },
    { id: 'bookmarks', label: '收藏' },
    { id: 'settings', label: '设置' },
  ]
</script>

<template>
  <Stack gap="md" align="start">
    <CommandPalette :items="items" @select="item => (picked = item.label)">
      <Button variant="outline" tone="neutral">打开面板</Button>
    </CommandPalette>
    <Text v-if="picked" size="sm" tone="muted">已选择:{{ picked }}</Text>
  </Stack>
</template>
vue

示例

分组与说明

分组各有标题,条目的 description 显示在标签下方。没有查询时分组按给定顺序排列,有查询时含最佳匹配的分组靠前,没有匹配条目的分组不显示。

<script setup lang="ts">
  import { Button, CommandPalette, type CommandItems } from '@hina-ui/vue'

  const items: CommandItems = [
    {
      label: '最近阅读',
      items: [
        { id: 'book-1', label: '星之继承者', description: '詹姆斯·P·霍根' },
        { id: 'book-2', label: '海伯利安', description: '丹·西蒙斯' },
      ],
    },
    {
      label: '书单',
      items: [
        { id: 'list-1', label: '今年想读', description: '12 本' },
        { id: 'list-2', label: '硬科幻入门', description: '8 本' },
      ],
    },
  ]
</script>

<template>
  <CommandPalette :items="items" placeholder="搜索书与书单">
    <Button variant="outline" tone="neutral">搜索</Button>
  </CommandPalette>
</template>
vue

图标与按键提示

icon 显示在标签前,kbd 是显示在行末的按键提示,只用于提示,面板不会替你绑定这些按键。

<script setup lang="ts">
  import { Copy, Moon, PenLine, Trash2 } from '@lucide/vue'
  import { Button, CommandPalette, type CommandItems } from '@hina-ui/vue'

  const items: CommandItems = [
    { id: 'review', label: '新建书评', icon: PenLine, kbd: ['⌘', 'N'] },
    { id: 'copy', label: '复制链接', icon: Copy, kbd: ['⌘', 'C'] },
    { id: 'theme', label: '切换主题', icon: Moon, kbd: ['⌘', 'D'] },
    { id: 'delete', label: '删除书评', icon: Trash2, disabled: true },
  ]
</script>

<template>
  <CommandPalette :items="items">
    <Button variant="outline" tone="neutral">操作</Button>
  </CommandPalette>
</template>
vue

全局快捷键

hotkey 接受 mod+k 这样的组合,mod 对应 Mac 的 ⌘ 与其他平台的 Ctrl,还可以加上 shift 与 alt。面板打开时再按一次会关闭。

也可以按 ⌘⇧P 打开

<script setup lang="ts">
  import { Button, CommandPalette, Inline, Kbd, Text, type CommandItems } from '@hina-ui/vue'

  const items: CommandItems = [
    { id: 'home', label: '首页' },
    { id: 'library', label: '书架' },
    { id: 'settings', label: '设置' },
  ]
</script>

<template>
  <Inline gap="md" align="center">
    <CommandPalette :items="items" hotkey="mod+shift+p">
      <Button variant="outline" tone="neutral">搜索</Button>
    </CommandPalette>
    <Text size="sm" tone="muted">
      也可以按
      <Kbd>⌘</Kbd>
      <Kbd>⇧</Kbd>
      <Kbd>P</Kbd>
      打开
    </Text>
  </Inline>
</template>
vue

受控

open 支持双向绑定。省略默认插槽时不渲染触发器,面板只能从外部打开。

已关闭

<script setup lang="ts">
  import { ref } from 'vue'
  import { Button, CommandPalette, Inline, Text, type CommandItems } from '@hina-ui/vue'

  const open = ref(false)

  const items: CommandItems = [
    { id: 'home', label: '首页' },
    { id: 'library', label: '书架' },
    { id: 'settings', label: '设置' },
  ]
</script>

<template>
  <Inline gap="md" align="center">
    <Button variant="outline" tone="neutral" @click="open = true">从外部打开</Button>
    <Text size="sm" tone="muted">{{ open ? '已打开' : '已关闭' }}</Text>
    <CommandPalette v-model:open="open" :items="items" />
  </Inline>
</template>
vue

自定义过滤

search 支持双向绑定。设置 ignoreFilter 后面板不再自行过滤,条目列表完全由调用方决定,例如向服务端搜索。

<script setup lang="ts">
  import { computed, ref } from 'vue'
  import { Button, CommandPalette, type CommandItems } from '@hina-ui/vue'

  const search = ref('')

  const books = [
    { id: 'b1', label: '星之继承者', tags: ['科幻', '硬科幻'] },
    { id: 'b2', label: '海伯利安', tags: ['科幻', '太空歌剧'] },
    { id: 'b3', label: '基地', tags: ['科幻', '经典'] },
    { id: 'b4', label: '三体', tags: ['科幻', '中文'] },
  ]

  const items = computed<CommandItems>(() => {
    const query = search.value.trim()
    return books
      .filter(book => !query || book.tags.some(tag => tag.includes(query)))
      .map(book => ({ id: book.id, label: book.label, description: book.tags.join('、') }))
  })
</script>

<template>
  <CommandPalette v-model:search="search" :items="items" ignore-filter placeholder="按标签搜索">
    <Button variant="outline" tone="neutral">按标签搜索</Button>
  </CommandPalette>
</template>
vue

内联

设置 inline 后面板不再包进浮层,直接渲染在文档流里,适合嵌在页面中而不是由快捷键唤起。内联形态不注册全局快捷键,选中条目之后面板停在原地。

页面
首页
书架
收藏
设置⌘,
操作
新建书评记录一本刚读完的书
切换主题⌘D
<script setup lang="ts">
  import { Bookmark, Home, Library, Moon, PenLine, Settings } from '@lucide/vue'
  import { CommandPalette, type CommandItems } from '@hina-ui/vue'

  const items: CommandItems = [
    {
      label: '页面',
      items: [
        { id: 'home', label: '首页', icon: Home },
        { id: 'library', label: '书架', icon: Library },
        { id: 'bookmarks', label: '收藏', icon: Bookmark },
        { id: 'settings', label: '设置', icon: Settings, kbd: ['⌘', ','] },
      ],
    },
    {
      label: '操作',
      items: [
        { id: 'review', label: '新建书评', icon: PenLine, description: '记录一本刚读完的书' },
        { id: 'theme', label: '切换主题', icon: Moon, kbd: ['⌘', 'D'] },
      ],
    },
  ]
</script>

<template>
  <CommandPalette inline :items="items" class="max-w-sm" />
</template>
vue

搜索建议

closeOnSelect 默认为 true,选中条目后关闭面板;设为 false 后选中条目不关闭面板。条目自身的 closeOnSelect 优先于组件属性,内联面板始终不关闭。面板保持打开时搜索内容保留,焦点回到输入框。

示例在搜索词为空时列出最近搜索与热门搜索。这些条目设置 closeOnSelect: false,并在 onSelect 中把自身的文字写入 v-model:search 绑定的搜索词;也可以在 select 事件中写入。有搜索词后列出的结果选中时关闭面板。

#heading 插槽替换分组标题行的内容,参数是 { group }。标题行位于选项列表之外,其中的按钮可以用 Tab 到达,点击时焦点留在输入框;分组的无障碍名仍是 group.label,设置 virtualize 时同样生效。示例在「最近搜索」的标题行右侧放了清除按钮。

<script setup lang="ts">
  import { computed, ref } from 'vue'
  import { Button, CommandPalette, type CommandItem, type CommandItems } from '@hina-ui/vue'

  const search = ref('')

  const recent = ref(['狼与香辛料', '时雨泽惠一'])
  const hot = ['冰菓', '文学少女']

  const books: CommandItem[] = [
    { id: 'spice', label: '狼与香辛料', description: '支仓冻砂' },
    { id: 'kino', label: '奇诺之旅', description: '时雨泽惠一' },
    { id: 'hyouka', label: '冰菓', description: '米泽穗信' },
    { id: 'book-girl', label: '文学少女', description: '野村美月' },
  ]

  function suggest(text: string): CommandItem {
    return { id: text, label: text, closeOnSelect: false, onSelect: () => (search.value = text) }
  }

  const items = computed<CommandItems>(() =>
    search.value.trim()
      ? books
      : [
          ...(recent.value.length ? [{ label: '最近搜索', items: recent.value.map(suggest) }] : []),
          { label: '热门搜索', items: hot.map(suggest) },
        ],
  )
</script>

<template>
  <CommandPalette v-model:search="search" :items="items" placeholder="搜索书名或作者">
    <Button variant="outline" tone="neutral">搜索书籍</Button>
    <template #heading="{ group }">
      {{ group.label }}
      <Button
        v-if="group.label === '最近搜索'"
        size="xs"
        variant="ghost"
        tone="neutral"
        @click="recent = []"
      >
        清除
      </Button>
    </template>
  </CommandPalette>
</template>
vue

服务端搜索

loading 表示正在等待结果。没有条目时列表区显示加载内容;已有条目时列表下方出现一行状态,条目仍可选中。

#loading 插槽替换加载内容。#empty 插槽替换没有条目且不在加载时的内容,参数是 { search },search 是当前搜索词。两者的默认文字取自界面语言。无结果的提示与搜索服务不可用的提示都放在 #empty 中,显示哪一个由调用方的状态决定。

示例设置 ignoreFilter,用定时器模拟服务端搜索,Switch 模拟搜索服务不可用。

输入书名搜索
<script setup lang="ts">
  import { ref, watch } from 'vue'
  import { CommandPalette, Stack, Switch, type CommandItem } from '@hina-ui/vue'

  const books: CommandItem[] = [
    { id: 'spice', label: '狼与香辛料', description: '支仓冻砂' },
    { id: 'kino', label: '奇诺之旅', description: '时雨泽惠一' },
    { id: 'hyouka', label: '冰菓', description: '米泽穗信' },
    { id: 'book-girl', label: '文学少女', description: '野村美月' },
  ]

  const keyword = ref('')
  const offline = ref(false)
  const items = ref<CommandItem[]>([])
  const loading = ref(false)
  const failed = ref(false)

  watch([keyword, offline], ([text, down], _, onCleanup) => {
    const query = text.trim()
    failed.value = false
    if (!query) {
      items.value = []
      loading.value = false
      return
    }
    loading.value = true
    const timer = setTimeout(() => {
      items.value = down ? [] : books.filter(book => book.label.includes(query))
      failed.value = down
      loading.value = false
    }, 600)
    onCleanup(() => clearTimeout(timer))
  })
</script>

<template>
  <Stack align="start" class="w-full max-w-sm">
    <Switch v-model="offline">模拟服务不可用</Switch>
    <CommandPalette
      v-model:search="keyword"
      inline
      ignore-filter
      :items="items"
      :loading="loading"
      placeholder="试试「狼」或「少女」"
    >
      <template #loading>正在搜索</template>
      <template #empty="{ search }">
        {{
          failed
            ? '搜索服务暂时不可用'
            : search.trim()
              ? `没有找到「${search.trim()}」`
              : '输入书名搜索'
        }}
      </template>
    </CommandPalette>
  </Stack>
</template>
vue

自定义条目内容

#item 插槽替换每个条目行内的内容,默认的图标、标签、说明与按键提示不再渲染。行本身仍是列表选项,高亮、键盘导航、disabled 与选中不变,设置 virtualize 时同样生效。

插槽参数是 { item, match }。match 是搜索词在 item.label 中命中的范围 { start, end };搜索词为空、条目只经 keywords 或 description 命中、或者设置了 ignoreFilter 时为 null。

条目的 data 携带业务数据。组件从 items 推断它的类型,#item 插槽与 select 事件收到的 item.data 保留该类型;可用 CommandItems<Book> 声明条目列表。示例把作者写进 keywords,按作者搜索时 match 为 null,书名不标出。

狼与香辛料支仓冻砂 · 17 卷
奇诺之旅时雨泽惠一 · 23 卷
冰菓米泽穗信 · 6 卷
文学少女野村美月 · 8 卷
<script setup lang="ts">
  import { CommandPalette, Mark, Text, type CommandItems } from '@hina-ui/vue'

  interface Book {
    id: string
    title: string
    author: string
    volumes: number
  }

  const books: Book[] = [
    { id: 'spice', title: '狼与香辛料', author: '支仓冻砂', volumes: 17 },
    { id: 'kino', title: '奇诺之旅', author: '时雨泽惠一', volumes: 23 },
    { id: 'hyouka', title: '冰菓', author: '米泽穗信', volumes: 6 },
    { id: 'book-girl', title: '文学少女', author: '野村美月', volumes: 8 },
  ]

  const items: CommandItems<Book> = books.map(book => ({
    id: book.id,
    label: book.title,
    keywords: [book.author],
    data: book,
  }))
</script>

<template>
  <CommandPalette inline :items="items" placeholder="搜索书名或作者" class="max-w-sm">
    <template #item="{ item, match }">
      <Text as="span" size="sm" truncate class="min-w-0 flex-1">
        <template v-if="match">
          <Text as="span" size="inherit">{{ item.label.slice(0, match.start) }}</Text>
          <Mark>{{ item.label.slice(match.start, match.end) }}</Mark>
          <Text as="span" size="inherit">{{ item.label.slice(match.end) }}</Text>
        </template>
        <template v-else>{{ item.label }}</template>
      </Text>
      <Text as="span" size="xs" tone="muted" class="shrink-0">
        {{ item.data?.author }} · {{ item.data?.volumes }} 卷
      </Text>
    </template>
  </CommandPalette>
</template>
vue

自定义输入行

#input 插槽替换整个输入行,包括搜索图标与输入框;默认输入行的高度、内边距与分隔线一并移除。插槽内放且只放一个 CommandPaletteInput,它保留过滤、键盘导航、自动聚焦与 aria-label,并沿用面板的 placeholder。

书库
狼与香辛料支仓冻砂
奇诺之旅时雨泽惠一
冰菓米泽穗信
文学少女野村美月
<script setup lang="ts">
  import { CommandPalette, CommandPaletteInput, Inline, Tag, type CommandItems } from '@hina-ui/vue'

  const items: CommandItems = [
    { id: 'spice', label: '狼与香辛料', description: '支仓冻砂' },
    { id: 'kino', label: '奇诺之旅', description: '时雨泽惠一' },
    { id: 'hyouka', label: '冰菓', description: '米泽穗信' },
    { id: 'book-girl', label: '文学少女', description: '野村美月' },
  ]
</script>

<template>
  <CommandPalette inline :items="items" class="max-w-sm">
    <template #input>
      <Inline gap="sm" :wrap="false" class="border-line h-12 shrink-0 border-b px-4">
        <Tag>书库</Tag>
        <CommandPaletteInput placeholder="在书库中搜索" />
      </Inline>
    </template>
  </CommandPalette>
</template>
vue

虚拟滚动

virtualize 按需渲染可见范围附近的条目,与 VirtualList 共用测量与滚动底层。默认关闭;可传 { estimateSize, overscan } 调整预估行高和两侧预渲染数量,行高会按实际内容测量。键盘导航覆盖完整数据,禁用项会跳过。 搜索仍处理完整数据。 命令离开渲染范围后会卸载,持久状态应按命令 id 保存在外部。

已选: —

<script setup lang="ts">
  import { ref } from 'vue'
  import { CommandPalette, Stack, Text } from '@hina-ui/vue'

  const selected = ref('—')
  const items = Array.from({ length: 10000 }, (_, index) => ({
    id: String(index),
    label: `条目 ${String(index + 1).padStart(5, '0')}`,
    keywords: [`id-${index}`],
  }))
</script>

<template>
  <Stack gap="sm" class="w-96 max-w-full">
    <CommandPalette
      :items="items"
      :virtualize="{ estimateSize: 36, overscan: 6 }"
      inline
      aria-label="一万项"
      @select="selected = $event.label"
    />
    <Text size="sm" tone="muted">已选: {{ selected }}</Text>
  </Stack>
</template>
vue

行为

  • 打开后焦点落在输入框,首个条目自动高亮;输入时列表即时过滤,标签中匹配的片段以强调色标出。
  • 匹配按标签全等、标签开头、标签包含、关键词、说明的顺序排列;有查询时,含最佳匹配的分组排在前面。超出可见高度的条目在列表内滚动。
  • 方向键移动高亮,回车选中高亮项,鼠标悬停也会移动高亮。
  • 按 Esc 或点击遮罩关闭面板。选中条目默认也关闭面板;closeOnSelect 为 false 时面板保持打开,搜索内容保留,焦点回到输入框。关闭时清空搜索内容。
  • loading 时没有条目则列表区显示加载内容,有条目则列表下方显示一行状态,条目仍可选中。
  • 面板打开期间页面停止滚动,焦点限制在面板内,关闭后回到触发器。
  • 宽屏上面板停靠在视口上部,窄屏上贴顶并占满宽度。

无障碍

  • 面板是对话框,无障碍名取 label,默认为界面语言中的“命令面板”。
  • 输入框通过 aria-activedescendant 指向当前高亮的条目;列表使用 listbox 与 option 角色,分组带有各自的名称。
  • 图标对辅助技术隐藏,按键提示以 kbd 元素呈现。
  • 加载内容与空内容的容器带有 role="status";loading 时列表带有 aria-busy。

API

Props

T 是条目 data 的类型,从 items 推断,默认是 unknown。

属性
类型
默认值
说明
items
CommandItems<T>
—
必填。条目与分组
virtualize
VirtualizeOptions
false
虚拟滚动;预估行高按内容,overscan 6
placeholder
string
取自界面语言
输入框的占位文字
label
string
取自界面语言
面板的无障碍名
hotkey
string
—
全局快捷键,例如 mod+k
ignoreFilter
boolean
false
不自行过滤,条目列表由调用方决定
loading
boolean
false
显示加载内容,列表带有 aria-busy
closeOnSelect
boolean
true
选中条目后关闭面板;条目的 closeOnSelect 优先
inline
boolean
false
渲染为内联面板,不使用浮层
class
string
—
追加至面板的类名

双向绑定

名称
类型
说明
open
boolean
面板是否打开
search
string
输入框中的搜索词

事件

事件
参数
说明
select
(item: CommandItem<T>)
选中条目时触发

插槽

插槽
参数
说明
default
—
触发器
item
{ item: CommandItem<T>, match: CommandItemMatch | null }
条目行内的内容,替换默认的图标、标签、说明与按键提示
heading
{ group: CommandGroup<T> }
分组标题行的内容,替换默认的分组名
input
—
输入行,替换搜索图标与输入框
loading
—
加载内容,默认文字取自界面语言
empty
{ search: string }
没有条目且不在加载时的内容,默认文字取自界面语言

CommandPaletteInput

替换输入行时使用的输入框。只能在 CommandPalette 内使用,在外部使用时抛出错误。

属性
类型
默认值
说明
placeholder
string
面板的 placeholder
占位文字,优先于面板的 placeholder
class
string
—
追加至输入框的类名

其他属性透传到原生 input。

类型

字段
类型
说明
id
string
必填。条目的唯一标识
label
string
必填。标签
description
string
标签下方的说明,也参与匹配
keywords
string[]
参与匹配但不显示的关键词
icon
Component
标签前的图标
kbd
string[]
行末的按键提示
disabled
boolean
不可选中
closeOnSelect
boolean
选中后是否关闭面板,优先于组件的 closeOnSelect
data
T
随条目携带的数据,不参与匹配
onSelect
() => void
选中时调用

分组是 { label: string; items: CommandItem<T>[] },CommandItems<T> 是条目与分组的数组。

同时导出 CommandItem<T>、CommandGroup<T>、CommandItems<T>、CommandItemMatch、CommandItemSlotProps<T>、CommandHeadingSlotProps<T> 和 CommandEmptySlotProps 类型。

interface CommandItemMatch {
  start: number
  end: number
}
ts
type VirtualizeOptions = boolean | { estimateSize?: number; overscan?: number }
ts