Shopware Design

Data Table

Source
Experimental. The API may still change in a future release.

Usage

Data Table is a flexible table for presenting large, structured datasets in rows and columns. Define each column through a configuration object that sets its label, data property, renderer, and position, and drive the table entirely through props while reacting to its events, since it holds no internal data state. Use pagination, sorting, filtering, and search to help users work through large result sets, row selection and bulk actions when users need to act on several rows at once, and the caption prop to give screen readers a descriptive summary of the table.

import { MtDataTable } from "@shopware-ag/meteor-component-library";

Examples

Full width

The full layout lets the table span the available width instead of sitting in a centered card.

Empty state

When the data source is empty, the table shows a built-in empty state.

The column header stays in view while the rows scroll.

Basic usage

A minimal table needs a dataSource, a columns definition, and handlers for the pagination events:

<template>
  <mt-data-table
    :data-source="dataSource"
    :columns="columns"
    :pagination-limit="10"
    :pagination-current-page="1"
    :pagination-total-items="100"
    :is-loading="isLoading"
    @pagination-limit-change="handleLimitChange"
    @pagination-current-page-change="handlePageChange"
  />
</template>

<script setup>
  import { ref } from "vue";

  const dataSource = ref([
    { id: "1", name: "John Doe", email: "john@example.com" },
    { id: "2", name: "Jane Smith", email: "jane@example.com" },
    // ... more data
  ]);

  const columns = [
    { label: "Name", property: "name", sortable: true, position: 100 },
    { label: "Email", property: "email", sortable: true, position: 200 },
  ];

  const isLoading = ref(false);

  const handleLimitChange = (limit) => {
    // Handle page size change
  };

  const handlePageChange = (page) => {
    // Handle page change
  };
</script>

Column configuration

Each column is described by a configuration object that controls how the cell is rendered and how it behaves:

interface ColumnDefinition {
  label: string; // Column header label
  property: string; // Property path in the data source object
  renderer?: string; // How to render the cell ('text' | 'number' | 'price')
  position: number; // Column order (use increments of 100)
  sortable?: boolean; // Enable or disable sorting (default: true)
  width?: number; // Fixed column width
  allowResize?: boolean; // Allow column resizing
  visible?: boolean; // Show or hide the column
}

Managing data

Because Data Table holds no internal state, the parent owns all data and UI state and reacts to the table's events. A complete integration wires every interaction back to a single data-fetching function:

<template>
  <mt-data-table
    :data-source="dataSource"
    :columns="columns"
    :pagination-limit="limit"
    :pagination-current-page="currentPage"
    :pagination-total-items="totalItems"
    :is-loading="isLoading"
    :applied-filters="appliedFilters"
    @pagination-limit-change="handleLimitChange"
    @pagination-current-page-change="handlePageChange"
    @sort-change="handleSortChange"
    @search-value-change="handleSearchChange"
    @update:appliedFilters="handleFilterChange"
  />
</template>

<script setup>
  import { ref, onMounted } from "vue";

  // State management
  const dataSource = ref([]);
  const limit = ref(10);
  const currentPage = ref(1);
  const totalItems = ref(0);
  const isLoading = ref(false);
  const appliedFilters = ref([]);
  const sortBy = ref({ property: "", direction: "" });
  const searchTerm = ref("");

  // Data fetching
  const fetchData = async () => {
    isLoading.value = true;
    try {
      const response = await api.fetchData({
        page: currentPage.value,
        limit: limit.value,
        sort: sortBy.value,
        filters: appliedFilters.value,
        search: searchTerm.value,
      });

      dataSource.value = response.data;
      totalItems.value = response.total;
    } finally {
      isLoading.value = false;
    }
  };

  // Event handlers
  const handleLimitChange = (newLimit) => {
    limit.value = newLimit;
    currentPage.value = 1; // Reset to first page
    fetchData();
  };

  const handlePageChange = (newPage) => {
    currentPage.value = newPage;
    fetchData();
  };

  const handleSortChange = ({ property, direction }) => {
    sortBy.value = { property, direction };
    fetchData();
  };

  const handleSearchChange = (term) => {
    searchTerm.value = term;
    currentPage.value = 1; // Reset to first page
    fetchData();
  };

  const handleFilterChange = (newFilters) => {
    appliedFilters.value = newFilters;
    currentPage.value = 1; // Reset to first page
    fetchData();
  };

  // Initial data load
  onMounted(() => {
    fetchData();
  });
