MioUI

Command Palette

A ⌘K / Ctrl+K global command palette with grouped results, fuzzy match highlighting, arrow-key navigation and shortcut hints.

Press ⌘ K anywhere on the page; try typing “spm” or “theme”.

Includes

  • Top-aligned Dialog (align="top") + SearchInput; Enter and arrow keys are IME-guarded
  • Grouped results: Pages / Notes / Commands, each with icon, hint and Kbd shortcut
  • Fuzzy matching (substring first, then subsequence; contiguous and word-start bonuses) with accent-highlighted hits; keywords for aliases
  • ↑↓ navigation with a SNAPPY layoutId highlight that follows the pointer; ↵ runs, esc closes
  • EmptyState when nothing matches; footer hints ↑↓ select · ↵ open · esc close
  • useCommandPalette() registers the ⌘K / Ctrl+K hotkey; useModKey() returns ⌘ or Ctrl

Usage

import { CommandPalette, useCommandPalette, type CommandItem } from "@/components/blocks/command-palette";

const palette = useCommandPalette();   // toggles on ⌘K / Ctrl+K

const items: CommandItem[] = [
  { id: "home", group: "pages", label: "Home", icon: <House />, onRun: () => go("/") },
  { id: "new", group: "commands", label: "New note", icon: <Plus />, shortcut: ["⌘", "N"], onRun: newNote },
];

<Button onClick={() => palette.setOpen(true)}>Search <Kbd>⌘</Kbd><Kbd>K</Kbd></Button>
<CommandPalette {...palette.props} lang="en" items={items} />

Source

components/blocks/command-palette.tsx
'use client';
import { motion } from 'motion/react';
import { MagnifyingGlass } from '@phosphor-icons/react';
import {
  Fragment,
  useCallback,
  useEffect,
  useId,
  useMemo,
  useRef,
  useState,
  type KeyboardEvent,
  type ReactNode,
} from 'react';
import { twMerge } from 'tailwind-merge';
import { Dialog } from '@/components/ui/dialog';
import { Kbd, SearchInput } from '@/components/ui/input';
import { EmptyState } from '@/components/ui/feedback';
import { SPRING_SNAPPY } from '@/lib/motion';
import { useReducedMotionPref } from '@/lib/use-reduced-motion';

const COPY = {
  zh: {
    dialog: '命令面板',
    placeholder: '搜索页面、笔记或命令…',
    results: '搜索结果',
    clear: '清除',
    close: '关闭',
    emptyTitle: '没有找到结果',
    emptyDesc: (q: string) => `没有与“${q}”匹配的页面、笔记或命令。`,
    select: '选择',
    open: '打开',
    esc: '关闭',
    groups: { pages: '页面', notes: '笔记', commands: '命令' } as Record<string, string>,
  },
  en: {
    dialog: 'Command palette',
    placeholder: 'Search pages, notes or commands…',
    results: 'Results',
    clear: 'Clear',
    close: 'Close',
    emptyTitle: 'No results',
    emptyDesc: (q: string) => `Nothing matches “${q}” in pages, notes or commands.`,
    select: 'select',
    open: 'open',
    esc: 'close',
    groups: { pages: 'Pages', notes: 'Notes', commands: 'Commands' } as Record<string, string>,
  },
};

export interface CommandItem {
  id: string;
  label: string;
  /** 分组 id:pages / notes / commands,或任意自定义字符串 */
  group: string;
  icon?: ReactNode;
  /** 副标题(如笔记路径) */
  hint?: string;
  /** 快捷键键帽,如 ['⌘', 'N'] */
  shortcut?: string[];
  /** 额外的匹配词(拼音、别名) */
  keywords?: string[];
  onRun?: () => void;
}

export interface CommandPaletteProps {
  open: boolean;
  onOpenChange: (o: boolean) => void;
  items: CommandItem[];
  lang?: 'zh' | 'en';
  /** 分组顺序与标题;默认 pages / notes / commands */
  groups?: { id: string; label: string }[];
  /** 任一条目被执行(在 item.onRun 之后调用) */
  onRun?: (item: CommandItem) => void;
  placeholder?: string;
  /** 每组最多显示条数,默认 8 */
  limit?: number;
}

/* ---------- 模糊匹配:子序列 + 连续 / 词首加分,返回命中下标 ---------- */
export function fuzzyMatch(text: string, query: string): { score: number; indices: number[] } | null {
  const q = query.trim().toLowerCase();
  if (!q) return { score: 0, indices: [] };
  const s = text.toLowerCase();
  // 优先整段子串命中
  const at = s.indexOf(q);
  if (at >= 0) {
    return { score: 100 - at + (at === 0 ? 50 : 0), indices: Array.from({ length: q.length }, (_, i) => at + i) };
  }
  const indices: number[] = [];
  let score = 0;
  let prev = -2;
  let j = 0;
  for (let i = 0; i < s.length && j < q.length; i++) {
    if (q[j] === ' ') {
      j++;
      i--;
      continue;
    }
    if (s[i] === q[j]) {
      indices.push(i);
      score += i === prev + 1 ? 6 : 1;
      if (i === 0 || /[\s\-_/.]/.test(s[i - 1])) score += 4;
      prev = i;
      j++;
    }
  }
  if (j < q.replace(/\s+$/, '').length) return null;
  return { score: score - s.length * 0.05, indices };
}

