UI Bits

命令面板

导航 · 组件 · 已复制 0 次

React

输入即过滤、↑↓ 移动、回车确认、Esc 关闭。焦点始终留在输入框,选中项用 aria-activedescendant 指出去——一旦把焦点移到列表项上,读屏用户每按一次方向键都会听到整个列表被重读。分组标题不参与键盘循环,跳过的逻辑放在拍平后的一维数组里做,不在渲染时判断。

调参预览

⌘K
  • 灵感

    • 三档定价的锚定排版Nike
    • 把空状态画成一封信Linear
    • 暗色仪表盘的分层Supabase
  • 组件

    • 流光按钮elements
    • 命令面板navigation
  • 页面

    • 更新日志/changelog
↑↓ 选择↵ 打开esc 清空
#2563EB
#2563EB
开
关

源码

'use client'

import { useId, useMemo, useRef, useState } from 'react'

export type PaletteItem = { id: string; label: string; group: string; hint?: string }

/**
 * 组合一套键盘可用的命令面板,最容易错的三处:
 *   1. 焦点必须留在输入框 —— 用 aria-activedescendant 表达"当前项",
 *      而不是把焦点 true 地移到 li 上(后者会让读屏每移动一次就重读整列)。
 *   2. 过滤后要把选中下标**重置为 0**,否则过滤到 2 条时下标还停在 5,
 *      回车会打开一个已经不在列表里的项。
 *   3. 分组标题不参与循环 —— 先把可见项拍平成一维数组,再用下标访问。
 */
export function CommandPalette({
  items,
  onPick,
  placeholder = '搜索页面、组件、产品…',
}: {
  items: PaletteItem[]
  onPick?: (item: PaletteItem) => void
  placeholder?: string
}) {
  const [query, setQuery] = useState('')
  const [active, setActive] = useState(0)
  const listId = useId()
  const inputRef = useRef<HTMLInputElement>(null)

  const flat = useMemo(() => {
    const q = query.trim().toLowerCase()
    return items.filter(
      (it) => !q || it.label.toLowerCase().includes(q) || it.group.toLowerCase().includes(q),
    )
  }, [items, query])

  const groups = useMemo(() => {
    const map = new Map<string, PaletteItem[]>()
    for (const it of flat) map.set(it.group, [...(map.get(it.group) ?? []), it])
    return [...map.entries()]
  }, [flat])

  function move(step: number) {
    if (flat.length === 0) return
    setActive((i) => (i + step + flat.length) % flat.length)
  }

  function onKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
    if (e.key === 'ArrowDown') {
      e.preventDefault()
      move(1)
    } else if (e.key === 'ArrowUp') {
      e.preventDefault()
      move(-1)
    } else if (e.key === 'Enter') {
      e.preventDefault()
      const picked = flat[active]
      if (picked) onPick?.(picked)
    } else if (e.key === 'Escape') {
      e.preventDefault()
      setQuery('')
      setActive(0)
    }
  }

  return (
    <div className="w-full max-w-md overflow-hidden rounded-xl border border-gray-200 bg-white shadow-lg">
      <input
        ref={inputRef}
        role="combobox"
        aria-expanded
        aria-controls={listId}
        aria-activedescendant={flat[active] ? `${listId}-${flat[active].id}` : undefined}
        aria-label="搜索"
        value={query}
        placeholder={placeholder}
        onChange={(e) => {
          setQuery(e.target.value)
          setActive(0) // 关键:过滤后必须归零,见上面第 2 条
        }}
        onKeyDown={onKeyDown}
        className="w-full border-0 border-b border-gray-100 px-4 py-3 text-sm outline-none
                   focus:ring-2 focus:ring-blue-500/40"
      />

      <ul id={listId} role="listbox" className="max-h-72 overflow-auto py-1">
        {groups.map(([group, rows]) => (
          <li key={group}>
            {/* 组标题是纯装饰:不加 role="option",否则键盘会停在它上面 */}
            <p className="px-4 pb-1 pt-2 text-[11px] uppercase tracking-wide text-gray-400">
              {group}
            </p>
            <ul>
              {rows.map((it) => {
                const selected = flat[active]?.id === it.id
                return (
                  <li
                    key={it.id}
                    id={`${listId}-${it.id}`}
                    role="option"
                    aria-selected={selected}
                    onMouseEnter={() => setActive(flat.findIndex((x) => x.id === it.id))}
                    onClick={() => onPick?.(it)}
                    className={
                      selected
                        ? 'flex cursor-pointer items-center justify-between bg-blue-50 px-4 py-2 text-sm text-blue-700'
                        : 'flex cursor-pointer items-center justify-between px-4 py-2 text-sm text-gray-700'
                    }
                  >
                    <span>{it.label}</span>
                    {it.hint && <kbd className="text-[11px] text-gray-400">{it.hint}</kbd>}
                  </li>
                )
              })}
            </ul>
          </li>
        ))}
        {flat.length === 0 && <li className="px-4 py-6 text-center text-sm text-gray-400">没有匹配项</li>}
      </ul>
    </div>
  )
}

可调参数

参数类型默认值
强调色accent颜色#2563EB
显示快捷键shortcuts开关开
紧凑行高dense开关关

上方的调参预览可以直接拖动,改完点「复制当前代码」拿走带你自己参数的版本。

相似组件

按标签重合度推荐,不是运营手配的榜单。