Building a Table
The component needs two things from its host: the endpoint URL, and — for deep links — the initial state. Where they come from is not this package's concern. The examples below read them from an island payload via useIsland().props, but ordinary component props work just as well, whether the page around the table is rendered by Blade, Filament or Inertia.
A complete island
<script setup>
import { onMounted } from 'vue';
import { useIsland, useTranslations } from '@aaix/laravel-islands/vue';
import { DataTable, useDataTable } from '@aaix/laravel-islands-datagrid/vue';
import ProductRow from './components/ProductRow.vue';
const props = useIsland().props;
const { t } = useTranslations();
const DEFAULTS = {
q: '',
status: '',
sort: 'created_at',
dir: 'desc',
page: 1,
perPage: 30,
};
const { state, rows, meta, loading, error, onSearchInput, setSort, setFilter, goToPage, setPerPage, reload, fetchData } =
useDataTable(props.dataUrl, {
defaults: DEFAULTS,
initial: props.initial,
filterKeys: ['status'],
});
onMounted(() => fetchData());
</script>
<template>
<DataTable
:rows="rows"
:meta="meta"
:per-page="state.perPage"
:col-count="3"
:loading="loading"
:error="error"
:error-message="t('Could not load products')"
@retry="reload()"
@page-change="goToPage"
@per-page-change="setPerPage"
>
<template #toolbar>
<input type="search" :value="state.q" @input="onSearchInput($event.target.value)" />
</template>
<template #head>
<th>{{ t('SKU') }}</th>
<th>{{ t('Name') }}</th>
<th>
<button type="button" @click="setSort('created_at')">{{ t('Created') }}</button>
</th>
</template>
<ProductRow v-for="row in rows" :key="row.id" :row="row" />
<template #empty>
<p>{{ state.q ? t('No products match your search') : t('No products found') }}</p>
</template>
</DataTable>
</template>DEFAULTS is the contract. Declare the full shape up front — never add keys to state afterwards, or they will be missing from the request and the URL.
Domain rules stay in the island
Two filters that exclude each other are page logic, not framework logic. Mutate state directly and reload once, so a single request goes out:
function toggleOnline() {
state.online = state.online ? 0 : 1;
if (state.online) {
state.sold_out = 0;
}
reload({ resetPage: true });
}setFilter already resets to page one. When you mutate state yourself, pass { resetPage: true }.
Tabs, groups and counts
Anything the endpoint returns beyond rows and meta lands in payload:
const { payload } = useDataTable(props.dataUrl, { defaults: DEFAULTS });
const tabCounts = computed(() => payload.value.tabs ?? {});
const groups = computed(() => payload.value.groups ?? []);
const grouped = computed(() => Boolean(payload.value.grouped));Tab switching is island logic — it is a filter with a different shape:
function setTab(tab) {
if (state.tab === tab) {
return;
}
state.tab = tab;
state.page = 1;
reload();
}View toggles that never reach the server
Column visibility and similar view state belongs in state — it should survive a reload and be deep-linkable — but must not be sent to the endpoint. List those keys as clientOnly:
useDataTable(props.dataUrl, {
defaults: { ...DEFAULTS, showGross: false, showWeight: true },
clientOnly: ['showGross', 'showWeight'],
});Booleans are mirrored into the URL as 1 and 0.
Table or cards
The same datagrid can render as a table for scanning or as a card grid for browsing — useful on mobile, and for gallery-style views like products. Add mode to your defaults, place a ViewModeToggle in the toolbar and feed the #cards slot alongside the usual #head and rows:
const DEFAULTS = { ...FILTERS, mode: 'table', sort: 'updated_at', dir: 'desc', page: 1 };
useDataTable(props.dataUrl, {
defaults: DEFAULTS,
clientOnly: ['mode'], // the endpoint does not need it
});
useAutoCardMode({ state, key: 'products' }); // cards by default under 768 px<DataTable :mode="state.mode" …>
<template #toolbar>
…
<ViewModeToggle
:model-value="state.mode"
:labels="{ table: t('Table'), cards: t('Cards') }"
@update:model-value="(m) => { state.mode = m; syncUrl(); }"
/>
<SortMenu
v-if="state.mode === 'cards'"
:options="SORT_OPTIONS"
:sort="state.sort"
:dir="state.dir"
:label="t('Sort by')"
@sort="setSort"
/>
</template>
<template #head>…</template>
<template #default>
<ProductRow v-for="row in rows" :key="row.id" :row="row" />
</template>
<template #cards>
<ProductCard v-for="row in rows" :key="row.id" :row="row" />
</template>
</DataTable>state.mode flows through the URL like any other state key — a link stays a link, and a saved view keeps the mode it was in. In cards mode the header <th>s are ignored, so a SortMenu covers sorting instead.
Layout around the table
<DataTable> renders the card and nothing outside it. Page headers, tabs and side panels stay in the island. Extra classes go straight on the component:
<DataTable class="min-w-0 flex-1" …>When the toolbar needs a fixed height — to line a side panel's header up with it, for instance — set the custom property on an ancestor:
const rootStyle = computed(() => ({ '--table-toolbar-h': '61px' }));