function Highlight({ text, indices }: { text: string; indices: number[] }) {
  if (!indices.length) return <>{text}</>;
  const set = new Set(indices);
  const parts: { s: string; hit: boolean }[] = [];
  for (let i = 0; i < text.length; i++) {
    const hit = set.has(i);
    const last = parts[parts.length - 1];
    if (last && last.hit === hit) last.s += text[i];
    else parts.push({ s: text[i], hit });
  }
  return (
    <>
      {parts.map((p, i) =>
        p.hit ? (
          <mark key={i} className="rounded-xs bg-transparent font-semibold text-accent-text">
            {p.s}
          </mark>
        ) : (
          <Fragment key={i}>{p.s}</Fragment>
        ),
      )}
    </>
  );
}

/** 当前平台的修饰键显示:⌘ 或 Ctrl(挂载后判断) */
export function useModKey() {
  const [mod, setMod] = useState('⌘');
  useEffect(() => {
    const p = (navigator as Navigator & { userAgentData?: { platform?: string } }).userAgentData?.platform ?? navigator.platform ?? '';
    if (!/mac|iphone|ipad/i.test(p)) setMod('Ctrl');
  }, []);
  return mod;
}

/** ⌘K / Ctrl+K 全局快捷键:返回 open 状态与可直接展开到 <CommandPalette /> 的 props */
export function useCommandPalette({ hotkey = 'k', enabled = true }: { hotkey?: string; enabled?: boolean } = {}) {
  const [open, setOpen] = useState(false);
  useEffect(() => {
    if (!enabled) return;
    const onKey = (e: globalThis.KeyboardEvent) => {
      if (e.isComposing || e.keyCode === 229) return;
      if ((e.metaKey || e.ctrlKey) && !e.altKey && !e.shiftKey && e.key.toLowerCase() === hotkey) {
        e.preventDefault();
        setOpen((o) => !o);
      }
    };
    document.addEventListener('keydown', onKey);
    return () => document.removeEventListener('keydown', onKey);
  }, [hotkey, enabled]);
  const toggle = useCallback(() => setOpen((o) => !o), []);
  return { open, setOpen, toggle, props: { open, onOpenChange: setOpen } };
}

