Svelte renderer
<Table table={projects} prop="projects"
locale="en-US" stickyTop="4rem" class="[--st-row-height:3rem]" classes={{headerCell: 'uppercase tracking-wide'}}
columns={columnRenderers} filters={filterRenderers} actions={actionHandlers} onAction={handleOtherActions} layouts={layoutRenderers} />
table is a TablePayload; prop is its Inertia key. columns and filters map type names to snippets. Pages use numbered page links unless the table enables infiniteScroll() or you pass infinite (buffer and manualAfter tune it). stickyTop offsets the sticky header below your own sticky app bar. shortcuts={false} disables / (focus search), f (open the filter menu) and Escape (clear the selection). transport can inject an alternative transport in a test environment; production defaults to the Svelte Inertia router.
Styling
The UI is built from shadcn-svelte style components on bits-ui and styled with Tailwind CSS v4 utilities. Import the theme from your Tailwind stylesheet (@import "@spindle/table-svelte/theme.css";); it registers the package with @source, so its classes are generated with yours. The table is uncontained: no card or border wraps it, so it takes the look of the page around it.
Three layers of customization, from broad to specific:
- CSS variables. Every color, radius, size, blur and motion value is a
--st-*variable with a shadcn-token default:--st-accent,--st-foreground,--st-muted,--st-border,--st-hairline,--st-row-height,--st-cell-padding-x,--st-font-size,--st-control-height,--st-header-background,--st-row-hover,--st-row-selected,--st-radius,--st-switch-on,--st-ease,--st-loading-delay, and the Apple system tones (--st-blue,--st-green, …) used by badges and colored text. Override them on:root,.dark, or a single table (class="[--st-accent:#7c3aed]"). - Size. The defaults are roomy and readable: 15px text (
--st-font-size,--st-control-font-size), 14px and 13px secondary text (--st-font-size-sm,--st-font-size-xs), 52px rows (--st-row-height), 16px cell padding (--st-cell-padding-x), 40px/36px buttons and inputs (--st-control-height,--st-control-height-sm), and compact 28px active-filter chips (--st-chip-height). For a denser table, lower them together, e.g.class="[--st-font-size:0.8125rem] [--st-control-font-size:0.8125rem] [--st-row-height:2.75rem] [--st-cell-padding-x:0.75rem] [--st-control-height:2rem] [--st-control-height-sm:1.75rem]". classes. Merge Tailwind classes into named parts:root,views,toolbar,search,filterBar,filterChip,table,thead,headerCell,scroller,tbody,row,cell,groupRow,summaryRow,footer,pagination,bulkBar,emptyState,cards,card,runs. Classes are merged with tailwind-merge, so yours win over the defaults.- Selectors. Every part keeps a stable
st-*class (.st-row,.st-header-cell,.st-badge, …) and primitives carrydata-slot="st-…". Package rules use:where(), so any selector of yours overrides them.
The shadcn primitives are exported as ui (Button, Input, Checkbox, Switch, Select, Segmented, PopoverContent, MenuContent, DialogContent, …) for custom cell and filter renderers that should match.
Design options
Three opt-in variants change the look without touching the server:
<Table table={projects} prop="projects" viewsVariant="underline" appearance="grid" fullHeight />
views={false}hides the saved-views bar entirely, even when the table has preset or saved views. The table still opens its default view.viewsVariantpicks the saved-views bar design:segmented(default, pills in a sunken track) orunderline(classic tabs on a hairline with a sliding underline, colored by--st-tab-indicatorand--st-tab-indicator-size; hovering a tab shows a subtle ghost-button surface behind its label). Each variant is a recipe of Tailwind classes (root,nav,tab, optionallabel,indicator) inviewTabsVariants(packages/svelte/src/variants.ts); add a design there, or pass your own recipe object asviewsVariant. The bar exposes the name asdata-variant.appearance="grid"gives an Airtable-like grid while staying uncontained (no outer box): column dividers, a filled header with rounded corners and denser rows. It is driven by variables (--st-grid-line,--st-grid-header-background,--st-grid-header-radius,--st-grid-row-height) and bydata-appearance="grid"on the root. The theme registers anst-grid:Tailwind variant, so your ownclassescan target it (classes={{cell: 'st-grid:px-2'}}).fullHeightmakes the table fill its parent’s height (true=100%, or any CSS height such as"calc(100dvh - 4rem)"). The toolbar and footer stay put while the rows scroll inside.st-scroller(slotscroller), with the header sticky inside it. The last row has no bottom line, so a table with too few rows to fill the scroller trails off rather than closing mid-page. Soft shadows appear while more rows lie beyond the edges: the sticky header casts one over the rows scrolled beneath it, and one rises from the bottom edge; tune them with--st-scroll-fade-sizeand--st-scroll-fade-color. The parent must have a definite height, e.g. ah-dvh flex flex-collayout with the table’s container atflex-1 min-h-0. Infinite scroll and page changes work inside the scroller.
Server-side rendering
<Table> renders under Inertia SSR: it touches no browser APIs while rendering, formats dates in an explicit time zone so the server and browser produce the same text, and measures column widths, sticky offsets and scroll fades only after hydration. The server HTML contains the rows, so they are on screen before the JavaScript loads (with virtualization, the first 40 rows and a spacer for the rest). tests/frontend/ssr.test.ts renders the table with svelte/server to keep it that way. Pass design props (appearance, viewsVariant, fullHeight) from server data (props or cookies), not localStorage, so the server renders the same design that hydrates. See demo-app/README.md for a complete setup.
Rendering performance
Large tables stay fast because the prebuilt table does as little work per row as it can:
- Virtualization. Above 60 loaded rows, only the rows near the viewport are in the DOM (plus one screen of extra rows above and below), with a spacer row standing in for the rest. It works both when the page scrolls and inside a
fullHeightscroller, and with grouping, infinite scroll and client-side search. Rows may differ in height: each rendered row is measured, and rows not rendered yet are estimated from the average. Control it with thevirtualizeprop:'auto'(default),true(always) orfalse(render every row). Grid layouts (layout('grid'), column layouts) and reorder mode, which drags between rendered rows, always render every row. - Plain cells skip the cell component. Text and badge cells without links, actions, icons, descriptions, lists, tooltips, copy buttons or editing are rendered directly, with the same markup as the value part of a full cell.
- Selection checks are constant-time. Each row’s checked and enabled state comes from a lookup built once per selection (
selectionLookup(snapshot)in the core), rather than scanning the selected IDs and loaded rows for every row. - Classes are merged once. Cell and row class strings (including your
classes) are computed per column and per row variant, not per cell. - Toggles are native. Toggle columns and boolean filters use a plain
<button role="switch">instead of a headless-UI component.
What virtualization changes for users and tests:
- The browser’s find-in-page (Ctrl/Cmd+F) only finds rows that are rendered. Pass
virtualize={false}where that matters more than speed. - The table announces its full size to assistive technology with
aria-rowcount, and each row its position witharia-rowindex. - A focused row (for example an inline editor you are typing in) stays rendered while you scroll away from it. Arrow keys on row checkboxes render the next row before moving focus to it.
- End-to-end tests that count
[data-spindle-row]elements see only the rendered rows. Readaria-rowcounton the table, or render withvirtualize={false}.
See Benchmarks for measurements of the prebuilt table in Chromium.
Loading and layout stability
Queries keep the current rows on screen (slightly dimmed) and show an indeterminate hairline across the header; both appear only after --st-loading-delay (120ms), so fast responses never flash. Infinite scroll shows skeleton rows while the next page loads. Column widths are measured once and pinned proportionally, so paging, sorting and refreshes do not make columns jump; they are re-measured when columns are shown, hidden or reordered, or the table is resized. Inline editors reserve room for their status icon.
Inline editing
Text input, select, toggle and checkbox columns edit in place, like a spreadsheet. Text and select cells read as plain text, with a pencil or chevron appearing on hover and focus. Click a cell to select it. Double-click it, press Enter or F2, or just start typing to open an editor that covers the whole cell: typing replaces the value, and Backspace or Delete starts from empty. Enter saves and moves down (Shift+Enter up), Tab saves and moves right (Shift+Tab left), clicking elsewhere saves, and Escape cancels. Selects open their option list at once; pick with the arrow keys and Enter, or type to jump. Toggles and checkboxes change with a click on the control, Space or Enter on the cell, or a double-click anywhere in the cell.
A change is sent immediately as an Inertia optimistic update, and only that row is fetched back and merged by ID. The table, its scroll position and other rows are untouched, and other edits or navigation are never blocked. Each cell shows saving, saved and error states: a failed save outlines the cell in red, marks it aria-invalid and keeps the rejected value visible until you edit it again. Screen readers hear “Project, row 1 saved” or the validation message. snapshot.edits[cellEditKey(rowId, column)] exposes the same status to custom renderers, and controller.edit(rowId, column, value) returns the result. In card layouts (layout('grid'), column layouts), text and select editors stay visible inputs.
Keyboard navigation
The table body is an ARIA grid (role="grid") with a single Tab stop, so Tab moves past the table instead of through every checkbox and button in it. Controls inside cells are reached from their cell. Cells have the gridcell role (in Playwright, getByRole('gridcell')). The active cell is outlined (--st-cell-focus, --st-cell-focus-radius): always in the grid appearance and on editable cells, and for keyboard focus elsewhere.
| Keys | Action |
|---|---|
| ←/→/↑/↓ | Move to the neighboring cell (rows that are not rendered yet are rendered first) |
| Ctrl/Cmd + arrow | Jump to the first or last cell in that direction |
| Home / End | First / last cell in the row; with Ctrl/Cmd, in the table |
| Page Up / Page Down | Move up or down by one screen of rows |
| Enter, F2 | Edit the cell, toggle a boolean, select the row (checkbox column), move into the cell’s buttons or links, or open the row’s record |
| Typing, Backspace, Delete | Start editing a text cell with that key |
| Space | Toggle a boolean cell, otherwise select or deselect the row |
| Shift + arrow, Shift + Home/End/Page Up/Page Down | Extend the cell range (in the checkbox column, Shift + ↑/↓ extends the row selection) |
| Ctrl/Cmd + A | Select every cell |
| Ctrl/Cmd + C | Copy the selected cells, or the active one |
| Menu key, Shift + F10 | Open the cell menu (copy, copy with headers, select all) |
| ← / → on a group | Collapse / expand it |
| Escape | Leave a cell’s controls, cancel an edit, collapse a cell range, or clear the selection |
Cell ranges and copying
Select a rectangle of cells like in a spreadsheet: drag across cells (the table scrolls when you drag past its edge), Shift+click another cell, or hold Shift with the arrow keys. Ctrl/Cmd+A selects every cell. The range is tinted and outlined (--st-range-background, --st-range-border) and survives virtualization, including rows rendered later.
Ctrl/Cmd+C copies the range as tab-separated text (values with tabs, line breaks or quotes are quoted, as spreadsheets expect) and as an HTML table, so it pastes as rows and columns into Excel, Google Sheets, Numbers, docs and email. Values are what the cells show, never cut short by display limits: money and dates formatted, lists in full, booleans as TRUE/FALSE. Checkbox, action and link cells are left out; group rows copy their label and totals. Right-click (or the Menu key) opens a menu to Copy, Copy with headers or Select all cells. Right-clicking inside the range keeps it, and outside it selects the clicked cell first.
The core exports the pieces for custom UIs: cellText(cell, column, locale) and toClipboard(rows, headers?).
Moving columns
Drag a column header to move the column: a ghost follows the pointer and a line marks where it will land. From the keyboard, focus a header and press Alt+Shift+←/→. The new order is saved like any column-manager change (state, URL and saved views) and announced to screen readers. A press that does not move stays a click, so sortable headers still sort. columnManager(reorderable: false) turns this off.
Grouping
Rows can be grouped by several nested levels, like AG Grid’s row grouping. A Group column holds the tree: each group row has a chevron, its label and its record count, and shows its totals (group-scope summarizers) under their columns. Group rows are part of the grid, so the keyboard reaches them: Enter toggles one, ← and → collapse and expand it on its group cell, and Space selects its loaded records. With groups, the table has the treegrid role and every row an aria-level.
The group panel above the rows (groupPanel, default auto: shown once rows are grouped) shows the levels as chips:
- drag a column header onto it to group by that column (the panel lights up while a groupable column is dragged);
- drag chips, or use Alt+Shift+←/→ on them, to change the nesting order;
- flip a level’s direction with its arrow, or remove it with ×;
- use Expand all and Collapse all.
The toolbar’s Group menu adds and removes levels too, and is how the first level is added by default. Pass groupPanel="always" to show the panel whenever the table has groups (with a hint to drop a column header there), or "never" to hide it.
When rows are grouped and paginated, pages hold groups rather than records (see group pagination). Top-level groups show their full record counts and totals for the current filters and start collapsed. Expanding one loads its subgroups, or at the deepest level its records, a page at a time, with a pager inside the group. Loaded records join the table’s data, so selection, actions and inline edits work on them as usual. The headless controller exposes this as loadGroup({key, path}, page), and the snapshot reports progress in groupLoads.
Accessibility
- Checkbox and switch outlines use
--st-control-border, at least 3:1 against the background (WCAG 1.4.11), and row checkboxes are always fully visible. - Switches have a 40×24px hit area. Selection checkboxes select from anywhere in their cell, and editable cells respond to clicks anywhere inside them.
- Editable cells describe how to edit them (“Press Enter or double-click to edit”, “Press Space to change”). Saves and failures are announced in a polite live region.
- In forced-colors mode (Windows High Contrast), focus, the active cell, errors, selected rows, checkboxes and switches switch to system colors so they stay visible. Motion is reduced when the user asks for it.
Selection
Click a checkbox to select a row, Shift+click to select a range, or press on a checkbox and drag across rows to paint a selection (dragging back restores rows). From the keyboard, Space selects the active cell’s row and Shift+↑/↓ extend the selection (hold to sweep). Escape clears it. Selected records get a floating bulk bar with “Select all matching” and bulk actions. controller.selectMany(ids, checked) is available to custom renderers.
Filtering
Active filters, the search and column searches appear as chips under the toolbar; click a chip to edit it in place, or × to remove it. The filter menu (f) searches filter names and opens the matching editor: searchable option lists (remote options load through the optional Inertia prop), Any/Yes/No segments, number ranges, date ranges with relative presets and a calendar, and a rule builder whose rules apply together once complete. filterControls layouts (dropdown, modal, slide-over, above, above-collapsible, below) present every filter as a form, and deferred filters apply on Apply.
Search highlighting. The words of the search are highlighted in the cells it searched: the global search in its searchable columns (every column when none is marked searchable), and a column search in its own column. Matching follows the search itself: case and accents are ignored (cafe highlights “Café”), and under fuzzy search a word matched with a typo highlights the text it matched (“redsign” highlights “redesign”). Matches are painted with the browser’s CSS Custom Highlight API (::highlight(st-search)): the cell markup never changes, every kind of cell is covered (badges, lists, links, rich text and your own renderers), highlighting rows costs nothing when they render, and virtualized rows are highlighted as they appear. Style it with --st-search-highlight and --st-search-highlight-foreground; forced-colors mode uses the system Mark colors. Browsers without the API (Firefox before 140) show no highlight. Pass highlightSearch={false} to turn it off. findMatches(text, terms) and searchHighlight() are exported for custom UIs.
Exports include Table, DataGrid, DataCards, GroupSummaries, EmptyState, ReorderControls, ActionButtons, TableCell, EditableCell, FilterBar, FilterChip, FilterInput, FiltersPanel, ColumnManager, ViewBar, BulkBar, SearchField, ColumnLayout, useTable, setTableClasses and ui. DataGrid accepts a snapshot, controller, column renderers, locale, body ID, and action callback. It provides just the semantic table, suitable for a custom toolbar or surrounding layout.
The Svelte adapter creates/destroys subscriptions and cancels pending navigation on unmount. Mount it inside an initialized Inertia page for native scroll support. Server rendering does not access the DOM; relative date formatting can depend on the server/client clock. Dates use UTC for deterministic display. Use formatStateUsing() or a snippet for an application timezone.
Headings and page actions
Table renders no heading, description or page-level buttons; put those in your page around it. Actions that belong with the table’s controls go in toolbarActions().
<section aria-labelledby="projects-heading">
<h1 id="projects-heading">Projects</h1>
<Table table={projects} prop="projects" />
</section>
Custom empty states
Keep the prebuilt table while replacing its empty-state content with a Svelte snippet:
<script lang="ts">
import { Table, type TableSectionContext } from '@spindle/table-svelte';
import type { TablePayload, ActionHandler } from '@spindle/table-core';
let { projects, actionHandlers }: {
projects: TablePayload; actionHandlers: Record<string, ActionHandler>;
} = $props();
</script>
{#snippet emptyState(context: TableSectionContext)}
<div>
<span aria-hidden="true">✦</span>
{@render context.defaultContent()}
{#if context.snapshot.state.search}
<button disabled={context.snapshot.busy}
onclick={() => context.controller.setSearch('')}>Clear search</button>
{/if}
</div>
{/snippet}
<Table table={projects} prop="projects" {emptyState} actions={actionHandlers}/>
The snippet receives a reactive TableSectionContext: snapshot, controller, the empty state’s actions snippet, and defaultContent (which already includes those actions). Render either the whole default content or the action buttons separately to avoid duplicate buttons. The empty cell/container, toolbar, and Inertia scroll container remain owned by Table. Empty-state content works across ordinary tables, cards, nested column layouts, and aggregate reports.
For completely custom action buttons, call context.runAction(action) and disable them when !context.canRunAction(action). These use the same named/global handlers and confirmation flow as the prebuilt buttons. runAction() opens an interaction; the frontend handler’s invocation.submit() provides the server result. Link actions should remain links, using the core safeUrl() helper and appropriate new-tab attributes. PHP emptyStateIcon() accepts a literal text glyph; render your icon component inside emptyState for an icon library or illustration.
The lower-level DataGrid, DataCards, and GroupSummaries accept an optional parameterless emptyState snippet. Without it they display the configured empty-state heading, description, and icon. When composing these components yourself, provide your own snippet for actions. Exported EmptyState accepts table and an optional parameterless actions snippet for this purpose.
Completely custom Svelte UI
<script lang="ts">
import { useTable } from '@spindle/table-svelte';
import { formatCell, visibleColumns } from '@spindle/table-core';
import type { TablePayload } from '@spindle/table-core';
let { projects }: { projects: TablePayload } = $props();
const controller = useTable(() => projects, 'projects');
const columns = $derived(visibleColumns(projects.columns, $controller.state));
</script>
<input aria-label="Search projects" value={$controller.state.search}
oninput={event => controller.setSearch(event.currentTarget.value)} />
<table>
<thead><tr>{#each columns as column}<th>{column.label}</th>{/each}</tr></thead>
<tbody>{#each projects.data as row (row.id)}
<tr>{#each columns as column}<td>{formatCell(row.cells[column.name], column)}</td>{/each}</tr>
{/each}</tbody>
</table>
Wrap your custom rows in Inertia’s <InfiniteScroll data="projects" params={controller.scrollParams}> and set itemsElement to the actual row container to add scroll loading. useTable provides scrollParams to cancel pending scroll requests when search, filters, views, or other table state changes, and on unmount. The parameters also carry the current query explicitly, so scroll loading does not depend on a potentially stale address-bar query during rapid view switches. Pass them to keep older pages from appending after a query change. The prebuilt table wires them automatically. The controller sends Inertia’s reset for its own navigation methods.
Framework-independent core
import { createTable } from '@spindle/table-core';
const controller = createTable(payload, {
prop: 'projects',
url: () => window.location.href,
transport: {
get: (url, options) => router.get(url, {}, options),
post: (url, data, options) => router.post(url, data, options),
},
debounce: 250,
});
const unsubscribe = controller.subscribe(snapshot => render(snapshot));
controller.update(nextPayload); // call whenever Inertia updates the prop
// On teardown:
unsubscribe();
controller.dispose();
The core imports no Svelte, React, Vue or Inertia runtime. It uses a small transport interface, making future drivers straightforward. Do not share a controller between requests during SSR. url() should return the current page URL, including unrelated query parameters.
Controller methods
| Area | Methods |
|---|---|
| Lifecycle | subscribe, snapshot, update, dispose |
| Search | setSearch, setColumnSearch (debounced) |
| Filters | setFilter, setFilters, searchFilterOptions, cancelFilterOptions |
| Presentation | toggleSort(name, multi), toggleColumn, reorderColumns, setGroup, setPerPage |
| Navigation | reset, reload, goToPage |
| Selection | select, selectPage, selectGroup, selectAll, clearSelection, isSelected |
| Views | applyView, saveView, favoriteView, deleteView, reorderViews, defaultView |
| Actions | createAction (context, captured target, async submission, cancellation) |
| Mutations | runAction, runBulkAction, edit, reorder, moveRecord, useRecordOrder, toggleReordering |
| Background jobs | refreshRuns, cancelRunsRefresh, cancelRun, readRun |
runAction(name, recordId, data?, onSuccess?), runToolbarAction(name, data?, onSuccess?) and runBulkAction(name, data?, onSuccess?) expose callbacks for closing custom forms only after a successful request. snapshot.errors holds Laravel validation messages; snapshot.busy tracks controller navigation/mutations. Native infinite-scroll loading is separately owned by Inertia’s component. Read snapshots without mutating their objects directly.
Selection is ID-based. selectPage() means currently loaded records, which can span several infinite-scroll pages. selectAll() means all matching records, with except IDs. Filter/sort/view changes clear selection. Header checkbox and row labels are keyboard-accessible. Selection can be forced or disabled, capped, limited to loaded records, or restricted to a single group; see selection controls.
Utilities
visibleColumns(columns, state)applies order and hidden columns;columnsToggleable(table)says whether users may hide columns (a table-wide setting).formatCell(cell, column, locale?, now?)handles built-in text formatting.formatCodeTokens(cell, column, locale?)returns syntax tokens compatible with the displayed text and its length limit, orundefinedfor a plain-text fallback. Render token text with normal escaping and apply token classes to spans; custom UIs supply their own styles.safeUrl(value, image?)rejects executable/data/protocol-relative URLs.groupRows(rows)creates contiguous loaded groups.windowRows(rows, scrollTop, viewportHeight, rowHeight?, overscan?)returns a fixed-height window plus spacer sizes, for virtualizing a custom renderer. The prebuilt grid has its own variable-height virtualization (see Rendering performance).selectionLookup(snapshot)returns constant-timeselected(id)andselectable(row)checks, cached per selection, for rendering many rows.
Localization
locale controls number, currency and date formatting, and picks the built-in interface strings: English and Norwegian Bokmål ship by default (locale="nb", "nb-NO", "no" or "nn" selects Norwegian). messages overrides any built-in string, including accessible labels, filter operators, saved-view controls and the default confirmation dialog, or adds another language. Missing keys fall back to the locale’s catalog, then English. Message values can be strings with {name}, {id}, {count} or {action} placeholders, or functions for your application’s pluralization rules.
<script lang="ts">
import type { TableMessages } from '@spindle/table-svelte';
const messages: TableMessages = {
searchRecords: 'Søk i prosjekter',
searchPlaceholder: 'Søk…',
filters: 'Filtre',
columns: 'Kolonner',
cancel: 'Avbryt',
searchOptions: 'Søk etter {name}',
selected: ({ count }) => count === 1 ? 'Ett valg' : `${count} valg`,
};
</script>
<Table table={projects} prop="projects" locale="nb-NO" {messages} />
The catalogs live in @spindle/table-core (re-exported by @spindle/table-svelte): import englishMessages for the complete typed key catalog, norwegianMessages for the Norwegian one, and messagesForLocale(locale) or createTranslator(messages, fallback, locale) to use them in a custom UI. Translation configuration belongs to each table’s Svelte context, so separate tables or SSR requests do not overwrite one another. A reactive messages prop updates existing controls. To translate standalone primitives, call setTableTranslations(() => messages, () => locale) during their parent component’s initialization. Custom snippets can call getTableTranslator() in their parent component or use their existing application i18n library. Set the containing page’s lang and dir attributes in your application as appropriate.
Column/filter/action labels, descriptions, preset names and group titles originate on the server. Use Laravel’s __() when defining them. Default humanized definition labels, empty-state strings, soft-delete options and package validation failures also use Laravel translations. The package ships lang/nb.json with Norwegian for all of them; for another locale, add the English strings as keys in your application’s lang/{locale}.json (which also overrides the package’s own). User-created view names and raw record values are not translated automatically. Laravel’s own validation messages follow its current locale.
Action interaction is frontend-owned. The actions handler map and onAction fallback receive a headless ActionInvocation; build forms and dialogs using your own components. No form/schema builder is included. See actions.
Group reports
The prebuilt Table selects GroupSummaries automatically for table.config.groupsOnly. Headless renderers can use each row’s group.label, group.count, and group.summaries[columnName][label]. Group IDs are stable and suitable for keyed lists and Inertia merge matching; they are not record IDs. Record selection, reordering and row actions are unavailable in this mode. controller.setGroup(name, direction) changes report grouping; use a registered non-null group.
Import helpers
getImportConfig(action) exposes the explicit import destination metadata, and parseCsvHeaders(text, delimiter) reads the first CSV record for your mapping UI. CsvHeaderError.code supports application translations. These helpers are framework independent and have no runtime dependencies. The application-owned import example uses the same action invocation and result lifecycle as other actions. See CSV mappings for validation and limits.
Headless preference drafts
import { createTableControls } from '@spindle/table-core';
const controls = createTableControls(controller);
const unsubscribe = controls.subscribe(draft => {
// draft.filters, draft.hidden, draft.order
// draft.filtersDirty, draft.columnsDirty
});
controls.setFilter('status', 'active'); // No visit yet.
controls.toggleColumn('budget'); // Independent column draft.
controls.reorderColumns(['budget', 'name']);
controls.applyFilters(); // One filter visit.
// Once the visit finishes:
controls.applyColumns(); // One visibility/order visit.
// Component teardown:
unsubscribe();
controls.dispose();
setFilters(), resetFilters(), discardFilters(), resetColumns(), and discardColumns() operate on drafts. Reset uses server defaults; discard uses current applied state. Snapshots are copies, so modifying one cannot mutate an internal draft. The helper ignores undeclared filter/column names and mandatory column toggles. Apply is ignored while the table is busy; inputs should show that busy state. Call dispose() on teardown to release the controller subscription.
Drafts survive updates that only change records, pagination or another preference domain. A changed applied filter/column domain replaces that domain’s draft. Drafts are local to the mounted controller and are never included in saved views or action submissions. The normal controller.setFilter() / setFilters() APIs remain immediate, and controller.setColumns({hidden, order}) applies column preferences atomically.
Svelte exports FiltersPanel and ColumnManager, accepting snapshot, controller, and controls. FiltersPanel also accepts custom filter renderers; both accept onapply. Table creates and disposes the draft helper automatically and renders the layouts selected in the Laravel definition. These are table controls, independent of application-owned action forms.
Background job monitoring
snapshot.runs holds current job progress; runsLoading and runsError describe its independent refresh request. refreshRuns() uses the table’s optional Inertia progress prop, preserving loaded rows, selection, filters, and URL. The prebuilt table includes a localized BackgroundRuns panel with adaptive polling, cancellation, unread completion notifications, acknowledgment, and history. Applications can use the same controller methods with their own UI. See background jobs for polling and lifecycle details.