SCALE — Build Lab
UI部品 · REACT COMPONENT

コマンドパレット(⌘K)

CATEGORYUI部品 TYPEReact Component EFFORT180〜360分 DIFFICULTY
PRIMARY CODE
tsx
'use client';
import { useEffect, useState, useMemo } from 'react';

type Cmd = { id: string; label: string; action: () => void; keywords?: string };

export function CommandPalette({ commands }: { commands: Cmd[] }) {
  const [open, setOpen] = useState(false);
  const [q, setQ] = useState('');
  const [idx, setIdx] = useState(0);

  useEffect(() => {
    const onKey = (e: KeyboardEvent) => {
      if ((e.metaKey || e.ctrlKey) && e.key === 'k') { e.preventDefault(); setOpen(o => !o); setQ(''); setIdx(0); }
      if (e.key === 'Escape') setOpen(false);
    };
    window.addEventListener('keydown', onKey);
    return () => window.removeEventListener('keydown', onKey);
  }, []);

  const filtered = useMemo(() => {
    const ql = q.toLowerCase();
    return commands.filter(c =>
      c.label.toLowerCase().includes(ql) || (c.keywords?.toLowerCase().includes(ql) ?? false)
    );
  }, [q, commands]);

  if (!open) return null;
  return (
    <div onClick={() => setOpen(false)} style={{
      position: 'fixed', inset: 0, background: 'rgba(0,0,0,.6)', zIndex: 9999,
      display: 'flex', alignItems: 'flex-start', justifyContent: 'center', paddingTop: '15vh'
    }}>
      <div onClick={e => e.stopPropagation()} style={{
        background: '#0a0a0c', border: '1px solid rgba(255,255,255,.07)',
        width: '90%', maxWidth: 600, borderRadius: 8, overflow: 'hidden',
      }}>
        <input autoFocus value={q} onChange={e => { setQ(e.target.value); setIdx(0); }}
          onKeyDown={e => {
            if (e.key === 'ArrowDown') { e.preventDefault(); setIdx(i => Math.min(filtered.length - 1, i + 1)); }
            if (e.key === 'ArrowUp')   { e.preventDefault(); setIdx(i => Math.max(0, i - 1)); }
            if (e.key === 'Enter')     { filtered[idx]?.action(); setOpen(false); }
          }}
          placeholder="検索..." style={{
            width: '100%', padding: '1rem 1.25rem', background: 'transparent',
            border: 'none', color: '#fff', fontSize: '.95rem', outline: 'none',
          }} />
        <div style={{ borderTop: '1px solid rgba(255,255,255,.07)', maxHeight: 400, overflowY: 'auto' }}>
          {filtered.map((c, i) => (
            <div key={c.id} onClick={() => { c.action(); setOpen(false); }}
              style={{
                padding: '.75rem 1.25rem', cursor: 'pointer',
                background: i === idx ? 'rgba(165,180,252,.1)' : 'transparent',
                color: i === idx ? '#fff' : 'rgba(255,255,255,.85)',
              }}>
              {c.label}
            </div>
          ))}
        </div>
      </div>
    </div>
  );
}
前提条件
Tailwind CSS v4TypeScript 5
USE CASES
  • 任意のダッシュボードに組み込み

コマンドパレット(⌘K)

:LiTarget: 用途

Cmd+K で開く Spotlight 風コマンドパレット。アクション一覧 + 絞込検索。

:LiSparkle: 特徴

  • ⌘K ショートカット
  • 絞込検索
  • 上下キー選択
  • Enter で実行

:LiCode: コード(コピペ用)

'use client';
import { useEffect, useState, useMemo } from 'react';

type Cmd = { id: string; label: string; action: () => void; keywords?: string };

export function CommandPalette({ commands }: { commands: Cmd[] }) {
  const [open, setOpen] = useState(false);
  const [q, setQ] = useState('');
  const [idx, setIdx] = useState(0);

  useEffect(() => {
    const onKey = (e: KeyboardEvent) => {
      if ((e.metaKey || e.ctrlKey) && e.key === 'k') { e.preventDefault(); setOpen(o => !o); setQ(''); setIdx(0); }
      if (e.key === 'Escape') setOpen(false);
    };
    window.addEventListener('keydown', onKey);
    return () => window.removeEventListener('keydown', onKey);
  }, []);

  const filtered = useMemo(() => {
    const ql = q.toLowerCase();
    return commands.filter(c =>
      c.label.toLowerCase().includes(ql) || (c.keywords?.toLowerCase().includes(ql) ?? false)
    );
  }, [q, commands]);

  if (!open) return null;
  return (
    <div onClick={() => setOpen(false)} style={{
      position: 'fixed', inset: 0, background: 'rgba(0,0,0,.6)', zIndex: 9999,
      display: 'flex', alignItems: 'flex-start', justifyContent: 'center', paddingTop: '15vh'
    }}>
      <div onClick={e => e.stopPropagation()} style={{
        background: '#0a0a0c', border: '1px solid rgba(255,255,255,.07)',
        width: '90%', maxWidth: 600, borderRadius: 8, overflow: 'hidden',
      }}>
        <input autoFocus value={q} onChange={e => { setQ(e.target.value); setIdx(0); }}
          onKeyDown={e => {
            if (e.key === 'ArrowDown') { e.preventDefault(); setIdx(i => Math.min(filtered.length - 1, i + 1)); }
            if (e.key === 'ArrowUp')   { e.preventDefault(); setIdx(i => Math.max(0, i - 1)); }
            if (e.key === 'Enter')     { filtered[idx]?.action(); setOpen(false); }
          }}
          placeholder="検索..." style={{
            width: '100%', padding: '1rem 1.25rem', background: 'transparent',
            border: 'none', color: '#fff', fontSize: '.95rem', outline: 'none',
          }} />
        <div style={{ borderTop: '1px solid rgba(255,255,255,.07)', maxHeight: 400, overflowY: 'auto' }}>
          {filtered.map((c, i) => (
            <div key={c.id} onClick={() => { c.action(); setOpen(false); }}
              style={{
                padding: '.75rem 1.25rem', cursor: 'pointer',
                background: i === idx ? 'rgba(165,180,252,.1)' : 'transparent',
                color: i === idx ? '#fff' : 'rgba(255,255,255,.85)',
              }}>
              {c.label}
            </div>
          ))}
        </div>
      </div>
    </div>
  );
}

:LiHandPointer: 使い方

対象プロジェクトに該当ファイルをコピーして、props を流し込むだけ。

:LiAlertCircle: 注意事項

  • 依存パッケージを忘れず追加