/** 命令面板:靠上 Dialog + 搜索(IME 保护)+ 分组结果 + ↑↓ SNAPPY 高亮 + ↵ 执行 */
export function CommandPalette({ open, onOpenChange, items, lang = 'zh', groups, onRun, placeholder, limit = 8 }: CommandPaletteProps) {
  const t = COPY[lang];
  const uid = useId();
  const reduce = useReducedMotionPref();
  const [query, setQuery] = useState('');
  const [active, setActive] = useState(0);
  const listRef = useRef<HTMLDivElement>(null);
  const order = groups ?? ['pages', 'notes', 'commands'].map((id) => ({ id, label: t.groups[id] }));

  useEffect(() => {
    if (!open) {
      const tm = setTimeout(() => {
        setQuery('');
        setActive(0);
      }, 150);
      return () => clearTimeout(tm);
    }
  }, [open]);

  const sections = useMemo(() => {
    const known = new Set(order.map((g) => g.id));
    const allGroups = [...order, ...[...new Set(items.map((i) => i.group))].filter((g) => !known.has(g)).map((id) => ({ id, label: id }))];
    return allGroups
      .map((g) => {
        const rows = items
          .filter((it) => it.group === g.id)
          .map((it) => {
            const m = fuzzyMatch(it.label, query);
            const kw = m ? null : (it.keywords ?? []).map((k) => fuzzyMatch(k, query)).find(Boolean);
            const hit = m ?? (kw ? { score: kw.score - 20, indices: [] } : null);
            return hit ? { item: it, ...hit } : null;
          })
          .filter((r): r is NonNullable<typeof r> => r !== null)
          .sort((a, b) => (query ? b.score - a.score : 0))
          .slice(0, limit);
        return { ...g, rows };
      })
      .filter((g) => g.rows.length);
  }, [items, query, order, limit]);

  const flat = useMemo(() => sections.flatMap((s) => s.rows.map((r) => r.item)), [sections]);
  const clamped = Math.min(active, Math.max(0, flat.length - 1));

  useEffect(() => setActive(0), [query]);

  useEffect(() => {
    const el = listRef.current?.querySelector<HTMLElement>(`[data-index="${clamped}"]`);
    el?.scrollIntoView({ block: 'nearest' });
  }, [clamped]);

  const exec = (it?: CommandItem) => {
    if (!it) return;
    onOpenChange(false);
    // 等 Dialog 关闭、焦点还原后再执行
    requestAnimationFrame(() => {
      it.onRun?.();
      onRun?.(it);
    });
  };

  const onKey = (e: KeyboardEvent<HTMLInputElement>) => {
    if (e.nativeEvent.isComposing || e.keyCode === 229) return;
    if (!flat.length) return;
    if (e.key === 'ArrowDown') {
      e.preventDefault();
      setActive((clamped + 1) % flat.length);
    } else if (e.key === 'ArrowUp') {
      e.preventDefault();
      setActive((clamped - 1 + flat.length) % flat.length);
    } else if (e.key === 'Home' && e.ctrlKey) {
      e.preventDefault();
      setActive(0);
    } else if (e.key === 'End' && e.ctrlKey) {
      e.preventDefault();
      setActive(flat.length - 1);
    } else if (e.key === 'Enter') {
      e.preventDefault();
      exec(flat[clamped]);
    }
  };

  const optId = (i: number) => `${uid}-opt-${i}`;
  let idx = -1;

  return (
    <Dialog
      open={open}
      onOpenChange={onOpenChange}
      align="top"
      size="lg"
      hideClose
      aria-label={t.dialog}
      closeLabel={t.close}
      className="overflow-hidden p-0"
    >
      <div className="border-b border-hairline p-2">
        <SearchInput
          data-autofocus=""
          hotkey={false}
          size="lg"
          value={query}
          onValueChange={setQuery}
          onKeyDown={onKey}
          placeholder={placeholder ?? t.placeholder}
          clearLabel={t.clear}
          aria-label={t.placeholder}
          aria-controls={`${uid}-list`}
          aria-activedescendant={flat.length ? optId(clamped) : undefined}
          aria-autocomplete="list"
          autoComplete="off"
          spellCheck={false}
          className="bg-transparent shadow-none hover:shadow-none focus-within:shadow-none!"
        />
      </div>

      <div ref={listRef} id={`${uid}-list`} role="listbox" aria-label={t.results} className="max-h-[min(52vh,380px)] overflow-y-auto p-2">
        {flat.length === 0 ? (
          <EmptyState icon={<MagnifyingGlass />} title={t.emptyTitle} description={t.emptyDesc(query)} className="py-8" />
        ) : (
          sections.map((s) => (
            <div key={s.id} role="group" aria-labelledby={`${uid}-g-${s.id}`} className="pb-1 [&+&]:pt-1">
              <div id={`${uid}-g-${s.id}`} className="px-2.5 pb-1 pt-1.5 text-[12px] font-medium text-text-muted">
                {s.label}
              </div>
              {s.rows.map(({ item, indices }) => {
                idx += 1;
                const i = idx;
                const on = i === clamped;
                return (
                  <div
                    key={item.id}
                    id={optId(i)}
                    role="option"
                    aria-selected={on}
                    data-index={i}
                    onPointerMove={() => !on && setActive(i)}
                    onClick={() => exec(item)}
                    className={twMerge(
                      'relative flex h-10 cursor-default select-none items-center gap-3 rounded-lg px-2.5 text-sm',
                      on ? 'text-text-primary' : 'text-text-secondary',
                    )}
                  >
                    {on && (
                      <motion.span
                        layoutId={`${uid}-hl`}
                        transition={reduce ? { duration: 0 } : SPRING_SNAPPY}
                        className="absolute inset-0 rounded-lg bg-ghost-selected"
                      />
                    )}
                    {item.icon && (
                      <span aria-hidden className={twMerge('relative z-10 flex shrink-0 items-center [&_svg]:size-[18px]', on ? 'text-text-primary' : 'text-text-muted')}>
                        {item.icon}
                      </span>
                    )}
                    <span className="relative z-10 min-w-0 flex-1 truncate text-text-primary">
                      <Highlight text={item.label} indices={indices} />
                      {item.hint && <span className="ml-2 text-[12px] text-text-muted">{item.hint}</span>}
                    </span>
                    {item.shortcut && (
                      <span className="relative z-10 flex shrink-0 gap-1">
                        {item.shortcut.map((k, ki) => (
                          <Kbd key={ki}>{k}</Kbd>
                        ))}
                      </span>
                    )}
                  </div>
                );
              })}
            </div>
          ))
        )}
      </div>

      <div className="flex items-center gap-4 border-t border-hairline bg-surface-1 px-4 py-2 text-[12px] text-text-muted dark:bg-transparent">
        <span className="inline-flex items-center gap-1.5">
          <Kbd>↑</Kbd>
          <Kbd>↓</Kbd>
          {t.select}
        </span>
        <span className="inline-flex items-center gap-1.5">
          <Kbd>↵</Kbd>
          {t.open}
        </span>
        <span className="inline-flex items-center gap-1.5">
          <Kbd>esc</Kbd>
          {t.esc}
        </span>
      </div>
    </Dialog>
  );
}

On this page