コマンドパレット - Vibe Coding Showcase

コマンドパレット

キーボードショートカット(⌘+K)で起動する検索・アクション実行インターフェース。ファジー検索、履歴機能、カテゴリ分類、4つのテーマを提供

デザインプレビュー

デフォルトテーマ

または ⌘+K を押してください

機能: ファジー検索、履歴機能、カテゴリ分類、キーボードナビゲーション

用途: 一般的なWebアプリケーション、管理画面

ダークテーマ

機能: ダークモード対応、目に優しい配色

用途: 夜間作業、ダークモードアプリケーション

ミニマルテーマ

機能: シンプルなデザイン、軽量実装

用途: ミニマルなUI、シンプルなアプリケーション

モダンテーマ

機能: グラデーション、ブラー効果、モダンなデザイン

用途: 最新のWebアプリケーション、デザイン重視のアプリ

高度な機能

ファジー検索

文字の順序が多少違っても、関連するコマンドを見つけることができます。

「ほめ」→「ホームに移動」
「せて」→「環境設定」
「ダシボ」→「ダッシュボード」

使用履歴

よく使用するコマンドを学習し、優先的に表示します。

よく使用されるコマンドには青いドットが表示されます

コマンドパレットの特徴

高速検索エンジン

ファジー検索対応、リアルタイムフィルタリング、カテゴリ別グループ化で素早いアクセス

キーボードファースト

⌘+Kで瞬時に起動、矢印キーでナビゲーション、Enterで実行の完全キーボード操作

拡張可能な設計

カスタムコマンド追加、ショートカットキー設定、コマンド履歴、お気に入り機能

AI活用ガイド

基本構造の実装

プロンプト例:

"React TypeScriptでVS Codeのようなコマンドパレットを作成してください。⌘+Kで開き、検索、キーボードナビゲーション、アクション実行機能を含めてください。"

検索とフィルタリング

プロンプト例:

"コマンドパレットにファジー検索機能を追加し、カテゴリ別のグループ表示とハイライト機能を実装してください。"

パフォーマンス最適化

プロンプト例:

"大量のコマンドを効率的に処理するために、仮想スクロールとデバウンス検索を実装してください。"

実装コード

ファイルサイズ: 18.5KB TypeScript

コンポーネント実装

import React, { useState, useEffect, useRef, useMemo, useCallback } from 'react';

export interface Command {
  id: string;
  label: string;
  description?: string;
  icon: React.ReactNode;
  action: () => void;
  shortcut?: string;
  category: string;
  keywords?: string[];
  priority?: number;
}

interface CommandPaletteProps {
  commands: Command[];
  isOpen: boolean;
  onClose: () => void;
  placeholder?: string;
  theme?: 'default' | 'dark' | 'minimal' | 'modern';
  maxResults?: number;
  enableHistory?: boolean;
  fuzzySearch?: boolean;
  className?: string;
  overlayClassName?: string;
}

interface HistoryItem {
  commandId: string;
  timestamp: number;
  frequency: number;
}

