<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>
用法
import { CommandPalette } from '@hina-ui/vue'
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>
示例
分组与说明
分组各有标题,条目的 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>
图标与按键提示
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>
全局快捷键
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>
受控
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>
自定义过滤
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>
内联
设置 inline 后面板不再包进浮层,直接渲染在文档流里,适合嵌在页面中而不是由快捷键唤起。内联形态不注册全局快捷键,选中条目之后面板停在原地。
<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>
搜索建议
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>
服务端搜索
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>
自定义条目内容
#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,书名不标出。
<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>
自定义输入行
#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>
虚拟滚动
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>
行为
- 打开后焦点落在输入框,首个条目自动高亮;输入时列表即时过滤,标签中匹配的片段以强调色标出。
- 匹配按标签全等、标签开头、标签包含、关键词、说明的顺序排列;有查询时,含最佳匹配的分组排在前面。超出可见高度的条目在列表内滚动。
- 方向键移动高亮,回车选中高亮项,鼠标悬停也会移动高亮。
- 按 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
}
type VirtualizeOptions = boolean | { estimateSize?: number; overscan?: number }