SCALE — Build Lab
UI部品 · REACT COMPONENT

担当者ピッカー

CATEGORYUI部品 TYPEReact Component EFFORT30〜90分 DIFFICULTY
PRIMARY CODE
tsx · components/tasks/AssigneePicker.tsx
"use client";

import { useState, useRef, useEffect } from "react";
import { ChevronDown, Check, X } from "lucide-react";
import type { TaskMember } from "@/lib/tasks-api";

interface Props {
  value: string[];
  onChange: (next: string[]) => void;
  members: TaskMember[];
  /** 単一選択モード(複数選択を禁止) */
  singleMode?: boolean;
  /** プレースホルダー */
  placeholder?: string;
  /** 表示崩れ防止のため className 上書き可 */
  className?: string;
}

/**
 * 担当者の複数選択ピッカー。
 * 表示は「大串、細川」のように区切り。クリックで開いてチェックボックスで選択。
 * singleMode=true なら従来の単一選択互換。
 */
export default function AssigneePicker({ value, onChange, members, singleMode, placeholder = "未設定", className }: Props) {
  const [open, setOpen] = useState(false);
  const ref = useRef<HTMLDivElement>(null);
  useEffect(() => {
    const onDoc = (e: MouseEvent) => {
      if (!ref.current) return;
      if (!ref.current.contains(e.target as Node)) setOpen(false);
    };
    if (open) document.addEventListener('mousedown', onDoc);
    return () => document.removeEventListener('mousedown', onDoc);
  }, [open]);

  const toggle = (name: string) => {
    if (singleMode) {
      onChange(value.includes(name) ? [] : [name]);
      setOpen(false);
      return;
    }
    onChange(value.includes(name) ? value.filter(v => v !== name) : [...value, name]);
  };

  const display = value.length > 0 ? value.join('、') : placeholder;

  return (
    <div ref={ref} className={`relative ${className ?? ""}`}>
      <button
        type="button"
        onClick={() => setOpen(o => !o)}
        className="w-full flex items-center justify-between gap-2 px-3 py-2 text-sm rounded-lg bg-bg3 border border-border text-text hover:border-text3 focus:outline-none transition-colors"
      >
        <span className={`truncate text-left ${value.length === 0 ? 'text-text3' : ''}`}>{display}</span>
        <ChevronDown size={14} className="text-text3 shrink-0" />
      </button>
      {open && (
        <div className="absolute z-30 left-0 right-0 mt-1 max-h-72 overflow-y-auto bg-bg2 border border-border rounded-lg shadow-2xl">
          {value.length > 0 && !singleMode && (
            <button
              type="button"
              onClick={() => onChange([])}
              className="w-full flex items-center gap-2 px-3 py-2 text-xs text-text3 hover:text-text hover:bg-bg3 border-b border-border"
            >
              <X size={11} /> 全解除
            </button>
          )}
          {members.length === 0 ? (
            <div className="px-3 py-3 text-xs text-text3">メンバーが登録されていません</div>
          ) : (
            members.map(m => {
              const checked = value.includes(m.name);
              return (
                <button
                  key={m.id}
                  type="button"
                  onClick={() => toggle(m.name)}
                  className={`w-full flex items-center gap-2 px-3 py-2 text-sm text-left transition-colors ${checked ? 'bg-bg3/60 text-text' : 'text-text2 hover:text-text hover:bg-bg3/40'}`}
                >
                  <div className={`w-4 h-4 rounded border flex items-center justify-center shrink-0 ${checked ? 'bg-blue-500/30 border-blue-500/60' : 'border-border'}`}>
                    {checked && <Check size={11} className="text-white" />}
                  </div>
                  <span className="text-base shrink-0">{m.icon || m.name?.charAt(0) || '?'}</span>
                  <span className="flex-1 truncate">{m.name}</span>
                </button>
              );
            })
          )}
        </div>
      )}
    </div>
  );
}

前提条件
Tailwind CSS v4TypeScript 5
USE CASES
  • タスク管理
  • 案件管理
  • 採用管理

担当者ピッカー

:LiTarget: 用途

タスク・案件の担当者を素早く選択するUI。アバター表示・複数選択対応。

