Skip to content

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.

Orders
Status
Nothing to show

Asked for: · selected: 0

Nothing to show
Status
No orders in this period

Usage

vue
<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:

ts
{ 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:

vue
<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.

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.

vue
<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.

vue
<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:

vue
<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.

vue
<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

Order #1042
ExpandSKUItemQtyPriceLine total
KB-104Mechanical keyboard279.00 €153.00 €

Brown switches, ISO layout. Ships from the Riga warehouse.

MS-220Wireless mouse132.00 €32.00 €
CB-3MUSB-C cable, 3 m46.90 €26.40 €
Sum217.60 €
Discount−6.20 €
Total211.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.

vue
<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.

vue
<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.

A branch is fetched when it opens, and dragging one onto another opens that one too
TitleURLStatusUpdated
Home/live11.09.26
About the company/aboutlive02.09.26
Services/serviceslive28.08.26
Contacts/contactsdraft19.08.26
Drag a row before, after or onto another one — hold it over a closed branch.
vue
<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:

php
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:

ts
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: sortable columns 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 data is 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.
NameStateCityAddressZipCourierWeight
2026-05-01TomCaliforniaLos AngelesNo. 189, Grove St, Los AngelesCA 90036DHL1.0 kg
2026-05-02AdaCaliforniaLos AngelesNo. 189, Grove St, Los AngelesCA 90036UPS1.4 kg
2026-05-03GraceCaliforniaLos AngelesNo. 189, Grove St, Los AngelesCA 90036FedEx1.8 kg
2026-05-04AlanCaliforniaLos AngelesNo. 189, Grove St, Los AngelesCA 90036DHL2.2 kg
2026-05-05TomCaliforniaLos AngelesNo. 189, Grove St, Los AngelesCA 90036UPS2.6 kg
2026-05-06AdaCaliforniaLos AngelesNo. 189, Grove St, Los AngelesCA 90036FedEx3.0 kg
2026-05-07GraceCaliforniaLos AngelesNo. 189, Grove St, Los AngelesCA 90036DHL3.4 kg
2026-05-08AlanCaliforniaLos AngelesNo. 189, Grove St, Los AngelesCA 90036UPS3.8 kg
2026-05-09TomCaliforniaLos AngelesNo. 189, Grove St, Los AngelesCA 90036FedEx4.2 kg
2026-05-10AdaCaliforniaLos AngelesNo. 189, Grove St, Los AngelesCA 90036DHL4.6 kg
2026-05-11GraceCaliforniaLos AngelesNo. 189, Grove St, Los AngelesCA 90036UPS5.0 kg
2026-05-12AlanCaliforniaLos AngelesNo. 189, Grove St, Los AngelesCA 90036FedEx5.4 kg
2026-05-13TomCaliforniaLos AngelesNo. 189, Grove St, Los AngelesCA 90036DHL5.8 kg
2026-05-14AdaCaliforniaLos AngelesNo. 189, Grove St, Los AngelesCA 90036UPS6.2 kg
Shipments14

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.

ts
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.

vue
<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 hideOnCards is 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.
  • hideBelow does 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-actions slot.
vue
<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

FieldTypeDescription
keystringReads the value; a dotted path walks into a row
labelstringHeading text, defaults to the key
widthstring | numberFixed width; a number is pixels
minWidthstring | numberLower bound before the table scrolls
align'left' | 'center' | 'right'Cell alignment
sortablebooleanAdds the sort control
fixed'left' | 'right'Pins the column to an edge
formatter(value, row, index) => stringTurns the raw value into cell text
headerClassstringClass on the th
cellClassstringClass on the td
hiddenbooleanLeaves the column out
hideBelownumberDrops it while the table is narrower than this
hideOnCardsbooleanLeaves 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

SlotPropsWhat it replaces
cell-<key>row, value, index, columnThe contents of that cell
header-<key>columnThe heading text
summary-<key>row, valueOne figure in the summary
expandedrow, indexWhat an opened row shows
titleThe heading in the header bar
actionsButtons beside the search
filtersFields inside the funnel panel
appliedChips for the filters that are on
emptyThe "nothing to show" line
loadingThe overlay's spinner and text
footerA row across the whole table
vue
<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

PropTypeDefaultDescription
dataT[] | Paginated<T> | nullnullRows, or a whole paginator
columnsTableColumn<T>[]Required
rowKeystring | (row, index) => RowKey'id'Where a stable id comes from
titlestringHeading in the header bar
searchablebooleanfalseAdds the search field
searchPlaceholderstring'Search'Placeholder for it
searchDebouncenumber300Wait before search fires, in ms
filtersCountnumber0Count shown on the funnel; 0 leaves it bare
filtersLabelstring'Filters'Its name, and the heading of the panel
filtersWidth`numberstring`300
loadingbooleanfalseDims the table and marks it busy
emptyTextstring'Nothing to show'Shown when there are no rows
stripebooleanfalseAlternating row background
borderedbooleanfalseVertical rules between columns
hoverbooleantrueHighlight the row under the pointer
clickablebooleaninferredWhether a row leads anywhere
selectablebooleanfalseAdds the checkbox column
selectableIf(row) => booleanRows that cannot be picked
expandablebooleanfalseAdds the chevron column
expandableIf(row) => booleanRows with nothing to open
summaryTableSummaryRow[][]Lines under the table
cardsBelownumber480Width under which rows become cards
flushbooleanfalseLets the box around it do the spacing (see below)
paginationbooleanpaginatorPagination in the footer
perPageOptionsnumber[][]Page sizes it offers
persiststringStorage key for the state
maxHeightstring | numberScrolls rows under a stuck header
rowClass(row, index) => string | undefinedExtra class per row
layout'auto' | 'fixed''auto'Let the content size columns or not
size'sm' | 'md' | 'lg''md'Row height
ariaLabelstringNames 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.

vue
<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

php
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.

Released under the MIT License.