</script>

Filtering

Filters are declared through a filters array and applied through appliedFilters. Each filter follows this structure:

interface Filter {
  id: string;
  label: string;
  type: {
    id: string;
    options: Array<{
      id: string;
      label: string;
    }>;
  };
}
<template>
  <mt-data-table
    :data-source="dataSource"
    :columns="columns"
    :filters="filters"
    :applied-filters="appliedFilters"
    @update:appliedFilters="updateFilters"
  />
</template>

<script setup>
  const filters = [
    {
      id: "status",
      label: "Status",
      type: {
        id: "select",
        options: [
          { id: "active", label: "Active" },
          { id: "inactive", label: "Inactive" },
        ],
      },
    },
  ];

  const appliedFilters = ref([]);
  const updateFilters = (newFilters) => {
    appliedFilters.value = newFilters;
  };
</script>

Loading states

Toggle isLoading around data fetching so the table shows skeleton placeholders while data is on the way:

<template>
  <mt-data-table
    :data-source="dataSource"
    :columns="columns"
    :is-loading="isLoading"
  />
</template>

<script setup>
  const isLoading = ref(false);

  const fetchData = async () => {
    isLoading.value = true;
    try {
      // Fetch your data
    } finally {
      isLoading.value = false;
    }
  };
</script>

Creating a wrapper component

When several tables share the same data-management logic, wrap the component once to reduce boilerplate, centralize API integration, and keep table implementations consistent:

<!-- DataTableWrapper.vue -->
<template>
  <mt-data-table
    v-bind="$props"
    :data-source="dataSource"
    :pagination-limit="limit"
    :pagination-current-page="currentPage"
    :pagination-total-items="totalItems"
    :is-loading="isLoading"
    :applied-filters="appliedFilters"
    v-on="$listeners"
    @pagination-limit-change="handleLimitChange"
    @pagination-current-page-change="handlePageChange"
    @sort-change="handleSortChange"
    @search-value-change="handleSearchChange"
    @update:appliedFilters="handleFilterChange"
  >
    <template v-for="(_, slot) in $slots" #[slot]>
      <slot :name="slot" />
    </template>
  </mt-data-table>
</template>

<script setup>
  import { ref, onMounted } from "vue";

  const props = defineProps({
    fetchDataFn: { type: Function, required: true },
    defaultSort: {
      type: Object,
      default: () => ({ property: "", direction: "" }),
    },
    // ... other forwarded props
  });

  const dataSource = ref([]);
  const limit = ref(10);
  const currentPage = ref(1);
  const totalItems = ref(0);
  const isLoading = ref(false);
  const appliedFilters = ref([]);
  const sortBy = ref(props.defaultSort);
  const searchTerm = ref("");

  const fetchData = async () => {
    isLoading.value = true;
    try {
      const response = await props.fetchDataFn({
        page: currentPage.value,
        limit: limit.value,
        sort: sortBy.value,
        filters: appliedFilters.value,
        search: searchTerm.value,
      });

      dataSource.value = response.data;
      totalItems.value = response.total;
    } finally {
      isLoading.value = false;
    }
  };

  const handleLimitChange = (newLimit) => {
    limit.value = newLimit;
    currentPage.value = 1;
    fetchData();
  };
  const handlePageChange = (newPage) => {
    currentPage.value = newPage;
    fetchData();
  };
  const handleSortChange = ({ property, direction }) => {
    sortBy.value = { property, direction };
    fetchData();
  };
  const handleSearchChange = (term) => {
    searchTerm.value = term;
    currentPage.value = 1;
    fetchData();
  };
  const handleFilterChange = (newFilters) => {
    appliedFilters.value = newFilters;
    currentPage.value = 1;
    fetchData();
  };

  onMounted(() => {
    fetchData();
  });