:LiSparkle: 特徴

  • アバター表示
  • 複数選択
  • 検索
  • キーボード操作対応

:LiCode: 実コード(SCALE Base より自動抽出)

:LiInfo: components/tasks/AssigneePicker.tsx の中身そのもの。コピペ即可。

"use client";

import { useState, useRef, useEffect } from "react";
import { ChevronDown, Check, X } from "lucide-react";
import type { TaskMember } from "@/lib/tasks-api";

interface Props {
  value: string[];
  onChange: (next: string[]) => void;
  members: TaskMember[];
  /** 単一選択モード(複数選択を禁止) */
  singleMode?: boolean;
  /** プレースホルダー */
  placeholder?: string;
  /** 表示崩れ防止のため className 上書き可 */
  className?: string;
}

/**
 * 担当者の複数選択ピッカー。
 * 表示は「大串、細川」のように区切り。クリックで開いてチェックボックスで選択。
 * singleMode=true なら従来の単一選択互換。
 */
export default function AssigneePicker({ value, onChange, members, singleMode, placeholder = "未設定", className }: Props) {
  const [open, setOpen] = useState(false);
  const ref = useRef<HTMLDivElement>(null);
  useEffect(() => {
    const onDoc = (e: MouseEvent) => {
      if (!ref.current) return;
      if (!ref.current.contains(e.target as Node)) setOpen(false);
    };
    if (open) document.addEventListener('mousedown', onDoc);
    return () => document.removeEventListener('mousedown', onDoc);
  }, [open]);

  const toggle = (name: string) => {
    if (singleMode) {
      onChange(value.includes(name) ? [] : [name]);
      setOpen(false);
      return;
    }
    onChange(value.includes(name) ? value.filter(v => v !== name) : [...value, name]);
  };

  const display = value.length > 0 ? value.join('、') : placeholder;

  return (
    <div ref={ref} className={`relative ${className ?? ""}`}>
      <button
        type="button"
        onClick={() => setOpen(o => !o)}
        className="w-full flex items-center justify-between gap-2 px-3 py-2 text-sm rounded-lg bg-bg3 border border-border text-text hover:border-text3 focus:outline-none transition-colors"
      >
        <span className={`truncate text-left ${value.length === 0 ? 'text-text3' : ''}`}>{display}</span>
        <ChevronDown size={14} className="text-text3 shrink-0" />
      </button>
      {open && (
        <div className="absolute z-30 left-0 right-0 mt-1 max-h-72 overflow-y-auto bg-bg2 border border-border rounded-lg shadow-2xl">
          {value.length > 0 && !singleMode && (
            <button
              type="button"
              onClick={() => onChange([])}
              className="w-full flex items-center gap-2 px-3 py-2 text-xs text-text3 hover:text-text hover:bg-bg3 border-b border-border"
            >
              <X size={11} /> 全解除
            </button>
          )}
          {members.length === 0 ? (
            <div className="px-3 py-3 text-xs text-text3">メンバーが登録されていません</div>
          ) : (
            members.map(m => {
              const checked = value.includes(m.name);
              return (
                <button
                  key={m.id}
                  type="button"
                  onClick={() => toggle(m.name)}
                  className={`w-full flex items-center gap-2 px-3 py-2 text-sm text-left transition-colors ${checked ? 'bg-bg3/60 text-text' : 'text-text2 hover:text-text hover:bg-bg3/40'}`}
                >
                  <div className={`w-4 h-4 rounded border flex items-center justify-center shrink-0 ${checked ? 'bg-blue-500/30 border-blue-500/60' : 'border-border'}`}>
                    {checked && <Check size={11} className="text-white" />}
                  </div>
                  <span className="text-base shrink-0">{m.icon || m.name?.charAt(0) || '?'}</span>
                  <span className="flex-1 truncate">{m.name}</span>
                </button>
              );
            })
          )}
        </div>
      )}
    </div>
  );
}

:LiFolder: ソースファイルのパス

/Users/oogushiyuuki/Library/CloudStorage/GoogleDrive-y-ogushi@scale-group.co.jp/マイドライブ/AI/scale-base/components/tasks/AssigneePicker.tsx

:LiHandPointer: 使い方

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

:LiAlertCircle: 注意事項

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