bulk-select-actions について
bulk-select-actionsは、テーブルやリストに複数選択と一括操作を実装するスキルです。選択やツールバーの再利用可能なパターンに加え、破壊的操作の確認方法も扱います。
主な機能
- 01ページ側で管理する選択状態
- 02不確定状態に対応するチェックボックス
- 03フローティング一括操作ツールバー
- 04項目の状態に応じた操作
- 05破壊的操作の確認
- 06再利用可能なテーブルと選択のテンプレート
活用シーン
- 01データテーブルへの複数選択追加
- 02一括アーカイブやステータス変更の提供
- 03一括削除時の確認
- 04複数のリストで選択UIを再利用
インストール
コマンドをコピーしてターミナルで実行します。
$
npx skills add https:/ / github.com/ blink-new/ claude --skill bulk-select-actionsインストール後、Claude Code で/bulk-select-actionsを入力して読み込まれたか確認します。
同梱ファイル
SKILL.md と一緒にインストールされる参考資料やスクリプト。
assets/components/bulk-actions-toolbar.tsxselectable-table.tsxassets/hooks/use-bulk-selection.ts詳細
このスキルを使用する場面
次の場合に使用します。
- 複数選択機能を備えたテーブルやリストを作成する
- 一括操作(削除、アーカイブ、エクスポート、ステータス変更)を実装する
- 選択時に表示されるフローティング操作ツールバーが必要
- 複数のテーブルで選択時の UX を統一したい
技術スタック
| パッケージ | バージョン | 目的 |
|---|---|---|
@radix-ui/react-checkbox | ^1.x | 不確定状態に対応するチェックボックス |
tailwindcss-animate | ^1.x | 表示アニメーション |
shadcn/ui | latest | AlertDialog、Button、DropdownMenu |
lucide-react | ^0.x | アイコン |
アーキテクチャの概要
┌─────────────────────────────────────────────────────────────┐
│ Page Component (selection state owner) │
│ ├── selectedIds: Set<string> │
│ ├── onSelectionChange: (ids: Set<string>) => void │
│ └── bulk action handlers │
├─────────────────────────────────────────────────────────────┤
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Table Component │ │
│ │ ┌─────┬─────────────────────────────────────────┐ │ │
│ │ │ ☑️ │ Header row with select-all checkbox │ │ │
│ │ ├─────┼─────────────────────────────────────────┤ │ │
│ │ │ ☐ │ Row 1 │ │ │
│ │ │ ☑️ │ Row 2 (selected) │ │ │
│ │ │ ☑️ │ Row 3 (selected) │ │ │
│ │ │ ☐ │ Row 4 │ │ │
│ │ └─────┴─────────────────────────────────────────┘ │ │
│ └─────────────────────────────────────────────────────┘ │
├─────────────────────────────────────────────────────────────┤
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Floating Toolbar (fixed bottom center, z-50) │ │
│ │ ┌──────────────┬────────────┬────────────┬───────┐ │ │
│ │ │ 2 selected ✕ │ Action 1 │ Action 2 │ Delete│ │ │
│ │ └──────────────┴────────────┴────────────┴───────┘ │ │
│ └─────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
重要なパターン
1. 選択状態の管理(ページレベル)
選択状態はテーブルではなく、ページコンポーネントに保持する必要があります。
// page.tsx
"use client";
import { useState } from "react";
export default function ItemsPage() {
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
// Clear selection when filters change
const handleFilterChange = (value: string) => {
setFilter(value);
setSelectedIds(new Set());
};
return (
<>
<ItemsTable
items={items}
selectedIds={selectedIds}
onSelectionChange={setSelectedIds}
/>
<BulkActionsToolbar
selectedIds={selectedIds}
onClearSelection={() => setSelectedIds(new Set())}
// ... action handlers
/>
</>
);
}
2. チェックボックスの不確定状態(重要)
"indeterminate" 値を指定した組み込みの checked プロパティを使用します。
// ✅ CORRECT - Use built-in indeterminate prop
<Checkbox
checked={allSelected ? true : someSelected ? "indeterminate" : false}
onCheckedChange={handleSelectAll}
aria-label="Select all"
/>
// ❌ WRONG - Manual ref approach (gets overwritten by Radix)
<Checkbox
checked={allSelected}
ref={(el) => {
if (el) {
(el as HTMLButtonElement).dataset.state = someSelected
? "indeterminate"
: "checked";
}
}}
/>
3. 選択ロジック
// Table component
const allSelected = items.length > 0 && items.every((item) => selectedIds.has(item.id));
const someSelected = items.some((item) => selectedIds.has(item.id)) && !allSelected;
const handleSelectAll = () => {
if (allSelected) {
onSelectionChange(new Set()); // Deselect all
} else {
onSelectionChange(new Set(items.map((item) => item.id))); // Select all
}
};
const handleSelectOne = (id: string, checked: boolean) => {
const newSet = new Set(selectedIds);
if (checked) {
newSet.add(id);
} else {
newSet.delete(id);
}
onSelectionChange(newSet);
};
4. フローティングツールバーの位置とアニメーション
// CRITICAL: These exact classes for consistent UX
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-50 animate-in fade-in slide-in-from-bottom-4 duration-200">
<div className="flex items-center gap-2 bg-background border rounded-lg shadow-lg px-4 py-3">
{/* Toolbar content */}
</div>
</div>
5. ツールバーの構造
export function BulkActionsToolbar({
selectedIds,
onClearSelection,
onAction1,
onAction2,
onDelete,
isLoading = false,
}: BulkActionsToolbarProps) {
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const selectedCount = selectedIds.size;
// Hide when nothing selected
if (selectedCount === 0) {
return null;
}
return (
<>
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-50 animate-in fade-in slide-in-from-bottom-4 duration-200">
<div className="flex items-center gap-2 bg-background border rounded-lg shadow-lg px-4 py-3">
{/* Selected count with clear button */}
<div className="flex items-center gap-2 pr-3 border-r">
<span className="text-sm font-medium">
{selectedCount} selected
</span>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0"
onClick={onClearSelection}
>
<X className="h-4 w-4" />
</Button>
</div>
{/* Action buttons */}
<Button variant="outline" size="sm" onClick={onAction1} disabled={isLoading}>
<Icon className="h-4 w-4 mr-2" />
Action 1
</Button>
{/* Destructive action - always last, with confirmation */}
<Button
variant="outline"
size="sm"
onClick={() => setDeleteDialogOpen(true)}
disabled={isLoading}
className="text-red-600 hover:text-red-700 hover:bg-red-50"
>
<Trash2 className="h-4 w-4 mr-2" />
Delete
</Button>
</div>
</div>
{/* Confirmation dialog for destructive actions */}
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Items</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete {selectedCount} item
{selectedCount === 1 ? "" : "s"}? This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => { onDelete(); setDeleteDialogOpen(false); }}
className="bg-red-600 hover:bg-red-700"
>
Delete {selectedCount} Item{selectedCount === 1 ? "" : "s"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}
6. 行のチェックボックス — 行クリックを防止する
<TableRow
className="cursor-pointer"
onClick={() => onRowClick(item.id)}
>
<TableCell onClick={(e) => e.stopPropagation()}>
<Checkbox
checked={selectedIds.has(item.id)}
onCheckedChange={(checked) => handleSelectOne(item.id, !!checked)}
aria-label={`Select ${item.name}`}
/>
</TableCell>
{/* ... other cells */}
</TableRow>
7. 項目の状態に応じたアクション
// Check selected items' states for conditional actions
const selectedItems = items.filter((item) => selectedIds.has(item.id));
const allDraft = selectedItems.every((item) => item.status === "DRAFT");
const allPublished = selectedItems.every((item) => item.status === "PUBLISHED");
// Only show "Publish" if all selected are DRAFT
{allDraft && (
<Button variant="outline" size="sm" onClick={onPublish}>
<Globe className="h-4 w-4 mr-2" />
Publish
</Button>
)}
// Only show "Close" if all selected are PUBLISHED
{allPublished && (
<Button variant="outline" size="sm" onClick={onClose}>
<XCircle className="h-4 w-4 mr-2" />
Close
</Button>
)}
テーブルコンポーネントの Props インターフェース
interface SelectableTableProps<T extends { id: string }> {
items: T[];
isLoading?: boolean;
selectedIds: Set<string>;
onSelectionChange: (ids: Set<string>) => void;
onRowClick?: (id: string) => void;
}
ツールバーコンポーネントの Props インターフェース
interface BulkActionsToolbarProps {
selectedIds: Set<string>;
onClearSelection: () => void;
isLoading?: boolean;
// Add specific action handlers as needed
}
スタイル設定のパターン
チェックボックス列の幅
<TableHead className="w-12">
<Checkbox ... />
</TableHead>
選択解除ボタン
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0" // Compact square button
onClick={onClearSelection}
>
<X className="h-4 w-4" />
</Button>
破壊的操作のボタン
<Button
variant="outline"
size="sm"
className="text-red-600 hover:text-red-700 hover:bg-red-50"
>
<Trash2 className="h-4 w-4 mr-2" />
Delete
</Button>
確認ダイアログのアクション
<AlertDialogAction className="bg-red-600 hover:bg-red-700">
Delete {count} Item{count === 1 ? "" : "s"}
</AlertDialogAction>
選択数のセクション(区切り線付き)
<div className="flex items-center gap-2 pr-3 border-r">
<span className="text-sm font-medium">{count} selected</span>
<ClearButton />
</div>
避けるべきよくある間違い
❌ フィルター変更時に選択を解除するために useEffect を使用する
// WRONG - ESLint error, causes cascading renders
useEffect(() => {
setSelectedIds(new Set());
}, [filter]);
✅ フィルターハンドラーで選択を解除する
// CORRECT - Clear inline when filter changes
const handleFilterChange = (value: string) => {
setFilter(value);
setSelectedIds(new Set());
};
❌ テーブルコンポーネントに選択状態を保持する
// WRONG - State should be lifted to page
function Table() {
const [selectedIds, setSelectedIds] = useState(new Set());
// ...
}
✅ ページコンポーネントに選択状態を保持する
// CORRECT - Page owns state, passes to children
function Page() {
const [selectedIds, setSelectedIds] = useState(new Set());
return (
<>
<Table selectedIds={selectedIds} onSelectionChange={setSelectedIds} />
<Toolbar selectedIds={selectedIds} onClearSelection={() => setSelectedIds(new Set())} />
</>
);
}
❌ 行のチェックボックスで stopPropagation を呼び出さない
// WRONG - Clicking checkbox also triggers row click
<TableRow onClick={onRowClick}>
<TableCell>
<Checkbox onCheckedChange={handleSelect} />
</TableCell>
</TableRow>
✅ チェックボックスのセルでイベントの伝播を止める
// CORRECT - Checkbox click doesn't bubble to row
<TableRow onClick={onRowClick}>
<TableCell onClick={(e) => e.stopPropagation()}>
<Checkbox onCheckedChange={handleSelect} />
</TableCell>
</TableRow>
❌ 破壊的な一括操作に確認を設けない
// WRONG - Dangerous actions need confirmation
<Button onClick={onBulkDelete}>Delete All</Button>
✅ 破壊的な操作は必ず確認する
// CORRECT - AlertDialog for confirmation
<Button onClick={() => setDeleteDialogOpen(true)}>Delete</Button>
<AlertDialog open={deleteDialogOpen}>
{/* Confirmation content */}
</AlertDialog>
ファイル構成
src/
├── app/
│ └── (dashboard)/
│ └── dashboard/
│ └── items/
│ └── page.tsx # Selection state owner
├── components/
│ └── items/
│ ├── items-table.tsx # Table with checkboxes
│ ├── items-bulk-actions-toolbar.tsx # Floating toolbar
│ └── index.ts # Exports
└── hooks/
└── use-items.ts # Include bulk mutation hooks
一括ミューテーションフックのパターン
// hooks/use-items.ts
export function useBulkDeleteItems(teamId?: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (ids: string[]) => {
const response = await fetch(`/api/teams/${teamId}/items/bulk-delete`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ids }),
});
if (!response.ok) throw new Error("Failed to delete items");
return response.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["items", teamId] });
},
});
}
関連アセットファイル
| アセット | 説明 |
|---|---|
assets/components/bulk-actions-toolbar.tsx | 汎用ツールバーテンプレート |
assets/components/selectable-table.tsx | チェックボックスで選択できるテーブル |
assets/hooks/use-bulk-selection.ts | 再利用可能な選択フック |
チェックリスト
- 選択状態がページコンポーネントに保持されている(テーブルではない)
- チェックボックスで
checked={allSelected ? true : someSelected ? "indeterminate" : false}を使用している - フローティングツールバーに
fixed bottom-6 left-1/2 -translate-x-1/2 z-50が設定されている - ツールバーに
animate-in fade-in slide-in-from-bottom-4 duration-200が設定されている -
selectedCount === 0の場合、ツールバーがnullを返す - 行のチェックボックスセルに
onClick={(e) => e.stopPropagation()}が設定されている - 選択数のセクションに区切り線
border-rがある - 破壊的な操作に確認ダイアログがある
- 破壊的な操作のボタンに
text-red-600 hover:text-red-700 hover:bg-red-50が設定されている - 確認アクションに
bg-red-600 hover:bg-red-700が設定されている - フィルター変更時に選択が解除される(useEffect ではなくハンドラー内)
- 一括ミューテーションフックが成功時にクエリを無効化する
コメント
Markdown 対応