マルチセレクト - Vibe Coding Showcase

マルチセレクト

複数の項目を選択できるドロップダウン。検索、タグ表示、グループ化対応

デザインプレビュー

マルチセレクトの特徴

高度な検索機能

リアルタイム検索で大量の選択肢から素早く目的の項目を見つけることができます

タグ形式の表示

選択した項目をタグとして表示し、個別に削除可能。視認性が高く直感的な操作が可能

グループ化対応

関連する項目をグループ化して整理。大量の選択肢を論理的に分類して表示

AI活用ガイド

フォーム統合型マルチセレクト

プロンプト例:

ユーザー登録フォームに組み込むマルチセレクトを実装してください。「興味のあるトピック」を複数選択できるようにし、必須項目として最低1つ選択必要、最大5つまで選択可能とします。バリデーション機能、エラーメッセージ表示、選択済み項目のカウント表示を含めてください。

階層型カテゴリー選択

プロンプト例:

ECサイトの商品カテゴリー選択用のマルチセレクトを作成してください。カテゴリーは3階層(大分類/中分類/小分類)で構成し、親カテゴリーを選択すると子カテゴリーも自動選択される機能、選択したカテゴリーのパスを表示する機能、カテゴリー数の多い場合の仮想スクロール対応を実装してください。

非同期データ対応

プロンプト例:

APIから動的に選択肢を取得するマルチセレクトを実装してください。検索文字列に応じてAPIをコールし候補を表示、ローディング表示、エラーハンドリング、選択済み項目の永続化(localStorage)、最近選択した項目の表示機能を含めてください。

実装コード

ファイルサイズ: 5.0KB TypeScript

コンポーネント実装

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>
  );
}