Skip to content

Table

Overview

The VBsbTable component is a versatile, customizable table designed for displaying tabular data in Vue 3 applications using Vuetify. It includes features such as search, pagination, conditional formatting, and actions. The component can be used for desktop and mobile views, making it adaptable to responsive layouts.

Usage Examples

Basic Example

The following example demonstrates a basic usage of VBsbTable, including search, conditional formatting, and actions:

html
<template>
  <v-bsb-table
    searchable
    :options="tableOptions"
    :items="tableData"
    @action="handleAction"
    @beforeFetch="beforeFetchHandler"
    @afterFetch="afterFetchHandler"
    @submit="submitHandler"
  />
</template>

<script setup lang="ts">
  import { ref } from 'vue'

  const tableOptions = ref({
    title: 'User Data Table',
    columns: [
      { column: 'name', title: 'Name' },
      { column: 'email', title: 'Email' },
      { column: 'phone', title: 'Phone' },
      { column: 'website', title: 'Website' },
    ],
    actions: [{ action: 'new', format: { icon: '$mdiPlus' } }],
  })

  const tableData = ref([
    {
      name: 'Leanne Graham',
      email: 'leanne@example.com',
      phone: '123-456',
      website: 'example.com',
    },
    { name: 'Ervin Howell', email: 'ervin@example.com', phone: '456-789', website: 'example.com' },
  ])

  function handleAction(action) {
    console.log('Action triggered:', action)
  }

  function beforeFetchHandler(data) {
    console.log('Before fetch:', data)
  }

  function afterFetchHandler(data) {
    console.log('After fetch:', data)
  }

  function submitHandler(data) {
    console.log('Form submit:', data)
  }
</script>

Advanced Example with Conditional Formatting and Form Actions

The example below demonstrates the component with custom actions and conditional formatting for specific data fields:

html
<template>
  <v-bsb-table
    searchable
    :options="tableOptions"
    :items="tableData"
    @action="handleAction"
    @submit="submitHandler"
  />
</template>

<script setup lang="ts">
  import { ref } from 'vue'

  const tableOptions = ref({
    title: 'Employee Data',
    columns: [
      {
        column: 'name',
        title: 'Employee Name',
        format: { condition: 'starts-with', params: 'A', color: 'blue' },
      },
      { column: 'email', title: 'Email', format: { condition: 'email', color: 'green' } },
      { column: 'role', title: 'Role' },
    ],
    actions: [
      { action: 'edit', format: { icon: '$mdiPencil', text: 'Edit' } },
      { action: 'delete', format: { icon: '$mdiDelete', color: 'red', variant: 'flat' } },
    ],
  })

  const tableData = ref([
    { name: 'Alice Johnson', email: 'alice@example.com', role: 'Manager' },
    { name: 'Bob Smith', email: 'bob@example.com', role: 'Developer' },
  ])

  function handleAction(action) {
    console.log('Action triggered:', action)
  }

  function submitHandler(data) {
    console.log('Form submitted:', data)
  }
</script>

API

Props

PropTypeDefaultDescription
titleString""Title for the table.
searchableBooleanfalseIf true, enables a search bar.
searchLabelString"Search"Label for the search input field.
searchPlaceholderString""Placeholder for the search input field.
refreshableBooleantrueIf true, adds a refresh button to the footer.
itemsArray<BsbTableItem>RequiredArray of items (data rows) to display in the table.
itemsPerPageNumber10Number of items displayed per page.
currentPageNumber1Current page of the table.
optionsBsbTableOptions{}Configuration options for columns, actions, and more.
shortenNumbernullLength to which text fields will be shortened.
navigationFormatBsbTableFormatObject with styling defaultsFormat options for navigation buttons.
actionFormatBsbTableFormatObject with styling defaultsFormat options for action buttons.
apiString""Base URL for API requests. If provided, enables server-side data handling.

Emits

EventArgumentsDescription
action{ action: string, item: BsbTableItem }Emitted when an action button is clicked.
beforeFetch{ items: Array, itemsPerPage: number, currentPage: number, newPage: number, search: string }Fired before fetching items.
afterFetch{ items: Array, itemsPerPage: number, currentPage: number, newPage: number, search: string }Fired after fetching items.
submitRecord<string, unknown>Emitted with form data when a form is submitted in an action.

Exposes

MethodDescription
fetchMethod to manually trigger data fetching and pagination.

Types

BsbTableItem

An object representing a single item (row) in the table. The object keys should match the column identifiers in options.columns.

