ui: convert ActivityTable to shadcn-svelte data-table
Replace the hand-rolled table with a TanStack Table-backed shadcn data-table using the FlexRender/createSvelteTable helpers, with DropdownMenu column visibility, Select page-size, and icon-button pagination. Column visibility and page size persist to localStorage. Move tooltip usage to the canonical shadcn-svelte pattern by adding a single root Tooltip.Provider in App.svelte and using Tooltip.Root/ Trigger/Content directly in the activity-table sub-components (HeaderLabel, MetaCell), dropping the custom Tooltip/MetadataTooltip wrappers. - add @tanstack/table-core and shadcn data-table helpers - split cell/header renderers into activity-table/* sub-components - switch pagination/visibility to TanStack Table state driven by table.nextPage/previousPage/setPageIndex/setPageSize and column.toggleVisibility
This commit is contained in:
Generated
+14
@@ -8,6 +8,7 @@
|
||||
"name": "ui-svelte",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@tanstack/table-core": "^8.21.3",
|
||||
"chart.js": "4.5.1",
|
||||
"highlight.js": "^11.11.1",
|
||||
"katex": "^0.16.28",
|
||||
@@ -817,6 +818,19 @@
|
||||
"vite": "^5.2.0 || ^6 || ^7"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/table-core": {
|
||||
"version": "8.21.3",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz",
|
||||
"integrity": "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
}
|
||||
},
|
||||
"node_modules/@tsconfig/svelte": {
|
||||
"version": "5.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@tsconfig/svelte/-/svelte-5.0.8.tgz",
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
"vitest": "^4.1.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/table-core": "^8.21.3",
|
||||
"chart.js": "4.5.1",
|
||||
"highlight.js": "^11.11.1",
|
||||
"katex": "^0.16.28",
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
import Playground from "./routes/Playground.svelte";
|
||||
import PlaygroundStub from "./routes/PlaygroundStub.svelte";
|
||||
import * as Sidebar from "$lib/components/ui/sidebar/index.js";
|
||||
import * as Tooltip from "$lib/components/ui/tooltip/index.js";
|
||||
import { Separator } from "$lib/components/ui/separator/index.js";
|
||||
import { enableAPIEvents, checkPerformanceEnabled } from "./stores/api";
|
||||
import { initScreenWidth, initSystemThemeListener, isDarkMode, appTitle, connectionState } from "./stores/theme";
|
||||
@@ -80,6 +81,7 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<Tooltip.Provider>
|
||||
<Sidebar.Provider>
|
||||
<AppSidebar />
|
||||
<Sidebar.Inset class="h-screen min-w-0 overflow-hidden">
|
||||
@@ -101,3 +103,4 @@
|
||||
</main>
|
||||
</Sidebar.Inset>
|
||||
</Sidebar.Provider>
|
||||
</Tooltip.Provider>
|
||||
|
||||
@@ -2,22 +2,34 @@
|
||||
import type { ActivityLogEntry, ReqRespCapture } from "../lib/types";
|
||||
import { getCapture } from "../stores/api";
|
||||
import { persistentStore } from "../stores/persistent";
|
||||
import { onMount } from "svelte";
|
||||
import Tooltip from "./Tooltip.svelte";
|
||||
import MetadataTooltip from "./MetadataTooltip.svelte";
|
||||
import CaptureDialog from "./CaptureDialog.svelte";
|
||||
import { Columns3, GripVertical } from "@lucide/svelte";
|
||||
import {
|
||||
type ColumnDef,
|
||||
type PaginationState,
|
||||
type VisibilityState,
|
||||
getCoreRowModel,
|
||||
getPaginationRowModel,
|
||||
} from "@tanstack/table-core";
|
||||
import {
|
||||
FlexRender,
|
||||
createSvelteTable,
|
||||
renderComponent,
|
||||
} from "$lib/components/ui/data-table/index.js";
|
||||
import * as Table from "$lib/components/ui/table/index.js";
|
||||
import * as Card from "$lib/components/ui/card/index.js";
|
||||
import * as Select from "$lib/components/ui/select/index.js";
|
||||
import * as DropdownMenu from "$lib/components/ui/dropdown-menu/index.js";
|
||||
import { Button } from "$lib/components/ui/button/index.js";
|
||||
|
||||
type ColumnKey = string;
|
||||
|
||||
interface ColumnDef {
|
||||
key: ColumnKey;
|
||||
label: string;
|
||||
defaultVisible: boolean;
|
||||
}
|
||||
import {
|
||||
Columns3,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ChevronsLeft,
|
||||
ChevronsRight,
|
||||
} from "@lucide/svelte";
|
||||
import HeaderLabel from "./activity-table/HeaderLabel.svelte";
|
||||
import ViewCaptureButton from "./activity-table/ViewCaptureButton.svelte";
|
||||
import MetaCell from "./activity-table/MetaCell.svelte";
|
||||
|
||||
interface Props {
|
||||
metrics: ActivityLogEntry[];
|
||||
@@ -41,150 +53,6 @@
|
||||
cardClass = "",
|
||||
}: Props = $props();
|
||||
|
||||
function buildColumns(withModel: boolean): ColumnDef[] {
|
||||
const cols: ColumnDef[] = [
|
||||
{ key: "id", label: "ID", defaultVisible: true },
|
||||
{ key: "time", label: "Time", defaultVisible: true },
|
||||
];
|
||||
if (withModel) cols.push({ key: "model", label: "Model", defaultVisible: true });
|
||||
cols.push(
|
||||
{ key: "req_path", label: "Path", defaultVisible: false },
|
||||
{ key: "resp_status_code", label: "Status", defaultVisible: true },
|
||||
{ key: "resp_content_type", label: "Content-Type", defaultVisible: false },
|
||||
{ key: "cached", label: "Cached", defaultVisible: true },
|
||||
{ key: "prompt", label: "Prompt", defaultVisible: true },
|
||||
{ key: "generated", label: "Generated", defaultVisible: true },
|
||||
{ key: "drafted", label: "Drafted", defaultVisible: false },
|
||||
{ key: "prompt_speed", label: "Prompt Speed", defaultVisible: true },
|
||||
{ key: "gen_speed", label: "Gen Speed", defaultVisible: true },
|
||||
{ key: "duration", label: "Duration", defaultVisible: true },
|
||||
{ key: "capture", label: "Capture", defaultVisible: true },
|
||||
{ key: "meta", label: "Meta", defaultVisible: false }
|
||||
);
|
||||
return cols;
|
||||
}
|
||||
|
||||
// svelte-ignore state_referenced_locally
|
||||
const columns: ColumnDef[] = buildColumns(showModelColumn);
|
||||
const defaultVisibleKeys = columns.filter((c) => c.defaultVisible).map((c) => c.key);
|
||||
|
||||
// svelte-ignore state_referenced_locally
|
||||
const visibleColumns = persistentStore<ColumnKey[]>(`${storagePrefix}-columns`, defaultVisibleKeys);
|
||||
// svelte-ignore state_referenced_locally
|
||||
const columnOrder = persistentStore<ColumnKey[]>(
|
||||
`${storagePrefix}-column-order`,
|
||||
columns.map((c) => c.key)
|
||||
);
|
||||
// svelte-ignore state_referenced_locally
|
||||
const pageSizeStore = persistentStore<number>(`${storagePrefix}-page-size`, 10);
|
||||
|
||||
let page = $state(0);
|
||||
let totalPages = $derived(Math.max(1, Math.ceil(metrics.length / $pageSizeStore)));
|
||||
let pageMetrics = $derived(metrics.slice(page * $pageSizeStore, (page + 1) * $pageSizeStore));
|
||||
let displayMetrics = $derived(showPagination ? pageMetrics : metrics);
|
||||
|
||||
// Reset page when data source or page size changes
|
||||
$effect(() => {
|
||||
metrics;
|
||||
$pageSizeStore;
|
||||
page = 0;
|
||||
});
|
||||
|
||||
let columnsMenuOpen = $state(false);
|
||||
let dropdownContainer: HTMLDivElement | null = $state(null);
|
||||
let dragKey: ColumnKey | null = $state(null);
|
||||
let dragOverKey: ColumnKey | null = $state(null);
|
||||
|
||||
onMount(() => {
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === "Escape" && columnsMenuOpen) columnsMenuOpen = false;
|
||||
}
|
||||
function handleClick(e: MouseEvent) {
|
||||
if (columnsMenuOpen && dropdownContainer && !dropdownContainer.contains(e.target as Node)) {
|
||||
columnsMenuOpen = false;
|
||||
}
|
||||
}
|
||||
document.addEventListener("keydown", handleKeydown);
|
||||
document.addEventListener("click", handleClick);
|
||||
return () => {
|
||||
document.removeEventListener("keydown", handleKeydown);
|
||||
document.removeEventListener("click", handleClick);
|
||||
};
|
||||
});
|
||||
|
||||
function toggleColumn(key: ColumnKey) {
|
||||
const current = $visibleColumns;
|
||||
if (current.includes(key)) {
|
||||
if (current.length > 1) visibleColumns.set(current.filter((k) => k !== key));
|
||||
} else {
|
||||
visibleColumns.set([...current, key]);
|
||||
}
|
||||
}
|
||||
|
||||
function isColumnVisible(key: ColumnKey): boolean {
|
||||
return $visibleColumns.includes(key);
|
||||
}
|
||||
|
||||
function handleDragStart(e: DragEvent, key: ColumnKey) {
|
||||
dragKey = key;
|
||||
e.dataTransfer?.setData("text/plain", key);
|
||||
if (e.dataTransfer) e.dataTransfer.effectAllowed = "move";
|
||||
}
|
||||
|
||||
function handleDragOver(e: DragEvent, key: ColumnKey) {
|
||||
e.preventDefault();
|
||||
if (e.dataTransfer) e.dataTransfer.dropEffect = "move";
|
||||
dragOverKey = key;
|
||||
}
|
||||
|
||||
function handleDrop(e: DragEvent, targetKey: ColumnKey) {
|
||||
e.preventDefault();
|
||||
if (!dragKey || dragKey === targetKey) return;
|
||||
const order = [...$columnOrder];
|
||||
const fromIndex = order.indexOf(dragKey);
|
||||
let toIndex = order.indexOf(targetKey);
|
||||
if (fromIndex === -1 || toIndex === -1) return;
|
||||
order.splice(fromIndex, 1);
|
||||
if (fromIndex < toIndex) toIndex -= 1;
|
||||
order.splice(toIndex, 0, dragKey);
|
||||
columnOrder.set(order);
|
||||
}
|
||||
|
||||
function handleDragEnd() {
|
||||
dragKey = null;
|
||||
dragOverKey = null;
|
||||
}
|
||||
|
||||
function orderByColumnOrder<T extends { key: ColumnKey }>(cols: T[]): T[] {
|
||||
return cols.slice().sort((a, b) => {
|
||||
const aIndex = $columnOrder.indexOf(a.key);
|
||||
const bIndex = $columnOrder.indexOf(b.key);
|
||||
if (aIndex === -1 && bIndex === -1) return 0;
|
||||
if (aIndex === -1) return 1;
|
||||
if (bIndex === -1) return -1;
|
||||
return aIndex - bIndex;
|
||||
});
|
||||
}
|
||||
|
||||
let orderedColumns = $derived(orderByColumnOrder(columns));
|
||||
|
||||
let activeVisibleColumns = $derived(
|
||||
orderByColumnOrder(columns.filter((c) => isColumnVisible(c.key))).map((c) => c.key)
|
||||
);
|
||||
|
||||
let columnLabelMap = $derived(Object.fromEntries(columns.map((c) => [c.key, c.label])));
|
||||
|
||||
$effect(() => {
|
||||
const staticKeys = new Set(columns.map((c) => c.key));
|
||||
const order = $columnOrder;
|
||||
const hasStale = order.some((k) => !staticKeys.has(k));
|
||||
const missing = columns.filter((c) => !order.includes(c.key)).map((c) => c.key);
|
||||
if (hasStale || missing.length > 0) {
|
||||
const cleaned = order.filter((k) => staticKeys.has(k));
|
||||
columnOrder.set([...cleaned, ...missing]);
|
||||
}
|
||||
});
|
||||
|
||||
function formatSpeed(speed: number): string {
|
||||
return speed < 0 ? "unknown" : speed.toFixed(2) + " t/s";
|
||||
}
|
||||
@@ -212,6 +80,80 @@
|
||||
: "-";
|
||||
}
|
||||
|
||||
interface ColMeta {
|
||||
id: string;
|
||||
label: string;
|
||||
defaultVisible: boolean;
|
||||
}
|
||||
|
||||
function buildColumnMeta(withModel: boolean): ColMeta[] {
|
||||
const cols: ColMeta[] = [
|
||||
{ id: "id", label: "ID", defaultVisible: true },
|
||||
{ id: "time", label: "Time", defaultVisible: true },
|
||||
];
|
||||
if (withModel) cols.push({ id: "model", label: "Model", defaultVisible: true });
|
||||
cols.push(
|
||||
{ id: "req_path", label: "Path", defaultVisible: false },
|
||||
{ id: "resp_status_code", label: "Status", defaultVisible: true },
|
||||
{ id: "resp_content_type", label: "Content-Type", defaultVisible: false },
|
||||
{ id: "cached", label: "Cached", defaultVisible: true },
|
||||
{ id: "prompt", label: "Prompt", defaultVisible: true },
|
||||
{ id: "generated", label: "Generated", defaultVisible: true },
|
||||
{ id: "drafted", label: "Drafted", defaultVisible: false },
|
||||
{ id: "prompt_speed", label: "Prompt Speed", defaultVisible: true },
|
||||
{ id: "gen_speed", label: "Gen Speed", defaultVisible: true },
|
||||
{ id: "duration", label: "Duration", defaultVisible: true },
|
||||
{ id: "capture", label: "Capture", defaultVisible: true },
|
||||
{ id: "meta", label: "Meta", defaultVisible: false }
|
||||
);
|
||||
return cols;
|
||||
}
|
||||
|
||||
let columnMeta = $derived(buildColumnMeta(showModelColumn));
|
||||
|
||||
let columnLabelMap = $derived(
|
||||
Object.fromEntries(columnMeta.map((c) => [c.id, c.label])) as Record<string, string>
|
||||
);
|
||||
|
||||
let defaultVisibility = $derived.by(() => {
|
||||
const v: VisibilityState = {};
|
||||
for (const c of columnMeta) v[c.id] = c.defaultVisible;
|
||||
return v;
|
||||
});
|
||||
|
||||
// svelte-ignore state_referenced_locally
|
||||
const storedVisibility = persistentStore<VisibilityState>(
|
||||
`${storagePrefix}-columns`,
|
||||
{}
|
||||
);
|
||||
|
||||
// svelte-ignore state_referenced_locally
|
||||
let columnVisibility = $state<VisibilityState>(
|
||||
Object.keys($storedVisibility).length > 0 ? $storedVisibility : defaultVisibility
|
||||
);
|
||||
|
||||
// svelte-ignore state_referenced_locally
|
||||
const storedPageSize = persistentStore<number>(`${storagePrefix}-page-size`, 10);
|
||||
|
||||
// svelte-ignore state_referenced_locally
|
||||
let pagination = $state<PaginationState>({
|
||||
pageIndex: 0,
|
||||
pageSize: showPagination ? $storedPageSize : 1,
|
||||
});
|
||||
|
||||
// When not paginating, show every row; reset page on data/pageSize change.
|
||||
$effect(() => {
|
||||
if (!showPagination) {
|
||||
pagination.pageSize = metrics.length || 1;
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
metrics;
|
||||
pagination.pageSize;
|
||||
pagination.pageIndex = 0;
|
||||
});
|
||||
|
||||
let selectedCapture = $state<ReqRespCapture | null>(null);
|
||||
let dialogOpen = $state(false);
|
||||
let loadingCaptureId = $state<number | null>(null);
|
||||
@@ -229,13 +171,136 @@
|
||||
selectedCapture = null;
|
||||
}
|
||||
|
||||
let thClass = $derived(compact ? "px-4 py-2 font-medium" : "px-6 py-3 font-medium");
|
||||
let tdClass = $derived(compact ? "px-4 py-2" : "px-6 py-4");
|
||||
let rowClass = $derived(
|
||||
compact
|
||||
? "hover:bg-muted/50 whitespace-nowrap border-b"
|
||||
: "hover:bg-muted/50 whitespace-nowrap border-b text-sm transition-colors"
|
||||
function buildColumns(withModel: boolean): ColumnDef<ActivityLogEntry>[] {
|
||||
const cols: ColumnDef<ActivityLogEntry>[] = [
|
||||
{
|
||||
id: "id",
|
||||
header: "ID",
|
||||
cell: ({ row }) => String(row.original.id + 1),
|
||||
},
|
||||
{
|
||||
id: "time",
|
||||
header: "Time",
|
||||
cell: ({ row }) => formatRelativeTime(row.original.timestamp),
|
||||
},
|
||||
];
|
||||
|
||||
if (withModel) {
|
||||
cols.push({
|
||||
id: "model",
|
||||
header: "Model",
|
||||
cell: ({ row }) => row.original.model ?? "-",
|
||||
});
|
||||
}
|
||||
|
||||
cols.push(
|
||||
{
|
||||
id: "req_path",
|
||||
header: "Path",
|
||||
cell: ({ row }) => row.original.req_path || "-",
|
||||
},
|
||||
{
|
||||
id: "resp_status_code",
|
||||
header: "Status",
|
||||
cell: ({ row }) => String(row.original.resp_status_code || "-"),
|
||||
},
|
||||
{
|
||||
id: "resp_content_type",
|
||||
header: "Content-Type",
|
||||
cell: ({ row }) => row.original.resp_content_type || "-",
|
||||
},
|
||||
{
|
||||
id: "cached",
|
||||
header: () => renderComponent(HeaderLabel, { label: "Cached", tooltip: "prompt tokens from cache" }),
|
||||
cell: ({ row }) =>
|
||||
row.original.tokens.cache_tokens > 0
|
||||
? row.original.tokens.cache_tokens.toLocaleString()
|
||||
: "-",
|
||||
},
|
||||
{
|
||||
id: "prompt",
|
||||
header: () => renderComponent(HeaderLabel, { label: "Prompt", tooltip: "new prompt tokens processed" }),
|
||||
cell: ({ row }) => row.original.tokens.input_tokens.toLocaleString(),
|
||||
},
|
||||
{
|
||||
id: "generated",
|
||||
header: "Generated",
|
||||
cell: ({ row }) => row.original.tokens.output_tokens.toLocaleString(),
|
||||
},
|
||||
{
|
||||
id: "drafted",
|
||||
header: () => renderComponent(HeaderLabel, { label: "Drafted", tooltip: "acceptance rate (accepted/drafted)" }),
|
||||
cell: ({ row }) =>
|
||||
formatDrafted(row.original.tokens.draft_tokens, row.original.tokens.draft_acc_tokens),
|
||||
},
|
||||
{
|
||||
id: "prompt_speed",
|
||||
header: "Prompt Speed",
|
||||
cell: ({ row }) => formatSpeed(row.original.tokens.prompt_per_second),
|
||||
},
|
||||
{
|
||||
id: "gen_speed",
|
||||
header: "Gen Speed",
|
||||
cell: ({ row }) => formatSpeed(row.original.tokens.tokens_per_second),
|
||||
},
|
||||
{
|
||||
id: "duration",
|
||||
header: "Duration",
|
||||
cell: ({ row }) => formatDuration(row.original.duration_ms),
|
||||
},
|
||||
{
|
||||
id: "capture",
|
||||
header: "Capture",
|
||||
cell: ({ row }) =>
|
||||
renderComponent(ViewCaptureButton, {
|
||||
hasCapture: row.original.has_capture,
|
||||
loading: loadingCaptureId === row.original.id,
|
||||
onclick: () => viewCapture(row.original.id),
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: "meta",
|
||||
header: "Meta",
|
||||
cell: ({ row }) =>
|
||||
renderComponent(MetaCell, { metadata: row.original.metadata }),
|
||||
}
|
||||
);
|
||||
return cols;
|
||||
}
|
||||
|
||||
let columns = $derived(buildColumns(showModelColumn));
|
||||
|
||||
const table = createSvelteTable({
|
||||
get data() {
|
||||
return metrics;
|
||||
},
|
||||
get columns() {
|
||||
return columns;
|
||||
},
|
||||
state: {
|
||||
get pagination() {
|
||||
return pagination;
|
||||
},
|
||||
get columnVisibility() {
|
||||
return columnVisibility;
|
||||
},
|
||||
},
|
||||
onPaginationChange: (updater) => {
|
||||
pagination =
|
||||
typeof updater === "function" ? updater(pagination) : updater;
|
||||
if (showPagination) storedPageSize.set(pagination.pageSize);
|
||||
},
|
||||
onColumnVisibilityChange: (updater) => {
|
||||
columnVisibility =
|
||||
typeof updater === "function" ? updater(columnVisibility) : updater;
|
||||
storedVisibility.set(columnVisibility);
|
||||
},
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
});
|
||||
|
||||
let thClass = $derived(compact ? "px-4 py-2 h-9" : "px-6 py-3 h-12");
|
||||
let tdClass = $derived(compact ? "px-4 py-2" : "px-6 py-4");
|
||||
</script>
|
||||
|
||||
<Card.Root class="shrink-0 gap-0 overflow-hidden py-0 {cardClass}">
|
||||
@@ -250,13 +315,15 @@
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
{#if showPagination}
|
||||
<span class="text-muted-foreground text-xs">Per page</span>
|
||||
<span class="text-muted-foreground text-xs">Rows</span>
|
||||
<Select.Root
|
||||
type="single"
|
||||
value={String($pageSizeStore)}
|
||||
onValueChange={(v) => pageSizeStore.set(Number(v))}
|
||||
value={String(pagination.pageSize)}
|
||||
onValueChange={(v) => table.setPageSize(Number(v))}
|
||||
>
|
||||
<Select.Trigger class="h-7 w-16 text-xs">{$pageSizeStore}</Select.Trigger>
|
||||
<Select.Trigger size="sm" class="h-7 w-[4.5rem] text-xs">
|
||||
{pagination.pageSize}
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
{#each [5, 10, 25, 50] as size (size)}
|
||||
<Select.Item value={String(size)}>{size}</Select.Item>
|
||||
@@ -264,193 +331,106 @@
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
{/if}
|
||||
<div bind:this={dropdownContainer}>
|
||||
<div class="relative">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onclick={() => (columnsMenuOpen = !columnsMenuOpen)}
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger
|
||||
class="hover:bg-muted inline-flex size-7 items-center justify-center rounded-[min(var(--radius-md),12px)]"
|
||||
title="Select columns"
|
||||
>
|
||||
<Columns3 />
|
||||
</Button>
|
||||
{#if columnsMenuOpen}
|
||||
<div
|
||||
class="bg-popover text-popover-foreground absolute right-0 top-full z-20 mt-1 min-w-[16rem] rounded-md border py-1 shadow-md"
|
||||
role="list"
|
||||
>
|
||||
<div
|
||||
class="text-muted-foreground border-b px-3 py-2 text-xs font-medium uppercase tracking-wider"
|
||||
role="presentation"
|
||||
>
|
||||
<Columns3 class="size-4" />
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content align="end" class="min-w-[16rem] p-0">
|
||||
<DropdownMenu.Label class="text-muted-foreground border-b px-3 py-2 text-xs font-medium uppercase tracking-wider">
|
||||
Columns
|
||||
</div>
|
||||
{#each orderedColumns as col (col.key)}
|
||||
{@const key = col.key}
|
||||
<div
|
||||
class="hover:bg-accent flex items-center gap-2 px-3 py-1.5 text-sm transition-colors {dragOverKey ===
|
||||
key && dragKey !== key
|
||||
? 'bg-primary/10 ring-primary/40 ring-1'
|
||||
: ''} {dragKey === key ? 'opacity-40' : ''}"
|
||||
role="listitem"
|
||||
ondragover={(e) => handleDragOver(e, key)}
|
||||
ondrop={(e) => handleDrop(e, key)}
|
||||
</DropdownMenu.Label>
|
||||
{#each table.getAllColumns() as column (column.id)}
|
||||
{#if column.getCanHide()}
|
||||
<DropdownMenu.CheckboxItem
|
||||
checked={column.getIsVisible()}
|
||||
onCheckedChange={(v) => column.toggleVisibility(!!v)}
|
||||
>
|
||||
<span
|
||||
class="text-muted-foreground flex cursor-grab select-none"
|
||||
draggable={true}
|
||||
role="button"
|
||||
tabindex="-1"
|
||||
aria-label="Drag to reorder {col.label}"
|
||||
ondragstart={(e) => handleDragStart(e, key)}
|
||||
ondragend={handleDragEnd}
|
||||
>
|
||||
<GripVertical class="size-4" />
|
||||
</span>
|
||||
<label class="flex flex-1 cursor-pointer items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isColumnVisible(key)}
|
||||
onchange={() => toggleColumn(key)}
|
||||
class="accent-primary rounded-none"
|
||||
/>
|
||||
{col.label}
|
||||
</label>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{columnLabelMap[column.id] ?? column.id}
|
||||
</DropdownMenu.CheckboxItem>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</div>
|
||||
</Card.Header>
|
||||
<Card.Content class="overflow-x-auto p-0">
|
||||
<table class="min-w-full text-sm">
|
||||
<thead class="text-muted-foreground border-b text-left text-xs uppercase tracking-wider">
|
||||
<tr>
|
||||
{#each activeVisibleColumns as key (key)}
|
||||
<th class={thClass}>
|
||||
{#if key === "cached"}
|
||||
Cached <Tooltip content="prompt tokens from cache" />
|
||||
{:else if key === "prompt"}
|
||||
Prompt <Tooltip content="new prompt tokens processed" />
|
||||
{:else if key === "drafted"}
|
||||
Drafted <Tooltip content="acceptance rate (accepted/drafted)" />
|
||||
{:else}
|
||||
{columnLabelMap[key] ?? key}
|
||||
<Table.Root class="min-w-full">
|
||||
<Table.Header>
|
||||
{#each table.getHeaderGroups() as headerGroup (headerGroup.id)}
|
||||
<Table.Row>
|
||||
{#each headerGroup.headers as header (header.id)}
|
||||
<Table.Head class={thClass} colspan={header.colSpan}>
|
||||
{#if !header.isPlaceholder}
|
||||
<FlexRender content={header.column.columnDef.header} context={header.getContext()} />
|
||||
{/if}
|
||||
</th>
|
||||
</Table.Head>
|
||||
{/each}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#if displayMetrics.length === 0}
|
||||
<tr>
|
||||
<td colspan={activeVisibleColumns.length} class="text-muted-foreground px-4 py-6 text-center text-sm">
|
||||
</Table.Row>
|
||||
{/each}
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#each table.getRowModel().rows as row (row.id)}
|
||||
<Table.Row>
|
||||
{#each row.getVisibleCells() as cell (cell.id)}
|
||||
<Table.Cell class={tdClass}>
|
||||
<FlexRender content={cell.column.columnDef.cell} context={cell.getContext()} />
|
||||
</Table.Cell>
|
||||
{/each}
|
||||
</Table.Row>
|
||||
{:else}
|
||||
<Table.Row>
|
||||
<Table.Cell colspan={columns.length} class="text-muted-foreground py-6 text-center text-sm">
|
||||
{emptyMessage}
|
||||
</td>
|
||||
</tr>
|
||||
{:else}
|
||||
{#each displayMetrics as metric (metric.id)}
|
||||
<tr class={rowClass}>
|
||||
{#each activeVisibleColumns as key (key)}
|
||||
<td class={tdClass}>
|
||||
{#if key === "id"}
|
||||
{metric.id + 1}
|
||||
{:else if key === "time"}
|
||||
{formatRelativeTime(metric.timestamp)}
|
||||
{:else if key === "model"}
|
||||
{metric.model}
|
||||
{:else if key === "req_path"}
|
||||
{metric.req_path || "-"}
|
||||
{:else if key === "resp_status_code"}
|
||||
{#if metric.error_msg}
|
||||
<span class="text-destructive cursor-help" title={metric.error_msg}>
|
||||
{metric.resp_status_code || "-"}
|
||||
</span>
|
||||
{:else}
|
||||
{metric.resp_status_code || "-"}
|
||||
{/if}
|
||||
{:else if key === "resp_content_type"}
|
||||
{metric.resp_content_type || "-"}
|
||||
{:else if key === "cached"}
|
||||
{metric.tokens.cache_tokens > 0 ? metric.tokens.cache_tokens.toLocaleString() : "-"}
|
||||
{:else if key === "prompt"}
|
||||
{metric.tokens.input_tokens.toLocaleString()}
|
||||
{:else if key === "generated"}
|
||||
{metric.tokens.output_tokens.toLocaleString()}
|
||||
{:else if key === "drafted"}
|
||||
{formatDrafted(metric.tokens.draft_tokens, metric.tokens.draft_acc_tokens)}
|
||||
{:else if key === "prompt_speed"}
|
||||
{formatSpeed(metric.tokens.prompt_per_second)}
|
||||
{:else if key === "gen_speed"}
|
||||
{formatSpeed(metric.tokens.tokens_per_second)}
|
||||
{:else if key === "duration"}
|
||||
{formatDuration(metric.duration_ms)}
|
||||
{:else if key === "capture"}
|
||||
{#if metric.has_capture}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
onclick={() => viewCapture(metric.id)}
|
||||
disabled={loadingCaptureId === metric.id}
|
||||
>
|
||||
{loadingCaptureId === metric.id ? "..." : "View"}
|
||||
</Button>
|
||||
{:else}
|
||||
<span class="text-muted-foreground">-</span>
|
||||
{/if}
|
||||
{:else if key === "meta"}
|
||||
{#if Object.keys(metric.metadata || {}).length > 0}
|
||||
<MetadataTooltip metadata={metric.metadata}>
|
||||
<span class="text-muted-foreground hover:text-foreground cursor-help">...</span>
|
||||
</MetadataTooltip>
|
||||
{:else}
|
||||
<span class="text-muted-foreground">-</span>
|
||||
{/if}
|
||||
{:else}
|
||||
-
|
||||
{/if}
|
||||
</td>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{/each}
|
||||
</tr>
|
||||
{/each}
|
||||
{/if}
|
||||
</tbody>
|
||||
</table>
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
|
||||
{#if showPagination && metrics.length > 0}
|
||||
<div class="flex items-center justify-between gap-2 border-t px-4 py-2 text-sm">
|
||||
<span class="text-muted-foreground text-xs">
|
||||
Page {page + 1} of {totalPages} · {metrics.length} total
|
||||
Page {pagination.pageIndex + 1} of {table.getPageCount()} · {metrics.length} total
|
||||
</span>
|
||||
<div class="flex gap-1">
|
||||
<Button variant="outline" size="sm" onclick={() => (page = 0)} disabled={page === 0}>
|
||||
First
|
||||
<div class="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onclick={() => table.setPageIndex(0)}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
title="First page"
|
||||
>
|
||||
<ChevronsLeft />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={() => (page = Math.max(0, page - 1))}
|
||||
disabled={page === 0}
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onclick={() => table.previousPage()}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
title="Previous page"
|
||||
>
|
||||
Prev
|
||||
<ChevronLeft />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={() => (page = Math.min(totalPages - 1, page + 1))}
|
||||
disabled={page >= totalPages - 1}
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onclick={() => table.nextPage()}
|
||||
disabled={!table.getCanNextPage()}
|
||||
title="Next page"
|
||||
>
|
||||
Next
|
||||
<ChevronRight />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={() => (page = totalPages - 1)}
|
||||
disabled={page >= totalPages - 1}
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onclick={() => table.setPageIndex(table.getPageCount() - 1)}
|
||||
disabled={!table.getCanNextPage()}
|
||||
title="Last page"
|
||||
>
|
||||
Last
|
||||
<ChevronsRight />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
<script lang="ts">
|
||||
import * as Tooltip from "$lib/components/ui/tooltip/index.js";
|
||||
|
||||
interface Props {
|
||||
label: string;
|
||||
tooltip?: string;
|
||||
}
|
||||
|
||||
let { label, tooltip }: Props = $props();
|
||||
</script>
|
||||
|
||||
{label}{#if tooltip}
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger class="cursor-help align-middle normal-case">ⓘ</Tooltip.Trigger>
|
||||
<Tooltip.Content>{tooltip}</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
{/if}
|
||||
@@ -0,0 +1,33 @@
|
||||
<script lang="ts">
|
||||
import * as Tooltip from "$lib/components/ui/tooltip/index.js";
|
||||
|
||||
interface Props {
|
||||
metadata: Record<string, string> | undefined;
|
||||
}
|
||||
|
||||
let { metadata }: Props = $props();
|
||||
|
||||
let entries = $derived(Object.entries(metadata || {}));
|
||||
</script>
|
||||
|
||||
{#if entries.length > 0}
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger>
|
||||
<span class="text-muted-foreground hover:text-foreground cursor-help">...</span>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content class="min-w-[12rem] max-w-[24rem] normal-case">
|
||||
<table class="w-full text-left">
|
||||
<tbody>
|
||||
{#each entries as [key, value]}
|
||||
<tr class="border-b border-white/10 last:border-0">
|
||||
<td class="py-1 pr-3 font-medium whitespace-nowrap text-primary">{key}</td>
|
||||
<td class="py-1 break-all">{value}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
{:else}
|
||||
<span class="text-muted-foreground">-</span>
|
||||
{/if}
|
||||
@@ -0,0 +1,19 @@
|
||||
<script lang="ts">
|
||||
import { Button } from "$lib/components/ui/button/index.js";
|
||||
|
||||
interface Props {
|
||||
hasCapture: boolean;
|
||||
loading: boolean;
|
||||
onclick: () => void;
|
||||
}
|
||||
|
||||
let { hasCapture, loading, onclick }: Props = $props();
|
||||
</script>
|
||||
|
||||
{#if hasCapture}
|
||||
<Button variant="outline" size="xs" {onclick} disabled={loading}>
|
||||
{loading ? "..." : "View"}
|
||||
</Button>
|
||||
{:else}
|
||||
<span class="text-muted-foreground">-</span>
|
||||
{/if}
|
||||
@@ -0,0 +1,142 @@
|
||||
import {
|
||||
type RowData,
|
||||
type TableOptions,
|
||||
type TableOptionsResolved,
|
||||
type TableState,
|
||||
type Updater,
|
||||
createTable,
|
||||
} from "@tanstack/table-core";
|
||||
|
||||
/**
|
||||
* Creates a reactive TanStack table object for Svelte.
|
||||
* @param options Table options to create the table with.
|
||||
* @returns A reactive table object.
|
||||
* @example
|
||||
* ```svelte
|
||||
* <script>
|
||||
* const table = createSvelteTable({ ... })
|
||||
* </script>
|
||||
*
|
||||
* <table>
|
||||
* <thead>
|
||||
* {#each table.getHeaderGroups() as headerGroup}
|
||||
* <tr>
|
||||
* {#each headerGroup.headers as header}
|
||||
* <th colspan={header.colSpan}>
|
||||
* <FlexRender content={header.column.columnDef.header} context={header.getContext()} />
|
||||
* </th>
|
||||
* {/each}
|
||||
* </tr>
|
||||
* {/each}
|
||||
* </thead>
|
||||
* <!-- ... -->
|
||||
* </table>
|
||||
* ```
|
||||
*/
|
||||
export function createSvelteTable<TData extends RowData>(options: TableOptions<TData>) {
|
||||
const resolvedOptions: TableOptionsResolved<TData> = mergeObjects(
|
||||
{
|
||||
state: {},
|
||||
onStateChange() {},
|
||||
renderFallbackValue: null,
|
||||
mergeOptions: (
|
||||
defaultOptions: TableOptions<TData>,
|
||||
options: Partial<TableOptions<TData>>
|
||||
) => {
|
||||
return mergeObjects(defaultOptions, options);
|
||||
},
|
||||
},
|
||||
options
|
||||
);
|
||||
|
||||
const table = createTable(resolvedOptions);
|
||||
let state = $state<TableState>(table.initialState);
|
||||
|
||||
function updateOptions() {
|
||||
table.setOptions(() => {
|
||||
return mergeObjects(resolvedOptions, options, {
|
||||
state: mergeObjects(state, options.state || {}),
|
||||
|
||||
onStateChange: (updater: Updater<TableState>) => {
|
||||
if (updater instanceof Function) state = updater(state);
|
||||
else state = mergeObjects(state, updater);
|
||||
|
||||
options.onStateChange?.(updater);
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
updateOptions();
|
||||
|
||||
$effect.pre(() => {
|
||||
updateOptions();
|
||||
});
|
||||
|
||||
return table;
|
||||
}
|
||||
|
||||
type MaybeThunk<T extends object> = T | (() => T | null | undefined);
|
||||
type Intersection<T extends readonly unknown[]> = (T extends [infer H, ...infer R]
|
||||
? H & Intersection<R>
|
||||
: unknown) & {};
|
||||
|
||||
/**
|
||||
* Lazily merges several objects (or thunks) while preserving
|
||||
* getter semantics from every source.
|
||||
*
|
||||
* Proxy-based to avoid known WebKit recursion issue.
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export function mergeObjects<Sources extends readonly MaybeThunk<any>[]>(
|
||||
...sources: Sources
|
||||
): Intersection<{ [K in keyof Sources]: Sources[K] }> {
|
||||
const resolve = <T extends object>(src: MaybeThunk<T>): T | undefined =>
|
||||
typeof src === "function" ? (src() ?? undefined) : src;
|
||||
|
||||
const findSourceWithKey = (key: PropertyKey) => {
|
||||
for (let i = sources.length - 1; i >= 0; i--) {
|
||||
const obj = resolve(sources[i]);
|
||||
if (obj && key in obj) return obj;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
return new Proxy(Object.create(null), {
|
||||
get(_, key) {
|
||||
const src = findSourceWithKey(key);
|
||||
|
||||
return src?.[key as never];
|
||||
},
|
||||
|
||||
has(_, key) {
|
||||
return !!findSourceWithKey(key);
|
||||
},
|
||||
|
||||
ownKeys(): (string | symbol)[] {
|
||||
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
||||
const all = new Set<string | symbol>();
|
||||
for (const s of sources) {
|
||||
const obj = resolve(s);
|
||||
if (obj) {
|
||||
for (const k of Reflect.ownKeys(obj) as (string | symbol)[]) {
|
||||
all.add(k);
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...all];
|
||||
},
|
||||
|
||||
getOwnPropertyDescriptor(_, key) {
|
||||
const src = findSourceWithKey(key);
|
||||
if (!src) return undefined;
|
||||
return {
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
value: (src as any)[key],
|
||||
writable: true,
|
||||
};
|
||||
},
|
||||
}) as Intersection<{ [K in keyof Sources]: Sources[K] }>;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<script
|
||||
lang="ts"
|
||||
generics="TData, TValue, TContext extends HeaderContext<TData, TValue> | CellContext<TData, TValue>"
|
||||
>
|
||||
import type { CellContext, ColumnDefTemplate, HeaderContext } from "@tanstack/table-core";
|
||||
import { RenderComponentConfig, RenderSnippetConfig } from "./render-helpers.js";
|
||||
import type { Attachment } from "svelte/attachments";
|
||||
type Props = {
|
||||
/** The cell or header field of the current cell's column definition. */
|
||||
content?: TContext extends HeaderContext<TData, TValue>
|
||||
? ColumnDefTemplate<HeaderContext<TData, TValue>>
|
||||
: TContext extends CellContext<TData, TValue>
|
||||
? ColumnDefTemplate<CellContext<TData, TValue>>
|
||||
: never;
|
||||
/** The result of the `getContext()` function of the header or cell */
|
||||
context: TContext;
|
||||
|
||||
/** Used to pass attachments that can't be gotten through context */
|
||||
attach?: Attachment;
|
||||
};
|
||||
|
||||
let { content, context, attach }: Props = $props();
|
||||
</script>
|
||||
|
||||
{#if typeof content === "string"}
|
||||
{content}
|
||||
{:else if content instanceof Function}
|
||||
<!-- It's unlikely that a CellContext will be passed to a Header -->
|
||||
<!-- eslint-disable-next-line @typescript-eslint/no-explicit-any -->
|
||||
{@const result = content(context as any)}
|
||||
{#if result instanceof RenderComponentConfig}
|
||||
{@const { component: Component, props } = result}
|
||||
<Component {...props} {attach} />
|
||||
{:else if result instanceof RenderSnippetConfig}
|
||||
{@const { snippet, params } = result}
|
||||
{@render snippet({ ...params, attach })}
|
||||
{:else}
|
||||
{result}
|
||||
{/if}
|
||||
{/if}
|
||||
@@ -0,0 +1,3 @@
|
||||
export { default as FlexRender } from "./flex-render.svelte";
|
||||
export { renderComponent, renderSnippet } from "./render-helpers.js";
|
||||
export { createSvelteTable } from "./data-table.svelte.js";
|
||||
@@ -0,0 +1,111 @@
|
||||
import type { Component, ComponentProps, Snippet } from "svelte";
|
||||
|
||||
/**
|
||||
* A helper class to make it easy to identify Svelte components in
|
||||
* `columnDef.cell` and `columnDef.header` properties.
|
||||
*
|
||||
* > NOTE: This class should only be used internally by the adapter. If you're
|
||||
* reading this and you don't know what this is for, you probably don't need it.
|
||||
*
|
||||
* @example
|
||||
* ```svelte
|
||||
* {@const result = content(context as any)}
|
||||
* {#if result instanceof RenderComponentConfig}
|
||||
* {@const { component: Component, props } = result}
|
||||
* <Component {...props} />
|
||||
* {/if}
|
||||
* ```
|
||||
*/
|
||||
export class RenderComponentConfig<TComponent extends Component> {
|
||||
component: TComponent;
|
||||
props: ComponentProps<TComponent> | Record<string, never>;
|
||||
constructor(
|
||||
component: TComponent,
|
||||
props: ComponentProps<TComponent> | Record<string, never> = {}
|
||||
) {
|
||||
this.component = component;
|
||||
this.props = props;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A helper class to make it easy to identify Svelte Snippets in `columnDef.cell` and `columnDef.header` properties.
|
||||
*
|
||||
* > NOTE: This class should only be used internally by the adapter. If you're
|
||||
* reading this and you don't know what this is for, you probably don't need it.
|
||||
*
|
||||
* @example
|
||||
* ```svelte
|
||||
* {@const result = content(context as any)}
|
||||
* {#if result instanceof RenderSnippetConfig}
|
||||
* {@const { snippet, params } = result}
|
||||
* {@render snippet(params)}
|
||||
* {/if}
|
||||
* ```
|
||||
*/
|
||||
export class RenderSnippetConfig<TProps> {
|
||||
snippet: Snippet<[TProps]>;
|
||||
params: TProps;
|
||||
constructor(snippet: Snippet<[TProps]>, params: TProps) {
|
||||
this.snippet = snippet;
|
||||
this.params = params;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A helper function to help create cells from Svelte components through ColumnDef's `cell` and `header` properties.
|
||||
*
|
||||
* This is only to be used with Svelte Components - use `renderSnippet` for Svelte Snippets.
|
||||
*
|
||||
* @param component A Svelte component
|
||||
* @param props The props to pass to `component`
|
||||
* @returns A `RenderComponentConfig` object that helps svelte-table know how to render the header/cell component.
|
||||
* @example
|
||||
* ```ts
|
||||
* // +page.svelte
|
||||
* const defaultColumns = [
|
||||
* columnHelper.accessor('name', {
|
||||
* header: header => renderComponent(SortHeader, { label: 'Name', header }),
|
||||
* }),
|
||||
* columnHelper.accessor('state', {
|
||||
* header: header => renderComponent(SortHeader, { label: 'State', header }),
|
||||
* }),
|
||||
* ]
|
||||
* ```
|
||||
* @see {@link https://tanstack.com/table/latest/docs/guide/column-defs}
|
||||
*/
|
||||
export function renderComponent<
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
T extends Component<any>,
|
||||
Props extends ComponentProps<T>,
|
||||
>(component: T, props: Props = {} as Props) {
|
||||
return new RenderComponentConfig(component, props);
|
||||
}
|
||||
|
||||
/**
|
||||
* A helper function to help create cells from Svelte Snippets through ColumnDef's `cell` and `header` properties.
|
||||
*
|
||||
* The snippet must only take one parameter.
|
||||
*
|
||||
* This is only to be used with Snippets - use `renderComponent` for Svelte Components.
|
||||
*
|
||||
* @param snippet
|
||||
* @param params
|
||||
* @returns - A `RenderSnippetConfig` object that helps svelte-table know how to render the header/cell snippet.
|
||||
* @example
|
||||
* ```ts
|
||||
* // +page.svelte
|
||||
* const defaultColumns = [
|
||||
* columnHelper.accessor('name', {
|
||||
* cell: cell => renderSnippet(nameSnippet, { name: cell.row.name }),
|
||||
* }),
|
||||
* columnHelper.accessor('state', {
|
||||
* cell: cell => renderSnippet(stateSnippet, { state: cell.row.state }),
|
||||
* }),
|
||||
* ]
|
||||
* ```
|
||||
* @see {@link https://tanstack.com/table/latest/docs/guide/column-defs}
|
||||
*/
|
||||
export function renderSnippet<TProps>(snippet: Snippet<[TProps]>, params: TProps = {} as TProps) {
|
||||
return new RenderSnippetConfig(snippet, params);
|
||||
}
|
||||
Reference in New Issue
Block a user