モダンUIコンポーネントの実装パターン
実装ガイドReact、Vue、Angularで共通して使えるUIパターンを解説
複数の項目を選択できるドロップダウン。検索、タグ表示、グループ化対応
リアルタイム検索で大量の選択肢から素早く目的の項目を見つけることができます
選択した項目をタグとして表示し、個別に削除可能。視認性が高く直感的な操作が可能
関連する項目をグループ化して整理。大量の選択肢を論理的に分類して表示
プロンプト例:
ユーザー登録フォームに組み込むマルチセレクトを実装してください。「興味のあるトピック」を複数選択できるようにし、必須項目として最低1つ選択必要、最大5つまで選択可能とします。バリデーション機能、エラーメッセージ表示、選択済み項目のカウント表示を含めてください。
プロンプト例:
ECサイトの商品カテゴリー選択用のマルチセレクトを作成してください。カテゴリーは3階層(大分類/中分類/小分類)で構成し、親カテゴリーを選択すると子カテゴリーも自動選択される機能、選択したカテゴリーのパスを表示する機能、カテゴリー数の多い場合の仮想スクロール対応を実装してください。
プロンプト例:
APIから動的に選択肢を取得するマルチセレクトを実装してください。検索文字列に応じてAPIをコールし候補を表示、ローディング表示、エラーハンドリング、選択済み項目の永続化(localStorage)、最近選択した項目の表示機能を含めてください。
import React, { useState, useRef, useEffect, useMemo } from 'react';
import { ChevronDownIcon, XMarkIcon, CheckIcon } from '@heroicons/react/24/outline';
interface Option {
value: string;
label: string;
group?: string;
}
interface MultiSelectProps {
options: Option[];
value?: string[];
onChange?: (value: string[]) => void;
placeholder?: string;
searchable?: boolean;
groupBy?: boolean;
maxItems?: number;
disabled?: boolean;
className?: string;
}
export const MultiSelect: React.FC<MultiSelectProps> = ({
options,
value = [],
onChange,
placeholder = '選択してください',
searchable = true,
groupBy = false,
maxItems,
disabled = false,
className = ''
}) => {
const [isOpen, setIsOpen] = useState(false);
const [searchTerm, setSearchTerm] = useState('');
const [selectedValues, setSelectedValues] = useState<string[]>(value);
const dropdownRef = useRef<HTMLDivElement>(null);
const searchInputRef = useRef<HTMLInputElement>(null);
// 選択されたオプションの詳細を取得
const selectedOptions = useMemo(() =>
options.filter(opt => selectedValues.includes(opt.value)),
[options, selectedValues]
);
// フィルタリングされたオプション
const filteredOptions = useMemo(() => {
if (!searchTerm) return options;
return options.filter(opt =>
opt.label.toLowerCase().includes(searchTerm.toLowerCase())
);
}, [options, searchTerm]);
// グループ化されたオプション
const groupedOptions = useMemo(() => {
if (!groupBy) return { '': filteredOptions };
return filteredOptions.reduce((acc, opt) => {
const group = opt.group || 'その他';
if (!acc[group]) acc[group] = [];
acc[group].push(opt);
return acc;
}, {} as Record<string, Option[]>);
}, [filteredOptions, groupBy]);
// オプションの選択/解除
const toggleOption = (optionValue: string) => {
const newValues = selectedValues.includes(optionValue)
? selectedValues.filter(v => v !== optionValue)
: [...selectedValues, optionValue];
if (maxItems && newValues.length > maxItems) {
return;
}
setSelectedValues(newValues);
onChange?.(newValues);
};
// タグの削除
const removeTag = (valueToRemove: string) => {
const newValues = selectedValues.filter(v => v !== valueToRemove);
setSelectedValues(newValues);
onChange?.(newValues);
};
// 外側クリックで閉じる
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
// ドロップダウンを開いたときに検索入力にフォーカス
useEffect(() => {
if (isOpen && searchable && searchInputRef.current) {
searchInputRef.current.focus();
}
}, [isOpen, searchable]);
return (
<div className={`relative ${className}`} ref={dropdownRef}>
{/* メインコンテナ */}
<div
onClick={() => !disabled && setIsOpen(!isOpen)}
className={`
min-h-[42px] px-3 py-2 border rounded-lg cursor-pointer
${disabled ? 'bg-gray-100 cursor-not-allowed' : 'bg-white hover:border-gray-400'}
${isOpen ? 'border-blue-500 ring-2 ring-blue-500/20' : 'border-gray-300'}
`}
>
<div className="flex items-center justify-between gap-2">
<div className="flex-1 flex flex-wrap gap-1">
{selectedOptions.length === 0 ? (
<span className="text-gray-500">{placeholder}</span>
) : (
selectedOptions.map(opt => (
<span
key={opt.value}
className="inline-flex items-center gap-1 px-2 py-1 bg-blue-100 text-blue-800 rounded-full text-sm"
>
{opt.label}
<button
onClick={(e) => {
e.stopPropagation();
removeTag(opt.value);
}}
className="hover:bg-blue-200 rounded-full p-0.5"
>
<XMarkIcon className="w-3 h-3" />
</button>
</span>
))
)}
</div>
<ChevronDownIcon
className={`w-5 h-5 text-gray-400 transition-transform ${isOpen ? 'rotate-180' : ''}`}
/>
</div>
</div>
{/* ドロップダウン */}
{isOpen && (
<div className="absolute z-50 w-full mt-1 bg-white border border-gray-200 rounded-lg shadow-lg">
{/* 検索ボックス */}
{searchable && (
<div className="p-2 border-b border-gray-200">
<input
ref={searchInputRef}
type="text"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="検索..."
className="w-full px-3 py-1.5 text-sm border border-gray-300 rounded focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
</div>
)}
{/* オプションリスト */}
<div className="max-h-60 overflow-y-auto">
{Object.entries(groupedOptions).map(([group, groupOptions]) => (
<div key={group}>
{groupBy && group && (
<div className="px-3 py-1.5 text-xs font-semibold text-gray-500 bg-gray-50">
{group}
</div>
)}
{groupOptions.map(option => (
<div
key={option.value}
onClick={() => toggleOption(option.value)}
className={`
flex items-center gap-2 px-3 py-2 cursor-pointer
hover:bg-gray-50
${selectedValues.includes(option.value) ? 'bg-blue-50' : ''}
`}
>
<div className={`
w-4 h-4 border rounded flex items-center justify-center
${selectedValues.includes(option.value)
? 'bg-blue-500 border-blue-500'
: 'border-gray-300'}
`}>
{selectedValues.includes(option.value) && (
<CheckIcon className="w-3 h-3 text-white" />
)}
</div>
<span className="text-sm">{option.label}</span>
</div>
))}
</div>
))}
{filteredOptions.length === 0 && (
<div className="px-3 py-8 text-center text-sm text-gray-500">
該当する項目がありません
</div>
)}
</div>
{/* フッター */}
{maxItems && (
<div className="px-3 py-2 text-xs text-gray-500 border-t border-gray-200">
{selectedValues.length} / {maxItems} 選択中
</div>
)}
</div>
)}
</div>
);
};
// 使用例
export const MultiSelectExample = () => {
const [selectedFrameworks, setSelectedFrameworks] = useState<string[]>(['react', 'vue']);
const frameworkOptions: Option[] = [
{ value: 'react', label: 'React', group: 'ライブラリ' },
{ value: 'vue', label: 'Vue.js', group: 'ライブラリ' },
{ value: 'angular', label: 'Angular', group: 'フレームワーク' },
{ value: 'svelte', label: 'Svelte', group: 'ライブラリ' },
{ value: 'nextjs', label: 'Next.js', group: 'フレームワーク' },
{ value: 'nuxtjs', label: 'Nuxt.js', group: 'フレームワーク' },
{ value: 'gatsby', label: 'Gatsby', group: 'フレームワーク' },
{ value: 'remix', label: 'Remix', group: 'フレームワーク' },
];
const languageOptions: Option[] = [
{ value: 'js', label: 'JavaScript' },
{ value: 'ts', label: 'TypeScript' },
{ value: 'python', label: 'Python' },
{ value: 'java', label: 'Java' },
{ value: 'csharp', label: 'C#' },
{ value: 'go', label: 'Go' },
{ value: 'rust', label: 'Rust' },
{ value: 'php', label: 'PHP' },
];
return (
<div className="p-8 max-w-2xl mx-auto space-y-6">
<h3 className="text-lg font-semibold mb-4">マルチセレクトの例</h3>
{/* 基本的な使用 */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
使用フレームワーク(グループ化あり)
</label>
<MultiSelect
options={frameworkOptions}
value={selectedFrameworks}
onChange={setSelectedFrameworks}
placeholder="フレームワークを選択"
searchable={true}
groupBy={true}
/>
<p className="mt-2 text-sm text-gray-600">
選択中: {selectedFrameworks.join(', ') || 'なし'}
</p>
</div>
{/* 最大選択数制限 */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
プログラミング言語(最大3つまで)
</label>
<MultiSelect
options={languageOptions}
placeholder="言語を選択(最大3つ)"
maxItems={3}
searchable={true}
/>
</div>
{/* 検索なし */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
シンプルな選択(検索なし)
</label>
<MultiSelect
options={[
{ value: 'read', label: '読み取り' },
{ value: 'write', label: '書き込み' },
{ value: 'delete', label: '削除' },
{ value: 'admin', label: '管理者' },
]}
placeholder="権限を選択"
searchable={false}
/>
</div>
{/* 無効化状態 */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
無効化されたマルチセレクト
</label>
<MultiSelect
options={frameworkOptions}
value={['react', 'nextjs']}
disabled
/>
</div>
</div>
);
};
// 基本的な使用例
import { MultiSelect } from './components/multi-select';
import { useState } from 'react';
function UserPreferencesForm() {
const [selectedSkills, setSelectedSkills] = useState<string[]>([]);
const skillOptions = [
{ value: 'react', label: 'React', group: 'フロントエンド' },
{ value: 'vue', label: 'Vue.js', group: 'フロントエンド' },
{ value: 'node', label: 'Node.js', group: 'バックエンド' },
{ value: 'python', label: 'Python', group: 'バックエンド' },
{ value: 'docker', label: 'Docker', group: 'インフラ' },
{ value: 'aws', label: 'AWS', group: 'インフラ' },
];
return (
<form className="space-y-4">
<div>
<label className="block text-sm font-medium mb-2">
保有スキル(最大5つまで)
</label>
<MultiSelect
options={skillOptions}
value={selectedSkills}
onChange={setSelectedSkills}
placeholder="スキルを選択してください"
searchable={true}
groupBy={true}
maxItems={5}
/>
<p className="mt-2 text-sm text-gray-600">
{selectedSkills.length}/5 選択中
</p>
</div>
<button
type="submit"
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
disabled={selectedSkills.length === 0}
>
保存
</button>
</form>
);
}