workspace-ui-kit/components/workspace/CandidateListPane.tsx
snc777 7cb2209735 feat: Pane2にD&Dとアーカイブを追加
- Pane2 の候補者カードを D&D で並び替え・アーカイブできるように
- README のサンプル画面スクリーンショットを最新版に差し替え
2026-05-09 16:12:21 +09:00

562 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { useState } from "react";
import {
Archive,
ArchiveRestore,
ChevronDown,
MoreHorizontal,
Plus,
Star,
} from "lucide-react";
import {
DndContext,
DragOverlay,
KeyboardSensor,
PointerSensor,
closestCenter,
useDroppable,
useSensor,
useSensors,
type Announcements,
type DragEndEvent,
type DragStartEvent,
type ScreenReaderInstructions,
} from "@dnd-kit/core";
import {
SortableContext,
sortableKeyboardCoordinates,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { cn } from "@/lib/utils";
import {
type CandidateRow,
type Group,
type StageKey,
} from "@/lib/schema";
import { DeleteConfirmDialog } from "@/components/workspace/DeleteConfirmDialog";
import { SortableCandidateRow } from "@/components/workspace/SortableCandidateRow";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { ScrollArea } from "@/components/ui/scroll-area";
import { AddItemDialog } from "@/components/workspace/AddItemDialog";
import { STAGE_LABELS } from "@/lib/labels";
// dnd-kit のスクリーンリーダー向け日本語化。
const screenReaderInstructions: ScreenReaderInstructions = {
draggable:
"Space または Enter で候補者を持ち上げ、矢印キーで移動、Space で確定、Esc でキャンセルします。",
};
type CandidateListPaneProps = {
groups: Group[];
selectedCandidateId: string;
onSelectCandidate: (id: string) => void;
onAddCandidate: (stage: StageKey, name: string) => void;
onArchiveCandidate: (id: string) => void;
onRestoreCandidate: (id: string) => void;
onMoveCandidate: (id: string, toStage: StageKey, toIndex: number) => void;
};
export function CandidateListPane({
groups,
selectedCandidateId,
onSelectCandidate,
onAddCandidate,
onArchiveCandidate,
onRestoreCandidate,
onMoveCandidate,
}: CandidateListPaneProps) {
const [addDialogStage, setAddDialogStage] = useState<{
stage: StageKey;
label: string;
} | null>(null);
const [archiveTarget, setArchiveTarget] = useState<{
id: string;
name: string;
} | null>(null);
const [archivedOpen, setArchivedOpen] = useState(false);
const [activeDragId, setActiveDragId] = useState<string | null>(null);
// PointerSensor の distance 制約で「クリック」と「ドラッグ」を区別する
// 6px 以上動かさないとドラッグ起動しない → 行クリック / メニュー操作と衝突しない)。
// KeyboardSensor は Tab 移動 → Space で持ち上げ → 矢印で移動 → Enter で確定。
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
);
const stageGroups = groups.filter(
(g): g is Extract<Group, { kind: "stage" }> => g.kind === "stage",
);
const archivedGroup = groups.find(
(g): g is Extract<Group, { kind: "archived" }> => g.kind === "archived",
);
// ドラッグ中の浮遊行表示用に元データを引く
const activeDragRow: { row: CandidateRow; stage: StageKey } | null = (() => {
if (!activeDragId) return null;
for (const g of stageGroups) {
const row = g.items.find((r) => r.id === activeDragId);
if (row) return { row, stage: g.stage };
}
return null;
})();
const announcements: Announcements = {
onDragStart: ({ active }) => {
const name = (active.data.current?.name as string | undefined) ?? "候補者";
return `${name}を持ち上げました。`;
},
onDragOver: ({ active, over }) => {
const name = (active.data.current?.name as string | undefined) ?? "候補者";
if (!over) return `${name}を移動中です。`;
const overContainer = over.data.current?.containerId as
| StageKey
| undefined;
if (overContainer)
return `${name}を「${STAGE_LABELS[overContainer]}」の上に移動しました。`;
return `${name}を移動中です。`;
},
onDragEnd: ({ active, over }) => {
const name = (active.data.current?.name as string | undefined) ?? "候補者";
if (!over) return `${name}の移動をキャンセルしました。`;
const overContainer =
(over.data.current?.containerId as StageKey | undefined) ??
(typeof over.id === "string" &&
stageGroups.some((g) => g.stage === over.id)
? (over.id as StageKey)
: undefined);
if (!overContainer) return `${name}を確定しました。`;
return `${name}を「${STAGE_LABELS[overContainer]}」に移動しました。`;
},
onDragCancel: ({ active }) => {
const name = (active.data.current?.name as string | undefined) ?? "候補者";
return `${name}の移動をキャンセルしました。`;
},
};
const handleDragStart = (event: DragStartEvent) => {
setActiveDragId(String(event.active.id));
};
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
setActiveDragId(null);
if (!over) return;
const activeContainer = active.data.current?.containerId as
| StageKey
| undefined;
// over の所属コンテナは、要素ドロップなら data.current.containerId、
// SortableContext そのものへのドロップなら over.id= "stage" のキー)。
const overContainer =
(over.data.current?.containerId as StageKey | undefined) ??
(typeof over.id === "string" &&
stageGroups.some((g) => g.stage === over.id)
? (over.id as StageKey)
: undefined);
if (!activeContainer || !overContainer) return;
const targetGroup = stageGroups.find((g) => g.stage === overContainer);
if (!targetGroup) return;
let toIndex: number;
if (active.id === over.id) return;
const overIndexInTarget = targetGroup.items.findIndex(
(r) => r.id === over.id,
);
if (overIndexInTarget >= 0) {
// 同コンテナ内: active を一旦取り除いた後の index を保つため、
// active が同コンテナ内で over より前にあれば overIndexInTarget はそのまま、
// 後ろなら -1 ずれる、という自然な算出になる。Workspace.moveCandidate 側で
// active を除いた配列上で挿入するため、ここでは単純に over の現 index を渡す。
toIndex = overIndexInTarget;
} else {
// 空コンテナ・末尾領域へのドロップ: 末尾に追加
toIndex = targetGroup.items.length;
}
onMoveCandidate(String(active.id), overContainer, toIndex);
};
return (
<section className="flex w-[280px] shrink-0 flex-col border-r border-border bg-background">
<header className="flex h-12 shrink-0 items-center border-b border-border px-3">
<h2 className="truncate text-sm font-semibold text-foreground">
</h2>
</header>
<ScrollArea className="min-h-0 flex-1">
<DndContext
// 固定 id を渡して SSR/CSR 間の `aria-describedby` 採番ズレ
// DndDescribedBy-N の連番による hydration mismatch を回避する
id="pane2-candidate-dnd"
sensors={sensors}
collisionDetection={closestCenter}
accessibility={{ announcements, screenReaderInstructions }}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragCancel={() => setActiveDragId(null)}
>
<div className="flex flex-col gap-5 px-3 py-4">
{stageGroups.map((group) => (
<StageGroup
key={`stage:${group.stage}`}
stage={group.stage}
label={group.label}
items={group.items}
selectedCandidateId={selectedCandidateId}
onSelectCandidate={onSelectCandidate}
onAddRequest={() =>
setAddDialogStage({
stage: group.stage,
label: group.label,
})
}
onArchiveRequest={(id, name) =>
setArchiveTarget({ id, name })
}
/>
))}
{archivedGroup && (
<ArchivedGroup
label={archivedGroup.label}
items={archivedGroup.items}
open={archivedOpen}
onOpenChange={setArchivedOpen}
selectedCandidateId={selectedCandidateId}
onSelectCandidate={onSelectCandidate}
onRestore={onRestoreCandidate}
/>
)}
</div>
<DragOverlay>
{activeDragRow && (
<div className="flex items-center gap-2 rounded-md bg-accent px-2.5 py-2.5 text-accent-foreground shadow-lg">
<Avatar className="size-8 shrink-0">
<AvatarFallback className="bg-primary/10 text-xs text-primary">
{activeDragRow.row.name[0] ?? "?"}
</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<p className="truncate text-sm">{activeDragRow.row.name}</p>
</div>
</div>
)}
</DragOverlay>
</DndContext>
</ScrollArea>
{addDialogStage && (
<AddItemDialog
open={addDialogStage !== null}
onOpenChange={(open) => {
if (!open) setAddDialogStage(null);
}}
title="候補者を追加"
description={`${addDialogStage.label}」ステージに候補者を追加します`}
fieldLabel="氏名"
fieldId="candidate-name"
placeholder="例: 山田 太郎"
onAdd={(name) => onAddCandidate(addDialogStage.stage, name)}
/>
)}
<DeleteConfirmDialog
open={archiveTarget !== null}
onOpenChange={(open) => {
if (!open) setArchiveTarget(null);
}}
title="候補者をアーカイブしますか?"
itemName={archiveTarget?.name ?? ""}
description={`${archiveTarget?.name ?? ""}」をアーカイブします。後で「アーカイブ済み」から復元できます。`}
actionLabel="アーカイブ"
onConfirm={() => {
if (archiveTarget) {
onArchiveCandidate(archiveTarget.id);
setArchiveTarget(null);
}
}}
/>
</section>
);
}
function StageGroup({
stage,
label,
items,
selectedCandidateId,
onSelectCandidate,
onAddRequest,
onArchiveRequest,
}: {
stage: StageKey;
label: string;
items: CandidateRow[];
selectedCandidateId: string;
onSelectCandidate: (id: string) => void;
onAddRequest: () => void;
onArchiveRequest: (id: string, name: string) => void;
}) {
// 空ステージでもドロップを受け取れるようにする(最後の 1 名を別ステージへ
// 動かした後の戻し先を保つため、ステージは常時表示)。
// SortableContext id (= stage) は要素の並び替え用、useDroppable id は
// 「コンテナ自体」の drop ターゲット用。両者を分けることで衝突を避ける。
const { setNodeRef, isOver } = useDroppable({
id: `dropzone:${stage}`,
data: { containerId: stage },
});
return (
<div>
<div className="sticky top-0 z-10 -mx-3 mb-2 flex items-center justify-between gap-2 bg-background px-5 py-1.5">
<div className="flex min-w-0 items-center gap-1.5">
<h3 className="truncate text-xs font-medium text-muted-foreground">
{label}
</h3>
<Badge variant="secondary" size="xs">
{items.length}
</Badge>
</div>
<Button
type="button"
variant="ghost"
size="icon-xs"
onClick={onAddRequest}
aria-label={`${label} に候補者を追加`}
className="text-muted-foreground hover:text-foreground"
>
<Plus aria-hidden="true" />
</Button>
</div>
<SortableContext
id={stage}
items={items.map((i) => i.id)}
strategy={verticalListSortingStrategy}
>
<ul
ref={setNodeRef}
data-stage={stage}
className={cn(
"flex flex-col gap-1",
items.length === 0 && "min-h-12 rounded-md border border-dashed border-border/70 p-2",
items.length === 0 && isOver && "border-primary/60 bg-primary/5",
)}
>
{items.length === 0 ? (
<li
className={cn(
"pointer-events-none flex h-8 items-center justify-center text-xs",
isOver ? "text-primary" : "text-muted-foreground",
)}
aria-hidden="true"
>
</li>
) : (
items.map((cand) => (
<SortableCandidateRow
key={cand.id}
cand={cand}
stage={stage}
selected={cand.id === selectedCandidateId}
onSelect={onSelectCandidate}
actions={
<DropdownMenuItem
variant="destructive"
onSelect={() => onArchiveRequest(cand.id, cand.name)}
>
<Archive />
</DropdownMenuItem>
}
/>
))
)}
</ul>
</SortableContext>
</div>
);
}
function ArchivedGroup({
label,
items,
open,
onOpenChange,
selectedCandidateId,
onSelectCandidate,
onRestore,
}: {
label: string;
items: CandidateRow[];
open: boolean;
onOpenChange: (open: boolean) => void;
selectedCandidateId: string;
onSelectCandidate: (id: string) => void;
onRestore: (id: string) => void;
}) {
return (
<Collapsible open={open} onOpenChange={onOpenChange}>
<CollapsibleTrigger
nativeButton={false}
render={
<div
className={cn(
"group/archived-trigger sticky top-0 z-10 -mx-3 mb-2 flex cursor-pointer items-center justify-between gap-2 bg-background px-5 py-1.5",
"rounded-md outline-none focus-visible:ring-3 focus-visible:ring-ring/50",
)}
/>
}
>
<div className="flex min-w-0 items-center gap-1.5">
<h3 className="truncate text-xs font-medium text-muted-foreground">
{label}
</h3>
<Badge variant="secondary" size="xs">
{items.length}
</Badge>
</div>
<ChevronDown
aria-hidden="true"
className="size-4 text-muted-foreground transition-[color,transform] group-hover/archived-trigger:text-foreground in-data-[panel-open]:rotate-180"
/>
<span className="sr-only">{`${label}を開く`}</span>
</CollapsibleTrigger>
<CollapsibleContent>
<ul className="flex flex-col gap-1" data-stage="archived">
{items.map((cand) => (
<ArchivedRowItem
key={cand.id}
cand={cand}
selected={cand.id === selectedCandidateId}
onSelect={onSelectCandidate}
onRestore={onRestore}
/>
))}
</ul>
</CollapsibleContent>
</Collapsible>
);
}
function ArchivedRowItem({
cand,
selected,
onSelect,
onRestore,
}: {
cand: CandidateRow;
selected: boolean;
onSelect: (id: string) => void;
onRestore: (id: string) => void;
}) {
return (
<li className="group/candidate relative">
<button
type="button"
onClick={() => onSelect(cand.id)}
className={cn(
"flex w-full items-center gap-3 rounded-md px-2.5 py-2.5 text-left transition-colors",
"outline-none focus-visible:ring-3 focus-visible:ring-ring/50",
selected
? "bg-accent text-accent-foreground"
: "text-foreground hover:bg-muted",
)}
>
<Avatar className="size-8 shrink-0">
<AvatarFallback className="bg-primary/10 text-xs text-primary">
{cand.name[0] ?? "?"}
</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<p className="truncate text-sm">{cand.name}</p>
</div>
<span className="transition-opacity group-focus-within/candidate:opacity-0 group-hover/candidate:opacity-0">
<ScoreBadge avg={cand.averageScore} selected={selected} />
</span>
</button>
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
type="button"
variant="ghost"
size="icon-xs"
className={cn(
"absolute top-1/2 right-1 -translate-y-1/2",
"opacity-0 group-focus-within/candidate:opacity-100 group-hover/candidate:opacity-100",
"transition-opacity",
"text-muted-foreground hover:text-foreground",
)}
aria-label={`${cand.name} の操作`}
>
<MoreHorizontal />
</Button>
}
/>
<DropdownMenuContent side="right" align="start">
<DropdownMenuGroup>
<DropdownMenuItem onSelect={() => onRestore(cand.id)}>
<ArchiveRestore />
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</li>
);
}
function ScoreBadge({
avg,
selected,
}: {
avg: number | null;
selected: boolean;
}) {
if (avg === null) {
return (
<span
className={cn(
"shrink-0 text-xs",
selected ? "text-accent-foreground/80" : "text-muted-foreground",
)}
aria-label="未評価"
>
</span>
);
}
return (
<span
className={cn(
"inline-flex shrink-0 items-center gap-0.5 text-xs tabular-nums",
selected ? "text-accent-foreground" : "text-foreground/80",
)}
aria-label={`平均スコア ${avg.toFixed(1)} / 5`}
>
<Star aria-hidden className="size-3 fill-current" />
{avg.toFixed(1)}
</span>
);
}