BsbTableFormat

Format object used for defining styles, icons, and other configurations for table actions, navigation, and conditional formatting.

typescript
export type BsbTableFormat = {
  condition?:
    | 'required'
    | 'min-length'
    | 'max-length'
    | 'equals'
    | 'equals-not'
    | 'starts-with'
    | 'ends-with'
    | 'greater-than'
    | 'less-than'
    | 'in-range'
    | 'set'
    | 'password'
    | 'email'
    | 'url'
    | 'ip'
    | 'regexp'
    | 'same-as'
    | 'is-json'
    | 'custom'
  params?: unknown
  text?: string
  icon?: string
  color?: string
  variant?: 'flat' | 'outlined' | 'plain' | 'text' | 'elevated' | 'tonal'
  density?: 'compact' | 'default' | 'comfortable'
  size?: 'x-small' | 'small' | 'default' | 'large' | 'x-large'
  class?: string
  to?: string
  target?: string
}

BsbTableOptions

Options object used to configure the table columns, actions, and overall layout.

typescript
export type BsbTableOptions = {
  title?: string
  columns: Array<BsbTableColumn>
  actions?: Array<BsbTableAction>
}

BsbTableColumn

Defines each column in the table, including the title, formatting, and optional actions.

typescript
export type BsbTableColumn = {
  primary?: boolean // Indicates if this column contains the primary key
  column: string // Column identifier
  title?: string // Display title for the column
  actions?: Array<BsbTableAction> // Actions available for this column
  format?: Array<BsbTableFormat> | BsbTableFormat // Formatting options
  shorten?: number // Number of characters to show before truncating
}

BsbTableAction

Describes each action that can be performed on a row item, including its label, icon, and any associated form.

typescript
export type BsbTableAction = {
  action: string
  format?: BsbTableFormat
  form?: BsbFormOptions
  condition?: BsbTableCondition[] | BsbTableCondition // Conditions that determine action visibility
}

BsbTableCondition

Defines conditions for showing/hiding actions based on item values.

typescript
export type BsbTableCondition = {
  type: 'equals' | 'not-equals' | 'greater-than' | 'less-than' | 'in-range'
  name: string // Name of the field to check
  value: unknown // Value to compare against
}

Check Form component for BsbFormOptions

Source

Component
vue
<template>
  <v-text-field
    v-show="props.searchable && showTable"
    v-model="search"
    clearable
    hide-details="auto"
    :label="t(props.searchLabel)"
    :placeholder="props.searchPlaceholder ? t(props.searchPlaceholder) : ''"
    append-icon="$mdiMagnify"
    @keydown.enter.prevent="fetch(1)"
    @click:clear="fetch(1)"
    @click:append="fetch(1)"
  ></v-text-field>
  <v-defaults-provider
    :defaults="{
      VBtn: {
        color: props.actionFormat.color,
        variant: props.actionFormat.variant,
        density: props.actionFormat.density,
        size: props.actionFormat.size,
        class: props.actionFormat.class,
      },
    }"
  >
    <v-sheet v-if="mobile" color="transparent">
      <v-table v-if="currentItems.length == 0">
        <tbody>
          <tr>
            <td>{{ t('no.data') }}</td>
          </tr>
        </tbody>
      </v-table>
      <v-table class="mb-4" v-for="item in currentItems" :key="String(item[0])">
        <tbody>
          <tr v-for="column in columns" :key="column.column">
            <th class="th-width-auto">{{ column.title }}</th>
            <td :class="column.actions ? 'text-right' : ''">
              <v-bsb-table-cell :column :item :shorten @action="onAction" />
            </td>
          </tr>
        </tbody>
      </v-table>
    </v-sheet>
    <v-sheet v-else color="transparent">
      <v-table>
        <thead>
          <tr>
            <th v-for="column in columns" :key="column.column">{{ column.title }}</th>
          </tr>
        </thead>
        <tbody>
          <tr v-for="item in currentItems" :key="String(item[0])">
            <td
              v-for="column in columns"
              :key="column.column"
              :class="column.actions ? 'td-width-auto text-right' : ''"
            >
              <v-bsb-table-cell :column :item :shorten @action="onAction" />
            </td>
          </tr>
          <tr v-for="item in dummyItems" :key="String(item[0])">
            <td v-for="column in columns" :key="column.column"></td>
          </tr>
        </tbody>
      </v-table>
    </v-sheet>
  </v-defaults-provider>
  <v-defaults-provider
    :defaults="{
      VBtn: {
        color: props.navigationFormat.color,
        variant: props.navigationFormat.variant,
        density: props.navigationFormat.density,
        size: props.navigationFormat.size,
        class: props.navigationFormat.class,
      },
    }"
  >
    <v-table class="transparent">
      <tfoot>
        <tr>
          <td class="text-center">
            <v-btn
              :disabled="!hasPrevPage"
              icon="$mdiChevronLeft"
              @click="fetch(currentPage - 1)"
            ></v-btn>
            <v-btn
              :disabled="!hasNextPage"
              icon="$mdiChevronRight"
              @click="fetch(currentPage + 1)"
            ></v-btn>
          </td>
          <td v-if="props.refreshable" class="td-width-auto">
            <v-btn icon="$mdiRefresh" @click="fetch()"></v-btn>
          </td>
          <td class="text-right td-width-auto">
            <v-btn
              data-cy="action"
              v-for="action in actions"
              :key="action.action"
              :text="action.format?.text ? t(action.format?.text) : ''"
              :icon="action.format?.icon"
              :color="action.format?.color"
              :variant="action.format?.variant"
              :density="action.format?.density"
              :size="action.format?.size"
              @click="onAction(action, {})"
            ></v-btn>
          </td>
        </tr>
      </tfoot>
    </v-table>
  </v-defaults-provider>
  <v-dialog activator="parent" v-if="showForm">
    <v-sheet class="pa-4">
      <v-bsb-form :data="formData" :options="formOptions" @cancel="onCancel" @submit="onSubmit" />
    </v-sheet>
  </v-dialog>
  <v-overlay v-model="loading" contained persistent />