const CommandPalette: React.FC<CommandPaletteProps> = ({
  commands = [],
  isOpen = false,
  onClose = () => {},
  placeholder = 'アクションを検索...',
  theme = 'default',
  maxResults = 20,
  enableHistory = true,
  fuzzySearch = true,
  className = '',
  overlayClassName = ''
}) => {
  const [query, setQuery] = useState('');
  const [selectedIndex, setSelectedIndex] = useState(0);
  const [history, setHistory] = useState<HistoryItem[]>([]);
  const inputRef = useRef<HTMLInputElement>(null);
  const resultsRef = useRef<HTMLDivElement>(null);

  // ファジー検索関数
  const fuzzyMatch = useCallback((text: string, query: string): { score: number; matches: number[] } => {
    if (!query) return { score: 1, matches: [] };
    
    const textLower = text.toLowerCase();
    const queryLower = query.toLowerCase();
    
    if (!fuzzySearch) {
      const includes = textLower.includes(queryLower);
      return { 
        score: includes ? 1 : 0, 
        matches: includes ? [textLower.indexOf(queryLower)] : []
      };
    }

    let score = 0;
    let queryIndex = 0;
    const matches: number[] = [];
    
    for (let i = 0; i < textLower.length && queryIndex < queryLower.length; i++) {
      if (textLower[i] === queryLower[queryIndex]) {
        matches.push(i);
        score += 1;
        queryIndex++;
      }
    }
    
    if (queryIndex === queryLower.length) {
      // 連続マッチングにボーナス
      let consecutiveBonus = 0;
      for (let i = 1; i < matches.length; i++) {
        if (matches[i] === matches[i - 1] + 1) {
          consecutiveBonus += 0.5;
        }
      }
      
      // 開始位置ボーナス
      const startBonus = matches[0] === 0 ? 2 : 0;
      
      return { 
        score: (score + consecutiveBonus + startBonus) / textLower.length, 
        matches 
      };
    }
    
    return { score: 0, matches: [] };
  }, [fuzzySearch]);

  // コマンドフィルタリングとソート
  const filteredCommands = useMemo(() => {
    if (!query.trim()) {
      // クエリがない場合は頻度順にソート
      const sortedByHistory = [...commands].sort((a, b) => {
        const aHistory = history.find(h => h.commandId === a.id);
        const bHistory = history.find(h => h.commandId === b.id);
        
        if (aHistory && bHistory) {
          return bHistory.frequency - aHistory.frequency;
        }
        if (aHistory) return -1;
        if (bHistory) return 1;
        
        return (b.priority || 0) - (a.priority || 0);
      });
      
      return sortedByHistory.slice(0, maxResults).map(command => ({
        command,
        score: 1,
        labelMatches: [],
        descMatches: []
      }));
    }

    const scored = commands
      .map(command => {
        const labelMatch = fuzzyMatch(command.label, query);
        const descMatch = command.description ? fuzzyMatch(command.description, query) : { score: 0, matches: [] };
        const keywordMatch = command.keywords ? 
          Math.max(...command.keywords.map(k => fuzzyMatch(k, query).score)) : 0;
        
        const maxScore = Math.max(labelMatch.score, descMatch.score, keywordMatch);
        
        if (maxScore > 0) {
          // 履歴による重み付け
          const historyItem = history.find(h => h.commandId === command.id);
          const historyBonus = historyItem ? historyItem.frequency * 0.1 : 0;
          
          return {
            command,
            score: maxScore + historyBonus,
            labelMatches: labelMatch.matches,
            descMatches: descMatch.matches
          };
        }
        
        return null;
      })
      .filter((item): item is NonNullable<typeof item> => item !== null)
      .sort((a, b) => b.score - a.score)
      .slice(0, maxResults);

    return scored;
  }, [commands, query, history, maxResults, fuzzyMatch]);

  // カテゴリ別にグループ化
  const groupedCommands = useMemo(() => {
    const groups: Record<string, typeof filteredCommands> = {};
    
    filteredCommands.forEach(item => {
      const category = item.command.category;
      if (!groups[category]) {
        groups[category] = [];
      }
      groups[category].push(item);
    });
    
    return groups;
  }, [filteredCommands]);

  // 履歴の読み込み
  useEffect(() => {
    if (enableHistory && typeof window !== 'undefined') {
      try {
        const savedHistory = localStorage.getItem('command-palette-history');
        if (savedHistory) {
          setHistory(JSON.parse(savedHistory));
        }
      } catch (error) {
        console.error('Failed to load command history:', error);
      }
    }
  }, [enableHistory]);

  // 履歴の保存
  const saveToHistory = useCallback((commandId: string) => {
    if (!enableHistory || typeof window === 'undefined') return;

    setHistory(prev => {
      const existing = prev.find(item => item.commandId === commandId);
      const newHistory = existing
        ? prev.map(item =>
            item.commandId === commandId
              ? { ...item, frequency: item.frequency + 1, timestamp: Date.now() }
              : item
          )
        : [...prev, { commandId, timestamp: Date.now(), frequency: 1 }];
      
      // 最大100件まで保持
      const sortedHistory = newHistory
        .sort((a, b) => b.frequency - a.frequency)
        .slice(0, 100);
      
      try {
        localStorage.setItem('command-palette-history', JSON.stringify(sortedHistory));
      } catch (error) {
        console.error('Failed to save command history:', error);
      }
      
      return sortedHistory;
    });
  }, [enableHistory]);

  // フォーカス管理
  useEffect(() => {
    if (isOpen && inputRef.current) {
      inputRef.current.focus();
      setQuery('');
      setSelectedIndex(0);
    }
  }, [isOpen]);

  // キーボードナビゲーション
  useEffect(() => {
    const handleKeyDown = (e: KeyboardEvent) => {
      if (!isOpen) return;

      switch (e.key) {
        case 'Escape':
          onClose();
          break;
        case 'ArrowDown':
          e.preventDefault();
          setSelectedIndex(prev => 
            prev < filteredCommands.length - 1 ? prev + 1 : 0
          );
          break;
        case 'ArrowUp':
          e.preventDefault();
          setSelectedIndex(prev => 
            prev > 0 ? prev - 1 : filteredCommands.length - 1
          );
          break;
        case 'Enter':
          e.preventDefault();
          executeCommand();
          break;
        case 'Tab':
          e.preventDefault();
          if (e.shiftKey) {
            setSelectedIndex(prev => 
              prev > 0 ? prev - 1 : filteredCommands.length - 1
            );
          } else {
            setSelectedIndex(prev => 
              prev < filteredCommands.length - 1 ? prev + 1 : 0
            );
          }
          break;
      }
    };

    document.addEventListener('keydown', handleKeyDown);
    return () => document.removeEventListener('keydown', handleKeyDown);
  }, [isOpen, selectedIndex, filteredCommands.length, onClose]);

  // 選択項目のスクロール
  useEffect(() => {
    if (isOpen && resultsRef.current) {
      const selectedElement = resultsRef.current.querySelector(`[data-index="${selectedIndex}"]`);
      if (selectedElement) {
        selectedElement.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
      }
    }
  }, [selectedIndex, isOpen]);

  // コマンド実行
  const executeCommand = useCallback(() => {
    if (filteredCommands[selectedIndex]) {
      const command = filteredCommands[selectedIndex].command;
      saveToHistory(command.id);
      command.action();
      onClose();
    }
  }, [selectedIndex, filteredCommands, saveToHistory, onClose]);

  // ハイライト表示
  const highlightMatches = (text: string, matches: number[]) => {
    if (matches.length === 0) return text;
    
    const result: React.ReactNode[] = [];
    let lastIndex = 0;
    
    matches.forEach((matchIndex, i) => {
      if (matchIndex > lastIndex) {
        result.push(text.slice(lastIndex, matchIndex));
      }
      result.push(
        <mark key={i} className="bg-yellow-200 text-yellow-900 px-0.5 rounded">
          {text[matchIndex]}
        </mark>
      );
      lastIndex = matchIndex + 1;
    });
    
    if (lastIndex < text.length) {
      result.push(text.slice(lastIndex));
    }
    
    return <>{result}</>;
  };

  // テーマスタイル
  const themeStyles = {
    default: {
      overlay: 'bg-black bg-opacity-50',
      container: 'bg-white',
      input: 'text-gray-900 placeholder-gray-500',
      category: 'text-gray-500',
      commandItem: 'hover:bg-gray-50',
      selectedItem: 'bg-blue-50 border-r-2 border-blue-500',
      footer: 'bg-gray-50 text-gray-500'
    },
    dark: {
      overlay: 'bg-black bg-opacity-70',
      container: 'bg-gray-900',
      input: 'text-white placeholder-gray-400',
      category: 'text-gray-400',
      commandItem: 'hover:bg-gray-800',
      selectedItem: 'bg-blue-900 border-r-2 border-blue-400',
      footer: 'bg-gray-800 text-gray-400'
    },
    minimal: {
      overlay: 'bg-white bg-opacity-90',
      container: 'bg-white border border-gray-200',
      input: 'text-gray-900 placeholder-gray-500',
      category: 'text-gray-600',
      commandItem: 'hover:bg-gray-100',
      selectedItem: 'bg-gray-100',
      footer: 'bg-gray-50 text-gray-600'
    },
    modern: {
      overlay: 'bg-gradient-to-br from-purple-900/20 to-blue-900/20 backdrop-blur-sm',
      container: 'bg-white/95 backdrop-blur-xl border border-white/20',
      input: 'text-gray-900 placeholder-gray-500',
      category: 'text-gray-500',
      commandItem: 'hover:bg-white/50',
      selectedItem: 'bg-gradient-to-r from-blue-50 to-purple-50 border-r-2 border-blue-500',
      footer: 'bg-white/50 text-gray-500'
    }
  };

  const styles = themeStyles[theme];

  if (!isOpen) return null;

  return (
    <div 
      className={`fixed inset-0 z-50 flex items-start justify-center pt-20 px-4 transition-opacity duration-200 ${styles.overlay} ${overlayClassName}`}
      onClick={(e) => e.target === e.currentTarget && onClose()}
    >
      <div 
        className={`
          w-full max-w-2xl transform transition-all duration-200 scale-100
          rounded-xl shadow-2xl ${styles.container} ${className}
        `}
      >
        {/* 検索バー */}
        <div className="p-4 border-b border-gray-200">
          <div className="relative">
            <svg 
              className="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400" 
              fill="none" 
              stroke="currentColor" 
              viewBox="0 0 24 24"
            >
              <path 
                strokeLinecap="round" 
                strokeLinejoin="round" 
                strokeWidth={2} 
                d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" 
              />
            </svg>
            <input
              ref={inputRef}
              type="text"
              value={query}
              onChange={(e) => {
                setQuery(e.target.value);
                setSelectedIndex(0);
              }}
              placeholder={placeholder}
              className={`
                w-full pl-10 pr-4 py-3 bg-transparent border-none focus:outline-none 
                ${styles.input}
              `}
              autoComplete="off"
              spellCheck={false}
            />
            <div className="absolute right-3 top-1/2 transform -translate-y-1/2 text-xs text-gray-400">
              ESC
            </div>
          </div>
        </div>

        {/* 結果リスト */}
        <div ref={resultsRef} className="max-h-96 overflow-y-auto">
          {filteredCommands.length === 0 ? (
            <div className="p-8 text-center text-gray-500">
              <svg 
                className="w-12 h-12 mx-auto mb-4 text-gray-300" 
                fill="none" 
                stroke="currentColor" 
                viewBox="0 0 24 24"
              >
                <path 
                  strokeLinecap="round" 
                  strokeLinejoin="round" 
                  strokeWidth={2} 
                  d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" 
                />
              </svg>
              <p>該当するアクションが見つかりません</p>
              {query && (
                <p className="text-sm mt-2">
                  「{query}」に一致するコマンドはありません
                </p>
              )}
            </div>
          ) : (
            Object.entries(groupedCommands).map(([category, commands]) => (
              <div key={category} className="border-b border-gray-100 last:border-b-0">
                <div className={`px-4 py-2 text-xs font-medium uppercase tracking-wide ${styles.category}`}>
                  {category}
                </div>
                {commands.map((item) => {
                  const globalIndex = filteredCommands.indexOf(item);
                  const isSelected = selectedIndex === globalIndex;
                  const historyItem = history.find(h => h.commandId === item.command.id);
                  
                  return (
                    <div
                      key={item.command.id}
                      data-index={globalIndex}
                      className={`
                        px-4 py-3 cursor-pointer flex items-center justify-between transition-colors
                        ${isSelected ? styles.selectedItem : styles.commandItem}
                      `}
                      onClick={() => {
                        setSelectedIndex(globalIndex);
                        executeCommand();
                      }}
                      onMouseEnter={() => setSelectedIndex(globalIndex)}
                    >
                      <div className="flex items-center space-x-3 min-w-0 flex-1">
                        <div className="flex-shrink-0 w-5 h-5 text-gray-400">
                          {item.command.icon}
                        </div>
                        <div className="min-w-0 flex-1">
                          <div className="font-medium text-gray-900">
                            {highlightMatches(item.command.label, item.labelMatches)}
                          </div>
                          {item.command.description && (
                            <div className="text-sm text-gray-500 truncate">
                              {highlightMatches(item.command.description, item.descMatches)}
                            </div>
                          )}
                        </div>
                      </div>
                      <div className="flex items-center space-x-2 flex-shrink-0">
                        {historyItem && enableHistory && (
                          <div className="w-2 h-2 bg-blue-400 rounded-full" title="よく使用されます" />
                        )}
                        {item.command.shortcut && (
                          <span className="text-xs text-gray-500 font-mono">
                            {item.command.shortcut}
                          </span>
                        )}
                      </div>
                    </div>
                  );
                })}
              </div>
            ))
          )}
        </div>

        {/* フッター */}
        <div className={`px-4 py-3 border-t border-gray-200 rounded-b-xl ${styles.footer}`}>
          <div className="flex items-center justify-between text-xs">
            <span>↑↓ で選択、Enter で実行、Tab でナビゲーション</span>
            <span>ESC で閉じる</span>
          </div>
        </div>
      </div>
    </div>
  );
};