</script>

Consuming the wrapper then takes much less code:

<!-- YourPage.vue -->
<template>
  <data-table-wrapper
    :columns="columns"
    :fetch-data-fn="fetchUsers"
    :default-sort="{ property: 'name', direction: 'asc' }"
  />
</template>

<script setup>
  import { ref } from "vue";
  import DataTableWrapper from "./DataTableWrapper.vue";

  const columns = [
    { label: "Name", property: "name", sortable: true, position: 100 },
    // ... other columns
  ];

  const fetchUsers = async (params) => {
    const response = await api.users.list(params);
    return { data: response.users, total: response.total };
  };
</script>

From here you can extend the wrapper with error handling and retry logic, caching, export, bulk actions, or persistent table state.

API reference

Props

PropTypeDefault
current-page *
Define the current page of the table.
number
columns *
The defintions for the columns which should be displayed in the table.
ColumnProperty
data-source *
The data source which contains the data for the current state of the table.
DataSourcePropType
pagination-limit *
Define the limit of items per page.
number
pagination-total-items *
Define the total amount of items.
number
filters
All available filters
Filter[][]
caption
Caption for accessibility
string"Data table"
title
Define the title of the table.
string""
subtitle
Define the subtitle of the table.
string""
is-loading
If active then the table will be in loading state.
booleanfalse
column-changes
Optional property. When you want to override the current column information with the given changes, you can pass them here. The changes will be applied to the current column information. This is useful for saving and loading the current column configuration when the user customizes the table.
Record<string, ColumnChanges>reactive({})
layout
The layout of the data table.
"default" | "full""default"
allow-bulk-delete
If active user can do bulk delete by selecting items
booleanfalse
allow-bulk-edit
If active user can do bulk edit by selecting items
booleanfalse
allow-row-selection
If active user can select rows and can perform actions on them.
booleanfalse
bulk-edit-more-actions
Add more custom bulk edit actions
{ id: string; label: string; onClick: () => void; icon?: string | undefined; type?: MtPopoverItemType | undefined; metaCopy?: string | undefined; contextualDetail?: string | undefined; }[] | undefined[]
disable-delete
Disable the possibility to delete items
booleanfalse
disable-edit
Disable the possibility to edit items
booleanfalse
disable-search
If active then the search input will be disabled.
booleanfalse
disable-settings-table
Disable the possibility to settings table
booleanfalse
additional-context-buttons
Additional context buttons to show in the context menu
{ type?: "default" | "critical" | "active" | undefined; label: string; key: string; }[][]
pagination-options
Define the available pagination limits.
number[][5, 10, 25, 50]
applied-filters
Filters in use by the user
Filter[][]
enable-row-numbering
* Enable numbered rows
booleanfalse
show-outlines
Enable or disable outlines for the table.
booleantrue
show-stripes
Enable or disable the stripe design for the table.
booleantrue
enable-outline-framing
Enable or disable outline framing on hover
booleanfalse
sort-by
Define the current sort by property.
string""
sort-direction
Define the current sort direction.
"ASC" | "DESC""ASC"
search-value
Define the current search value.
string""
selected-rowsstring[][]
number-of-results
Displays how many results are found
numberundefined
enable-reload
Activate the reload button at the top right corner of the table.
booleanfalse
disable-row-selectstring[][]
on-open-details((...args: any[]) => any) | undefined
on-bulk-delete((...args: any[]) => any) | undefined
on-bulk-edit((...args: any[]) => any) | undefined
on-change-show-outlines((...args: any[]) => any) | undefined
on-change-show-stripes((...args: any[]) => any) | undefined
on-change-outline-framing((...args: any[]) => any) | undefined
on-change-enable-row-numbering((...args: any[]) => any) | undefined
on-reload((...args: any[]) => any) | undefined
on-pagination-limit-change((...args: any[]) => any) | undefined
on-pagination-current-page-change((...args: any[]) => any) | undefined
on-search-value-change((...args: any[]) => any) | undefined
on-sort-change((...args: any[]) => any) | undefined
on-selection-change((...args: any[]) => any) | undefined
on-multiple-selection-change((...args: any[]) => any) | undefined
on-item-delete((...args: any[]) => any) | undefined
on-update:applied-filters((...args: any[]) => any) | undefined
on-context-select((...args: any[]) => any) | undefined

