BulkActionsMenu
Floating Action Button (FAB) for bulk operations on selected rows.
Overview
The BulkActionsMenu is a fixed-position FAB that appears when rows are selected. It provides quick access to bulk operations like export, delete, and copy actions.
Best for: Bulk operations on multiple selected rows
Key Features:
- ✅ Floating action button (FAB) with direction control
- ✅ Shows/hides based on selection count
- ✅ Selection counter badge
- ✅ Multiple action buttons with tooltips
- ✅ Clear selection button
- ✅ Mobile-friendly positioning
Props
| Prop | Type | Default | Required | Description |
|---|---|---|---|---|
selectedCount | number | - | ✅ Yes | Number of currently selected rows |
totalRows | number | - | ✅ Yes | Total number of rows available |
disabled | boolean | false | - | Disable all actions |
Events
| Event | Payload | Description |
|---|---|---|
export | - | Export selected rows |
delete | - | Delete selected rows |
copy-ids | - | Copy row IDs to clipboard |
clear-selection | - | Clear all selected rows |
Basic Usage
<template>
<div class="table-container">
<!-- Row checkboxes and table rows -->
<div v-for="row in rows" :key="row.id" class="row">
<RowSelectionCheckbox
:row-id="String(row.id)"
:is-selected="selectedRows.has(row.id)"
@toggle="(id, selected) => toggleRow(id, selected)"
/>
<span>{{ row.name }}</span>
</div>
<!-- Bulk actions FAB - appears when rows selected -->
<BulkActionsMenu
:selected-count="selectedRows.size"
:total-rows="rows.length"
@export="handleExport"
@delete="handleDelete"
@copy-ids="handleCopyIds"
@clear-selection="clearSelection"
/>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import BulkActionsMenu from '@/components/Table/BulkActionsMenu.vue'
import RowSelectionCheckbox from '@/components/Table/RowSelectionCheckbox.vue'
const rows = ref([
{ id: 1, name: 'Document 1' },
{ id: 2, name: 'Document 2' },
{ id: 3, name: 'Document 3' }
])
const selectedRows = ref(new Set<string>())
function toggleRow(rowId: string, isSelected: boolean) {
if (isSelected) {
selectedRows.value.add(rowId)
} else {
selectedRows.value.delete(rowId)
}
}
function clearSelection() {
selectedRows.value.clear()
}
function handleExport() {
const selected = Array.from(selectedRows.value)
console.log('Exporting:', selected)
// Export selected rows
}
function handleDelete() {
if (confirm('Delete selected rows?')) {
const selected = Array.from(selectedRows.value)
rows.value = rows.value.filter(r => !selected.includes(String(r.id)))
selectedRows.value.clear()
}
}
function handleCopyIds() {
const ids = Array.from(selectedRows.value).join(', ')
navigator.clipboard.writeText(ids)
}
</script>2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
Visual States
Hidden (No Selection)
When selectedCount is 0, the component is completely hidden:
<BulkActionsMenu
:selected-count="0"
:total-rows="10"
/>
<!-- Not rendered -->2
3
4
5
Visible (Selection Active)
When selectedCount > 0, the FAB appears with counter badge:
<BulkActionsMenu
:selected-count="3"
:total-rows="10"
/>
<!-- FAB shows "3 selected" -->2
3
4
5
Built-in Actions
Export Selected
@export
// Emitted when user clicks export button
// Parent should handle exporting selected rows2
3
Delete Selected
@delete
// Emitted when user clicks delete button
// Parent should handle deletion with confirmation2
3
Copy Row IDs
@copy-ids
// Emitted when user clicks copy IDs button
// Parent should copy selected row IDs to clipboard2
3
Clear Selection
@clear-selection
// Emitted when user clicks X button on counter badge
// Parent should clear all selected rows2
3
Advanced Examples
With Loading State
<script setup lang="ts">
const exporting = ref(false)
async function handleExport() {
exporting.value = true
try {
const selected = Array.from(selectedRows.value)
const blob = await generateExport(selected)
downloadFile(blob, 'export.csv')
} finally {
exporting.value = false
}
}
</script>
<template>
<BulkActionsMenu
:selected-count="selectedRows.size"
:total-rows="rows.length"
:disabled="exporting"
@export="handleExport"
/>
</template>2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
With Confirmation Dialogs
async function handleDelete() {
const count = selectedRows.value.size
const confirmed = await showConfirmDialog(
`Delete ${count} selected row(s)?`,
'This action cannot be undone.'
)
if (confirmed) {
const selected = Array.from(selectedRows.value)
// Delete rows
selectedRows.value.clear()
}
}2
3
4
5
6
7
8
9
10
11
12
13
Custom Action Handling
const actionHandlers = {
export: handleExport,
delete: handleDelete,
copyIds: handleCopyIds
}
function performAction(action: string) {
const handler = actionHandlers[action as keyof typeof actionHandlers]
if (handler) {
handler()
}
}2
3
4
5
6
7
8
9
10
11
12
Real-World Example: Bulk Invoice Operations
This example demonstrates how to use BulkActionsMenu for bulk invoice operations like approval, rejection, and export.
Implementation with Invoice Approval
<template>
<div class="invoice-management">
<!-- Invoice table with selection -->
<div class="invoice-table">
<div class="table-header">
<div class="col-checkbox">
<RowSelectionCheckbox
row-id="master"
:is-master="true"
:is-selected="allInvoicesSelected"
:total-rows="selectableInvoices.length"
:selected-count="selectedInvoices.size"
@toggle-all="toggleSelectAllInvoices"
/>
</div>
<div class="col-vendor">Vendor</div>
<div class="col-amount">Amount</div>
<div class="col-date">Invoice Date</div>
<div class="col-status">Status</div>
</div>
<div v-for="invoice in invoices" :key="invoice.id" class="invoice-row">
<div class="col-checkbox">
<RowSelectionCheckbox
:row-id="invoice.id"
:is-selected="selectedInvoices.has(invoice.id)"
:disabled="invoice.status !== 'pending'"
@toggle="(id, selected) => toggleInvoice(id, selected)"
/>
</div>
<div class="col-vendor">
<strong>{{ invoice.vendor }}</strong>
<div class="doc-number">{{ invoice.documentNumber }}</div>
</div>
<div class="col-amount">{{ invoice.currency }} {{ invoice.amount.toFixed(2) }}</div>
<div class="col-date">{{ new Date(invoice.invoiceDate).toLocaleDateString() }}</div>
<div class="col-status">
<span :class="`status-badge ${invoice.status}`">
{{ invoice.status.toUpperCase() }}
</span>
</div>
</div>
</div>
<!-- Bulk actions FAB -->
<BulkActionsMenu
:selected-count="selectedInvoices.size"
:total-rows="invoices.length"
:disabled="isProcessing"
@export="handleExportInvoices"
@delete="handleRejectInvoices"
@copy-ids="handleCopyInvoiceIds"
@clear-selection="clearInvoiceSelection"
/>
<!-- Additional bulk action buttons in FAB context -->
<div v-if="selectedInvoices.size > 0" class="bulk-action-toolbar">
<button class="action-btn approve" @click="handleApproveInvoices" :disabled="isProcessing">
✓ Approve ({{ selectedInvoices.size }})
</button>
<button class="action-btn reject" @click="handleRejectInvoices" :disabled="isProcessing">
✗ Reject ({{ selectedInvoices.size }})
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import BulkActionsMenu from '@/components/Table/BulkActionsMenu.vue'
import RowSelectionCheckbox from '@/components/Table/RowSelectionCheckbox.vue'
interface Invoice {
id: string
documentNumber: string
vendor: string
amount: number
currency: string
invoiceDate: string
status: 'pending' | 'approved' | 'rejected'
extractionConfidence: number
}
const invoices = ref<Invoice[]>([
{
id: 'INV-2024-001',
documentNumber: 'INV-001234',
vendor: 'Acme Corporation',
amount: 1250.00,
currency: 'USD',
invoiceDate: '2024-01-15',
status: 'pending',
extractionConfidence: 0.98
},
{
id: 'INV-2024-002',
documentNumber: 'INV-001235',
vendor: 'Global Supplies Inc',
amount: 3750.50,
currency: 'USD',
invoiceDate: '2024-01-16',
status: 'pending',
extractionConfidence: 0.95
},
{
id: 'INV-2024-003',
documentNumber: 'RE-2024-789',
vendor: 'Tech Solutions GmbH',
amount: 2100.00,
currency: 'EUR',
invoiceDate: '2024-01-18',
status: 'pending',
extractionConfidence: 0.97
},
{
id: 'INV-2024-004',
documentNumber: 'INV-UK-445',
vendor: 'British Office Ltd',
amount: 890.00,
currency: 'GBP',
invoiceDate: '2024-01-20',
status: 'pending',
extractionConfidence: 0.89
},
{
id: 'INV-2024-005',
documentNumber: 'F-2024-1122',
vendor: 'Consulting Partners SA',
amount: 8500.00,
currency: 'EUR',
invoiceDate: '2024-01-22',
status: 'approved',
extractionConfidence: 0.93
},
{
id: 'INV-2024-006',
documentNumber: 'INV-2024-556',
vendor: 'Cloud Services Corp',
amount: 4200.00,
currency: 'USD',
invoiceDate: '2024-01-25',
status: 'approved',
extractionConfidence: 0.99
}
])
const selectedInvoices = ref(new Set<string>())
const isProcessing = ref(false)
const selectableInvoices = computed(() =>
invoices.value.filter(inv => inv.status === 'pending')
)
const allInvoicesSelected = computed(() => {
return selectedInvoices.value.size === selectableInvoices.value.length &&
selectableInvoices.value.length > 0
})
function toggleInvoice(invoiceId: string, isSelected: boolean) {
if (isSelected) {
selectedInvoices.value.add(invoiceId)
} else {
selectedInvoices.value.delete(invoiceId)
}
}
function toggleSelectAllInvoices(isSelected: boolean) {
selectedInvoices.value.clear()
if (isSelected) {
selectableInvoices.value.forEach(inv => selectedInvoices.value.add(inv.id))
}
}
async function handleApproveInvoices() {
const count = selectedInvoices.value.size
if (!confirm(`Approve ${count} invoice(s)?`)) return
isProcessing.value = true
try {
const selected = Array.from(selectedInvoices.value)
// Simulate API call
await new Promise(r => setTimeout(r, 1000))
invoices.value.forEach(inv => {
if (selected.includes(inv.id)) {
inv.status = 'approved'
}
})
selectedInvoices.value.clear()
console.log(`Approved ${count} invoices`)
} finally {
isProcessing.value = false
}
}
async function handleRejectInvoices() {
const count = selectedInvoices.value.size
if (!confirm(`Reject ${count} invoice(s)?`)) return
isProcessing.value = true
try {
const selected = Array.from(selectedInvoices.value)
// Simulate API call
await new Promise(r => setTimeout(r, 1000))
invoices.value = invoices.value.filter(inv => !selected.includes(inv.id))
selectedInvoices.value.clear()
console.log(`Rejected ${count} invoices`)
} finally {
isProcessing.value = false
}
}
async function handleExportInvoices() {
const selected = Array.from(selectedInvoices.value)
console.log('Exporting invoices:', selected)
// Generate CSV and download
}
function handleCopyInvoiceIds() {
const ids = Array.from(selectedInvoices.value).join(', ')
navigator.clipboard.writeText(ids)
console.log('Copied invoice IDs to clipboard')
}
function clearInvoiceSelection() {
selectedInvoices.value.clear()
}
</script>
<style scoped>
.invoice-management {
position: relative;
background: white;
border-radius: 8px;
overflow: hidden;
}
.invoice-table {
border: 1px solid #e0e0e0;
border-radius: 8px;
overflow: hidden;
}
.table-header {
display: grid;
grid-template-columns: 50px 1fr 140px 130px 100px;
gap: 12px;
padding: 16px;
background: #f5f5f5;
border-bottom: 1px solid #e0e0e0;
font-weight: 600;
font-size: 0.9em;
align-items: center;
}
.invoice-row {
display: grid;
grid-template-columns: 50px 1fr 140px 130px 100px;
gap: 12px;
padding: 16px;
border-bottom: 1px solid #e0e0e0;
align-items: center;
transition: background 0.2s;
}
.invoice-row:hover {
background: #fafafa;
}
.col-checkbox {
display: flex;
justify-content: center;
}
.col-vendor {
overflow: hidden;
text-overflow: ellipsis;
}
.doc-number {
font-size: 0.85em;
color: #666;
font-weight: normal;
}
.col-amount,
.col-date {
font-size: 0.95em;
text-align: right;
}
.col-status {
text-align: center;
}
.status-badge {
display: inline-block;
padding: 4px 8px;
border-radius: 4px;
font-size: 0.75em;
font-weight: 600;
}
.status-badge.pending {
background: #fff3cd;
color: #856404;
}
.status-badge.approved {
background: #d4edda;
color: #155724;
}
.status-badge.rejected {
background: #f8d7da;
color: #721c24;
}
.bulk-action-toolbar {
position: sticky;
bottom: 0;
display: flex;
gap: 8px;
padding: 16px;
background: white;
border-top: 1px solid #e0e0e0;
justify-content: center;
}
.action-btn {
padding: 8px 16px;
border: none;
border-radius: 4px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.action-btn.approve {
background: #34eea9;
color: white;
}
.action-btn.approve:hover:not(:disabled) {
background: #2dd897;
}
.action-btn.reject {
background: #ff6b6b;
color: white;
}
.action-btn.reject:hover:not(:disabled) {
background: #ff5252;
}
.action-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
</style>2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
What This Example Shows
- Selective Bulk Actions: Only pending invoices can be selected and approved
- Status Transitions: Update invoice statuses based on bulk actions (approve/reject)
- Confirmation Dialogs: User confirmation before destructive operations
- Processing State: Disable interactions while operations are in progress
- Multi-action Toolbar: Additional buttons beyond FAB for frequently used actions
- Real Invoice Data: Multiple vendors, currencies, and status states
Real-World Example: Batch Document Processing
This example shows bulk operations on documents in a processing queue - export, delete, and reassign to different processing workers.
Implementation with Batch Operations
<template>
<div class="document-batch-manager">
<!-- Document queue table -->
<div class="document-table">
<div class="table-header">
<div class="col-checkbox">
<RowSelectionCheckbox
row-id="master"
:is-master="true"
:is-selected="allDocumentsSelected"
:total-rows="selectableDocuments.length"
:selected-count="selectedDocuments.size"
@toggle-all="toggleSelectAllDocuments"
/>
</div>
<div class="col-file">File Name</div>
<div class="col-type">Type</div>
<div class="col-status">Status</div>
<div class="col-quality">Quality</div>
</div>
<div v-for="doc in documents" :key="doc.id" class="document-row">
<div class="col-checkbox">
<RowSelectionCheckbox
:row-id="doc.id"
:is-selected="selectedDocuments.has(doc.id)"
:disabled="doc.processingStatus === 'processing'"
@toggle="(id, selected) => toggleDocument(id, selected)"
/>
</div>
<div class="col-file">
{{ doc.fileName }}
<div class="doc-meta">{{ doc.pages }} pages · {{ formatDate(doc.uploadDate) }}</div>
</div>
<div class="col-type">
<span class="doc-type-badge">{{ doc.documentType }}</span>
</div>
<div class="col-status">
<span :class="`status-badge ${doc.processingStatus}`">
{{ formatStatus(doc.processingStatus) }}
</span>
</div>
<div class="col-quality">
{{ doc.processingStatus === 'completed' ? `${(doc.ocrConfidence * 100).toFixed(0)}%` : '—' }}
</div>
</div>
</div>
<!-- Bulk actions FAB -->
<BulkActionsMenu
:selected-count="selectedDocuments.size"
:total-rows="documents.length"
:disabled="isProcessing"
@export="handleExportDocuments"
@delete="handleDeleteDocuments"
@copy-ids="handleCopyDocumentIds"
@clear-selection="clearDocumentSelection"
/>
<!-- Additional batch operations -->
<div v-if="selectedDocuments.size > 0" class="batch-operations">
<div class="operation-group">
<label>Reassign to Worker:</label>
<select v-model="reassignTarget" :disabled="isProcessing">
<option value="">Select worker...</option>
<option value="worker-1">Worker 1</option>
<option value="worker-2">Worker 2</option>
<option value="worker-3">Worker 3</option>
</select>
<button
class="action-btn reassign"
@click="handleReassignDocuments"
:disabled="isProcessing || !reassignTarget"
>
Reassign
</button>
</div>
<div class="operation-group">
<button
class="action-btn retry"
@click="handleRetryFailedDocuments"
:disabled="isProcessing"
>
🔄 Retry Failed ({{ failedCount }})
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import BulkActionsMenu from '@/components/Table/BulkActionsMenu.vue'
import RowSelectionCheckbox from '@/components/Table/RowSelectionCheckbox.vue'
interface Document {
id: string
fileName: string
documentType: 'Invoice' | 'PO' | 'Receipt' | 'Contract'
uploadDate: string
pages: number
processingStatus: 'queued' | 'processing' | 'completed' | 'failed'
ocrConfidence: number
assignedWorker?: string
}
const documents = ref<Document[]>([
{
id: 'DOC-2024-001',
fileName: 'invoice_acme_jan2024.pdf',
documentType: 'Invoice',
uploadDate: '2024-01-15T10:30:00Z',
pages: 3,
processingStatus: 'completed',
ocrConfidence: 0.96,
assignedWorker: 'worker-1'
},
{
id: 'DOC-2024-002',
fileName: 'po_global_supply.pdf',
documentType: 'PO',
uploadDate: '2024-01-16T14:22:00Z',
pages: 2,
processingStatus: 'completed',
ocrConfidence: 0.98,
assignedWorker: 'worker-1'
},
{
id: 'DOC-2024-003',
fileName: 'receipt_office_depot.jpg',
documentType: 'Receipt',
uploadDate: '2024-01-17T09:15:00Z',
pages: 1,
processingStatus: 'completed',
ocrConfidence: 0.92,
assignedWorker: 'worker-2'
},
{
id: 'DOC-2024-004',
fileName: 'contract_tech_solutions.pdf',
documentType: 'Contract',
uploadDate: '2024-01-18T16:45:00Z',
pages: 5,
processingStatus: 'processing',
ocrConfidence: 0.0,
assignedWorker: 'worker-3'
},
{
id: 'DOC-2024-005',
fileName: 'invoice_cloud_services.pdf',
documentType: 'Invoice',
uploadDate: '2024-01-19T11:20:00Z',
pages: 1,
processingStatus: 'queued',
ocrConfidence: 0.0,
assignedWorker: undefined
},
{
id: 'DOC-2024-006',
fileName: 'receipt_utilities.pdf',
documentType: 'Receipt',
uploadDate: '2024-01-20T13:30:00Z',
pages: 1,
processingStatus: 'failed',
ocrConfidence: 0.45,
assignedWorker: 'worker-2'
},
{
id: 'DOC-2024-007',
fileName: 'po_british_office.pdf',
documentType: 'PO',
uploadDate: '2024-01-21T15:00:00Z',
pages: 3,
processingStatus: 'queued',
ocrConfidence: 0.0,
assignedWorker: undefined
},
{
id: 'DOC-2024-008',
fileName: 'invoice_consulting.pdf',
documentType: 'Invoice',
uploadDate: '2024-01-22T12:00:00Z',
pages: 2,
processingStatus: 'failed',
ocrConfidence: 0.35,
assignedWorker: 'worker-1'
}
])
const selectedDocuments = ref(new Set<string>())
const reassignTarget = ref('')
const isProcessing = ref(false)
const selectableDocuments = computed(() =>
documents.value.filter(doc => doc.processingStatus !== 'processing')
)
const allDocumentsSelected = computed(() => {
return selectedDocuments.value.size === selectableDocuments.value.length &&
selectableDocuments.value.length > 0
})
const failedCount = computed(() =>
Array.from(selectedDocuments.value).filter(id =>
documents.value.find(d => d.id === id && d.processingStatus === 'failed')
).length
)
function toggleDocument(docId: string, isSelected: boolean) {
if (isSelected) {
selectedDocuments.value.add(docId)
} else {
selectedDocuments.value.delete(docId)
}
}
function toggleSelectAllDocuments(isSelected: boolean) {
selectedDocuments.value.clear()
if (isSelected) {
selectableDocuments.value.forEach(doc => selectedDocuments.value.add(doc.id))
}
}
async function handleExportDocuments() {
const selected = Array.from(selectedDocuments.value)
console.log('Exporting documents:', selected)
}
async function handleDeleteDocuments() {
const count = selectedDocuments.value.size
if (!confirm(`Delete ${count} document(s)?`)) return
isProcessing.value = true
try {
const selected = Array.from(selectedDocuments.value)
await new Promise(r => setTimeout(r, 800))
documents.value = documents.value.filter(doc => !selected.includes(doc.id))
selectedDocuments.value.clear()
console.log(`Deleted ${count} documents`)
} finally {
isProcessing.value = false
}
}
async function handleReassignDocuments() {
if (!reassignTarget.value) return
const count = selectedDocuments.value.size
isProcessing.value = true
try {
const selected = Array.from(selectedDocuments.value)
await new Promise(r => setTimeout(r, 800))
documents.value.forEach(doc => {
if (selected.includes(doc.id)) {
doc.assignedWorker = reassignTarget.value
}
})
selectedDocuments.value.clear()
reassignTarget.value = ''
console.log(`Reassigned ${count} documents to ${reassignTarget.value}`)
} finally {
isProcessing.value = false
}
}
async function handleRetryFailedDocuments() {
const failed = Array.from(selectedDocuments.value).filter(id =>
documents.value.find(d => d.id === id && d.processingStatus === 'failed')
)
if (failed.length === 0) return
isProcessing.value = true
try {
await new Promise(r => setTimeout(r, 800))
documents.value.forEach(doc => {
if (failed.includes(doc.id)) {
doc.processingStatus = 'queued'
}
})
console.log(`Retrying ${failed.length} failed documents`)
} finally {
isProcessing.value = false
}
}
function handleCopyDocumentIds() {
const ids = Array.from(selectedDocuments.value).join(', ')
navigator.clipboard.writeText(ids)
}
function clearDocumentSelection() {
selectedDocuments.value.clear()
}
function formatDate(dateString: string): string {
return new Date(dateString).toLocaleDateString()
}
function formatStatus(status: string): string {
return status.charAt(0).toUpperCase() + status.slice(1).replace('_', ' ')
}
</script>
<style scoped>
.document-batch-manager {
position: relative;
background: white;
border-radius: 8px;
overflow: hidden;
}
.document-table {
border: 1px solid #e0e0e0;
border-radius: 8px;
overflow: hidden;
}
.table-header {
display: grid;
grid-template-columns: 50px 2fr 100px 120px 100px;
gap: 12px;
padding: 16px;
background: #f5f5f5;
border-bottom: 1px solid #e0e0e0;
font-weight: 600;
font-size: 0.9em;
align-items: center;
}
.document-row {
display: grid;
grid-template-columns: 50px 2fr 100px 120px 100px;
gap: 12px;
padding: 16px;
border-bottom: 1px solid #e0e0e0;
align-items: center;
transition: background 0.2s;
}
.document-row:hover {
background: #fafafa;
}
.col-checkbox {
display: flex;
justify-content: center;
}
.col-file {
overflow: hidden;
}
.doc-meta {
font-size: 0.85em;
color: #666;
font-weight: normal;
}
.doc-type-badge {
display: inline-block;
padding: 4px 8px;
background: #e8f4f8;
color: #2388AE;
border-radius: 4px;
font-size: 0.85em;
font-weight: 600;
}
.status-badge {
display: inline-block;
padding: 4px 8px;
border-radius: 4px;
font-size: 0.75em;
font-weight: 600;
text-align: center;
}
.status-badge.completed {
background: #d4edda;
color: #155724;
}
.status-badge.processing {
background: #fff3cd;
color: #856404;
}
.status-badge.queued {
background: #e2e3e5;
color: #383d41;
}
.status-badge.failed {
background: #f8d7da;
color: #721c24;
}
.batch-operations {
padding: 16px;
background: #f9f9f9;
border-top: 1px solid #e0e0e0;
display: flex;
gap: 16px;
flex-wrap: wrap;
align-items: center;
}
.operation-group {
display: flex;
gap: 8px;
align-items: center;
}
.operation-group label {
font-weight: 500;
font-size: 0.9em;
}
.operation-group select {
padding: 6px 8px;
border: 1px solid #d0d0d0;
border-radius: 4px;
font-size: 0.9em;
}
.action-btn {
padding: 6px 12px;
border: none;
border-radius: 4px;
font-weight: 600;
font-size: 0.9em;
cursor: pointer;
transition: all 0.2s;
}
.action-btn.reassign {
background: #2388AE;
color: white;
}
.action-btn.reassign:hover:not(:disabled) {
background: #1a6a8a;
}
.action-btn.retry {
background: #34eea9;
color: white;
}
.action-btn.retry:hover:not(:disabled) {
background: #2dd897;
}
.action-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
</style>2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
What This Example Shows
- Batch Processing: Handle multiple document operations at once
- Processing Status Handling: Cannot select documents that are actively processing
- Worker Assignment: Reassign batch of documents to different processing workers
- Selective Retry: Retry only failed documents from selection
- Status Transitions: Update document states (queued, processing, completed, failed)
- Real Document Data: Multiple document types with realistic processing metadata
Positioning
The FAB is fixed positioned:
- Desktop: Bottom-right corner (32px from edges)
- Mobile: Bottom-right corner (20px from edges)
The selection counter is centered at the bottom:
- Desktop: Bottom center (32px from bottom)
- Mobile: Bottom center (20px from bottom)
Disabled State
<!-- All actions disabled -->
<BulkActionsMenu
:selected-count="3"
:total-rows="10"
:disabled="true"
/>2
3
4
5
6
Testing
Unit Test Example
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import BulkActionsMenu from './BulkActionsMenu.vue'
describe('BulkActionsMenu', () => {
it('is hidden when no rows selected', () => {
const wrapper = mount(BulkActionsMenu, {
props: {
selectedCount: 0,
totalRows: 10
}
})
expect(wrapper.find('.bulk-fab').exists()).toBe(false)
})
it('shows when rows are selected', () => {
const wrapper = mount(BulkActionsMenu, {
props: {
selectedCount: 3,
totalRows: 10
}
})
expect(wrapper.find('.bulk-fab').exists()).toBe(true)
expect(wrapper.text()).toContain('3 selected')
})
it('emits export event', async () => {
const wrapper = mount(BulkActionsMenu, {
props: {
selectedCount: 3,
totalRows: 10
}
})
await wrapper.find('[aria-label*="Export"]').trigger('click')
expect(wrapper.emitted('export')).toBeTruthy()
})
it('emits clear-selection event', async () => {
const wrapper = mount(BulkActionsMenu, {
props: {
selectedCount: 3,
totalRows: 10
}
})
await wrapper.find('.close-button').trigger('click')
expect(wrapper.emitted('clear-selection')).toBeTruthy()
})
})2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
Browser Support
Supported in all modern browsers:
- Chrome 88+
- Firefox 78+
- Safari 14+
- Edge 88+
Related Components
- RowSelectionCheckbox - Row selection
- DocBitsTable - Complete table with bulk actions
- RowActionsMenu - Per-row actions
Source Code
- Component:
src/components/Table/BulkActionsMenu.vue(158 lines) - Tests: 22 component tests
Changelog
v1.0.0 (2025-12-18)
- Initial release
- FAB with multiple action buttons
- Selection counter badge
- Tooltips for each action
- Mobile-responsive positioning