</template>

<script setup lang="ts">
import type { BsbFormOptions } from './VBsbForm.vue'
import VBsbTableCell from './VBsbTableCell.vue'

const { t } = useI18n()

export type BsbTableItem = Record<string, unknown>

export type BsbTableFormat = {
  condition?:
    | 'required'
    | 'min-length'
    | 'max-length'
    | 'equals'
    | 'equals-not'
    | 'starts-with'
    | 'ends-with'
    | 'greater-than'
    | 'less-than'
    | 'in-range'
    | 'set'
    | 'password'
    | 'email'
    | 'url'
    | 'ip'
    | 'regexp'
    | 'same-as'
    | 'is-json'
    | 'custom'
  params?: unknown
  text?: string
  icon?: string
  color?: string
  variant?: 'flat' | 'outlined' | 'plain' | 'text' | 'elevated' | 'tonal'
  density?: 'compact' | 'default' | 'comfortable'
  size?: 'x-small' | 'small' | 'default' | 'large' | 'x-large'
  class?: string
  to?: string
  target?: string
}

export type BsbTableCondition = {
  type: 'equals' | 'not-equals' | 'greater-than' | 'less-than' | 'in-range'
  name: string
  value: unknown
}

export type BsbTableAction = {
  action: string
  format?: BsbTableFormat
  form?: BsbFormOptions
  condition?: BsbTableCondition[] | BsbTableCondition
}

export type BsbTableColumn = {
  primary?: boolean
  column: string
  title?: string
  actions?: BsbTableAction[]
  format?: BsbTableFormat[] | BsbTableFormat
  shorten?: number
}

export type BsbTableOptions = {
  title?: string
  columns: BsbTableColumn[]
  actions?: BsbTableAction[]
}

const props = defineProps({
  title: {
    type: String,
    default: '',
  },
  searchable: {
    type: Boolean,
    default: false,
  },
  searchLabel: {
    type: String,
    default: 'Search',
  },
  searchPlaceholder: {
    type: String,
    default: '',
  },
  refreshable: {
    type: Boolean,
    default: true,
  },
  items: {
    type: Array as PropType<BsbTableItem[]>,
    required: false,
  },
  itemsPerPage: {
    type: Number,
    default: 10,
  },
  currentPage: {
    type: Number,
    default: 1,
  },
  options: {
    type: Object as PropType<BsbTableOptions>,
    default: () => ({}),
  },
  shorten: {
    type: Number,
    default: Number.MAX_SAFE_INTEGER,
  },
  navigationFormat: {
    type: Object as PropType<BsbTableFormat>,
    default: () => ({
      color: 'primary',
      variant: 'outlined',
      density: 'default',
      size: 'default',
      class: 'mt-2 ml-2',
    }),
  },
  actionFormat: {
    type: Object as PropType<BsbTableFormat>,
    default: () => ({
      color: 'primary',
      variant: 'outlined',
      density: 'default',
      size: 'default',
      class: 'mt-2 mb-2 ml-2',
    }),
  },
  api: {
    type: String,
    default: '',
  },
})