Exposed

NameType
tComposerTranslation<{ en: { itemsPerPage: string; filter: { numberOfResults: string; addFilter: string; fetchingFilteredResults: string; }; columnSettings: { sortAscending: string; sortDescending: string; hideColumn: string; }; addColumnIndicator: { popoverTitle: string; tooltipMessage: string; }; contextButtons: { edit: string; delete: string; }; emptyState: { headline: string; description: string; }; bulkEdit: { itemsSelected: string; edit: string; delete: string; more: string; }; reload: { tooltip: string; }; }; de: { itemsPerPage: string; filter: { numberOfResults: string; addFilter: string; fetchingFilteredResults: string; }; columnSettings: { sortAscending: string; sortDescending: string; hideColumn: string; }; addColumnIndicator: { popoverTitle: string; tooltipMessage: string; }; contextButtons: { edit: string; delete: string; }; emptyState: { headline: string; description: string; }; bulkEdit: { itemsSelected: string; edit: string; delete: string; more: string; }; reload: { tooltip: string; }; }; }, "en" | "de", RemoveIndexSignature<{ [x: string]: LocaleMessageValue<VueMessageType>; }>, never, "filter" | "reload" | "itemsPerPage" | "columnSettings" | "addColumnIndicator" | "contextButtons" | "emptyState" | "bulkEdit" | "filter.numberOfResults" | "filter.addFilter" | "filter.fetchingFilteredResults" | "reload.tooltip" | "columnSettings.sortAscending" | "columnSettings.sortDescending" | "columnSettings.hideColumn" | "addColumnIndicator.popoverTitle" | "addColumnIndicator.tooltipMessage" | "contextButtons.delete" | "contextButtons.edit" | "emptyState.description" | "emptyState.headline" | "bulkEdit.delete" | "bulkEdit.edit" | "bulkEdit.itemsSelected" | "bulkEdit.more", "filter" | "reload" | "itemsPerPage" | "columnSettings" | "addColumnIndicator" | "contextButtons" | "emptyState" | "bulkEdit" | "filter.numberOfResults" | "filter.addFilter" | "filter.fetchingFilteredResults" | "reload.tooltip" | "columnSettings.sortAscending" | "columnSettings.sortDescending" | "columnSettings.hideColumn" | "addColumnIndicator.popoverTitle" | "addColumnIndicator.tooltipMessage" | "contextButtons.delete" | "contextButtons.edit" | "emptyState.description" | "emptyState.headline" | "bulkEdit.delete" | "bulkEdit.edit" | "bulkEdit.itemsSelected" | "bulkEdit.more">
sortedColumnsColumnDefinition[]
isFirstVisibleColumn(column: ColumnDefinition) => boolean
addColumnOptions{ id: string; label: string; parentGroup: undefined; position: number; isVisible: boolean; isClickable: boolean; isSortable: boolean; isHidable: boolean; disabled: boolean; }[]
renderColumnDataCellStyle(column: ColumnDefinition) => { width: string; "min-width": string; "max-width": string; "white-space": "normal" | "nowrap"; }
renderColumnHeaderStyle(column: ColumnDefinition) => { "max-width": string; width: string; "min-width": string; "white-space": "normal" | "nowrap"; }
tableWrapperany
emitReload() => void
emitPaginationLimitChange(limitValue: number) => void
emitPaginationCurrentPageChange(currentPage: number) => void
emitSearchValueChange(searchValue: string) => void
paginationOptionsConverted{ id: number; label: string; value: number; }[]
startColumnResizing(column: ColumnDefinition | null) => void
columnHeaderRefsRecord<string, HTMLElement>
columnDataCellRefsRecord<string, HTMLElement[]>
setColumnDataCellRefs({ el, column, index, }: { el?: HTMLElement | undefined; column: ColumnDefinition; index: number; }) => void
dataTableHTMLElement | null
dragConfigPartial<DragConfig<ColumnDefinition & { dropZone?: "before" | "after" | undefined; }>>
dropConfigPartial<DropConfig<unknown> & { dropZone?: "before" | "after" | undefined; }>
resetAllChanges() => void
changeColumnPosition(columnId: string, targetColumnId: string, insertPosition?: "before" | "after") => void
isColumnVisible(column: ColumnDefinition) => boolean
changeColumnVisibility(columnProperty: string, visibility: boolean) => void
emitSortChange(property: string, direction: "ASC" | "DESC") => void
onColumnSettingsSortChange(property: string, direction: "ASC" | "DESC", chainMethod?: (() => void) | undefined) => void
MtDataTableClasses{ "mt-data-table__layout-default": boolean; "mt-data-table__layout-full": boolean; "mt-data-table__first-column-fixed": boolean; "mt-data-table__last-column-fixed": boolean; "mt-data-table__stripes": boolean; "mt-data-table__outlines": boolean; "mt-data-table__column-outline-framing-active": boolean; }
tableStylingVariables{ "--fixed-left-column-width": string; "--fixed-right-column-width": string; }
getSelectionValue(dataId: string) => boolean
onRowSelect(dataId: string) => void
somethingSelectedboolean
bulkEditSegmentedControlActionsSegmentedControlActionsProp
handleSelectAll() => void
highlightedColumnstring | null
setHighlightedColumn(column: ColumnDefinition | null) => void
getColumnDataCellClasses(column: ColumnDefinition) => string[]
getColumnHeaderClasses(column: ColumnDefinition) => string[]
getPreviousVisibleColumn(column: ColumnDefinition) => ColumnDefinition | null
getColumnDataRowClasses(rowId: string) => string[]
getColumnHeaderInnerWrapperClasses(column: ColumnDefinition) => string[]
forceHighlightedColumnboolean
addColumnOptionsSearchstring
onAddColumnOptionClick(columnProperty: string, previousColumnProperty: string) => void
onAddColumnSearch(value: string) => void
currentHoveredColumnstring | null
currentHoveredRowstring | null
setCurrentHoveredCell(columnProperty: string | null, rowId: string) => void
isPrimaryColumn(column: ColumnDefinition) => boolean
emptyData{}[]
getRealIndex(index: number) => number
isDraggingboolean
filterChildViews{ name: string; title: string; }[]
removeFilter(id: string) => void
addOption(filterId: string, optionId: string) => void
removeOption(filterId: string, optionId: string) => void
isOptionSelected(filterId: string, optionId: string) => boolean
handleSearchUpdate(value: string) => void

Best practices

Do
  • Provide a unique id field for every row in the data source.
  • Use increments of 100 for column positions to leave room for inserting columns later.
  • Enable sorting only on columns where it is meaningful.
  • Use server-side pagination and keep the current page's data source small for large datasets.
  • Show the loading state while data is being fetched.
  • Set the caption prop to describe the table for screen readers.
Don't
  • Do not expect the table to manage its own data, sorting, or pagination state.
  • Do not load an entire large dataset into the data source at once.
  • Do not offer long or irrelevant filter option lists.

Behavior

  • Data Table is a dumb component: it renders data and emits events but keeps no internal state.
  • All data and UI state, including current page, page size, sort column and direction, applied filters, search term, and selected rows, must be provided through props.
  • User interactions such as sorting, filtering, pagination, search, and selection emit events that the parent must handle to update the data.
  • Set isLoading to true while fetching data so the table shows skeleton placeholders, then set it back to false when the data arrives.
  • When data management is repeated across several tables, wrap the component to centralize fetching, state, and event handling.

Accessibility

  • Use the caption prop to provide a descriptive summary of the table for screen readers.
  • Keep column labels clear so the purpose of each column is understandable on its own.