Table
WxTable renders rows and reports what the user did with them. It takes a Laravel paginator as it arrives, so a controller that already returns ->paginate() needs no reshaping.
| Status | |||||
|---|---|---|---|---|---|
| Nothing to show | |||||
Asked for: — · selected: 0
| Status | ||||
|---|---|---|---|---|
| No orders in this period | ||||
Usage
<script setup lang="ts">
import { ref } from 'vue'
import type { Paginated, TableColumn, TableState } from '@webx-ui/core'
interface Order {
id: number
customer: string
total: number
}
const orders = ref<Paginated<Order> | null>(null)
const loading = ref(false)
const columns: TableColumn<Order>[] = [
{ key: 'id', label: '#', width: 80, sortable: true },
{ key: 'customer', label: 'Customer', sortable: true },
{ key: 'total', label: 'Total', align: 'right', formatter: (value) => `€${value}` },
]
async function load(state: TableState) {
loading.value = true
const query = new URLSearchParams({
page: String(state.page),
per_page: String(state.perPage),
})
if (state.search) query.set('search', state.search)
if (state.sort) query.set('sort', `${state.sort.order === 'desc' ? '-' : ''}${state.sort.key}`)
orders.value = await fetch(`/api/orders?${query}`).then((response) => response.json())
loading.value = false
}
</script>
<template>
<wx-table
title="Orders"
searchable
selectable
persist="orders"
:data="orders"
:columns="columns"
:loading="loading"
row-key="id"
@state-change="load"
/>
</template>data takes either the paginator or a plain array — the table only ever reads rows out of it.
One event, one fetch
The page, the page size, the sort and the search term are the four things a backend needs, so they travel together in @state-change:
{ page: 1, perPage: 15, sort: { key: 'customer', order: 'asc' }, search: 'ada' }It fires once on mount — carrying whatever persist restored — and again whenever any part of it changes. That is deliberately your initial load as well: hang the fetch on this event and there is no second place where a request can start, and no watcher racing the restored state.
Sorting and searching both send the page back to the first, because the page number of a result set that just changed means nothing.
Each part is still a model of its own (v-model:page, v-model:sort, …) when a query parameter or a store needs to own it.
Pagination is built in
Hand the table a paginator and the pagination appears in the footer by itself — no slot, no second component to wire:
<wx-table :data="orders" :columns="columns" :per-page-options="[15, 30, 50]" @state-change="load" />per-page starts at 15 and the size control only shows up once per-page-options is given. Turn the whole thing off with :pagination="false", force it on for a plain array with :pagination="true", and replace it entirely with #footer — the slot wins.
Header and search
A title and searchable give the table a bar of its own: the heading on the left, the search field and anything in #actions on the right.
<wx-table v-model:search="term" title="Orders" searchable @search="reload">
<template #actions>
<wx-button size="sm">Export</wx-button>
</template>
</wx-table>The field answers every keystroke; the state waits for the typing to settle first — 300 ms by default, :search-debounce="0" to report immediately. So v-model:search is the text on screen and @state-change is the moment to call the backend, which is the difference between one request and one request per letter. @search fires alongside it if the term alone is what you want.
Filters behind a funnel
Three dropdowns standing open in the header are three controls of chrome above the first row, and on a phone that is half the screen before any data. #filters puts them behind one button: the table draws a funnel beside the search, and the slot is the panel it opens.
<wx-table :data="page" :columns="columns" searchable :filters-count="applied.length">
<template #filters>
<wx-form-item label="Rubric">
<wx-select v-model="rubric" :options="rubrics" clearable @change="reload" />
</wx-form-item>
<wx-form-item label="Author">
<wx-select v-model="author" :options="authors" clearable @change="reload" />
</wx-form-item>
</template>
<template #applied>
<wx-badge v-for="chip in applied" :key="chip.key" size="sm" round closable @close="chip.clear()">
{{ chip.label }}
</wx-badge>
</template>
</wx-table>A shut panel says nothing about itself, and a list narrowed by something the reader cannot see is a list that looks wrong. Two things answer that. filtersCount puts the number on the funnel, and #applied says what the filters are set to — chips the reader can take off one at a time.
The chips stand in the header row itself, in the space between the title and the search, because that space is already there and a strip of its own costs a line above every filtered list. They wrap into it rather than pushing the search field off the row.
The chips are the caller's, because only the caller knows that rubric=2 reads "Rubric: News" and what taking it off means. The table gives the place, the spacing and one promise: while the strip holds no elements it is not drawn at all, so a list with no filters on looks exactly as it did before.
Remembering where the user was
persist names a key and the table writes the page, the size, the sort and the search term under it, restoring them on the next visit:
<wx-table persist="orders" :data="orders" :columns="columns" @state-change="load" />Reading happens after mount rather than during setup, so a page rendered on a server does not disagree with what the browser hydrates. The restored state arrives in the first @state-change, which means the initial fetch is the correct one rather than a default fetch followed by a second.
Nothing is stored unless the key is given, and a storage that is unavailable, full, or holding something the table did not write is ignored — remembering is a convenience, not a feature to fail over. The selection is deliberately not part of it: keys outlive paging but should not outlive the tab.
Use one key per table per application: orders, orders-archive, not table.
Sorting is reported, not applied
Clicking a sortable heading cycles ascending, descending, off, and each step lands in sort and in @sort-change. The rows on screen do not move.
That is deliberate. What is on screen is one page out of an ordered query, so sorting it here would shuffle fifteen rows and leave the other hundred where they were — the answer would be wrong and would look right. Ordering belongs to whoever built the page, which for a paginated table is always the backend. A small array that is not paginated is sorted the same way: in the caller, before it reaches data.
Selection
v-model:selected holds row keys, not rows, so a selection survives paging away and back. The header checkbox works on the current page only and leaves keys picked elsewhere alone.
<wx-table
v-model:selected="selected"
:data="orders"
:columns="columns"
selectable
row-key="id"
:selectable-if="(row) => row.status !== 'refunded'"
/>@selection-change hands back the keys and the rows behind them that are on this page.
Name a rowKey whenever rows can be selected or expanded. Without one the table falls back to the row's position, which renders fine and then hands out the wrong keys after a sort.
Summary
| Expand | SKU | Item | Qty | Price | Line total |
|---|---|---|---|---|---|
| KB-104 | Mechanical keyboard | 2 | 79.00 € | 153.00 € | |
Brown switches, ISO layout. Ships from the Riga warehouse. | |||||
| MS-220 | Wireless mouse | 1 | 32.00 € | 32.00 € | |
| CB-3M | USB-C cable, 3 m | 4 | 6.90 € | 26.40 € | |
| Sum | 217.60 € | ||||
| Discount | −6.20 € | ||||
| Total | 211.40 € | ||||
summary is a list of lines under the table. Each has a label and figures keyed by column, and the label spans every column ahead of the first figure — which is what a total wants: a caption on the left, a number under its own column.
<script setup lang="ts">
const summary = computed(() => [
{ label: 'Sum', cells: { line: money(sum) } },
{ label: 'Discount', cells: { line: `−${money(discount)}` } },
{ label: 'Total', cells: { line: money(sum - discount) }, strong: true },
])
</script>
<template>
<wx-table :data="items" :columns="columns" :summary="summary" />
</template>It is a prop rather than a slot on purpose: figures are data, and data survives being written down as JSON — which is what @webx-ui/schema will render an admin from. When a figure needs markup, summary-<key> takes over that cell without giving up the structure.
Several lines stack in order, and strong: true marks the one that matters. The arithmetic stays with the caller: a discount is not always a sum of a column, and a table that guesses at totals is a table that is confidently wrong once a month.
#footer still works alongside it — that is where the pagination goes.
Expandable rows
A row can open to show what does not fit in it, which is how a wide table stays narrow.
<wx-table v-model:expanded="open" :data="items" :columns="columns" expandable row-key="id">
<template #expanded="{ row }">
<p>{{ row.note }}</p>
</template>
</wx-table>v-model:expanded holds keys, like the selection, so several rows can be open at once and the set is the caller's to control — open one by default by seeding the array. expandable-if decides which rows have anything to show; the ones that do not get no chevron rather than an empty panel.
Rows that nest
tree turns the rows into a structure: the first column carries the indentation and the disclosure, every other column is still a column. It is the pages screen and the categories screen — the tree and the data in one pane, instead of a sidebar tree beside a list of the same records.
| Title | URL | Status | Updated | |
|---|---|---|---|---|
| Home | / | live | 11.09.26 | |
| About the company | /about | live | 02.09.26 | |
| Services | /services | live | 28.08.26 | |
| Contacts | /contacts | draft | 19.08.26 |
<wx-table
v-model:expanded="open"
:columns="columns"
:data="roots"
:tree="{ lazy: true, load, draggable: true }"
row-key="id"
@node-drop="save"
/>data is the roots, and load(row) fetches one level — a catalogue of five thousand categories is not a payload, it is a series of them. A row says whether it is worth a chevron with has_children, which is withCount('children') under a name of your choosing:
Page::whereNull('parent_id')->withCount('children')->get();Nothing said about children still gets a chevron: a branch nobody described is worth one request to find out. A false — or a count of zero — is taken at its word and draws a leaf.
Without lazy the table takes the tree it is given, nested under children, and opens it locally.
Moving a row
draggable makes every row something to pick up, and which third of a row the pointer is over decides where the dragged one lands: the edges put it before or after, the middle puts it inside. A row can never land inside its own subtree.
Holding a row over a closed branch opens it — springDelay, 600ms by default — and where that branch has not been fetched, opening it fetches it. So a move across the tree is one drag rather than a drag, a wait, and another drag. Dropping into a branch that has never been opened fetches it first as well: the position a row lands at is not something to guess at.
node-drop carries everything a backend needs, and the rows on screen are already rearranged:
function save(event: TableNodeDropEvent) {
return api.patch(`/pages/${event.row.id}/move`, {
parent_id: event.parent?.id ?? null,
position: event.index,
})
}Dragging is a pointer gesture, and this table has no keyboard equivalent for it — Tree does, with Alt and the arrow keys. For a move across a long distance, and for the keyboard, give the row a Move action and open a picker with openModal.
What a tree turns off
- Sorting. A heading in tree mode is a heading:
sortablecolumns are drawn without their control. Ordering rows would either scatter the branches or sort quietly inside each of them, and the order that matters here is the one dragging produces. - Pagination. A page of a tree cuts branches in half, and the rows are not a page anyway — a lazy tree asks for a level at a time. It stays off even when
datais a paginator. - Expandable rows. The chevron belongs to the branch. A row that must show more than fits belongs in a drawer or a detail pane.
Fixed columns, sticky header and footer
| Name | State | City | Address | Zip | Courier | Weight | ||
|---|---|---|---|---|---|---|---|---|
| 2026-05-01 | Tom | California | Los Angeles | No. 189, Grove St, Los Angeles | CA 90036 | DHL | 1.0 kg | |
| 2026-05-02 | Ada | California | Los Angeles | No. 189, Grove St, Los Angeles | CA 90036 | UPS | 1.4 kg | |
| 2026-05-03 | Grace | California | Los Angeles | No. 189, Grove St, Los Angeles | CA 90036 | FedEx | 1.8 kg | |
| 2026-05-04 | Alan | California | Los Angeles | No. 189, Grove St, Los Angeles | CA 90036 | DHL | 2.2 kg | |
| 2026-05-05 | Tom | California | Los Angeles | No. 189, Grove St, Los Angeles | CA 90036 | UPS | 2.6 kg | |
| 2026-05-06 | Ada | California | Los Angeles | No. 189, Grove St, Los Angeles | CA 90036 | FedEx | 3.0 kg | |
| 2026-05-07 | Grace | California | Los Angeles | No. 189, Grove St, Los Angeles | CA 90036 | DHL | 3.4 kg | |
| 2026-05-08 | Alan | California | Los Angeles | No. 189, Grove St, Los Angeles | CA 90036 | UPS | 3.8 kg | |
| 2026-05-09 | Tom | California | Los Angeles | No. 189, Grove St, Los Angeles | CA 90036 | FedEx | 4.2 kg | |
| 2026-05-10 | Ada | California | Los Angeles | No. 189, Grove St, Los Angeles | CA 90036 | DHL | 4.6 kg | |
| 2026-05-11 | Grace | California | Los Angeles | No. 189, Grove St, Los Angeles | CA 90036 | UPS | 5.0 kg | |
| 2026-05-12 | Alan | California | Los Angeles | No. 189, Grove St, Los Angeles | CA 90036 | FedEx | 5.4 kg | |
| 2026-05-13 | Tom | California | Los Angeles | No. 189, Grove St, Los Angeles | CA 90036 | DHL | 5.8 kg | |
| 2026-05-14 | Ada | California | Los Angeles | No. 189, Grove St, Los Angeles | CA 90036 | UPS | 6.2 kg | |
| Shipments | 14 | |||||||
fixed: 'left' or 'right' on a column pins it while the rest scrolls sideways. The checkbox and chevron columns are pinned along with it — a checkbox sliding under a frozen name column is worse than no freezing at all.
const columns = [
{ key: 'date', label: 'Date', width: 130, fixed: 'left' },
// ...
{ key: 'actions', label: '', width: 100, fixed: 'right' },
]Give a pinned column a width anyway. The table works out where each one comes to rest by measuring the heading row — not by adding up the widths you declared, because a declared width is honoured only while there is room. A table that has to scroll squeezes every column proportionally, so the numbers stop being true exactly when pinning starts to matter. A width is still worth giving: it decides how wide the column actually is, and it is what the offsets fall back on for the first paint, before anything has been measured.
The measurement is retaken when the table is resized and when its rows or columns change.
Pinning stops when there is no room for it
Below 600px the table gives up freezing and scrolls as a whole. On a phone the frozen columns take most of the screen and the columns the reader came for have nowhere to scroll into view — a table that cannot show its own contents is worse than one without pinning.
The width in question is the table's, not the window's: a table in a narrow panel on a wide desktop has the same problem. Nothing to configure and nothing to remember at the call site.
Choosing the height
max-height is the only knob, and that is the recommendation: a number for pixels, or any CSS length as a string.
<wx-table :data="rows" :columns="columns" :max-height="420" />
<wx-table :data="rows" :columns="columns" max-height="60vh" />Setting it turns the header and the footer sticky and scrolls the rows between them, which is why there is no separate sticky prop — a stuck header with nothing scrolling under it is decoration.
There is deliberately no height. A fixed height pads a short result with blank space and tells the user the table failed to load; a maximum leaves three rows looking like three rows and only takes over when there are forty. Use 60vh when the table should follow the window, and pixels when it sits in a panel whose size you already know.
On a phone
A table narrower than its widest row is a set of columns nobody can read, and on a phone that is every table. Below cardsBelow — the width of the table, not the window — each row is drawn as a card of label/value pairs instead:
The cells are the same cells: the same cell-<key> slots, the same formatters. A screen written for the table needs nothing added to survive a phone.
Two things are worth saying explicitly:
- A column with
hideOnCardsis left out. A date somebody scans down a column is worth a line on a wide screen and is the first thing to go on a narrow one. hideBelowdoes not apply: it is about columns that will not fit beside each other, and a card stacks them.- Row actions have no column to live in, so they go along the top of the card through the
card-actionsslot.
<wx-table :data="page" :columns="columns">
<template #card-actions="{ row }">
<wx-actions size="sm" @click.stop>
<wx-action type="remove" @click="remove(row)" />
</wx-actions>
</template>
</wx-table>cardsBelow="0" keeps the columns at any width.
One page is not pagination
The footer appears only when there is more than one page. The control costs a line of the screen and asks to be read, and on a phone that line is most of what is left.
Columns
| Field | Type | Description |
|---|---|---|
key | string | Reads the value; a dotted path walks into a row |
label | string | Heading text, defaults to the key |
width | string | number | Fixed width; a number is pixels |
minWidth | string | number | Lower bound before the table scrolls |
align | 'left' | 'center' | 'right' | Cell alignment |
sortable | boolean | Adds the sort control |
fixed | 'left' | 'right' | Pins the column to an edge |
formatter | (value, row, index) => string | Turns the raw value into cell text |
headerClass | string | Class on the th |
cellClass | string | Class on the td |
hidden | boolean | Leaves the column out |
hideBelow | number | Drops it while the table is narrower than this |
hideOnCards | boolean | Leaves it out of a card |
A dotted key reads through an eager-loaded relation, so user.name lands in its own column without a formatter. A path that goes nowhere renders as empty rather than as undefined.
Slots
| Slot | Props | What it replaces |
|---|---|---|
cell-<key> | row, value, index, column | The contents of that cell |
header-<key> | column | The heading text |
summary-<key> | row, value | One figure in the summary |
expanded | row, index | What an opened row shows |
title | — | The heading in the header bar |
actions | — | Buttons beside the search |
filters | — | Fields inside the funnel panel |
applied | — | Chips for the filters that are on |
empty | — | The "nothing to show" line |
loading | — | The overlay's spinner and text |
footer | — | A row across the whole table |
<template #cell-status="{ value }">
<wx-tag :type="value === 'paid' ? 'success' : 'warning'">{{ value }}</wx-tag>
</template>A column whose key matches nothing in the row is a fine way to add an actions column: give it a key of actions and fill it from #cell-actions.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
data | T[] | Paginated<T> | null | null | Rows, or a whole paginator |
columns | TableColumn<T>[] | — | Required |
rowKey | string | (row, index) => RowKey | 'id' | Where a stable id comes from |
title | string | — | Heading in the header bar |
searchable | boolean | false | Adds the search field |
searchPlaceholder | string | 'Search' | Placeholder for it |
searchDebounce | number | 300 | Wait before search fires, in ms |
filtersCount | number | 0 | Count shown on the funnel; 0 leaves it bare |
filtersLabel | string | 'Filters' | Its name, and the heading of the panel |
filtersWidth | `number | string` | 300 |
loading | boolean | false | Dims the table and marks it busy |
emptyText | string | 'Nothing to show' | Shown when there are no rows |
stripe | boolean | false | Alternating row background |
bordered | boolean | false | Vertical rules between columns |
hover | boolean | true | Highlight the row under the pointer |
clickable | boolean | inferred | Whether a row leads anywhere |
selectable | boolean | false | Adds the checkbox column |
selectableIf | (row) => boolean | — | Rows that cannot be picked |
expandable | boolean | false | Adds the chevron column |
expandableIf | (row) => boolean | — | Rows with nothing to open |
summary | TableSummaryRow[] | [] | Lines under the table |
cardsBelow | number | 480 | Width under which rows become cards |
flush | boolean | false | Lets the box around it do the spacing (see below) |
pagination | boolean | paginator | Pagination in the footer |
perPageOptions | number[] | [] | Page sizes it offers |
persist | string | — | Storage key for the state |
maxHeight | string | number | — | Scrolls rows under a stuck header |
rowClass | (row, index) => string | undefined | — | Extra class per row |
layout | 'auto' | 'fixed' | 'auto' | Let the content size columns or not |
size | 'sm' | 'md' | 'lg' | 'md' | Row height |
ariaLabel | string | — | Names the table for a screen reader |
Models: v-model:sort (TableSort | null), v-model:selected (RowKey[]), v-model:expanded (RowKey[]), v-model:search (string), v-model:page (number), v-model:per-page (number).
Events: state-change (TableState), row-click (row, index, event), sort-change (TableSort | null), selection-change (keys, rows), expand-change (keys, rows), search (term).
flush, and what it does not flatten
A table on its own keeps margins around its head and its rows. Inside a card the card already keeps them, so flush takes the table's away and the rows reach the card's edges — which is what a row should do.
The head goes with them. The search field ends where the row of headings under it ends, so the distance from the card to everything inside it is the one number the card already keeps; under the head is the panel's own step, because the head and the rows are two things in a card and everything else in one is spaced by that.
One thing stays: in card mode the column of cards keeps a step above and below it, because a column has to start and end somewhere. Sideways it is flush like everything else, so the cards stand exactly where the filter above them stands.
The table scrolls its own rows
Given less height than it needs — a pane in a two-column screen, a sheet on a phone — the table scrolls inside instead of spilling out of whatever holds it: the head stays, the rows or the cards move under it. Given no such limit it is as tall as its rows and the page does the scrolling, which is what an ordinary page wants. maxHeight is the way to ask for the first behaviour explicitly.
A row that leads nowhere says so
The pointer and the highlight on a row are a promise that clicking it opens something. Listening for row-click is how the table learns there is one, so an ordinary list needs nothing else.
clickable is for a list where the answer changes while it is on screen — a bin, an archive, a picker that takes several rows at once. The listener a component was rendered with cannot be read again, so a table left to infer would keep the cursor it no longer earns. :clickable="false" withdraws the whole promise: no pointer, no highlight, and no row-click either.
<wx-table :data="rows" :columns="columns" :clickable="!inBin" @row-click="open" />Loading keeps the rows
The overlay sits over the table rather than replacing it, so a page being refreshed still says what it said a moment ago. aria-busy goes on the table element, and the empty state waits until the load finishes — a table that flashes "nothing to show" between two full pages is telling the user something untrue.
It brings its own table styles
A table is the component most exposed to whatever else the page loads. This documentation site is a fair example: VitePress restyles every table into a scrolling block, puts a border on all four sides of every cell and paints each tr opaque. That took the layout away from the table, the stickiness away from the header and the stripes away from the rows — visible only once the component was embedded somewhere real.
So the component states display, overflow, margin, border and the row background outright rather than inheriting them. Dropping it into an admin that already loads Bootstrap or Tailwind's preflight should look the same as it does here.
The same goes for how fast things change. A row's colour reaches the eye by two routes — the row paints it behind cells that are transparent, and a pinned cell paints it itself — so both have to move at one speed. VitePress fades a tr over half a second and leaves cells alone, which had the pinned columns snapping to the hover colour while the rest of the row was still on its way there.
Two different things are drawn at the edge of a pinned column, and it is worth keeping them apart. The seam — the hairline of scrolling content that shows through the boundary pixel — is covered by a strip of its own rather than by a box shadow, because Firefox declines to paint a shadow on a cell in a collapsed-border table and the hairline was still there in one browser out of two. The shadow that makes the frozen block read as floating is a box shadow, and it appears only on the side that still has something scrolled out of view: a table with nothing hidden either side draws neither.
It also sets its own width: 100% and min-width: 0. A flex or grid item refuses to shrink below its content, and the content here is a table that can be twice the width of the page — without it the inner scroller never scrolls and the whole document does instead, which is the kind of thing that only shows up inside somebody's layout.
The Laravel side
public function index(Request $request)
{
return Order::query()
->when($request->string('search'), fn ($query, $term) => $query->where('customer', 'like', "%{$term}%"))
->when($request->string('sort'), function ($query, $sort) {
$descending = str_starts_with($sort, '-');
$query->orderBy(ltrim($sort, '-'), $descending ? 'desc' : 'asc');
})
->paginate($request->integer('per_page', 15));
}The response goes straight into data. The keys the table reads — data, current_page, last_page, per_page, total, from, to — are the ones the paginator already produces, and from and to are used as sent rather than recalculated, so the count under the table matches the one the database gave.