// フック: Command Paletteの状態管理
export const useCommandPalette = (commands: Command[] = []) => {
  const [isOpen, setIsOpen] = useState(false);

  useEffect(() => {
    // クライアントサイドでのみ実行
    if (typeof window === 'undefined') return;
    
    const handleKeyDown = (e: KeyboardEvent) => {
      if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
        e.preventDefault();
        setIsOpen(true);
      }
    };

    document.addEventListener('keydown', handleKeyDown);
    return () => document.removeEventListener('keydown', handleKeyDown);
  }, []);

  return {
    isOpen,
    openPalette: () => setIsOpen(true),
    closePalette: () => setIsOpen(false),
    commands
  };
};

export default CommandPalette;

使用例

import CommandPalette, { useCommandPalette, Command } from './CommandPalette';

// コマンドの定義
const commands: Command[] = [
  {
    id: 'new',
    label: '新規作成',
    description: '新しいプロジェクトを作成します',
    icon: <PlusIcon />,
    action: () => console.log('新規作成'),
    shortcut: '⌘+N',
    category: 'アクション',
    keywords: ['create', 'add'],
    priority: 10
  },
  {
    id: 'home',
    label: 'ホームに移動',
    description: 'ホーム画面に移動します',
    icon: <HomeIcon />,
    action: () => window.location.href = '/',
    shortcut: '⌘+H',
    category: 'ナビゲーション',
    priority: 8
  }
];

// フックを使用した実装
function App() {
  const { isOpen, openPalette, closePalette, CommandPalette: Palette } = useCommandPalette(commands);

  return (
    <div>
      <button onClick={openPalette}>
        コマンドパレットを開く
      </button>
      
      {/* ⌘+K で自動的に開きます */}
      <Palette
        theme="default"
        enableHistory={true}
        fuzzySearch={true}
        maxResults={20}
      />
    </div>
  );
}

// 手動実装
function ManualApp() {
  const [isOpen, setIsOpen] = useState(false);

  return (
    <div>
      <CommandPalette
        commands={commands}
        isOpen={isOpen}
        onClose={() => setIsOpen(false)}
        theme="modern"
        placeholder="コマンドを検索..."
        enableHistory={true}
        fuzzySearch={true}
      />
    </div>
  );
}