Custom Cells
Three ways to render a column differently from the default:
- Slot — quick per-table override via
<template #cell-{path}>. - Type swap — replace every cell of one
typefor one table. - Named component — opt-in per column with
@ui.table.component, swap it via the:componentsmap.
The first is template-local. The second and third reuse a component across every table that opts in.
The cell contract
Every cell component receives exactly two props:
import type { ColumnDef } from "@atscript/ui";
defineProps<{
row: Record<string, unknown>;
column: ColumnDef;
}>();row is the raw row object from the server. column is the runtime column descriptor — type, label, path, precision, currency ref, etc.
The cell uses getCellValue(row, column.path) to walk the dotted path:
<script setup lang="ts">
import { computed } from "vue";
import { getCellValue } from "@atscript/vue-table";
import type { ColumnDef } from "@atscript/ui";
const props = defineProps<{
row: Record<string, unknown>;
column: ColumnDef;
}>();
const value = computed(() => getCellValue(props.row, props.column.path));
</script>
<template>
<td>{{ value }}</td>
</template>Render a <td>. The cell sits inside a <tr> rendered by <AsTable> — it owns the row, not the cell. Set classes / styles directly on the <td> you emit.
Annotation-driven bindings
@ui.table.classes, @ui.table.styles, @ui.table.attr, and their fn.* variants are applied automatically by the per-cell resolver (useCellResolver) before the cell mounts. Your custom cell doesn't need to read them — they v-bind onto the cell's <td> outer element via the parent's <component :is="..." v-bind="cellBindings">.
The type for cell-type maps is TAsCellTypeComponents:
import type { TAsCellTypeComponents } from "@atscript/vue-table";
const types: TAsCellTypeComponents = {
...createDefaultCellTypes(),
rating: MyRatingCell,
};Pattern A — type swap
Replace every cell of one type for one table. Useful when an entire project standardises on a richer renderer (your own date picker, your own currency formatter).
import { createDefaultCellTypes } from "@atscript/vue-table";
import MyDateCell from "./cells/MyDateCell.vue";
const types = { ...createDefaultCellTypes(), date: MyDateCell, datetime: MyDateCell };<AsTableRoot url="/db/orders" :types="types" />Every date and datetime column on this table renders through MyDateCell.
Pattern B — named per-column override
@ui.table.component tags a single field; the table looks it up in the :components map. Other columns of the same type stay on the default.
@db.table 'products'
export interface Product {
@meta.id id: string
@meta.label 'Status'
@ui.table.component 'statusBadge'
status: 'active' | 'archived' | 'pending'
}import StatusBadgeCell from "./cells/StatusBadgeCell.vue";
const components = { statusBadge: StatusBadgeCell };<AsTableRoot url="/db/products" :components="components" />The resolver tries named lookup first, falls back to type lookup, then AsTableCellValue. Tagging more fields with @ui.table.component 'statusBadge' reuses the same renderer.
Pattern C — slot
The fastest override is a slot on the table. No .as change, no component file — just inline template per column:
<AsTable>
<template #cell-price="{ value }">
<td class="text-right tabular-nums">${{ Number(value).toFixed(2) }}</td>
</template>
<template #cell-inStock="{ value }">
<td>
<span :class="['as-status-badge', value ? 'scope-good' : 'scope-error']">
{{ value ? "In Stock" : "Out of Stock" }}
</span>
</td>
</template>
</AsTable><AsTable> exposes one slot per column path plus four global slots:
#header-<path>— replace the<th>.#cell-<path>— replace the<td>.#empty— body when results are empty.#query-loading— overlay while a fetch is in flight.#error— body when the last query failed (receives{ error }).
Pick a slot when the override is one-off and template-local. Pick a component swap (Pattern A or B) when the cell repeats across tables.
Worked example: status badge
A reusable cell that paints a coloured pill using the scope-good / scope-error tokens from vunor's shortcuts engine. Works on any column whose value is a discriminator string.
<!-- cells/StatusBadgeCell.vue -->
<script setup lang="ts">
import { computed } from "vue";
import { getCellValue } from "@atscript/vue-table";
import type { ColumnDef } from "@atscript/ui";
const props = defineProps<{
row: Record<string, unknown>;
column: ColumnDef;
}>();
const value = computed(() => getCellValue(props.row, props.column.path));
const scope = computed(() => {
switch (value.value) {
case "active":
case "shipped":
return "scope-good";
case "archived":
case "cancelled":
return "scope-error";
case "pending":
return "scope-attention";
default:
return "scope-neutral";
}
});
</script>
<template>
<td>
<span class="as-status-badge" :class="scope">{{ value }}</span>
</td>
</template>Add the matching shortcut to ui-styles (or your own UnoCSS preset) so the class compiles:
defineShortcuts({
"as-status-badge": "inline-flex items-center px-$s py-$xxs text-callout rounded-base c8-light",
});Wire it on the .as:
@db.table 'orders'
export interface Order {
@meta.id id: string
@meta.label 'Status'
@ui.table.component 'statusBadge'
status: 'pending' | 'shipped' | 'cancelled'
}And on the Vue mount:
import StatusBadgeCell from "./cells/StatusBadgeCell.vue";
const components = { statusBadge: StatusBadgeCell };<AsTableRoot url="/db/orders" :components="components" />Every order row now shows a coloured status pill; the rest of the table stays on the default renderers.
Next steps
- Cells — the built-in cell library and locale provider.
- Customization — swap dialogs, headers, row actions, the column menu.