const emits = defineEmits(['action', 'beforeFetch', 'afterFetch', 'submit'])

const http = props.api ? useHttp({ baseURL: props.api }) : undefined

const search = ref('')

const { mobile } = useDisplay()
const showTable = ref(true)
const loading = ref(false)

const currentItems = ref<BsbTableItem[]>([])
const itemsPerPage = ref(props.itemsPerPage)
const currentPage = ref(props.currentPage)
const hasPrevPage = computed(() => currentPage.value > 1)
const hasNextPage = ref(false)
const dummyItems = ref<BsbTableItem[]>([])

const columns = props.options.columns
const actions = props.options.actions || []

const showForm = ref(false)
const formData = ref<Record<string, unknown>>({})
const formOptions = ref<BsbFormOptions>({
  fields: [],
  errors: [],
})

async function onAction(action: BsbTableAction, item: BsbTableItem) {
  emits('action', { action: action.action, item })
  if (action.form) {
    formData.value = item
    formOptions.value = action.form
    showForm.value = true
  }
}

async function onCancel() {
  showForm.value = false
}

async function onSubmit(data: Record<string, unknown>) {
  emits('submit', data)
  const primaryKey = columns.find((column) => column.primary)?.column
  if (primaryKey) {
    const index = currentItems.value.findIndex(
      (item) => item[primaryKey] === formData.value[primaryKey],
    )
    if (index !== -1) currentItems.value[index] = { ...currentItems.value[index], ...data }
  }
  showForm.value = false
}

async function fetch(newPage?: number) {
  loading.value = true
  emits('beforeFetch', {
    items: currentItems.value,
    itemsPerPage: itemsPerPage.value,
    currentPage: currentPage.value,
    newPage: newPage,
    search: search.value,
  })
  if (newPage) currentPage.value = newPage
  let newItems = props.items || []
  if (http) {
    const { data } = await http.get('', {
      search: search.value,
      offset: itemsPerPage.value * (currentPage.value - 1),
      limit: itemsPerPage.value + 1,
    })
    newItems = (data as { items: BsbTableItem[] })?.items
    hasNextPage.value = newItems.length > itemsPerPage.value
    currentItems.value = newItems.slice(0, itemsPerPage.value)
  } else {
    if (search.value)
      newItems = newItems.filter((item: BsbTableItem) => {
        return Object.values(item).some((value: unknown) => {
          return String(value).toLowerCase().includes(search.value.toLowerCase())
        })
      })
    hasNextPage.value = currentPage.value < newItems.length / itemsPerPage.value
    currentItems.value = newItems.slice(
      itemsPerPage.value * (currentPage.value - 1),
      itemsPerPage.value * (currentPage.value - 1) + itemsPerPage.value,
    )
  }
  dummyItems.value =
    currentItems.value.length < itemsPerPage.value
      ? Array.from({ length: itemsPerPage.value - currentItems.value.length }, () => ({}))
      : []
  emits('afterFetch', {
    items: currentItems.value,
    itemsPerPage: itemsPerPage.value,
    currentPage: currentPage.value,
    newPage: newPage,
    search: search.value,
  })
  loading.value = false
}

onMounted(async () => fetch(1))

defineExpose({
  fetch,
})
</script>

<style scoped>
.v-theme--light.v-table tbody tr:nth-of-type(odd) {
  background-color: rgba(0, 0, 0, 0.03);
}
.v-theme--dark.v-table tbody tr:nth-of-type(odd) {
  background-color: rgba(0, 0, 0, 0.5);
}

thead th,
tbody th {
  font-weight: bold !important;
}

.th-width-auto,
.td-width-auto {
  width: 1px;
  white-space: nowrap;
}

.transparent {
  background-color: transparent;
}
</style>

