Skip to content

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

PropTypeDefaultRequiredDescription
selectedCountnumber-✅ YesNumber of currently selected rows
totalRowsnumber-✅ YesTotal number of rows available
disabledbooleanfalse-Disable all actions

Events

EventPayloadDescription
export-Export selected rows
delete-Delete selected rows
copy-ids-Copy row IDs to clipboard
clear-selection-Clear all selected rows

Basic Usage

vue
<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>

Visual States

Hidden (No Selection)

When selectedCount is 0, the component is completely hidden:

vue
<BulkActionsMenu
  :selected-count="0"
  :total-rows="10"
/>
<!-- Not rendered -->

Visible (Selection Active)

When selectedCount > 0, the FAB appears with counter badge:

vue
<BulkActionsMenu
  :selected-count="3"
  :total-rows="10"
/>
<!-- FAB shows "3 selected" -->

Built-in Actions

Export Selected

typescript
@export
// Emitted when user clicks export button
// Parent should handle exporting selected rows

Delete Selected

typescript
@delete
// Emitted when user clicks delete button
// Parent should handle deletion with confirmation

Copy Row IDs

typescript
@copy-ids
// Emitted when user clicks copy IDs button
// Parent should copy selected row IDs to clipboard

Clear Selection

typescript
@clear-selection
// Emitted when user clicks X button on counter badge
// Parent should clear all selected rows

Advanced Examples

With Loading State

vue
<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>

With Confirmation Dialogs

typescript
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()
  }
}

Custom Action Handling

typescript
const actionHandlers = {
  export: handleExport,
  delete: handleDelete,
  copyIds: handleCopyIds
}

function performAction(action: string) {
  const handler = actionHandlers[action as keyof typeof actionHandlers]
  if (handler) {
    handler()
  }
}

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

vue
<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>

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

vue
<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>

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

vue
<!-- All actions disabled -->
<BulkActionsMenu
  :selected-count="3"
  :total-rows="10"
  :disabled="true"
/>

Testing

Unit Test Example

typescript
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()
  })
})

Browser Support

Supported in all modern browsers:

  • Chrome 88+
  • Firefox 78+
  • Safari 14+
  • Edge 88+

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

DocBits Component Library