wui
组件

命令菜单 Command

快速可组合的命令调色板与全局资源搜索列表,支持拼音与关键词模糊检索、全键盘上下导航、分类分组及空状态。

第三方依赖 · lucide-react第三方依赖 · motion

基础用法

最简单的命令菜单列表。输入关键词即可实时过滤选项,支持按键盘上下键与回车进行选择:

Loading…

安装与引入

通过 CLI 自动添加组件,或手动复制源码至项目中:

pnpm dlx @wui-design/cli@latest add @wui/command
安装基础依赖与图标库
pnpm add motion lucide-react clsx tailwind-merge
复制组件源码到 components/ui/command.tsx
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 (根容器)

属性类型默认值说明
querystring—受控模式下的搜索关键词。
defaultQuerystring""非受控模式下的初始搜索关键词。
onQueryChange(query: string) => void—输入框搜索内容发生改变时的回调函数。
shouldFilterbooleantrue是否由组件内置算法自动过滤选项。若接入远程服务端搜索,请设为 false。
filter(value: string, query: string, keywords: string[]) => boolean—自定义选项的模糊搜索匹配算法规则。
defaultActiveValuestring—首次渲染时优先高亮的选项值,并自动滚动到可见区域;Combobox 用它让已选项在打开时可见。

CommandInput

绑定 Command 搜索状态的原生 <input> 包装:

属性类型默认值说明
placeholderstring"搜索命令..."输入框的占位提示文本。
classNamestring—应用于输入框的额外 CSS 类名。

CommandItem

可搜索、可通过键盘选中的命令项:

属性类型默认值说明
valuestring—当前项的唯一文本标识,默认参与模糊搜索比对并传递给 onSelect。
keywordsstring[][]参与搜索匹配的额外关键词或别名(如拼音、英文缩写),不会在界面中渲染。
disabledbooleanfalse是否禁用该命令项(禁用后跳过键盘焦点与点击响应)。
onSelect(value: string) => void—当用户通过鼠标点击或键盘 Enter 选中该命令时触发的回调。

CommandGroup / CommandSeparator / CommandShortcut

属性类型默认值说明
headingReact.ReactNode—分组标题内容(CommandGroup 专用)。
classNamestring—应用于相应组件的额外 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 时瞬间切换。