担当者ピッカー
: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: 注意事項
- 依存パッケージを忘れず追加