<i18n>
{
  "en": {
    "No data": "No data",
    "Search": "Search"
  }
}
</i18n>
Cell Sub Component
vue
<template>
  <v-chip
    v-if="formatted && !href && format.text"
    :color="format.color"
    :prepend-icon="format.icon"
    :variant="format.variant"
    :text="text"
  />
  <span v-if="formatted && !href && !format.text && format.icon"
    ><v-icon :icon="format.icon" :color="format.color"
  /></span>
  <a v-if="href" :href="href" :target="format.target ?? '_self'">{{ text }}</a>
  <span v-if="!formatted && !href">{{ text }}</span>

  <v-dialog v-if="shortened">
    <template v-slot:activator="{ props: activatorProps }">
      <v-btn
        v-bind="activatorProps"
        text="..."
        variant="tonal"
        density="default"
        size="x-small"
        rounded="lg"
      ></v-btn>
    </template>
    <template v-slot:default="{ isActive }">
      <v-card>
        <v-card-title>{{ column.title }}</v-card-title>
        <v-card-text>{{ item[column.column] }}</v-card-text>
        <v-card-actions>
          <v-spacer></v-spacer>
          <v-btn :text="t('Close')" @click="isActive.value = false"></v-btn>
        </v-card-actions>
      </v-card>
    </template>
  </v-dialog>

  <span v-for="action in column.actions" :key="action.action">
    <v-btn
      v-if="showAction(action, item)"
      data-cy="action"
      @click="onAction(action, item)"
      :key="action.action"
      :text="t(action.format?.text ?? '')"
      :icon="action.format?.icon"
      :color="action.format?.color"
      :variant="action.format?.variant"
      :density="action.format?.density"
      :size="action.format?.size"
      :class="action.format?.class"
    ></v-btn>
  </span>
</template>
<script setup lang="ts">
import type {
  BsbTableItem,
  BsbTableColumn,
  BsbTableFormat,
  BsbTableAction,
  BsbTableCondition,
} from './VBsbTable.vue'

const { t } = useI18n()

const { item, column, shorten } = defineProps<{
  item: BsbTableItem
  column: BsbTableColumn
  shorten?: number
}>()

const emits = defineEmits<{
  action: [BsbTableAction, BsbTableItem]
}>()

function onAction(action: BsbTableAction, item: BsbTableItem) {
  emits('action', action, item)
}

const text = ref('')
const shortened = ref(false)
const format = ref<BsbTableFormat>({})
const formatted = ref(false)
const href = ref('')

type ConditionChecker = (condition: BsbTableCondition) => boolean
type ConditionEvaluator = (condition: BsbTableAction['condition']) => boolean
function showAction(action: BsbTableAction, item: BsbTableItem): boolean {
  if (!action.condition) return true

  const checkCondition: ConditionChecker = ({ name, type, value }) => {
    const itemValue = item[name]
    const numValue = Number(itemValue)

    switch (type) {
      case 'equals':
        return itemValue === value
      case 'not-equals':
        return itemValue !== value
      case 'greater-than':
        return numValue > Number(value)
      case 'less-than':
        return numValue < Number(value)
      case 'in-range': {
        const [min, max] = value as [number, number]
        return numValue >= min && numValue <= max
      }
      default:
        return false
    }
  }

  const evaluateCondition: ConditionEvaluator = (condition) => {
    return Array.isArray(condition)
      ? condition.some(evaluateCondition)
      : checkCondition(condition as BsbTableCondition)
  }

  return evaluateCondition(action.condition)
}

function validate(
  condition?: string,
  params?: unknown,
  value?: unknown,
  message?: string,
): boolean | string {
  const validationRules: { [key: string]: () => boolean | string } = {
    required: () => !!value || message || false,
    'min-length': () =>
      (typeof value === 'string' && value.length >= Number(params)) || message || false,
    'max-length': () =>
      (typeof value === 'string' && value.length <= Number(params)) || message || false,
    equals: () => value === params || message || false,
    'equals-not': () => value !== params || message || false,
    'starts-with': () =>
      (typeof value === 'string' && value.startsWith(params as string)) || message || false,
    'ends-with': () =>
      (typeof value === 'string' && value.endsWith(params as string)) || message || false,
    'greater-than': () => Number(value) > Number(params) || message || false,
    'less-than': () => Number(value) < Number(params) || message || false,
    'in-range': () => {
      const [min, max] = params as [number, number]
      return (Number(value) >= min && Number(value) <= max) || message || false
    },
    set: () => (Array.isArray(params) && params.includes(value)) || message || false,
    password: () =>
      /^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d]{8,}$/.test(value as string) || message || false,
    email: () => /^[\w.-]+@([\w-]+\.)+[\w-]{2,4}$/.test(value as string) || message || false,
    url: () =>
      /^(https?:\/\/)?([\w-]+\.)+[\w-]+(\/\S*)?$/.test(value as string) || message || false,
    ip: () =>
      /^(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/.test(
        value as string,
      ) ||
      message ||
      false,
    regexp: () => new RegExp(String(params)).test(value as string) || message || false,
    'same-as': () => value === params || message || false,
    'is-json': () =>
      (typeof value === 'string' &&
        (() => {
          try {
            JSON.parse(value)
            return true
          } catch {
            return false
          }
        })()) ||
      message ||
      false,
    custom: () => (typeof params === 'function' && params(value)) || message || false,
  }

  return condition && validationRules[condition] ? validationRules[condition]() : message || false
}

