モダンUIコンポーネントの実装パターン
実装ガイドReact、Vue、Angularで共通して使えるUIパターンを解説
キーボードショートカット(⌘+K)で起動する検索・アクション実行インターフェース。ファジー検索、履歴機能、カテゴリ分類、4つのテーマを提供
または ⌘+K を押してください
機能: ファジー検索、履歴機能、カテゴリ分類、キーボードナビゲーション
用途: 一般的なWebアプリケーション、管理画面
機能: ダークモード対応、目に優しい配色
用途: 夜間作業、ダークモードアプリケーション
機能: シンプルなデザイン、軽量実装
用途: ミニマルなUI、シンプルなアプリケーション
機能: グラデーション、ブラー効果、モダンなデザイン
用途: 最新のWebアプリケーション、デザイン重視のアプリ
文字の順序が多少違っても、関連するコマンドを見つけることができます。
よく使用するコマンドを学習し、優先的に表示します。
ファジー検索対応、リアルタイムフィルタリング、カテゴリ別グループ化で素早いアクセス
⌘+Kで瞬時に起動、矢印キーでナビゲーション、Enterで実行の完全キーボード操作
カスタムコマンド追加、ショートカットキー設定、コマンド履歴、お気に入り機能
プロンプト例:
"React TypeScriptでVS Codeのようなコマンドパレットを作成してください。⌘+Kで開き、検索、キーボードナビゲーション、アクション実行機能を含めてください。"
プロンプト例:
"コマンドパレットにファジー検索機能を追加し、カテゴリ別のグループ表示とハイライト機能を実装してください。"
プロンプト例:
"大量のコマンドを効率的に処理するために、仮想スクロールとデバウンス検索を実装してください。"
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>
);
}