DataTable
Build a server-driven table with page-scoped selection and a visible busy state. The optional Inertia query helper keeps search, sorting, filters, and pagination usable across refresh, sharing, and Back/Forward.
The application still writes the caption, headers, rows, cells, links, actions, empty state, and every Tailwind class. There is no column schema, visual variant API, client-side data engine, or hidden link abstraction.
<script setup>
import { ref } from 'vue'
import Checkbox from '@/components/ui/checkbox/Checkbox.vue'
import DataTable from '@/components/ui/data-table/DataTable.vue'
defineProps({ services: { type: Array, required: true } })
const selected = ref([])
</script>
<template>
<DataTable
v-model:selected="selected"
:rows="services"
class="rounded-lg border border-gray-200"
table-class="min-w-160"
v-slot="table"
>
<caption class="caption-top px-4 py-3 text-left font-semibold">
Production services
</caption>
<thead class="border-y border-gray-200 bg-gray-50 text-xs text-gray-600">
<tr>
<th scope="col" class="w-12 px-4 py-3">
<Checkbox v-bind="table.pageSelection()" />
</th>
<th scope="col" class="px-4 py-3 font-medium">Service</th>
<th scope="col" class="px-4 py-3 font-medium">Status</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
<tr v-for="service in services" :key="service.id">
<td class="px-4 py-3">
<Checkbox
v-bind="table.rowSelection(service, `Select ${service.name}`)"
/>
</td>
<th scope="row" class="px-4 py-3 font-medium">
<a :href="`/services/${service.id}`">{{ service.name }}</a>
</th>
<td class="px-4 py-3">{{ service.status }}</td>
</tr>
</tbody>
</DataTable>
</template>import { useState } from 'react'
import Checkbox from '@/components/ui/checkbox/Checkbox.jsx'
import DataTable from '@/components/ui/data-table/DataTable.jsx'
export default function ServicesTable({ services }) {
const [selected, setSelected] = useState([])
return (
<DataTable
rows={services}
selected={selected}
onSelectedChange={setSelected}
className="rounded-lg border border-gray-200"
tableClassName="min-w-160"
>
{(table) => (
<>
<caption className="caption-top px-4 py-3 text-left font-semibold">
Production services
</caption>
<thead className="border-y border-gray-200 bg-gray-50 text-xs text-gray-600">
<tr>
<th scope="col" className="w-12 px-4 py-3">
<Checkbox {...table.pageSelection()} />
</th>
<th scope="col" className="px-4 py-3 font-medium">
Service
</th>
<th scope="col" className="px-4 py-3 font-medium">
Status
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{services.map((service) => (
<tr key={service.id}>
<td className="px-4 py-3">
<Checkbox
{...table.rowSelection(service, `Select ${service.name}`)}
/>
</td>
<th scope="row" className="px-4 py-3 font-medium">
<a href={`/services/${service.id}`}>{service.name}</a>
</th>
<td className="px-4 py-3">{service.status}</td>
</tr>
))}
</tbody>
</>
)}
</DataTable>
)
}<script>
import Checkbox from '$lib/components/ui/checkbox/Checkbox.svelte'
import DataTable from '$lib/components/ui/data-table/DataTable.svelte'
let { services } = $props()
let selected = $state([])
</script>
{#snippet content(table)}
<caption class="caption-top px-4 py-3 text-left font-semibold">
Production services
</caption>
<thead class="border-y border-gray-200 bg-gray-50 text-xs text-gray-600">
<tr>
<th scope="col" class="w-12 px-4 py-3">
<Checkbox {...table.pageSelection()} />
</th>
<th scope="col" class="px-4 py-3 font-medium">Service</th>
<th scope="col" class="px-4 py-3 font-medium">Status</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
{#each services as service (service.id)}
<tr>
<td class="px-4 py-3">
<Checkbox
{...table.rowSelection(service, `Select ${service.name}`)}
/>
</td>
<th scope="row" class="px-4 py-3 font-medium">
<a href={`/services/${service.id}`}>{service.name}</a>
</th>
<td class="px-4 py-3">{service.status}</td>
</tr>
{/each}
</tbody>
{/snippet}
<DataTable
rows={services}
bind:selected
class="rounded-lg border border-gray-200"
tableClass="min-w-160"
children={content}
/>View source BridgeServices.vue
<script setup>
import { computed, ref } from 'vue'
import Checkbox from '@/components/ui/checkbox/Checkbox.vue'
import DataTable from '@/components/ui/data-table/DataTable.vue'
import Input from '@/components/ui/input/Input.vue'
import Select from '@/components/ui/select/Select.vue'
const props = defineProps({ services: { type: Array, required: true } })
const selected = ref([])
const search = ref('')
const view = ref('all')
const attentionOnly = ref(false)
const sort = ref('name ASC')
const rows = computed(() => {
const query = search.value.trim().toLowerCase()
const result = props.services.filter((service) => {
if (view.value === 'platform' && service.owner !== 'Platform') return false
if (attentionOnly.value && service.status !== 'Attention') return false
return (
!query ||
[service.name, service.owner, service.status, service.region]
.join(' ')
.toLowerCase()
.includes(query)
)
})
const [field, direction] = sort.value.split(' ')
return result.sort((left, right) => {
const order = String(left[field]).localeCompare(String(right[field]))
return direction === 'ASC' ? order : -order
})
})
function ariaSort(field) {
const [active, direction] = sort.value.split(' ')
if (active !== field) return undefined
return direction === 'ASC' ? 'ascending' : 'descending'
}
function sortButton(field, label) {
const [active, direction] = sort.value.split(' ')
const next = active === field && direction === 'ASC' ? 'DESC' : 'ASC'
return {
type: 'button',
'aria-label': `Sort by ${label} ${next === 'ASC' ? 'ascending' : 'descending'}`,
onClick: () => {
sort.value = `${field} ${next}`
}
}
}
function sortState(field) {
const [active, direction] = sort.value.split(' ')
return active === field ? direction : undefined
}
function statusClasses(status) {
return {
Healthy: 'bg-emerald-400/10 text-emerald-300 ring-emerald-400/20',
Deploying: 'bg-sky-400/10 text-sky-300 ring-sky-400/20',
Attention: 'bg-amber-400/10 text-amber-300 ring-amber-400/20'
}[status]
}
</script>
<template>
<section
class="bg-gray-950 p-6 text-white"
aria-labelledby="bridge-services-title"
>
<header
class="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between"
>
<div>
<h2
id="bridge-services-title"
class="text-2xl font-semibold tracking-tight"
>
Bridge services
</h2>
<p class="mt-2 text-sm leading-6 text-gray-400">
Production services connected to this environment.
</p>
</div>
<a
href="/services/new"
class="inline-flex min-h-11 items-center justify-center gap-2 self-start rounded-lg bg-white px-4 text-sm font-medium text-gray-950 no-underline transition-colors hover:bg-gray-200 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white motion-reduce:transition-none"
>
<svg
viewBox="0 0 20 20"
fill="none"
stroke="currentColor"
class="size-4"
aria-hidden="true"
>
<path
d="M10 4v12M4 10h12"
stroke-linecap="round"
stroke-width="1.75"
/>
</svg>
New service
</a>
</header>
<div
class="mt-6 flex flex-col gap-3 rounded-xl bg-gray-900/60 p-3 ring-1 ring-white/10 lg:flex-row lg:items-center lg:justify-between"
>
<div class="flex flex-1 flex-col gap-3 sm:flex-row sm:flex-wrap">
<Input
v-model="search"
type="search"
aria-label="Search services"
placeholder="Search services..."
class="border-gray-700 bg-gray-950 text-white sm:w-64"
/>
<div class="sm:w-52">
<Select
v-model="view"
aria-label="Saved view"
:options="[
{ value: 'all', label: 'All services' },
{ value: 'platform', label: 'Platform services' }
]"
class="border-gray-700 bg-gray-950 text-white"
/>
</div>
<button
type="button"
:aria-pressed="attentionOnly"
class="inline-flex min-h-11 cursor-pointer items-center justify-center gap-2 whitespace-nowrap rounded-lg border border-gray-700 px-4 text-sm text-gray-300 transition-colors hover:border-gray-600 hover:bg-white/5 hover:text-white focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white aria-pressed:border-amber-400/50 aria-pressed:bg-amber-400/10 aria-pressed:text-amber-200 motion-reduce:transition-none sm:w-auto"
@click="attentionOnly = !attentionOnly"
>
<svg
viewBox="0 0 20 20"
fill="none"
stroke="currentColor"
class="size-4"
aria-hidden="true"
>
<path
d="M3 4h14l-5.5 6.2v4.3l-3 1.5v-5.8L3 4Z"
stroke-linejoin="round"
stroke-width="1.5"
/>
</svg>
Needs attention
</button>
</div>
<div
class="flex min-h-11 items-center justify-between gap-3 px-1 text-sm text-gray-400 lg:justify-end"
>
<span>{{ rows.length }} records</span>
<span
v-if="selected.length"
class="rounded-full bg-white/10 px-2.5 py-1 font-medium text-white"
>
{{ selected.length }} selected
</span>
<button
v-if="selected.length"
type="button"
class="cursor-pointer rounded-md px-2 py-1 font-medium text-white transition-colors hover:bg-white/10 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white motion-reduce:transition-none"
>
Actions
</button>
</div>
</div>
<div
class="mt-6 overflow-hidden rounded-xl bg-gray-950 ring-1 ring-white/10"
>
<DataTable
v-model:selected="selected"
:rows="rows"
class="overscroll-x-contain"
table-class="min-w-200 text-gray-100 dark:text-gray-100"
v-slot="table"
>
<caption class="sr-only">
Bridge service records
</caption>
<thead
class="border-b border-gray-800 bg-gray-900 text-xs uppercase tracking-wider text-gray-400"
>
<tr>
<th
scope="col"
class="sticky left-0 z-20 w-12 bg-gray-900 px-4 py-3"
>
<Checkbox
v-bind="table.pageSelection('Select all services on this page')"
class="text-white focus-visible:outline-white"
/>
</th>
<th
scope="col"
:aria-sort="ariaSort('name')"
class="sticky left-12 z-20 min-w-36 border-r border-gray-800 bg-gray-900 px-4 py-3 text-left font-medium"
>
<button
v-bind="sortButton('name', 'service')"
class="inline-flex cursor-pointer items-center gap-2 rounded-sm hover:text-white focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white"
>
Service
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
class="size-3.5"
aria-hidden="true"
>
<path
v-if="sortState('name') === 'ASC'"
d="m7 14 5-5 5 5"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
/>
<path
v-else-if="sortState('name') === 'DESC'"
d="m7 10 5 5 5-5"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
/>
<path
v-else
d="m8 9 4-4 4 4m0 6-4 4-4-4"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.75"
/>
</svg>
</button>
</th>
<th scope="col" class="px-4 py-3 text-left font-medium">Owner</th>
<th scope="col" class="px-4 py-3 text-left font-medium">Status</th>
<th scope="col" class="px-4 py-3 text-left font-medium">Region</th>
<th scope="col" class="w-16 px-4 py-3">
<span class="sr-only">Actions</span>
</th>
</tr>
</thead>
<tbody v-if="rows.length" class="divide-y divide-gray-900 bg-gray-950">
<tr
v-for="row in rows"
:key="row.id"
class="group hover:bg-white/5 focus-within:bg-white/5"
>
<td
:class="[
'sticky left-0 z-10 px-4 py-3',
table.isSelected(row)
? 'bg-gray-900'
: 'bg-gray-950 group-hover:bg-gray-900 group-focus-within:bg-gray-900'
]"
>
<Checkbox
v-bind="table.rowSelection(row, `Select ${row.name}`)"
class="text-white focus-visible:outline-white"
/>
</td>
<th
scope="row"
:class="[
'sticky left-12 z-10 border-r border-gray-900 px-4 py-3 text-left font-medium',
table.isSelected(row)
? 'bg-gray-900'
: 'bg-gray-950 group-hover:bg-gray-900 group-focus-within:bg-gray-900'
]"
>
<a
:href="`/services/${row.id}`"
class="rounded-sm text-white no-underline hover:underline hover:underline-offset-4 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white"
>
{{ row.name }}
</a>
</th>
<td class="px-4 py-3 text-gray-300">{{ row.owner }}</td>
<td class="px-4 py-3">
<span
:class="[
'inline-flex items-center gap-2 rounded-full px-2.5 py-1 text-xs font-medium ring-1 ring-inset',
statusClasses(row.status)
]"
>
<span
class="size-1.5 rounded-full bg-current"
aria-hidden="true"
></span>
{{ row.status }}
</span>
</td>
<td class="px-4 py-3 font-mono text-xs text-gray-400">
{{ row.region }}
</td>
<td class="px-4 py-3 text-right">
<a
:href="`/services/${row.id}/actions`"
:aria-label="`Actions for ${row.name}`"
class="inline-grid size-10 place-items-center rounded-lg text-gray-400 no-underline transition-colors hover:bg-white/10 hover:text-white focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white motion-reduce:transition-none"
>
<svg
viewBox="0 0 20 20"
fill="currentColor"
class="size-4"
aria-hidden="true"
>
<circle cx="4" cy="10" r="1.5" />
<circle cx="10" cy="10" r="1.5" />
<circle cx="16" cy="10" r="1.5" />
</svg>
</a>
</td>
</tr>
</tbody>
<tbody v-else class="bg-gray-950">
<tr>
<td
colspan="6"
class="px-4 py-16 text-center text-sm text-gray-400"
>
No matching services.
</td>
</tr>
</tbody>
</DataTable>
<footer
class="flex items-center justify-between border-t border-gray-800 bg-gray-900/40 px-4 py-3 text-sm text-gray-400"
>
<span class="tabular-nums">Page 1 of 8</span>
<nav aria-label="Service pages" class="flex gap-2">
<span
aria-disabled="true"
class="inline-flex min-h-10 items-center px-3 text-gray-600"
>
Previous
</span>
<a
href="?page=2"
class="inline-flex min-h-10 items-center gap-1 rounded-lg px-3 font-medium text-white no-underline transition-colors hover:bg-white/10 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white motion-reduce:transition-none"
>
Next
<svg
viewBox="0 0 20 20"
fill="none"
stroke="currentColor"
class="size-4"
aria-hidden="true"
>
<path
d="m7 4 6 6-6 6"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.75"
/>
</svg>
</a>
</nav>
</footer>
</div>
</section>
</template>
Installation
The command detects Vue, React, or Svelte, installs the framework's Inertia adapter and tailwind-merge, and writes DataTable, its query helper, and the Table registry dependency into the conventional UI directories.
Run from your application’s directory. Klean detects your framework and copies the matching source.
npx klean-ui add data-tableThe query helper is included because DataTable is intended for Boring Stack applications. Ignore it when the page already owns an equivalent server-query contract; the component itself does not require the helper at render time.
Table or DataTable?
Use Table when the page already has rows and only needs native tabular markup. Use DataTable when several server-owned list concerns must behave as one experience: search, filters, sorting, pagination, selection, and pending navigation.
DataTable composes Table rather than replacing it. Migrating a Table keeps the same <caption>, <thead>, <tbody>, <th>, <td>, links, buttons, and Tailwind classes.
Usage
Copy the example above for your framework.
Component API
| Input | Default | Purpose |
|---|---|---|
rows | [] | The rows rendered on the current server page. |
rowKey | id | A property name or function that returns each stable row key. |
selectable | every row | Returns false for rows the current user cannot select. |
selected | [] | Framework-native controlled or bindable selected keys. |
busy | false | Marks the native table busy and prevents duplicate selection while keeping current rows readable. |
class / className | — | Tailwind classes for the responsive scroll container. |
tableClass / tableClassName | — | Tailwind classes for the native Table. |
| default content | required | Native caption, sections, rows, headers, cells, links, buttons, and empty states. |
The content function or slot receives:
| Value | Purpose |
|---|---|
rows | The same current-page rows. |
selected, selectedCount | Current selected keys and count. |
allSelected, someSelected | Truthful current-page selection state. |
rowSelection(row, label?) | Props for one Klean Checkbox, including an automatic accessible label. |
pageSelection(label?) | Props for the current-page Checkbox, including its mixed state. |
isSelected(row) | Tests one visible row key. |
clearSelection() | Clears the current page selection. |
removeSelection(keys) | Removes completed or failed keys after a bulk action. |
Selection is deliberately page-scoped. When navigation, filtering, or permissions remove a row from the current result, its key leaves the selection. Cross-page selection requires a separate application-owned model because “all matching records” has server consequences that a component must not guess.
Durable server queries
useDataTableQuery for Vue and React, and createDataTableQuery for Svelte, keep the visible server query recoverable without making DataTable own application routes.
import { computed } from 'vue'
import { useDataTableQuery } from '@/components/ui/data-table/useDataTableQuery.js'
const props = defineProps({
query: { type: Object, required: true }
})
const dataTable = useDataTableQuery({
url: '/bridge/services',
query: computed(() => props.query),
defaults: {
page: 1,
search: '',
filters: {},
sort: 'createdAt DESC'
},
only: ['services', 'total', 'query']
})The helper follows these conventions:
- search waits briefly while the user types, replaces the current history entry, and returns to page 1;
- committed filters, sorts, and page changes create normal visits;
- current rows stay visible during navigation;
- scroll and local page state are preserved;
- optional
onlyvalues request the smallest useful Inertia prop set; - server props remain the source of truth when Back or Forward changes the URL;
- default
page=1, empty search, and empty filters disappear from the URL; - unrelated query parameters and the URL hash remain intact;
- sorting uses the familiar
field ASC/field DESCgrammar; - focus returns to the initiating control after the server response.
Use the returned helpers directly in native controls:
<Input
v-model="dataTable.search.value"
type="search"
aria-label="Search services"
/>
<th scope="col" :aria-sort="dataTable.ariaSort('name')">
<button v-bind="dataTable.sortButton('name', 'service name')">
Service
</button>
</th>
<button
type="button"
@click="dataTable.visit({ filters: { status: 'failed' }, page: 1 })"
>
Failed services
</button>The server still validates search, filter, sort, and page values. It returns the current rows, total, canonical query, authorization decisions, and any recoverable error. DataTable does not duplicate Waterline queries in the browser.
Links and actions stay truthful
A service name that opens a destination is an <a> or the Boring Stack <Link>. Sorting, filtering, bulk actions, and row menus are buttons. DataTable does not hide those decisions behind onRowClick, a route prop, or a cell configuration object.
This keeps Cmd/Ctrl-click, open-in-new-tab, copied URLs, Inertia navigation, and assistive technology behavior intact.
Loading, empty, and error states
- Keep the current rows mounted when
busyis true. Disable duplicate selection and show a nearby Spinner only when it adds useful feedback. - Render “No records yet” when the collection is genuinely empty.
- Render “No matching records” when filters or search produced zero rows, with a real control to clear them.
- Render a nearby Alert for a recoverable server failure. Do not turn the table into an error role.
- After a bulk mutation, call
removeSelection(succeededKeys)and leave failed rows selected when retrying them is useful.
Accessibility and responsive behavior
- Give every table a visible
<caption>or a visually hidden caption when nearby visible context already provides the same name. - Use
<th scope="col">for column headers and<th scope="row">for the row's primary identity. - Put a real button inside a sortable header and update
aria-sorton that<th>. - Give repeated links and actions specific names such as “Actions for api”.
- DataTable announces the selection count and supplies a real mixed current-page checkbox.
- Keep every current row in one native table. The outer DataTable container scrolls horizontally when caller-owned
min-w-*classes need more room. - When wide operational tables need more context on small screens, use caller-owned
stickyclasses to keep selection and the primary row identity visible while the remaining columns scroll. - Do not collapse a data relationship into cards merely to avoid horizontal scrolling. Use a list when the content was not tabular in the first place.
Styling with Tailwind
The wrapper, table, caption, headers, cells, statuses, links, actions, empty state, and pagination are styled where they are written. There is no variant, density prop, column style object, Klean color, or global DataTable theme.
Build a small application wrapper when several pages share one treatment. Use Tailwind for compact rows, stronger borders, different typography, or a dark surface.
When not to use
- Use Table for a static report or a small result that does not coordinate list state.
- Use a semantic list for independent records without meaningful columns.
- Use Combobox when the task is finding and choosing one value, not inspecting a result set.
- Use Command for a searchable collection of actions and destinations.
- Do not use DataTable for spreadsheet-style cell editing; editable invoice lines are usually a responsive form/list with explicit controls.
Complete framework source
The installer writes both the component and the framework-native query helper. Table is installed as a registry dependency.
<script setup>
import { computed, ref, useAttrs, watch } from 'vue'
import { twMerge } from 'tailwind-merge'
import Table from '../table/Table.vue'
defineOptions({ inheritAttrs: false })
const props = defineProps({
rows: { type: Array, default: () => [] },
rowKey: { type: [String, Function], default: 'id' },
selectable: { type: Function, default: () => true },
busy: { type: Boolean, default: false },
tableClass: { type: [String, Array, Object], default: undefined }
})
const selected = defineModel('selected', { default: () => [] })
const attrs = useAttrs()
const root = ref()
const rootAttrs = computed(() => {
const {
class: _class,
'data-slot': _dataSlot,
'data-busy': _dataBusy,
'data-empty': _dataEmpty,
...rest
} = attrs
return rest
})
function keyFor(row) {
return typeof props.rowKey === 'function'
? props.rowKey(row)
: row?.[props.rowKey]
}
function canSelect(row) {
return props.selectable(row) !== false
}
const selectableKeys = computed(() => props.rows.filter(canSelect).map(keyFor))
const selectableKeySet = computed(() => new Set(selectableKeys.value))
const selectedKeySet = computed(() => new Set(selected.value))
const selectedCount = computed(() => selectedKeySet.value.size)
const allSelected = computed(
() =>
selectableKeys.value.length > 0 &&
selectableKeys.value.every((key) => selectedKeySet.value.has(key))
)
const someSelected = computed(
() => selectedCount.value > 0 && !allSelected.value
)
const status = computed(() => {
if (selectedCount.value === 0) return 'No rows selected.'
return `${selectedCount.value} row${selectedCount.value === 1 ? '' : 's'} selected.`
})
function setSelected(next) {
selected.value = [...new Set(next)]
}
function isSelected(row) {
return selectedKeySet.value.has(keyFor(row))
}
function setRowSelected(row, checked) {
if (props.busy || !canSelect(row)) return
const key = keyFor(row)
const next = new Set(selected.value)
if (checked) next.add(key)
else next.delete(key)
setSelected(next)
}
function setPageSelected(checked) {
if (props.busy) return
setSelected(checked ? selectableKeys.value : [])
}
function clearSelection() {
setSelected([])
}
function removeSelection(keys) {
const removed = new Set(Array.isArray(keys) ? keys : [keys])
setSelected(selected.value.filter((key) => !removed.has(key)))
}
function rowSelection(row, label) {
const key = keyFor(row)
return {
modelValue: selectedKeySet.value.has(key),
disabled: props.busy || !canSelect(row),
'aria-label': label || `Select row ${String(key)}`,
'onUpdate:modelValue': (checked) => setRowSelected(row, checked)
}
}
function pageSelection(label = 'Select all rows on this page') {
return {
modelValue: allSelected.value,
indeterminate: someSelected.value,
disabled: props.busy || selectableKeys.value.length === 0,
'aria-label': label,
'onUpdate:modelValue': setPageSelected
}
}
watch(
selectableKeySet,
(keys) => {
const next = selected.value.filter((key) => keys.has(key))
if (
next.length !== selected.value.length ||
next.some((key, index) => !Object.is(key, selected.value[index]))
) {
setSelected(next)
}
},
{ immediate: true, flush: 'sync' }
)
defineExpose({
root,
clearSelection,
removeSelection
})
</script>
<template>
<div
ref="root"
v-bind="rootAttrs"
data-slot="data-table"
:data-busy="busy ? '' : undefined"
:data-empty="rows.length === 0 ? '' : undefined"
:class="twMerge('relative overflow-x-auto', attrs.class)"
>
<Table
:aria-busy="busy ? 'true' : undefined"
:class="twMerge('min-w-full', tableClass)"
>
<slot
:rows="rows"
:selected="selected"
:selected-count="selectedCount"
:all-selected="allSelected"
:some-selected="someSelected"
:is-selected="isSelected"
:row-selection="rowSelection"
:page-selection="pageSelection"
:clear-selection="clearSelection"
:remove-selection="removeSelection"
/>
</Table>
<span class="sr-only" aria-live="polite" aria-atomic="true">{{
status
}}</span>
</div>
</template>import {
forwardRef,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState
} from 'react'
import { twMerge } from 'tailwind-merge'
import Table from '../table/Table.jsx'
const DataTable = forwardRef(function DataTable(
{
rows = [],
rowKey = 'id',
selectable = () => true,
busy = false,
selected,
defaultSelected = [],
onSelectedChange,
tableClassName,
className,
children,
'data-slot': _dataSlot,
'data-busy': _dataBusy,
'data-empty': _dataEmpty,
...props
},
forwardedRef
) {
const rootRef = useRef(null)
const controlled = selected !== undefined
const [localSelected, setLocalSelected] = useState(defaultSelected)
const selectedKeys = controlled ? selected : localSelected
function keyFor(row) {
return typeof rowKey === 'function' ? rowKey(row) : row?.[rowKey]
}
const selectableKeys = useMemo(
() => rows.filter((row) => selectable(row) !== false).map(keyFor),
[rows, rowKey, selectable]
)
const selectableKeySet = useMemo(
() => new Set(selectableKeys),
[selectableKeys]
)
const selectedKeySet = useMemo(() => new Set(selectedKeys), [selectedKeys])
const allSelected =
selectableKeys.length > 0 &&
selectableKeys.every((key) => selectedKeySet.has(key))
const someSelected = selectedKeySet.size > 0 && !allSelected
function setSelected(next) {
const value = [...new Set(next)]
if (!controlled) setLocalSelected(value)
onSelectedChange?.(value)
}
function isSelected(row) {
return selectedKeySet.has(keyFor(row))
}
function setRowSelected(row, checked) {
if (busy || selectable(row) === false) return
const key = keyFor(row)
const next = new Set(selectedKeys)
if (checked) next.add(key)
else next.delete(key)
setSelected(next)
}
function setPageSelected(checked) {
if (busy) return
setSelected(checked ? selectableKeys : [])
}
function clearSelection() {
setSelected([])
}
function removeSelection(keys) {
const removed = new Set(Array.isArray(keys) ? keys : [keys])
setSelected(selectedKeys.filter((key) => !removed.has(key)))
}
function rowSelection(row, label) {
const key = keyFor(row)
return {
checked: selectedKeySet.has(key),
disabled: busy || selectable(row) === false,
'aria-label': label || `Select row ${String(key)}`,
onChange: (event) => setRowSelected(row, event.currentTarget.checked)
}
}
function pageSelection(label = 'Select all rows on this page') {
return {
checked: allSelected,
indeterminate: someSelected,
disabled: busy || selectableKeys.length === 0,
'aria-label': label,
onChange: (event) => setPageSelected(event.currentTarget.checked)
}
}
useEffect(() => {
const next = selectedKeys.filter((key) => selectableKeySet.has(key))
if (
next.length !== selectedKeys.length ||
next.some((key, index) => !Object.is(key, selectedKeys[index]))
) {
setSelected(next)
}
}, [selectableKeySet, selectedKeys])
useImperativeHandle(forwardedRef, () => ({
root: rootRef.current,
clearSelection,
removeSelection
}))
const api = {
rows,
selected: selectedKeys,
selectedCount: selectedKeySet.size,
allSelected,
someSelected,
isSelected,
rowSelection,
pageSelection,
clearSelection,
removeSelection
}
const status =
selectedKeySet.size === 0
? 'No rows selected.'
: `${selectedKeySet.size} row${selectedKeySet.size === 1 ? '' : 's'} selected.`
return (
<div
{...props}
ref={rootRef}
data-slot="data-table"
data-busy={busy ? '' : undefined}
data-empty={rows.length === 0 ? '' : undefined}
className={twMerge('relative overflow-x-auto', className)}
>
<Table
aria-busy={busy ? 'true' : undefined}
className={twMerge('min-w-full', tableClassName)}
>
{typeof children === 'function' ? children(api) : children}
</Table>
<span className="sr-only" aria-live="polite" aria-atomic="true">
{status}
</span>
</div>
)
})
export default DataTable<script>
import { twMerge } from "tailwind-merge";
import Table from "../table/Table.svelte";
let {
rows = [],
rowKey = "id",
selectable = () => true,
busy = false,
selected = $bindable([]),
tableClass,
class: className,
children,
"data-slot": _dataSlot,
"data-busy": _dataBusy,
"data-empty": _dataEmpty,
...props
} = $props();
let root = $state();
function keyFor(row) {
return typeof rowKey === "function" ? rowKey(row) : row?.[rowKey];
}
let selectableKeys = $derived(
rows.filter((row) => selectable(row) !== false).map(keyFor),
);
let selectableKeySet = $derived(new Set(selectableKeys));
let selectedKeySet = $derived(new Set(selected));
let selectedCount = $derived(selectedKeySet.size);
let allSelected = $derived(
selectableKeys.length > 0 &&
selectableKeys.every((key) => selectedKeySet.has(key)),
);
let someSelected = $derived(selectedCount > 0 && !allSelected);
let status = $derived(
selectedCount === 0
? "No rows selected."
: `${selectedCount} row${selectedCount === 1 ? "" : "s"} selected.`,
);
function setSelected(next) {
selected = [...new Set(next)];
}
function isSelected(row) {
return selectedKeySet.has(keyFor(row));
}
function setRowSelected(row, checked) {
if (busy || selectable(row) === false) return;
const key = keyFor(row);
const next = new Set(selected);
if (checked) next.add(key);
else next.delete(key);
setSelected(next);
}
function setPageSelected(checked) {
if (busy) return;
setSelected(checked ? selectableKeys : []);
}
export function clearSelection() {
setSelected([]);
}
export function removeSelection(keys) {
const removed = new Set(Array.isArray(keys) ? keys : [keys]);
setSelected(selected.filter((key) => !removed.has(key)));
}
export function getRoot() {
return root;
}
function rowSelection(row, label) {
const key = keyFor(row);
return {
checked: selectedKeySet.has(key),
disabled: busy || selectable(row) === false,
"aria-label": label || `Select row ${String(key)}`,
onchange: (event) => setRowSelected(row, event.currentTarget.checked),
};
}
function pageSelection(label = "Select all rows on this page") {
return {
checked: allSelected,
indeterminate: someSelected,
disabled: busy || selectableKeys.length === 0,
"aria-label": label,
onchange: (event) => setPageSelected(event.currentTarget.checked),
};
}
let api = {
get rows() {
return rows;
},
get selected() {
return selected;
},
get selectedCount() {
return selectedCount;
},
get allSelected() {
return allSelected;
},
get someSelected() {
return someSelected;
},
isSelected,
rowSelection,
pageSelection,
clearSelection,
removeSelection,
};
$effect(() => {
const next = selected.filter((key) => selectableKeySet.has(key));
if (
next.length !== selected.length ||
next.some((key, index) => !Object.is(key, selected[index]))
) {
setSelected(next);
}
});
</script>
<div
{...props}
bind:this={root}
data-slot="data-table"
data-busy={busy ? "" : undefined}
data-empty={rows.length === 0 ? "" : undefined}
class={twMerge("relative overflow-x-auto", className)}
>
<Table
aria-busy={busy ? "true" : undefined}
class={twMerge("min-w-full", tableClass)}
>
{@render children?.(api)}
</Table>
<span class="sr-only" aria-live="polite" aria-atomic="true">{status}</span>
</div>import { computed, nextTick, onBeforeUnmount, ref, toValue, watch } from 'vue'
import { router } from '@inertiajs/vue3'
function sameValue(left, right) {
if (Object.is(left, right)) return true
if (left && right && typeof left === 'object' && typeof right === 'object') {
return JSON.stringify(left) === JSON.stringify(right)
}
return false
}
function queryValue(value) {
if (value === undefined || value === null || value === '') return undefined
if (typeof value === 'object') return JSON.stringify(value)
return String(value)
}
export function dataTableUrl(source, query, defaults = {}) {
const raw = source || '/'
const absolute = /^[a-z][a-z\d+.-]*:/i.test(raw)
const url = new URL(raw, 'http://klean.invalid')
const cleanDefaults = { page: 1, search: '', filters: {}, ...defaults }
for (const [key, value] of Object.entries(query || {})) {
if (sameValue(value, cleanDefaults[key])) {
url.searchParams.delete(key)
continue
}
const encoded = queryValue(value)
if (encoded === undefined) url.searchParams.delete(key)
else url.searchParams.set(key, encoded)
}
return absolute ? url.href : `${url.pathname}${url.search}${url.hash}`
}
function directionFor(sort, field) {
const [activeField, direction = 'ASC'] = String(sort || '').split(/\s+/)
return activeField === field ? direction.toUpperCase() : undefined
}
function restoreFocus(intent) {
if (!intent || typeof document === 'undefined') return
requestAnimationFrame(async () => {
await nextTick()
if (intent.element?.isConnected) {
intent.element.focus()
return
}
const candidate = [...document.querySelectorAll('[data-table-focus]')].find(
(element) => element.dataset.tableFocus === intent.key
)
candidate?.focus()
})
}
export function useDataTableQuery(options) {
const busy = ref(false)
const search = ref('')
const currentQuery = computed(() => toValue(options.query) || {})
const currentDefaults = computed(() => toValue(options.defaults) || {})
let searchTimer
let syncingSearch = false
let focusIntent
function cancelSearch() {
clearTimeout(searchTimer)
searchTimer = undefined
}
function visit(updates = {}, visitOptions = {}) {
cancelSearch()
const {
replace = false,
trigger,
onStart,
onFinish,
...forwardedOptions
} = visitOptions
const next = {
...currentQuery.value,
search: search.value,
...updates
}
const href = dataTableUrl(toValue(options.url), next, currentDefaults.value)
const only = toValue(options.only) || []
const key = trigger?.dataset?.tableFocus
focusIntent = trigger ? { element: trigger, key } : undefined
router.visit(href, {
preserveState: true,
preserveScroll: true,
...(only.length ? { only } : {}),
...forwardedOptions,
replace,
onStart(event) {
busy.value = true
onStart?.(event)
},
onFinish(event) {
busy.value = false
const intent = focusIntent
focusIntent = undefined
restoreFocus(intent)
onFinish?.(event)
}
})
return href
}
function sort(field, trigger) {
if (busy.value) return
const direction = directionFor(currentQuery.value.sort, field)
const nextDirection = direction === 'ASC' ? 'DESC' : 'ASC'
return visit({ sort: `${field} ${nextDirection}`, page: 1 }, { trigger })
}
function ariaSort(field) {
const direction = directionFor(currentQuery.value.sort, field)
if (direction === 'ASC') return 'ascending'
if (direction === 'DESC') return 'descending'
return undefined
}
function sortButton(field, label = field) {
const direction = directionFor(currentQuery.value.sort, field)
const nextDirection = direction === 'ASC' ? 'descending' : 'ascending'
return {
type: 'button',
disabled: busy.value,
'data-table-focus': `sort:${field}`,
'aria-label': `Sort by ${label} ${nextDirection}`,
onClick: (event) => sort(field, event.currentTarget)
}
}
watch(
() => String(currentQuery.value.search ?? ''),
(value) => {
cancelSearch()
syncingSearch = true
search.value = value
syncingSearch = false
},
{ immediate: true, flush: 'sync' }
)
watch(
search,
(value) => {
if (
syncingSearch ||
String(currentQuery.value.search ?? '') === String(value)
) {
return
}
cancelSearch()
searchTimer = setTimeout(() => {
visit({ search: value, page: 1 }, { replace: true })
}, 300)
},
{ flush: 'sync' }
)
onBeforeUnmount(cancelSearch)
return {
search,
busy,
visit,
sort,
ariaSort,
sortButton,
cancelSearch
}
}import { useCallback, useEffect, useRef, useState } from 'react'
import { router } from '@inertiajs/react'
function sameValue(left, right) {
if (Object.is(left, right)) return true
if (left && right && typeof left === 'object' && typeof right === 'object') {
return JSON.stringify(left) === JSON.stringify(right)
}
return false
}
function queryValue(value) {
if (value === undefined || value === null || value === '') return undefined
if (typeof value === 'object') return JSON.stringify(value)
return String(value)
}
export function dataTableUrl(source, query, defaults = {}) {
const raw = source || '/'
const absolute = /^[a-z][a-z\d+.-]*:/i.test(raw)
const url = new URL(raw, 'http://klean.invalid')
const cleanDefaults = { page: 1, search: '', filters: {}, ...defaults }
for (const [key, value] of Object.entries(query || {})) {
if (sameValue(value, cleanDefaults[key])) {
url.searchParams.delete(key)
continue
}
const encoded = queryValue(value)
if (encoded === undefined) url.searchParams.delete(key)
else url.searchParams.set(key, encoded)
}
return absolute ? url.href : `${url.pathname}${url.search}${url.hash}`
}
function directionFor(sort, field) {
const [activeField, direction = 'ASC'] = String(sort || '').split(/\s+/)
return activeField === field ? direction.toUpperCase() : undefined
}
function restoreFocus(intent) {
if (!intent || typeof document === 'undefined') return
requestAnimationFrame(() => {
if (intent.element?.isConnected) {
intent.element.focus()
return
}
const candidate = [...document.querySelectorAll('[data-table-focus]')].find(
(element) => element.dataset.tableFocus === intent.key
)
candidate?.focus()
})
}
export function useDataTableQuery(options) {
const optionsRef = useRef(options)
optionsRef.current = options
const serverSearch = String(options.query?.search ?? '')
const [search, setSearch] = useState(serverSearch)
const [busy, setBusy] = useState(false)
const searchRef = useRef(search)
const focusIntent = useRef()
searchRef.current = search
useEffect(() => {
setSearch(serverSearch)
}, [serverSearch])
const visit = useCallback((updates = {}, visitOptions = {}) => {
const current = optionsRef.current
const {
replace = false,
trigger,
onStart,
onFinish,
...forwardedOptions
} = visitOptions
const next = {
...(current.query || {}),
search: searchRef.current,
...updates
}
const href = dataTableUrl(current.url, next, current.defaults || {})
const only = current.only || []
focusIntent.current = trigger
? { element: trigger, key: trigger.dataset?.tableFocus }
: undefined
router.visit(href, {
preserveState: true,
preserveScroll: true,
...(only.length ? { only } : {}),
...forwardedOptions,
replace,
onStart(event) {
setBusy(true)
onStart?.(event)
},
onFinish(event) {
setBusy(false)
const intent = focusIntent.current
focusIntent.current = undefined
restoreFocus(intent)
onFinish?.(event)
}
})
return href
}, [])
useEffect(() => {
if (search === serverSearch) return
const timer = setTimeout(() => {
visit({ search, page: 1 }, { replace: true })
}, 300)
return () => clearTimeout(timer)
}, [search, serverSearch, visit])
function sort(field, trigger) {
if (busy) return
const direction = directionFor(options.query?.sort, field)
const nextDirection = direction === 'ASC' ? 'DESC' : 'ASC'
return visit({ sort: `${field} ${nextDirection}`, page: 1 }, { trigger })
}
function ariaSort(field) {
const direction = directionFor(options.query?.sort, field)
if (direction === 'ASC') return 'ascending'
if (direction === 'DESC') return 'descending'
return undefined
}
function sortButton(field, label = field) {
const direction = directionFor(options.query?.sort, field)
const nextDirection = direction === 'ASC' ? 'descending' : 'ascending'
return {
type: 'button',
disabled: busy,
'data-table-focus': `sort:${field}`,
'aria-label': `Sort by ${label} ${nextDirection}`,
onClick: (event) => sort(field, event.currentTarget)
}
}
return {
search,
setSearch,
busy,
visit,
sort,
ariaSort,
sortButton
}
}import { router } from '@inertiajs/svelte'
function valueOf(value) {
return typeof value === 'function' ? value() : value
}
function sameValue(left, right) {
if (Object.is(left, right)) return true
if (left && right && typeof left === 'object' && typeof right === 'object') {
return JSON.stringify(left) === JSON.stringify(right)
}
return false
}
function queryValue(value) {
if (value === undefined || value === null || value === '') return undefined
if (typeof value === 'object') return JSON.stringify(value)
return String(value)
}
export function dataTableUrl(source, query, defaults = {}) {
const raw = source || '/'
const absolute = /^[a-z][a-z\d+.-]*:/i.test(raw)
const url = new URL(raw, 'http://klean.invalid')
const cleanDefaults = { page: 1, search: '', filters: {}, ...defaults }
for (const [key, value] of Object.entries(query || {})) {
if (sameValue(value, cleanDefaults[key])) {
url.searchParams.delete(key)
continue
}
const encoded = queryValue(value)
if (encoded === undefined) url.searchParams.delete(key)
else url.searchParams.set(key, encoded)
}
return absolute ? url.href : `${url.pathname}${url.search}${url.hash}`
}
function directionFor(sort, field) {
const [activeField, direction = 'ASC'] = String(sort || '').split(/\s+/)
return activeField === field ? direction.toUpperCase() : undefined
}
function restoreFocus(intent) {
if (!intent || typeof document === 'undefined') return
requestAnimationFrame(() => {
if (intent.element?.isConnected) {
intent.element.focus()
return
}
const candidate = [...document.querySelectorAll('[data-table-focus]')].find(
(element) => element.dataset.tableFocus === intent.key
)
candidate?.focus()
})
}
export function createDataTableQuery(options) {
const currentQuery = () => valueOf(options.query) || {}
let search = $state(String(currentQuery().search ?? ''))
let busy = $state(false)
let previousServerSearch = String(currentQuery().search ?? '')
let focusIntent
function visit(updates = {}, visitOptions = {}) {
const {
replace = false,
trigger,
onStart,
onFinish,
...forwardedOptions
} = visitOptions
const next = { ...currentQuery(), search, ...updates }
const href = dataTableUrl(
valueOf(options.url),
next,
valueOf(options.defaults) || {}
)
const only = valueOf(options.only) || []
focusIntent = trigger
? { element: trigger, key: trigger.dataset?.tableFocus }
: undefined
router.visit(href, {
preserveState: true,
preserveScroll: true,
...(only.length ? { only } : {}),
...forwardedOptions,
replace,
onStart(event) {
busy = true
onStart?.(event)
},
onFinish(event) {
busy = false
const intent = focusIntent
focusIntent = undefined
restoreFocus(intent)
onFinish?.(event)
}
})
return href
}
function sort(field, trigger) {
if (busy) return
const direction = directionFor(currentQuery().sort, field)
const nextDirection = direction === 'ASC' ? 'DESC' : 'ASC'
return visit({ sort: `${field} ${nextDirection}`, page: 1 }, { trigger })
}
function ariaSort(field) {
const direction = directionFor(currentQuery().sort, field)
if (direction === 'ASC') return 'ascending'
if (direction === 'DESC') return 'descending'
return undefined
}
function sortButton(field, label = field) {
const direction = directionFor(currentQuery().sort, field)
const nextDirection = direction === 'ASC' ? 'descending' : 'ascending'
return {
type: 'button',
disabled: busy,
'data-table-focus': `sort:${field}`,
'aria-label': `Sort by ${label} ${nextDirection}`,
onclick: (event) => sort(field, event.currentTarget)
}
}
$effect(() => {
const next = String(currentQuery().search ?? '')
if (next === previousServerSearch) return
previousServerSearch = next
search = next
})
$effect(() => {
const value = search
const serverValue = String(currentQuery().search ?? '')
if (value === serverValue) return
const timer = setTimeout(() => {
visit({ search: value, page: 1 }, { replace: true })
}, 300)
return () => clearTimeout(timer)
})
return {
get search() {
return search
},
set search(value) {
search = String(value ?? '')
},
get busy() {
return busy
},
visit,
sort,
ariaSort,
sortButton
}
}Related components
- Table — the native semantic primitive DataTable composes.
- Checkbox — page and row selection with a real mixed state.
- Input — searchable server queries.
- Select and Combobox — fixed and searchable filters.
- Menu — truthful row and bulk actions.
- Pagination — durable server-page links.
- Alert — recoverable query and mutation failures.
- Spinner — supplementary pending feedback.