Data Table
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.
Sticky header
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
| Prop | Type | Default |
|---|---|---|
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 | |
filtersAll available filters | Filter[] | [] |
captionCaption for accessibility | string | "Data table" |
titleDefine the title of the table. | string | "" |
subtitleDefine the subtitle of the table. | string | "" |
is-loadingIf active then the table will be in loading state. | boolean | false |
column-changesOptional 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({}) |
layoutThe layout of the data table. | "default" | "full" | "default" |
allow-bulk-deleteIf active user can do bulk delete by selecting items | boolean | false |
allow-bulk-editIf active user can do bulk edit by selecting items | boolean | false |
allow-row-selectionIf active user can select rows and can perform actions on them. | boolean | false |
bulk-edit-more-actionsAdd 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-deleteDisable the possibility to delete items | boolean | false |
disable-editDisable the possibility to edit items | boolean | false |
disable-searchIf active then the search input will be disabled. | boolean | false |
disable-settings-tableDisable the possibility to settings table | boolean | false |
additional-context-buttonsAdditional context buttons to show in the context menu | { type?: "default" | "critical" | "active" | undefined; label: string; key: string; }[] | [] |
pagination-optionsDefine the available pagination limits. | number[] | [5, 10, 25, 50] |
applied-filtersFilters in use by the user | Filter[] | [] |
enable-row-numbering*
Enable numbered rows | boolean | false |
show-outlinesEnable or disable outlines for the table. | boolean | true |
show-stripesEnable or disable the stripe design for the table. | boolean | true |
enable-outline-framingEnable or disable outline framing on hover | boolean | false |
sort-byDefine the current sort by property. | string | "" |
sort-directionDefine the current sort direction. | "ASC" | "DESC" | "ASC" |
search-valueDefine the current search value. | string | "" |
selected-rows | string[] | [] |
number-of-resultsDisplays how many results are found | number | undefined |
enable-reloadActivate the reload button at the top right corner of the table. | boolean | false |
disable-row-select | string[] | [] |
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
| Name | Type |
|---|---|
t | ComposerTranslation<{ 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"> |
sortedColumns | ColumnDefinition[] |
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"; } |
tableWrapper | any |
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 |
columnHeaderRefs | Record<string, HTMLElement> |
columnDataCellRefs | Record<string, HTMLElement[]> |
setColumnDataCellRefs | ({ el, column, index, }: { el?: HTMLElement | undefined; column: ColumnDefinition; index: number; }) => void |
dataTable | HTMLElement | null |
dragConfig | Partial<DragConfig<ColumnDefinition & { dropZone?: "before" | "after" | undefined; }>> |
dropConfig | Partial<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 |
somethingSelected | boolean |
bulkEditSegmentedControlActions | SegmentedControlActionsProp |
handleSelectAll | () => void |
highlightedColumn | string | 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[] |
forceHighlightedColumn | boolean |
addColumnOptionsSearch | string |
onAddColumnOptionClick | (columnProperty: string, previousColumnProperty: string) => void |
onAddColumnSearch | (value: string) => void |
currentHoveredColumn | string | null |
currentHoveredRow | string | null |
setCurrentHoveredCell | (columnProperty: string | null, rowId: string) => void |
isPrimaryColumn | (column: ColumnDefinition) => boolean |
emptyData | {}[] |
getRealIndex | (index: number) => number |
isDragging | boolean |
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
- Provide a unique
idfield 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
captionprop to describe the table for screen readers.
- 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
isLoadingto 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
captionprop 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.