watchEffect(() => {
  text.value = item[column.column] as string
  if (text.value) {
    const maxLength = column.shorten ?? shorten ?? Number.MAX_SAFE_INTEGER
    shortened.value = text.value.length > maxLength || maxLength === 0
    if (shortened.value) {
      text.value = maxLength === 0 ? '' : text.value.slice(0, maxLength)
    }
  }
  format.value = Array.isArray(column.format)
    ? column.format.find((f) => validate(f.condition, f.params, item[column.column])) || {}
    : column.format || {}
  formatted.value = Object.values(format.value).some((value) => value)
  href.value =
    !Array.isArray(column.format) && column.format?.to
      ? column.format.to.replace('$value', item[column.column] as string)
      : ''
})

defineExpose({ validate })
</script>
Test
ts
import { mount } from '@vue/test-utils'
import { describe, it, expect, beforeEach, vi } from 'vitest'
import VBsbTable from '@/components/VBsbTable.vue'
//import vuetify from '../../plugins/vuetify'

// Mock Data and Options
const items = [
  { name: 'Leanne Graham', email: 'leanne@example.com', phone: '123', website: 'example.com' },
  { name: 'Ervin Howell', email: 'ervin@example.com', phone: '456', website: 'example.com' },
  // Add more items as needed
]

const options = {
  title: 'Table Title',
  columns: [
    { column: 'name', title: 'Name' },
    { column: 'email', title: 'Email' },
    { column: 'phone', title: 'Phone' },
    { column: 'website', title: 'Website' },
  ],
  actions: [{ action: 'new', format: { icon: '$mdiPlus' } }],
}

// Helper function to mount the component
const factory = (props = {}) => {
  return mount(VBsbTable, {
    //    global: {
    //      plugins: [vuetify]
    //    },
    props: {
      items,
      options,
      searchable: true,
      refreshable: true,
      ...props,
    },
  })
}

describe('VBsbTable Component', () => {
  let wrapper: ReturnType<typeof factory>

  beforeEach(() => {
    window.innerWidth = 2048
    window.dispatchEvent(new Event('resize'))
    wrapper = factory()
  })

  it('displays the correct number of items', () => {
    const rows = wrapper.findAll('tbody tr')
    expect(rows.length).toBeGreaterThanOrEqual(items.length)
  })

  it('renders table headers according to columns', () => {
    const headers = wrapper.findAll('thead th')
    expect(headers.length).toBe(options.columns.length)
    headers.forEach((header, index) => {
      expect(header.text()).toBe(options.columns[index].title)
    })
  })

  it('emits `beforeFetch` and `afterFetch` events on fetch', async () => {
    const beforeFetch = vi.fn()
    const afterFetch = vi.fn()

    wrapper = factory({
      onBeforeFetch: beforeFetch,
      onAfterFetch: afterFetch,
    })

    await wrapper.vm.fetch(1) // Manually trigger fetch

    expect(beforeFetch).toHaveBeenCalled()
    expect(afterFetch).toHaveBeenCalled()
  })

  it('filters items based on search input', async () => {
    const searchInput = wrapper.find('input[type="text"]')
    await searchInput.setValue('Leanne')
    await wrapper.vm.$nextTick()

    const rows = wrapper.findAll('tbody tr')
    expect(rows.length).toBeGreaterThan(1)
    expect(rows[0].text()).toContain('Leanne Graham')
  })

  it('emits `submit` event when form is submitted', async () => {
    const submitSpy = vi.fn()
    wrapper = factory({
      onSubmit: submitSpy,
    })

    // Assume `showForm` is triggered and we submit a form
    if (wrapper.vm.onSubmit) {
      await wrapper.vm.onSubmit({ name: 'Test', email: 'test@example.com' })
    }
    expect(submitSpy).toHaveBeenCalledWith({ name: 'Test', email: 'test@example.com' })
  })
})