Table
A data table that sorts, filters, groups and pages your rows, in the browser or against your backend. Becomes a card list when its own container gets too narrow for a grid.
Playground
Name | Role | Department | Location |
|---|---|---|---|
Emma Wilson | Staff Engineer | Platform | Berlin |
Liam Chen | Product Designer | Design | Hamburg |
Sofia Martinez | Eng. Manager | Platform | Munich |
James Park | Frontend Dev | Product | Remote |
Aisha Patel | Data Scientist | Data | Berlin |
<script lang="ts">
import { createTableView, Table } from '@urbicon-ui/table';
import '@urbicon-ui/table/style/index.css';
const columns = [
{ accessor: 'name', title: 'Name', sortable: true, searchable: true },
{ accessor: 'role', title: 'Role', sortable: true, searchable: true },
{ accessor: 'department', title: 'Department', sortable: true, groupable: true },
{ accessor: 'location', title: 'Location', sortable: true }
];
const items = [
{ id: 1, name: 'Emma Wilson', role: 'Staff Engineer', department: 'Platform', location: 'Berlin' },
{ id: 2, name: 'Liam Chen', role: 'Product Designer', department: 'Design', location: 'Hamburg' },
{ id: 3, name: 'Sofia Martinez', role: 'Eng. Manager', department: 'Platform', location: 'Munich' },
{ id: 4, name: 'James Park', role: 'Frontend Dev', department: 'Product', location: 'Remote' },
{ id: 5, name: 'Aisha Patel', role: 'Data Scientist', department: 'Data', location: 'Berlin' },
{ id: 6, name: 'Noah Kim', role: 'DevOps Engineer', department: 'Platform', location: 'Hamburg' },
{ id: 7, name: 'Olivia Brown', role: 'UX Researcher', department: 'Design', location: 'Munich' },
{ id: 8, name: 'Lucas Weber', role: 'Backend Dev', department: 'Product', location: 'Berlin' }
];
const view = createTableView({ defaults: { pageSize: 5 } });
</script>
<Table
{columns}
{items}
{view}
cardsBelow="32rem"
searchPlaceholder="Search team..."
/>Column Factories
TableColumns builds a column with the cell component,
the alignment and the flags already set, so a typed column is one call. For a column no
factory covers, write the object yourself: Column Configuration.
Factory-Powered Table
Employee | Role | Status | Salary | Joined | Actions |
|---|---|---|---|---|---|
EW Emma Wilson emma@acme.dev | Staff Engineer | Active | 142,000.00 | ||
LC Liam Chen liam@acme.dev | Product Designer | Active | 98,000.00 | ||
SM Sofia Martinez sofia@acme.dev | Engineering Manager | Active | 165,000.00 | ||
JP James Park james@acme.dev | Frontend Developer | On leave | 92,000.00 | ||
AP Aisha Patel aisha@acme.dev | Data Scientist | Active | 128,000.00 | ||
NK Noah Kim noah@acme.dev | DevOps Engineer | Active | 115,000.00 |
<script>
import { Table, TableColumns, type Column } from '@urbicon-ui/table';
type Employee = {
id: number;
name: string;
role: string;
status: string;
salary: number;
joinedAt: string;
};
// Rows are keyed by `id` when they have one, by array index otherwise.
const items: Employee[] = [
{
id: 1, name: 'Emma Wilson', role: 'Staff Engineer',
status: 'active', salary: 142000, joinedAt: '2021-03-15'
}
// …
];
// The annotation is what checks the accessors: with `Column<Employee>[]`,
// a first argument that is not a key of the row is a type error. Without
// it, nothing checks them.
const cols: Column<Employee>[] = [
TableColumns.userAvatar('name', 'Employee'),
TableColumns.text('role', 'Role'),
// StatusBadge knows eleven statuses: active, inactive, pending, online,
// offline, processing, completed, failed, draft, published, archived.
// Anything else reads "Unknown" until you name it here.
TableColumns.status('status', 'Status', {
statusMap: {
'on-leave': { intent: 'warning', text: 'On leave', icon: true },
offboarding: { intent: 'neutral', text: 'Offboarding', icon: false }
}
}),
TableColumns.number('salary', 'Salary'),
TableColumns.date('joinedAt', 'Joined'),
// Every handler receives the row, and each button follows its own handler:
// pass onView and the view button appears, leave onDelete out and no delete
// button renders. The showView / showEdit / showDelete flags are for the
// two exceptions — rendering a button you handle elsewhere, or hiding one
// you do handle.
TableColumns.actions('Actions', {
onView: (employee) => {},
onEdit: (employee) => {}
})
];
</script>
<Table
{items}
columns={cols}
cardsBelow="36rem"
viewDefaults={{ pageSize: 6 }}
enableSmartFilter={false}
/>All nine:
| Factory | What it builds |
|---|---|
text | Plain text, with an optional formatter |
number | Right-aligned, locale-aware number formatting |
date | Locale-aware date formatting |
status | Coloured badge, centred and groupable |
userAvatar | Avatar next to the name |
link | Renders the value as an anchor |
copy | Click-to-copy button, centred and unsortable |
custom | The value as text, with your own classes, wrapping and click handling |
actions | View / edit / delete buttons; synthetic, no accessor |
Where to go next
Who does the work. A few hundred rows sort,
filter and page in the browser: Client Processing. Past a few thousand it becomes the backend's job, and you hand the table one page at a
time: Server Processing. Give it a query function and it runs the fetch itself: Query Function.
What the reader can change. Six settings decide
which rows they see: search, sort, page, page size, filters and grouping. They live in one view object (viewDefaults sets its starting values), and URL State puts it in the address bar, so a view can be reloaded, shared and read by the server. What each
setting does is on Filtering & Search and Sorting, Grouping & Summaries.
Once the rows are on screen. Row Selection for acting on rows, Custom Cells for rendering them your way, Virtual Scrolling and Sticky Pinning for long lists.
API Reference
Prop | Type | Default | Description | |
|---|---|---|---|---|
activeRowId | string | number | null | null | The row that is currently being shown elsewhere — the master/detail
pattern, where clicking a row renders that record beside or below the
table.
Deliberately separate from selection: a selection is a set the user has
marked for an action and brings a checkbox column with it, whereas a
current row is a *view* state with no consequence beyond what is on screen.
Marking one used to require selectionMode, which switched on that column
as a side effect.
The matching row gets aria-current="true" and a data-active attribute
(a hook for consumer CSS, e.g. emphasising a cell in that row), plus a
quiet ground of its own. Ids are matched against item.id, with the row
index as the same fallback the rest of the table uses. Pair it with
onRowClick — this prop only reflects state, it never sets it. | |
ariaLabel | string | undefined | Accessible label for the table, announced by screen readers. | |
autoApplyOnNavigation | boolean | true | Automatically apply pending live updates when the user navigates (page change, sort, filter, search). Since the view is already changing, applying buffered changes at this point is non-disruptive. | |
body | Snippet | undefined | Custom body snippet | |
cardsBelow | CardsBelowStep | "48rem" | The width below which the table stops being a grid and becomes one card per
row. Measured on the table's **own container** — not on the window — so a
table in a narrow column switches while the window stays wide.
The right step depends on the columns, which is why it is a prop: a
four-column index needs about 29rem and reads fine in a 32rem sidebar,
while a twelve-column report is already cramped at 48rem. Add up the column
widths and pick the next step above the sum.
Below the step the grid is not squeezed, it is replaced: the card list
takes over, and the grid only ever renders at or above the width it was
given. A grid wider than its container scrolls sideways. | |
cell | Snippet<[item: T, value: unknown, column: Column<T>]> | undefined | Global cell snippet that overrides rendering for ALL columns.
For per-column customization, prefer column.cell instead. | |
class | string | undefined | Additional CSS class names for the table container | |
columns | Column<T>[] | [] | Column configuration array defining the table structure | |
emptyState | Snippet | undefined | Custom empty state snippet. Named after its slotClasses.emptyState slot
(renamed from empty in v6.41 — loading is now the boolean state prop).
Must be table-row markup (<tr><td colspan="99">…) — it renders into the
grid's <tbody>. **Grid only:** the card list below
TableProps.cardsBelow renders
noDataText instead, because row markup cannot live in a <div>
(the parser drops the tags). Same contract as the two state snippets below. | |
enableColumnReorder | boolean | false | Enable drag-and-drop column reordering on desktop. Users can drag column headers to rearrange them. Also supports keyboard reorder via Shift+ArrowLeft/Right on focused headers. | |
enableColumnVisibility | boolean | true | Enable the column-visibility feature: the visibility menu in the smart
filter bar and the "hide column" action in every header menu. Set false
to remove both — this also reveals every currently-hidden column (including
one restored from persistence), so no column is ever stranded hidden without
a way back. For per-column control, set hideable: false on individual
columns instead. | |
enableLiveUpdates | boolean | false | Enable live update support. When enabled, a LiveUpdateBanner is shown
when pending inserts/updates/deletes are buffered. Get hold of
pushInsert, pushUpdate, pushDelete for your WebSocket/SSE handler via
onReady — or, from inside the table's own tree (a toolbar
snippet, a custom cell), via getTableContext(). | |
enableSmartFilter | boolean | true | Enable smart filtering functionality | |
errorState | Snippet | undefined | Custom error state snippet. Named after its slotClasses.errorState slot
(renamed from error in v6.41, alongside its two siblings).
Table-row markup, desktop only — mobile renders errorText. | |
errorText | string | i18n `error.loadingError` | Text displayed on error | |
expandedRowContent | Snippet<[item: T]> | undefined | Snippet to render expanded row content | |
fit | contentviewport | "content" | Make the table its own scroll container so wide **and** long lists scroll
*within* the table instead of pushing overflow onto the page.
- 'content' (default): the table grows with its content; vertical overflow
scrolls the page. Pair with sticky for page-relative pinning.
- 'viewport': the table is height-capped to the viewport and becomes a
self-contained scroll box. The column header (and group header when
grouping) pin to the top of the box and a total summary row to its bottom,
while the toolbar and pagination stay fixed outside the scrolling area —
only the rows scroll, in both axes. The
available height is measured automatically: the viewport, minus the space
reserved above the container by a pinned or clipping ancestor (zero in the
plain page flow, where the box can reach the top of the viewport). No magic
max-height is needed in the consumer.
Notes for 'viewport':
- An assertion, not a measurement: it tells the table it owns the page
height, and it is honoured at every width. Set it only on a table that is
the page's primary content — inside a scrolling article the table caps
itself against the viewport all the same and gives you a second scroller.
- Supersedes sticky: header/group pinning is intrinsic to the box, so the
sticky prop is ignored. stickyOffset still counts, as a floor under
the measured reservation: an app-shell bar that is an *ancestor* of the
table is measured, a position: fixed bar that is only a sibling is
not — declare its height and the cap leaves room for it.
- Mutually exclusive with virtualized, which already has a bounded scroll
box of its own (virtualHeight) and pins the same two layers against it;
fit has no effect when virtualized, and a
DEV-build console warning names the combination — the refusal is otherwise
invisible, since data-fit publishes the resolved "content".
- The box reaches the bottom of the viewport, so it assumes nothing sits
*below* it. An ancestor with bottom padding (or a following sibling) is
pushed past 100dvh and produces a second, page-level scrollbar next to
the table's own. The container reflects the resolved mode as
data-fit="viewport" (vs "content", also when virtualized), so a
layout can drop that inset — at every width, exactly as the cap applies at
every width:
main:has([data-fit='viewport']) { padding-block-end: 0 }. | |
groupHeaderContent | Snippet<[groupName: string, items: T[], isExpanded: boolean]> | null | Custom content for group headers | |
groupOrder | string[] | [] | Custom order for group display | |
header | Snippet | undefined | Custom header snippet — the <thead> of the table, in place of the
built-in one. In the virtualized layout the table sits in a scroll box of
virtualHeight and the built-in head pins itself to the top edge of that
box; a custom <thead> scrolls away with the rows unless it pins itself.
The classes the built-in head uses there are
tableHeaderVariants({ sticky: 'box' }).header() and .row() (exported
from the package) — sticky top-0 z-20 bg-surface-elevated on the
<thead>, plus an opaque ground and the underline on its <tr>. | |
initialSelectedIds | Array<string | number> | undefined | Initial selected row ids (if no persisted value exists). Seeds the
uncontrolled selection once when the table is created. Ignored entirely
when the controlled selectedIds prop is set — controlled always wins.
A selection restored via prefs.persistSelection takes
precedence. Later changes to this prop are ignored; users can still
change or clear the selection. Rows are keyed by item.id (row-index
fallback). Precedence when combined with
prefs.persistSelection goes by *presence*, not emptiness:
once storage holds a selection for this table — including the empty one
written when the user deselected everything — it wins and the seed no
longer applies. | |
items | T[] | [] | Array of data items to display in the table — the shorthand for
source={{ processing: 'client', items }}, and the right prop whenever the rows are all you
have to say. Reach for source once loading, error or a server
total come into it.
Items with an id property get better key stability for animations.
If no id is present, the array index is used as fallback key. | |
loadingState | Snippet | undefined | Custom loading state snippet, rendered while the source reports
loading. Named after its slotClasses.loadingState slot.
Table-row markup, desktop only — mobile renders loadingText. | |
loadingText | string | i18n `data.loading` | Text displayed during loading state. The loading *state* itself comes
from the source — { items, loading } for data you fetch
yourself, or the managed { query } flow where the table drives it. | |
mobileCardDetails | collapsedexpanded | "collapsed" | How much of a record a mobile card shows before it is opened. Below
TableProps.cardsBelow of the table's **own container** — not of the
window — the table renders one card per row instead of the grid.
- collapsed (default): the card shows the first two card columns —
title and label-less subtitle — and opens the rest on tap. A record
costs roughly a third of the height, so a phone screen holds three
instead of one.
- expanded: title on top, every other card column in the grid below it,
nothing hidden. The shape before v6.48.
Independent of expandedRowContent, which stays behind the chevron in
both modes. | |
multiExpand | boolean | false | Allow multiple rows to be expanded simultaneously. When false (default), expanding a row collapses the previously expanded one. | |
noDataText | string | i18n `data.empty` | Text displayed when no data is available | |
onReady | (context: TableContext) => void | — | Called once with the table's context after the table is set up — the
supported way to reach the imperative API from *outside* the table's tree
(getTableContext() only resolves inside it).
The context is a live object typed as TableContext — since v8 a
hand-written, deliberately narrow surface: pushInsert/pushUpdate/
pushDelete and applyAllUpdates for live feeds, the reactive state,
the derived collections and the documented action methods. Hold on to it
for the lifetime of the table; it is not re-created.
state.items and state.columns are reactive to *replacement*, not to
writes reaching inside them: state.items[0].name = 'x' changes the row
and re-renders nothing. Edit a row through pushUpdate, or assign a new
array. | |
onRowClick | (item: T) => void | undefined | Callback fired when a row is clicked. Receives the clicked row's data item. | |
onSelectionChange | (selectedItems: T[], selectedIds: Array<string | number>) => void | undefined | Callback fired when the selection changes — and only then. Paging,
sorting or a new page of server rows do not fire it.
The first argument is the selected rows, the second their ids. The two are
**not** interchangeable: rows can only be handed over for the items the
table currently holds, so under processing: 'server' the first argument
carries the selected rows *of the loaded page* while the second carries the
whole selection. The header checkbox states the same scope: in server
mode it selects the loaded page and says so (mixed state, page-scoped
label) — a selection here never silently claims rows the table has not
seen.
With controlled TableProps.selectedIds, write the **ids** back —
(items, ids) => (selectedIds = ids). Mapping the rows instead
(items.map((item) => item.id)) is correct in client mode and silently
drops every row from another page in server mode. | |
pagination | Snippet | undefined | Custom pagination snippet | |
prefs | TablePrefsConfig | undefined | Preference channel (#152): column visibility, column order, summaries —
and, opt-in, the selection. Preferences belong to the table, not the
view: nobody wants to share a link that hides columns on the other end,
so they live in web storage, never in the URL.
storage names the storage key (string shorthand or
{ key, kind?, debounceMs? }); defaults are the initial preferences
for a table nobody touched (applied at construction, SSR-visible);
persistSelection: true opts the selection into storage.
Using the same key string here and in bindViewToStorage is a naming
convention, not a link: the two channels stay independent, so persisting
both the view and the preferences always takes both statements. | |
preset | string | undefined | Apply a named preset registered via <BlocksProvider presets={{ Table: { … } }}>.
A preset's slotClasses use the same slot names as TableProps.slotClasses
and sit between the provider's defaults.Table and this instance's own
slotClasses. The resolved classes reach every subcomponent through the
table style context, so a preset styles the frame, the header, the rows,
the cells and the card list alike.
Prop-conditional overrides (in defaults.Table or in a preset) match
variant, size, cardsBelow, stickyToolbar and contained. Those
five, and no others: tableContainerVariants also carries pinnedSummary,
which the table decides per render rather than per instance and does not
publish here. The last two of the five are the resolved modes, not the
props they derive from: fit="viewport" matches { contained: true }, and
a toolbar pinned to the page (sticky, sticky="toolbar" or "both",
unless the table resolves to contained) matches { stickyToolbar: true }.
A name no provider registers applies nothing and warns in development. | |
rowClickSelects | boolean | false | Whether clicking anywhere on a row body toggles that row's selection, in
addition to the always-present checkbox.
On by default in selectionMode="single" (where a single click is the
expected gesture and there is no marquee/range interaction to conflict
with), as long as the row click means nothing else yet — neither
onRowClick nor expandedRowContent is set. Set it explicitly
to opt in for multi or for expandable rows, or to false to keep the
checkbox as the only selection target.
A click that ends a text selection *inside the row* never selects, so cell
content stays copyable. Applies to desktop rows (flat and grouped); mobile
cards keep the checkbox as their only selection control, since a selectable
card cannot be a button without nesting interactive elements.
Defaults to true only in selectionMode="single" without onRowClick
and without expandedRowContent — a row click that already expands must
not silently also select. | |
searchDebounceMs | number | undefined — the table's own mode-aware wait, which is not 0 | Debounce delay for search in milliseconds — the whole wait between a
keystroke and the search taking effect, in either processing mode.
Left unset, the wait depends on the mode: 300 ms while the table filters
client-side; in server mode the field writes through immediately and
whoever fetches does the waiting — the managed source's own debounceMs
(300 ms by default), or, where you fetch yourself, whatever delay you put
in front of your fetch.
An explicit value is honoured in both modes and stays the *total* against
a managed source (source={{ processing: 'server', query }}): the search
field holds the write back for it, and the fetch that write triggers goes
out at the end of that wait instead of adding source.debounceMs on top.
searchDebounceMs={300} against a source debouncing 300 ms fetches at
300 ms, not at 600; searchDebounceMs={0} fetches at once. It covers the
whole field, typing and clearing alike — Escape takes the same route as a
backspace, so the two clear at the same moment. Search is the only change
exempted: sort, filter, page and page size keep the source's debounce in
full.
Two things this does not reach. **Coalescing moves with the delay:** at an
explicit value the bar's timer is what collapses a burst of keystrokes,
and the source's debounce no longer folds search requests together — with
searchDebounceMs={150} against debounceMs: 800, every typing pause
over 150 ms sends a request (each superseded one is aborted). Leave the
prop unset to have the source's debounce do that job. **And the promise
is the managed flow's:** where you fetch yourself
({ processing: 'server', items, total } plus observeView, or any
hand-rolled effect), the table does not know about your delay and cannot
subtract itself from it — an explicit value and your own debounce add up
there, exactly as they always did. | |
searchPlaceholder | string | i18n `search.placeholder` | Placeholder text for search input | |
selectedIds | Array<string | number> | undefined | Controlled selected row ids. When set, initialSelectedIds is ignored
and the table adopts this value: it seeds the selection at construction —
the server HTML carries the selected rows — and every later prop value
replaces the selection. An empty array is a valid value — nothing
selected; undefined returns ownership to the table. User clicks still
change the selection and fire onSelectionChange — write the new
ids back into this prop, or the next change to it discards what the user
clicked. Never written to storage (prefs.persistSelection has no
effect), and a stored selection never overrides it — the prop is the
source of truth. | |
selectionMode | nonesinglemulti | "none" | Row selection mode.
- 'none': No selection (default)
- 'single': Only one row can be selected at a time
- 'multi': Multiple rows can be selected with checkboxes | |
size | smmdlg | "md" | Size variant for the table | |
slotClasses | Partial<TableSlotClasses> | {} | Per-slot class overrides merged with (or replacing, if unstyled) variant styles.
Available slots: container, toolbar, scrollArea, table, thead, tbody,
headerRow, headerCell, row, cell, groupHeader, summaryRow,
emptyState, loadingState, errorState, filterBar, mobileCard.
The strongest layer of the <BlocksProvider> cascade: the provider's
defaults.Table.slotClasses, then its matching overrides, then the active
preset, then this prop. A later layer wins per Tailwind bucket, so a
preset's cell: 'p-2' gives way to an instance cell: 'p-4'.
**Breaking change in v1.5:** the former wrapper slot has been replaced by
scrollArea. The former hardcoded overflow-hidden on wrapper blocked
position: sticky, see [docs/STICKY-PINNING.md](../../../../../docs/STICKY-PINNING.md). | |
source | TableSource<T> | undefined | Where the rows come from, and **who processes them** — sorts, filters,
searches and pages. Three shapes, and the invalid combinations of the old
mode/queryFn/loading/error/serverTotal props are not
expressible:
- { processing: 'client', items, loading?, error? } — the table does
that work in the browser; you fetched the rows, so loading/error
are yours to report
- { processing: 'server', items, total, loading?, error? } — your
backend does it; you fetch and hand in each page
- { processing: 'server', query, debounceMs? } — same, and the table
calls query when the view changes (first fetch immediate, later ones
debounced), manages loading/error and aborts superseded requests
processing is required on every variant: it decides whether the reader's
sort headers reorder the page in front of them or ask your backend for a
different one, which is too visible a difference to be inferred from a
total that happened to be passed.
For rows and nothing else, reach for items —
source={{ processing: 'client', items }} says the same thing. source
wins when both are set. | |
sticky | toolbarheaderboth | false | Pin toolbar/header/group-header to the top of the scroll ancestor on scroll.
- false (default): no pinning, layout matches v1.4.x
- true / 'both': toolbar + thead + group-header all pin
- 'toolbar': only the toolbar pins
- 'header': only the thead (and group-header when grouping is active) pins
Pair with stickyOffset for app shells that have a fixed top bar.
For tables wider than the viewport, prefer fit="viewport" — it contains
horizontal scroll inside the table instead of falling back to page-level
horizontal scroll (a sticky pin host cannot also be a horizontal scroll
ancestor). fit="viewport" supersedes sticky when set. | |
stickyOffset | number | 0 | Pixel offset for the topmost sticky layer (toolbar, or thead when no toolbar).
Writes the CSS custom property --blocks-table-sticky-top on the container.
Use this to push the pin below a fixed app shell top bar. With
fit="viewport" the same figure is a floor under the space the box reserves
above itself, for chrome the measurement cannot see (a fixed sibling bar). | |
toolbar | Snippet | undefined (renders default SmartFilterBar when enableSmartFilter) | Custom toolbar snippet. Replaces the default SmartFilterBar.
Renders inside the sticky toolbar wrapper when sticky is enabled — so a
custom toolbar inherits the pinning behavior without extra wiring.
Access the table context via getTableContext() to wire up custom filter UIs. | |
unstyled | boolean | false | Remove the default variant classes. Only user-provided slotClasses apply.
<BlocksProvider unstyled> sets the same switch, so the table and the
search field inside its toolbar lose their look in the same step. What
stays either way is the desktop/card layout switch: structure, not look. | |
variant | flushsurfaceframed | "flush" | Visual style of the table chrome (see the shipped VARIANT-CONTRACT.md § Table chrome):
- flush (default): no outer frame, sits inline in the reading flow
- surface: gentle surface-quiet tinted zone, no border
- framed: bordered + rounded + shadowed standalone block
Applies to both layouts — on a narrow container the same frame wraps the
mobile record list, whose records are separated by hairlines instead of
each carrying a frame. | |
view | TableView | undefined | The view object — the six shareable axes (search, sort, page, pageSize,
filters, groupBy) as one consumer-constructed reactive object, fully
resolved against its defaults. The table reads and writes its fields
directly (view.page, view.sort, …); decorate it with bindViewToUrl
(from @urbicon-ui/sveltekit-utils) and/or bindViewToStorage to give
the axes a home — the bindings are decorations over the object, not
props of the table.
Leave unset for a table that owns its view (zero-config); use
viewDefaults to adjust the defaults of that owned view. Passing
both fails loud (also in prod — a miswired view corrupts state either
way). Resolved once, at construction: a view is an identity, not a
value — a later change of this prop is ignored.
The table's own interaction handlers reset the page on a new search,
filter or grouping; a *direct field write* (view.search = 'x') does
not — write view.page = 1 alongside, or go through the context's
setSearch.
**Sharing one view across tables.** Several tables may mount the same
view: they read and write the same six axes, and a table takes no claim
of its own (a remounting {#if} child inherits the current state).
A **virtualized** table renders any grouping the view carries as
ungrouped — for itself only: the value stays on the view, and an
un-virtualized sibling of the same view keeps rendering it grouped.
One limit is worth knowing: a **managed source** ({ query }) on both
tables fetches once *per table* per interaction — a shared view is not
a shared cache; wire the
fetch once yourself and hand both tables a manual processing: 'server'
source if that matters. | |
viewDefaults | TableViewDefaults | undefined | Defaults for the view the table owns when no view prop is passed
— the one-liner for the most common configuration:
viewDefaults={{ pageSize: 25 }}. Mutually exclusive with view
(a consumer-owned view carries its own defaults); passing both fails
loud. Resolved once, at construction — a later change of this prop is
ignored. | |
virtualHeight | string | "600px" | Height of the virtualized table's scroll box — the whole box, the column
header and the summary row included: both are pinned inside it, so the
rows get virtualHeight minus the two. Only used when virtualized is
true. Accepts any CSS height value.
The summary pin is not this box's own trick: a total summary pins to the
bottom edge of whichever scroll box the table owns, virtualized or
fit="viewport". While the list is too short to fill this box — a filter
that matches a handful of rows — the summary sits under the last row
instead: position: sticky cannot leave the <table>, and a short table
does not reach the bottom of the box. | |
virtualized | boolean | false | Enable virtualization for large datasets.
When enabled, only visible rows are rendered for performance with >1000 items.
In client mode pagination is bypassed — the scrollable container holds all
filtered/sorted rows. In server mode the pager stays: the container scrolls
the loaded page, and paging remains the way to the rest of the result.
Not compatible with grouping — and virtualization wins: the grouping
affordances are suppressed, and a grouping arriving through the view
(its defaults, a URL, storage) renders ungrouped (DEV warns). The value
itself stays on the view and in the URL — an un-virtualized table
reading the same view still groups, and a stored grouping applies again
on the next load without virtualized. |
01 Types
Local type definitions used by this component.
Name | Kind | Category | Used by | Description | |
|---|---|---|---|---|---|
TableContext | interface | helper | 0 | The table's live context object — the **supported consumer surface** of the
store: reactive state, the derived collections, and the imperative API
(search, filter, sort, page, group, select, summarize, live-update
push/apply).
Handed to TableProps.onReady for consumers outside the table's
component tree, and returned by getTableContext() inside it (a toolbar
snippet, a custom cell). It is a live object for the lifetime of the table;
hold on to it, it is not re-created.
Hand-written and deliberately narrower than the store object behind it
(since v8): wiring and lifecycle members — column set/order/visibility
plumbing, focus internals, the managed-fetch sink, preference persistence —
are not part of the contract. Prefer the action methods here over writing
the matching view axis directly: the methods enforce the interaction side
effects (a new search, filter or grouping resets to page 1; a summary
mutation keeps the summary row's visibility consistent). A bare
view.search = 'x' is legitimate — it just changes only the search. | |
TableProps | interface | props | 0 | Props interface for Table component | |
Column | type | helper | 1 | Defines a table column. One of three shapes, discriminated by the
presence and type of accessor:
- DataColumnString — accessor: 'propertyName' (primitive-valued
key on T). id defaults to the accessor name.
- DataColumnFunction — accessor: (item) => value. id is required.
- SyntheticColumn — no accessor. id is required. Not
searchable/sortable/groupable. | |
TableItem | type | helper | 0 | A table data item. Table items are arbitrary records — the Table component
accesses values dynamically via a column's accessor. Values are typed as
unknown to force explicit narrowing in formatters, cells, and components. | |
TablePrefsConfig | interface | helper | 1 | The table's preference channel (#152): column visibility, column order,
summaries — and, opt-in, the selection. Preferences belong to the *table*:
nobody wants to share a link that hides columns on the other end, so they
live in web storage, never in the URL. The six view axes (search, sort,
page, page size, filters, grouping) are the view object's business —
persisted, if at all, through bindViewToStorage.
Stored values are read at construction and **applied after hydration**
(storage does not exist on the server, so anything applied earlier makes
the client's first render disagree with the server HTML). defaults are
deterministic on both sides and therefore apply at construction — a
default-hidden column is hidden in the server HTML too. A stored value
wins over the matching default, including a stored *empty* one. | |
PageDescriptor | interface | helper | 0 | One resolved answer to "what page is this, out of how much" — for the pager, the footer, the ARIA counts and the group wording alike. | |
TableState | interface | helper | 0 | Shared reactive table state. All concerns read from and write to this object.
The six view axes are **not** here (#166). Until v9 this interface mirrored
them — searchTerm, activeFilters, currentPage, itemsPerPage,
sortColumn/sortDirection, groupByKey — as getters onto the view, so
every axis had two names and a consumer had no rule for choosing. The view
object is the one address now: context.view.search, .filters, .page,
.pageSize, .sort, .groupBy. What remains here is what the table owns
and the view does not: rows, columns, load state, expansion, grouping
chrome, summaries, selection, and the prop-driven switches.
The one axis-shaped value that survived is effectiveGroupBy, the grouping
actually applied. It is not a spelling of view.groupBy — it can differ
from it, which is the whole reason it exists. It sits here because the
concerns share it through this object; consumers read it as
context.effectiveGroupBy. | |
LiveUpdateCounts | interface | helper | 0 | Summary of pending live changes. | |
SummaryConfig | interface | helper | 0 | Column summary configuration. Defines which column to aggregate and how. The store keeps at most one aggregation per column — when a set of configs carries duplicates, the later entry wins (same rule as re-adding a column). | |
Filter | interface | helper | 0 | Filter for table rows.
value is always a string — even for the comparing operators (greaterThan,
lessThan). This keeps filters serializable for persistence and consistent
with the text-input UI.
An **empty** value (or one that is only whitespace) matches every row for
every operator, rather than none: the filter is treated as not yet filled in.
The filter menu cannot produce one — it guards on the same .trim() — so an
empty value can only arrive through viewDefaults, a URL or storage binding,
or a programmatic addFilter.
The comparing operators resolve in two steps:
1. **Numeric** — when both the cell value and value convert via Number(),
they are compared as numbers (prices, counts, epoch timestamps).
2. **Date** — otherwise both sides are read as instants: Date instances,
numbers (epoch millis) and ISO-8601 strings (2021-03-15,
2021-03-15T09:00, 2021-03-15T09:00:00Z). Anything else — and any other
string format — never matches.
Date semantics: when value is a bare calendar date (YYYY-MM-DD, what the
SmartFilterBar's date input emits), both operators compare on **UTC day
boundaries** — greaterThan ("after") starts at the following midnight UTC,
lessThan ("before") ends at the filter day's midnight UTC, so a cell at
2021-03-15T09:00Z matches neither for 2021-03-15. A value *with* a time
of day compares instants strictly. Per the ECMAScript date-time string
format a date-only string is UTC midnight while a date-time string without an
offset is local time, so a Date built from local parts
(new Date(2021, 2, 15)) can land on the neighbouring UTC day — store ISO
strings or UTC-constructed dates for day-exact filtering. | |
FilterOperator | type | helper | 0 | Supported filter operators | |
CardsBelowStep | type | helper | 1 | ── Where the table stops being a grid and becomes a list of records ─────────
The widths the switch offers.
The step is a property of the COLUMNS, not of the component: a four-column
index fits in 29rem, a twelve-column report does not fit in 60. One constant
cannot serve both, and until this axis existed there was only one — 48rem,
carried over unchanged from the viewport era (md:hidden), where it meant
"is this a phone". Read against a box it means something else entirely, and
the landing page's own 32rem inventory column was rendering cards while its
four columns had room to spare.
Spelled out rather than derived from the map (keyof typeof …), because this
name is what a reader meets: it is the type of TableProps.cardsBelow, so
docs-gen prints it on the component page, in llms-full.txt and in the MCP
catalog. Derived, all three showed keyof typeof CARDS_BELOW_STEPS pointing
at a module-private const — a type resolving to nothing anyone can read, and
no way left to discover the seven values. | |
TableSource | type | helper | 1 | Where the table's rows come from, and **who processes them** — always an
object, never a bare array:
- { processing: 'client', items, loading?, error? } — the table sorts,
filters, searches and pages in the browser
- { processing: 'server', items, total, loading?, error? } — the backend
does that work; you fetch and hand in each page
- { processing: 'server', query, debounceMs? } — same, and the table
calls query for you when the view changes
The bare T[] arm was dropped in v9 (#161). It normalised into exactly the
same internal shape as { items }, so it bought no capability — it only
gave "how do I pass rows?" a third correct answer next to items and
source={{ items }}. The split that remains is a rule you can state:
items for just rows,
source for rows plus how they arrive and who processes them. | |
TableView | class | helper | 1 | The consumer-constructed view object: a class with $state fields,
resolved against defaults in the constructor.
Two write surfaces, deliberately:
- **Fields** (view.page = 3) — the table's interaction handlers and
consumer code. Counts as the reader's own change (user).
- **applyExternal** — bindings applying a value. Never counts as
the reader's change, so the storage binding can keep someone else's
link out of storage.
Construct it in the component that owns it (or a request-scoped load),
never in module scope: on the server a module-scope view is state shared
between requests. | |
TableViewDefaults | type | helper | 1 | Partial defaults for createTableView — unset axes fall back to the table's own. | |
ViewSort | interface | helper | 0 | Sort state of a view: a column and a direction, or null for unsorted. | |
TableSlotClasses | interface | helper | 0 | Per-slot class overrides for the Table component. Each key corresponds to a rendering slot in the component tree. | |
DataColumnString | interface | helper | 0 | Data column with a string accessor that names a primitive-valued property
on T. The accessor doubles as the default identifier — pass id only to
override (e.g. when two columns access the same field with different
formatters). | |
DataColumnFunction | interface | helper | 0 | Data column with a function accessor that derives the cell value from the
row. Use this for nested object lookups (item.expenseType?.name) or
computed values (${lastName}, ${firstName}). id is required because a
function carries no implicit identifier. | |
SyntheticColumn | interface | helper | 0 | Synthetic column without a data accessor — typical for action buttons,
derived visuals, or other UI that is not tied to a row property.
Synthetic columns are structurally excluded from search, sort, group and
summary because there is no value to operate on — the type omits the
Derivable mixin, so sortable: true / searchable: true on a synthetic
column would fail to compile. | |
ProcessingMode | type | helper | 0 | Who processes the rows, with the server side split by who drives the fetch.
This is ResolvedSource's mode — and the store's: state.mode
carries all three values, because collapsing the two server arms into one
'server' made them indistinguishable below the store, and the managed
arm's construction-time behaviour (it always fetches first) differs from
the manual arm's (the consumer already has rows). Code that only cares
about *where the processing happens* compares against 'client'. | |
SummaryType | type | helper | 0 | The closed union of aggregation codes — SummaryConfig['type'] derives from it. | |
ClientItemsSource | interface | helper | 0 | The table does the work: it sorts, filters, searches and pages the rows you give it, in the browser. Loading and error are yours to report because you fetched the rows — the *processing* is what this arm decides, not where the data came from. | |
ServerManualSource | interface | helper | 0 | The backend does the work, manual flow: you fetch, the table only renders.
processing: 'server' is mandatory because it turns the table's own
sorting, filtering, searching and paging off — a decision with visible
consequences for the reader, never something to be inferred from a total
field that happened to be passed through.
Out-of-range pages are yours to handle: the table clamps what it *displays*
into 1..totalPages, but the snapshot your fetch layer observes carries the
reader's raw intent, which a shared ?page=99 link can point past the end.
Clamp snapshot.page against your total, or answer with the last page —
an empty items with an unchanged total leaves the reader on an empty
body the pager claims is full. | |
ServerManagedSource | interface | helper | 0 | The backend does the work, managed flow: the table drives the fetch
lifecycle too, and owns loading/error/total outright — so the never
guards below make passing them a type error instead of the silently ignored
props they were in v7 (the DEV warning this union replaced).
Same processing: 'server' as ServerManualSource, deliberately:
which of the two applies is structural (a query function or rows), and
the difference has no consequence for the reader — the backend computes
either way and no control goes dead. Folding "who fetches" into the tag
would put two questions back in one field.
Feature-frozen: the view is the only thing that triggers a fetch, and that
is the whole remit. Refreshing, polling, caching and invalidating belong to
a data layer of your own, whose result reaches the table through
ServerManualSource. | |
TableViewSnapshot | interface | helper | 0 | A fully resolved view state — never undefined anywhere. | |
ViewAxis | type | helper | 0 | One of the six view axes. | |
ViewOrigin | type | helper | 0 | Who last wrote an axis — the one question a binding needs answered:
was this the reader?
- user — reader interaction through the table, or consumer code writing a
field. May be persisted.
- external — a binding applied a value (URL navigation, storage
hydration). Must never be persisted — "someone else's link stores
nothing". | |
BindingKind | type | helper | 0 | Kinds of bindings that can claim axes. One binding per kind per axis; a
url and a storage binding on the same axis is composition, two url
bindings on it are a programming error. | |
DataAccessor | type | helper | 0 | Resolves the allowed string-accessor type for T. For typed rows this is
the union of primitive-valued property names; for the default
Record<string, unknown> row type (where PrimitiveKeys<T> is never)
we widen back to string to keep the unconstrained call sites usable. | |
TablePage | interface | helper | 0 | Result a server source resolves with — the return shape of source.query,
and the same pair of fields a manual processing: 'server' source carries
as items and total.
total is spelled the same here as on ServerManualSource (#162): both
mean "how many rows match this query", and until v9 the managed flow
called it totalItems purely because that name came through unchanged
from v7. Which flow you use no longer changes what the field is called. |
Installation
Import
import { Table, TableColumns } from '@urbicon-ui/table';Styles
import '@urbicon-ui/table/style/index.css';