命令面板

搜索面板以快速缩放弹出,输入即筛选,用方向键切换时高亮块在结果之间滑动。

  • JS
  • 布局
  • 切换
  • 收录于

AI 提示词

用 React 和 CSS 制作命令面板。触发按钮打开一个面板,在 180ms 内从 opacity 0、scale(0.96)、translateY(-6px) 过渡到静止状态,缓动 cubic-bezier(0.16, 1, 0.3, 1)。面板包含一个搜索框(打开时自动聚焦)和一个命令列表,按不区分大小写的包含匹配进行筛选。维护一个当前项索引:ArrowDown 和 ArrowUp 循环移动,Enter 执行当前命令并关闭,Escape 关闭。不要给每一行改颜色,而是在列表后面放一个绝对定位的高亮块,用 transform: translateY(索引 × 行高) 和 160ms 过渡移动它,让它在行之间滑动。输入框设置 role="combobox",并带上 aria-expanded、aria-controls 和指向当前选项的 aria-activedescendant。筛选结果为空时显示“没有匹配项”。在 prefers-reduced-motion 下去掉缩放和滑动。命令为新建文件、搜索动效、切换主题、复制链接、打开设置。

源代码

Demo.tsx
'use client'

import { useId, useState } from 'react'
import s from './demo.module.css'

const ROW = 34

export default function CommandPalette({ labels }: { labels: Record<string, string> }) {
  const [open, setOpen] = useState(false)
  const [query, setQuery] = useState('')
  const [active, setActive] = useState(0)
  const id = useId()
  const items = labels.items.split('|').filter((t) => t.toLowerCase().includes(query.toLowerCase()))

  const close = () => {
    setOpen(false)
    setQuery('')
    setActive(0)
  }

  const onKey = (e: React.KeyboardEvent) => {
    if (e.key === 'Escape') close()
    if (!items.length) return
    if (e.key === 'ArrowDown') setActive((a) => (a + 1) % items.length)
    if (e.key === 'ArrowUp') setActive((a) => (a - 1 + items.length) % items.length)
    if (e.key === 'Enter') close()
    if (e.key.startsWith('Arrow')) e.preventDefault()
  }

  if (!open)
    return (
      <button type="button" className={s.trigger} onClick={() => setOpen(true)}>
        {labels.open} <kbd className={s.kbd}>⌘K</kbd>
      </button>
    )

  return (
    <div className={s.panel} onKeyDown={onKey}>
      <input
        className={s.input}
        role="combobox"
        aria-expanded="true"
        aria-controls={id}
        aria-activedescendant={items.length ? `${id}-${active}` : undefined}
        placeholder={labels.placeholder}
        value={query}
        autoFocus
        onBlur={(e) => !e.currentTarget.parentElement?.contains(e.relatedTarget) && close()}
        onChange={(e) => {
          setQuery(e.target.value)
          setActive(0)
        }}
      />
      <ul id={id} role="listbox" className={s.list}>
        {items.length > 0 && <li className={s.highlight} style={{ transform: `translateY(${active * ROW}px)` }} aria-hidden="true" />}
        {items.map((t, i) => (
          <li
            key={t}
            id={`${id}-${i}`}
            role="option"
            aria-selected={i === active}
            className={s.option}
            onPointerEnter={() => setActive(i)}
            onPointerDown={(e) => e.preventDefault()}
            onClick={close}
          >
            {t}
          </li>
        ))}
        {!items.length && <li className={s.empty}>{labels.empty}</li>}
      </ul>
    </div>
  )
}
demo.module.css
.trigger {
  display: inline-flex;
  align-items: center;
  gap: 10px;
  height: 40px;
  padding: 0 10px 0 16px;
  border: 1px solid var(--stage-rule);
  border-radius: var(--radius-sm);
  background: var(--stage-surface);
  color: var(--stage-ink);
  font: 500 var(--text-sm) / 1 var(--font-body);
  cursor: pointer;
}

.trigger:hover { border-color: var(--stage-accent); }
.trigger:focus-visible { outline: 2px solid var(--color-focus); outline-offset: 2px; }

.kbd {
  padding: 3px 6px;
  border-radius: 5px;
  background: var(--stage-bg);
  color: var(--stage-muted);
  font: 500 var(--text-xs) / 1 var(--font-mono);
}

.panel {
  width: min(280px, 100%);
  border: 1px solid var(--stage-rule);
  border-radius: 14px;
  background: var(--stage-surface);
  box-shadow: 0 16px 40px oklch(0% 0 0 / 0.45);
  overflow: hidden;
  transform-origin: 50% 0;
  animation: pop 180ms var(--ease-out);
}

@keyframes pop {
  from { opacity: 0; transform: translateY(-6px) scale(0.96); }
}

.input {
  width: 100%;
  height: 42px;
  padding: 0 14px;
  border: 0;
  border-bottom: 1px solid var(--stage-rule);
  outline: none;
  background: transparent;
  color: var(--stage-ink);
  font: 400 var(--text-sm) / 1 var(--font-body);
}

.input::placeholder { color: var(--stage-muted); }

.list {
  position: relative;
  margin: 0;
  padding: 6px;
  list-style: none;
}

.highlight {
  position: absolute;
  left: 6px;
  right: 6px;
  top: 6px;
  height: 34px;
  border-radius: 8px;
  background: var(--stage-bg);
  box-shadow: inset 2px 0 0 var(--stage-accent);
  transition: transform 160ms var(--ease-out);
}

.option, .empty {
  position: relative;
  display: flex;
  align-items: center;
  height: 34px;
  padding: 0 10px;
  font: 400 var(--text-sm) / 1 var(--font-body);
  color: var(--stage-muted);
  cursor: pointer;
}

.option[aria-selected='true'] { color: var(--stage-ink); }
.empty { cursor: default; }

@media (prefers-reduced-motion: reduce) {
  .panel { animation: none; }
  .highlight { transition: none; }
}

投稿创意

看到想收录的动效或主题?简单描述一下,会打开一个预先填好的 GitHub Issue,确认后即可提交。

你想投稿的是?

需要 GitHub 账号,投稿内容会公开。