组件
命令菜单 Command
快速可组合的命令调色板与全局资源搜索列表,支持拼音与关键词模糊检索、全键盘上下导航、分类分组及空状态。
基础用法
最简单的命令菜单列表。输入关键词即可实时过滤选项,支持按键盘上下键与回车进行选择:
Loading…
安装与引入
通过 CLI 自动添加组件,或手动复制源码至项目中:
pnpm dlx @wui-design/cli@latest add @wui/command安装基础依赖与图标库
pnpm add motion lucide-react clsx tailwind-merge复制组件源码到
components/ui/command.tsx"use client"
import * as React from "react"
import { SearchIcon } from "lucide-react"
import { motion, useReducedMotion } from "motion/react"
import { cn } from "@/lib/utils"
type CommandItemRecord = {
id: string
value: string
keywords: string[]
disabled: boolean
onSelect: (value: string) => void
}
type CommandContextValue = {
query: string
setQuery: (query: string) => void
activeId: string | null
setActiveId: (id: string) => void
visibleIds: Set<string>
isItemVisible: (value: string, keywords: string[]) => boolean
registerItem: (item: CommandItemRecord) => () => void
selectItem: (id: string) => void
listId: string
indicatorId: string
}
const CommandContext = React.createContext<CommandContextValue | null>(null)
const ITEM_SELECTOR = '[data-slot="command-item"]:not([data-disabled])'
function useCommandContext(component: string) {
const context = React.useContext(CommandContext)
if (!context) throw new Error(`${component} 必须在 Command 内使用。`)
return context
}
function fuzzyMatch(value: string, query: string, keywords: string[]) {
const text = [value, ...keywords].join(" ").toLocaleLowerCase()
return query
.trim()
.toLocaleLowerCase()
.split(/\s+/)
.every((term) => {
if (text.includes(term)) return true
let cursor = 0
for (const character of text) {
if (character === term[cursor]) cursor += 1
if (cursor === term.length) return true
}
return false
})
}
export interface CommandProps extends React.ComponentProps<"div"> {
/** 受控模式下的搜索词。 */
query?: string
/** 非受控模式下的初始搜索词。 */
defaultQuery?: string
/** 搜索词变化时触发。 */
onQueryChange?: (query: string) => void
/** 是否由组件过滤选项。关闭后可接入服务端搜索。@default true */
shouldFilter?: boolean
/** 自定义选项匹配规则。 */
filter?: (value: string, query: string, keywords: string[]) => boolean
/** 首次渲染时优先高亮的选项值,常用于让已选项在打开时可见。 */
defaultActiveValue?: string
}
/** 支持模糊搜索和键盘导航的命令列表容器。 */
function Command({
className,
query,
defaultQuery = "",
onQueryChange,
shouldFilter = true,
filter = fuzzyMatch,
defaultActiveValue,
onKeyDown,
ref,
children,
...props
}: CommandProps) {
const [internalQuery, setInternalQuery] = React.useState(defaultQuery)
const [items, setItems] = React.useState<CommandItemRecord[]>([])
const [activeId, setActiveId] = React.useState<string | null>(null)
const rootRef = React.useRef<HTMLDivElement | null>(null)
const preferredApplied = React.useRef(false)
const listId = React.useId()
const indicatorId = React.useId()
const search = query ?? internalQuery
const visibleItems = React.useMemo(
() =>
items.filter(
(item) =>
!shouldFilter ||
!search.trim() ||
filter(item.value, search, item.keywords)
),
[filter, items, search, shouldFilter]
)
const visibleIds = React.useMemo(
() => new Set(visibleItems.map((item) => item.id)),
[visibleItems]
)
const setRootRef = React.useCallback(
(node: HTMLDivElement | null) => {
rootRef.current = node
if (typeof ref === "function") ref(node)
else if (ref) ref.current = node
},
[ref]
)
function enabledNodes() {
return Array.from(
rootRef.current?.querySelectorAll<HTMLElement>(ITEM_SELECTOR) ?? []
)
}
React.useEffect(() => {
if (!visibleItems.length) return
if (!preferredApplied.current && defaultActiveValue !== undefined) {
const preferred = visibleItems.find(
(item) => item.value === defaultActiveValue && !item.disabled
)
if (preferred) {
preferredApplied.current = true
setActiveId(preferred.id)
document
.getElementById(preferred.id)
?.scrollIntoView({ block: "nearest" })
return
}
}
if (visibleItems.some((item) => item.id === activeId && !item.disabled))
return
setActiveId(enabledNodes()[0]?.id ?? null)
}, [activeId, defaultActiveValue, visibleItems])
const previousSearch = React.useRef(search)
React.useEffect(() => {
if (previousSearch.current === search) return
previousSearch.current = search
const first = enabledNodes()[0]
setActiveId(first?.id ?? null)
first?.scrollIntoView({ block: "nearest" })
}, [search])
function setQuery(nextQuery: string) {
if (query === undefined) setInternalQuery(nextQuery)
onQueryChange?.(nextQuery)
}
function isItemVisible(value: string, keywords: string[]) {
return !shouldFilter || !search.trim() || filter(value, search, keywords)
}
const registerItem = React.useCallback((item: CommandItemRecord) => {
setItems((current) => [
...current.filter((entry) => entry.id !== item.id),
item,
])
return () =>
setItems((current) => current.filter((entry) => entry.id !== item.id))
}, [])
function selectItem(id: string) {
const item = items.find((entry) => entry.id === id)
if (item && !item.disabled && visibleIds.has(id)) item.onSelect(item.value)
}
function moveActive(direction: 1 | -1 | "first" | "last") {
const nodes = enabledNodes()
if (!nodes.length) return
const currentIndex = nodes.findIndex((node) => node.id === activeId)
const nextIndex =
direction === "first"
? 0
: direction === "last"
? nodes.length - 1
: currentIndex < 0
? direction === 1
? 0
: nodes.length - 1
: (currentIndex + direction + nodes.length) % nodes.length
const next = nodes[nextIndex]
setActiveId(next.id)
next.scrollIntoView({ block: "nearest" })
}
return (
<CommandContext.Provider
value={{
query: search,
setQuery,
activeId,
setActiveId,
visibleIds,
isItemVisible,
registerItem,
selectItem,
listId,
indicatorId,
}}
>
<div
ref={setRootRef}
data-slot="command"
className={cn(
"bg-popover text-popover-foreground flex w-full flex-col overflow-hidden rounded-lg",
className
)}
onKeyDown={(event) => {
onKeyDown?.(event)
if (event.defaultPrevented || event.nativeEvent.isComposing) return
if (event.key === "ArrowDown") {
event.preventDefault()
moveActive(1)
} else if (event.key === "ArrowUp") {
event.preventDefault()
moveActive(-1)
} else if (event.key === "PageDown") {
event.preventDefault()
moveActive("last")
} else if (event.key === "PageUp") {
event.preventDefault()
moveActive("first")
} else if (event.key === "Enter" && activeId) {
event.preventDefault()
selectItem(activeId)
}
}}
{...props}
>
{children}
</div>
</CommandContext.Provider>
)
}
export interface CommandInputProps extends Omit<
React.ComponentProps<"input">,
"value" | "defaultValue" | "onChange"
> {}
/** 绑定 Command 搜索状态的输入框。 */
function CommandInput({ className, ...props }: CommandInputProps) {
const { activeId, query, setQuery, listId } =
useCommandContext("CommandInput")
return (
<div
data-slot="command-input-wrapper"
className="border-border/70 flex h-11 items-center gap-2 border-b px-3"
>
<SearchIcon className="text-muted-foreground size-4 shrink-0" />
<input
data-slot="command-input"
role="combobox"
aria-autocomplete="list"
aria-expanded="true"
aria-controls={listId}
aria-activedescendant={activeId ?? undefined}
autoComplete="off"
spellCheck={false}
value={query}
onChange={(event) => setQuery(event.target.value)}
className={cn(
"placeholder:text-muted-foreground h-full min-w-0 flex-1 bg-transparent text-sm outline-none disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
</div>
)
}
/** 命令选项的可滚动区域。 */
function CommandList({
className,
...props
}: React.ComponentProps<"div">) {
const { listId } = useCommandContext("CommandList")
return (
<motion.div
id={listId}
data-slot="command-list"
role="listbox"
layoutScroll
className={cn(
"max-h-72 scroll-py-1.5 overflow-y-auto overflow-x-hidden overscroll-contain p-1.5 [&:has([data-slot=command-item])_[data-slot=command-empty]]:hidden",
className
)}
{...(props as React.ComponentProps<typeof motion.div>)}
/>
)
}
/** 搜索无匹配项时显示的内容。 */
function CommandEmpty({
className,
children,
...props
}: React.ComponentProps<"div">) {
const { visibleIds } = useCommandContext("CommandEmpty")
const reduceMotion = useReducedMotion()
if (visibleIds.size) return null
return (
<div
data-slot="command-empty"
role="status"
className={cn(
"text-muted-foreground px-4 py-8 text-center text-sm",
className
)}
{...props}
>
<motion.div
initial={reduceMotion ? false : { opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.2, ease: [0.22, 1, 0.36, 1] }}
>
{children}
</motion.div>
</div>
)
}
/** 为一组选项提供标签和结构;组内没有可见选项时自动隐藏。 */
function CommandGroup({
className,
heading,
children,
...props
}: React.ComponentProps<"div"> & { heading?: React.ReactNode }) {
const headingId = React.useId()
return (
<div
data-slot="command-group"
role="group"
aria-labelledby={heading ? headingId : undefined}
className={cn(
"overflow-hidden [&:not(:has([data-slot=command-item]))]:hidden",
className
)}
{...props}
>
{heading ? (
<div
id={headingId}
data-slot="command-group-heading"
className="text-muted-foreground px-2 py-1.5 text-xs font-medium"
>
{heading}
</div>
) : null}
{children}
</div>
)
}
export interface CommandItemProps extends Omit<
React.ComponentProps<"div">,
"onSelect"
> {
/** 用于搜索和选择回调的稳定值。 */
value: string
/** 参与搜索的别名,例如拼音或缩写。 */
keywords?: string[]
/** 禁止聚焦和选择此项。 */
disabled?: boolean
/** 通过点击或 Enter 选中时触发。 */
onSelect?: (value: string) => void
}
/** 可搜索、可通过键盘选中的命令项,高亮背景会在选项间平滑滑动。 */
function CommandItem({
className,
value,
keywords = [],
disabled = false,
onSelect,
onMouseMove,
onClick,
children,
...props
}: CommandItemProps) {
const id = React.useId()
const reduceMotion = useReducedMotion()
const {
activeId,
setActiveId,
isItemVisible,
registerItem,
selectItem,
indicatorId,
} = useCommandContext("CommandItem")
const onSelectRef = React.useRef(onSelect)
onSelectRef.current = onSelect
const keywordsKey = keywords.join("\u0000")
React.useEffect(
() =>
registerItem({
id,
value,
keywords: keywordsKey ? keywordsKey.split("\u0000") : [],
disabled,
onSelect: (selectedValue) => onSelectRef.current?.(selectedValue),
}),
[disabled, id, keywordsKey, registerItem, value]
)
if (!isItemVisible(value, keywords)) return null
const active = activeId === id
return (
<div
id={id}
data-slot="command-item"
data-active={active || undefined}
data-disabled={disabled || undefined}
role="option"
aria-selected={active}
aria-disabled={disabled || undefined}
className={cn(
"data-[active=true]:text-accent-foreground relative isolate flex min-h-9 cursor-default select-none items-center gap-2 rounded-md px-2.5 py-2 text-sm outline-none transition-colors duration-150 data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-40 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
className
)}
onMouseDown={(event) => event.preventDefault()}
onMouseMove={(event) => {
onMouseMove?.(event)
if (!disabled && !event.defaultPrevented && !active) setActiveId(id)
}}
onClick={(event) => {
onClick?.(event)
if (!event.defaultPrevented) selectItem(id)
}}
{...props}
>
{active ? (
<motion.span
aria-hidden
data-slot="command-item-indicator"
layoutId={indicatorId}
className="bg-accent absolute inset-0 z-[-1] rounded-md"
transition={
reduceMotion
? { duration: 0 }
: { type: "spring", stiffness: 520, damping: 38, mass: 0.7 }
}
/>
) : null}
{children}
</div>
)
}
/** 命令列表中的视觉分隔线;搜索时自动隐藏。 */
function CommandSeparator({
className,
...props
}: React.ComponentProps<"div">) {
const { query } = useCommandContext("CommandSeparator")
if (query.trim()) return null
return (
<div
data-slot="command-separator"
role="separator"
className={cn("bg-border -mx-1.5 my-1.5 h-px", className)}
{...props}
/>
)
}
function CommandShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="command-shortcut"
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground",
className
)}
{...props}
/>
)
}
export {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
CommandShortcut,
}
属性 Props
Command (根容器)
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| query | string | — | 受控模式下的搜索关键词。 |
| defaultQuery | string | "" | 非受控模式下的初始搜索关键词。 |
| onQueryChange | (query: string) => void | — | 输入框搜索内容发生改变时的回调函数。 |
| shouldFilter | boolean | true | 是否由组件内置算法自动过滤选项。若接入远程服务端搜索,请设为 false。 |
| filter | (value: string, query: string, keywords: string[]) => boolean | — | 自定义选项的模糊搜索匹配算法规则。 |
| defaultActiveValue | string | — | 首次渲染时优先高亮的选项值,并自动滚动到可见区域;Combobox 用它让已选项在打开时可见。 |
CommandInput
绑定 Command 搜索状态的原生 <input> 包装:
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| placeholder | string | "搜索命令..." | 输入框的占位提示文本。 |
| className | string | — | 应用于输入框的额外 CSS 类名。 |
CommandItem
可搜索、可通过键盘选中的命令项:
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| value | string | — | 当前项的唯一文本标识,默认参与模糊搜索比对并传递给 onSelect。 |
| keywords | string[] | [] | 参与搜索匹配的额外关键词或别名(如拼音、英文缩写),不会在界面中渲染。 |
| disabled | boolean | false | 是否禁用该命令项(禁用后跳过键盘焦点与点击响应)。 |
| onSelect | (value: string) => void | — | 当用户通过鼠标点击或键盘 Enter 选中该命令时触发的回调。 |
CommandGroup / CommandSeparator / CommandShortcut
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| heading | React.ReactNode | — | 分组标题内容(CommandGroup 专用)。 |
| className | string | — | 应用于相应组件的额外 CSS 类名。 |
事件 Events
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| onSelect | (value: string) => void | — | 用户点击命令项或通过键盘 Enter 确认选中时触发,参数为该选项的 value。 |
| onQueryChange | (query: string) => void | — | 搜索输入框内容改变时触发,可用于触发防抖异步搜索请求。 |
使用场景与设计规范
Command 适用于高密度、高操作频率的现代效率工具:
- Spotlight 全局命令调色板:配合
Dialog与全局快捷键(如 ⌘ + K),让用户无需鼠标即可完成跨模块跳转、新建文档或主题切换。 - 拼音与别名搜索优化:中文场景下,建议为每个
CommandItem补充keywords={["xinjian", "doc", "new"]},使用户在未切换输入法时仍能搜出目标操作。 - 服务端搜索接入:对于数千条记录的海量检索,设置
shouldFilter={false},通过onQueryChange发起异步 API 检索,将返回的结果动态映射为CommandItem。
场景示例
⌘K 全局调色板弹窗 (Command Palette)
结合全局键盘监听、快捷键提示与分类分组:
Loading…
分组、别名与禁用项
使用 CommandGroup 建立操作分组,使用 CommandSeparator 划分视觉区域:
Loading…
无障碍与交互 Accessibility
- Combobox & Listbox 角色模型:输入框自动挂载
role="combobox"与aria-autocomplete="list";列表容器挂载role="listbox";选项挂载role="option"。 - 活动后代指示(aria-activedescendant):当前通过上下方向键高亮的项目 ID 会实时同步至输入框的
aria-activedescendant属性,读屏器无需移动焦点即可清晰播报当前选中的项目。 - 全键盘流畅控制:
- ↓ / ↑:按 DOM 顺序在可用命令项间循环切换高亮,并自动滚动到可见区域。
- PageUp / PageDown:跳到第一个或最后一个可用项。
- Enter:直接执行当前高亮项的
onSelect回调;中文输入法组词期间不会误触发。 - Esc:清空输入或在弹窗模式下关闭菜单。
- 搜索行为:输入关键词后高亮自动回到第一个匹配项;没有可见选项的
CommandGroup会连同标题一起隐藏,CommandSeparator在搜索时隐藏。 - 动效:高亮背景使用共享布局动画在选项之间平滑滑动(每个
Command实例独立),空状态淡入;开启prefers-reduced-motion时瞬间切换。