SwTable
SwTable is an admin-console data table: the columns config drives the header and column order, cells render item[key] by default, and complex content (thumbnails / status badges / action button groups) goes through the #cell-<key> scoped slot; it ships the admin-console big three — controlled column sorting (v-model:sort), row selection for batch actions (v-model:selected), and a loading transition — plus stripes / density / sticky-header visual knobs. Visuals follow the shadcn Table baseline.
It outputs a semantic <table> (SEO / a11y), wrapped in a .sw-table container with overflow-x: auto — on narrow screens the table scrolls horizontally by itself instead of blowing up the page layout. Baseline styles (border + radius / muted header / row hover / cell padding) go through --sw-table-* tokens; override the variables to reskin.
Boundaries: pagination / filtering are not built in — compose SwPagination / SwSegmented / SwSelect at the page layer; sorting manages the interaction, not the data (controlled — see below); heavy features (pinned columns / resizable columns / tree rows / virtual scrolling) wait for real data points. This aligns with the framework's "layout only, no business UI" rule.
Basic usage
Basic columns (default cells read item[key])
<script setup lang="ts">
const COLUMNS = [
{ key: 'name', label: '名称' },
{ key: 'category', label: '分类' },
{ key: 'count', label: '集数', width: '4rem', align: 'center' },
]
const rows = [{ id: 1, name: '未来都市', category: '动画', count: 12 }]
</script>
<template>
<SwTable :columns="COLUMNS" :items="rows" />
</template>
Cell / header slots (the admin-list shape)
#cell-<key> customizes cells per column (scope { item, value, index }); #header-<key> customizes headers (scope { column }):
Status badge + inline actions (#cell-* slots)
Empty placeholder
#empty renders as one full-width row when items is empty; omit it for an empty tbody:
#empty placeholder
Column sorting (controlled)
column.sortable makes the header clickable, cycling asc → desc → none through v-model:sort (the header renders an indicator arrow + aria-sort). The component doesn't sort the data — the business sorts locally (toSorted) or re-fetches with params (server-side sorting is the admin-console mainstream):
Column sorting (controlled tri-state; this demo sorts locally)
<script setup lang="ts">
const sort = ref<SwTableSort | undefined>()
// Server-side sorting: refetch with params on sort change
watch(sort, (s) => refetch({ orderBy: s?.key, order: s?.order }))
</script>
<template>
<SwTable v-model:sort="sort" :columns="COLUMNS" :items="rows" />
</template>
Row selection & batch actions
selectable renders a checkbox column + select-all header (indeterminate when partially selected); the selection set goes through v-model:selected (an array of rowKey values):
Row selection + batch action
Loading transition
loading dims the table and overlays a spinner while keeping the structure (no blank flash) — for "data present, refreshing" transitions; first-screen empty loading still belongs to the page layer's four states (skeletons). The two are complementary:
Page-flip transition (loading + SwPagination)
Visual knobs: stripes & density
striped shades even rows; size="compact" tightens cell padding — a common combo for data-dense pages:
Stripes + compact density (toggle to compare)
Sticky header (scrolling inside the container)
sticky-header + --sw-table-max-height turn the container into a scroll area with the header pinned on top:
Sticky header (30 rows scrolling in the container)
The full admin-console shape
Sorting + row selection + loading + row click + status badges + inline actions combined (a real ops list page):
Admin console combined (all capabilities)
Composing with pagination (page-layer data flow)
The table doesn't manage paging — use SwPagination to swap items per page:
<template>
<SwTable :columns="COLUMNS" :items="pageItems">
<template #empty>暂无数据</template>
</SwTable>
<SwPagination v-model:page="page" :total="total" :page-size="20" />
</template>
Props
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
columns | SwTableColumn[] | ✓ | — | Column config; each item is { key, label?, width?, align?, sortable? } |
items | T[] | ✓ | — | Row data (generic; the component doesn't care about the shape) |
rowKey | string | ((item: T) => string | number) | 'id' | Row key field name or getter; falls back to index when unresolvable | |
striped | boolean | false | Zebra stripes (even rows shaded) | |
size | 'default' | 'compact' | 'default' | Density (compact tightens cell padding). ⚠️ Not the same as size on SwThemeSwitch / SwLangSwitch (an icon pixel value) — those are dimensions, this is a density enum, matching Ant / Element Table.size | |
stickyHeader | boolean | false | Sticky header (pair with --sw-table-max-height for an in-container scroll area) | |
loading | boolean | false | Refresh transition (overlay + dimming, structure preserved) | |
selectable | boolean | false | Render a checkbox column + select-all header | |
sort (v-model:sort) | SwTableSort | undefined | undefined | Current sort state; undefined = unsorted | |
selected (v-model:selected) | (string | number)[] | [] | Selection set of rowKey values |
SwTableColumn: { key: string; label?: string; width?: string; align?: 'left' \| 'center' \| 'right'; sortable?: boolean } — key is both the default cell field and the #cell-<key> / #header-<key> slot-name suffix; width lands on <th> (a CSS width value); align defaults to 'left'; sortable makes the header clickable for sorting.
SwTableSort: { key: string; order: 'asc' \| 'desc' }. Both types are importable from the package: import type { SwTableColumn, SwTableSort } from '@swift-web/framework'.
Events
| Name | Payload | Description |
|---|---|---|
update:sort | SwTableSort | undefined | Fired on sortable header click (asc → desc → undefined cycle) |
update:selected | (string | number)[] | Row / select-all selection changed |
row-click | (item, index) | Fired on row click (clicks on inline interactive elements — button / a / input — don't trigger it); rows get cursor: pointer automatically when listened |
Inline actions (edit / publish / delete) still use SwButton etc. inside #cell-<key> slots with their own events.
Slots
| Name | Scope | Description |
|---|---|---|
cell-<key> | { item, value, index } | Customize cells per column. Renders String(item[key] ?? '') when omitted |
header-<key> | { column } | Customize headers per column. Renders column.label when omitted. ⚠️ On sortable columns the slot content is wrapped in a <button> (for keyboard access), so it must be phrasing content — putting a <button> / <a> there creates nested interactive elements |
empty | — | Rendered as one full-width row when items is empty (optional; empty tbody when omitted) |
Styling Tokens
Brand the table via --sw-table-* tokens (fallbacks are the default look):
| Token | Default | Applies to |
|---|---|---|
--sw-table-bg | var(--card) | table background |
--sw-table-border | var(--border) | outer border & row divider color |
--sw-table-radius | var(--radius) | table radius |
--sw-table-head-color | var(--muted-foreground) | header text |
--sw-table-head-bg | color-mix(in srgb, var(--foreground) 6%, transparent) | header background (foreground overlay: distinguishable from rows in any theme) |
--sw-table-hover-bg | color-mix(in srgb, var(--primary) 4%, transparent) | row hover background |
--sw-table-striped-bg | color-mix(in srgb, var(--foreground) 3%, transparent) | zebra even-row background (striped) |
--sw-table-cell-padding | 0.75rem 1rem | cell padding |
--sw-table-cell-padding-compact | 0.375rem 0.75rem | compact-density padding (size="compact") |
--sw-table-font-size | inherit | table font size |
--sw-table-max-height | none | container max height (set a value for in-container scrolling, pair with stickyHeader) |
Caveats
- Sortable headers are real
<button>s: onsortablecolumns the header content is wrapped in a<button>(the W3C ARIA APG Sortable Table pattern), so it is Tab-focusable and sorts on Enter / Space;aria-sortstays on the<th>. Targetbutton.th-inner:hoverto restyle the hover state. row-clickis a convenience layer, not the only path: rows are deliberately not made focusable (a long table would gain one Tab stop per row). Each row must contain a real focusable element performing the same action (a detail link or an action button); otherwise keyboard and screen-reader users cannot reach it at all — that fails WCAG 2.1.1 Keyboard (Level A), and mouse testing will never reveal it. Aconsole.warnfires in dev; the check is stripped from production builds.- Sorting doesn't sort the data:
v-model:sortis only the controlled interaction state — the business must sort itself (localtoSortedor refetch with params), otherwise clicking headers "only flips the arrow, the data doesn't move". - loading vs. the four view states:
loadingserves the "data present, refreshing" transition; first-screen loading / error / empty still belong to the page layer's four states (skeletons / retry entries), with#emptyas a light fallback row only. - Inline actions don't trigger row-click: clicks on button / a / input inside
#cell-*don't bubble into a row click — no manualstopPropagationneeded. - No built-in pagination / filtering: compose SwPagination / SwSegmented / SwSelect at the page layer.
- Lock pending on inline async actions: action buttons inside
#cell-*follow SB-001 (async@clickwith:loading), e.g. lock publish/unpublish by row id. - Horizontal scrolling on narrow screens is built in: the
.sw-tablecontainer shipsoverflow-x: auto; with many columns just give themwidthfor readable widths — don't wrap another scroll container outside.