DataTable
DataTable coordinates a real native table for server-driven application work. It keeps selection honest across the current page, exposes a truthful busy state, and offers an optional Inertia query helper so search, sort, filters, and pagination survive 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.
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 one command from a Boring Stack application. Klean detects the framework and conventional destination, then adds the framework-native source and its direct dependencies.
npx klean-ui add data-table- No initializer or configuration file
- No framework, alias, or theme questions
- No Klean runtime dependency
The 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
Vue
<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>
React
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>
)
}
Svelte
<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}
/>
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 product wrapper when several pages share one treatment. Slipway can preserve Bridge's dense dark operational surface; Hagfish can use its editorial borders and typography from the same component contract.
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.
Vue
<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 { 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
}
}
React
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
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
}
}
Svelte
<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 { 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.