Data Table
Table + TanStack Table v9 — filter, faceted filters, sortable columns, column visibility, row selection, row actions and pagination.
| Status | |||||||
|---|---|---|---|---|---|---|---|
MAG4 Magnificent Four | @dewi | live | $49,182 | 412 | +0.53% | ||
PREIPO Pre-IPO Basket | @noah | live | $31,770 | 265 | +4.10% | ||
AIDX AI Infra | @sarah | paused | $18,904 | 140 | −1.20% | ||
CHIPS Semis Core | @emily | live | $12,330 | 98 | +0.21% | ||
ENRGY Energy Transition | @lucas | paused | $8,412 | 61 | −0.44% |
| Status | |||||||
|---|---|---|---|---|---|---|---|
MAG4 Magnificent Four | @dewi | live | $49,182 | 412 | +0.53% | ||
PREIPO Pre-IPO Basket | @noah | live | $31,770 | 265 | +4.10% | ||
AIDX AI Infra | @sarah | paused | $18,904 | 140 | −1.20% | ||
CHIPS Semis Core | @emily | live | $12,330 | 98 | +0.21% | ||
ENRGY Energy Transition | @lucas | paused | $8,412 | 61 | −0.44% |
| Status | |||||||
|---|---|---|---|---|---|---|---|
MAG4 Magnificent Four | @dewi | live | $49,182 | 412 | +0.53% | ||
PREIPO Pre-IPO Basket | @noah | live | $31,770 | 265 | +4.10% | ||
AIDX AI Infra | @sarah | paused | $18,904 | 140 | −1.20% | ||
CHIPS Semis Core | @emily | live | $12,330 | 98 | +0.21% | ||
ENRGY Energy Transition | @lucas | paused | $8,412 | 61 | −0.44% |
import { DataTable } from "@/components/data-table/data-table";import { DataTableColumnHeader } from "@/components/data-table/data-table-column-header";import type { features } from "@/components/data-table/data-table-features";import { DataTableRowActions } from "@/components/data-table/data-table-row-actions";import { Badge } from "@/components/ui/badge";import { Checkbox } from "@/components/ui/checkbox";import { DropdownMenuItem, DropdownMenuSeparator,} from "@/components/ui/dropdown-menu";import { createColumnHelper } from "@tanstack/react-table";
type Index = { symbol: string; name: string; creator: string; status: "live" | "paused"; aum: number; holders: number; day: number;};
const data: Index[] = [ { symbol: "MAG4", name: "Magnificent Four", creator: "@dewi", status: "live", aum: 49182, holders: 412, day: 0.53, }, { symbol: "PREIPO", name: "Pre-IPO Basket", creator: "@noah", status: "live", aum: 31770, holders: 265, day: 4.1, }, { symbol: "AIDX", name: "AI Infra", creator: "@sarah", status: "paused", aum: 18904, holders: 140, day: -1.2, }, { symbol: "CHIPS", name: "Semis Core", creator: "@emily", status: "live", aum: 12330, holders: 98, day: 0.21, }, { symbol: "ENRGY", name: "Energy Transition", creator: "@lucas", status: "paused", aum: 8412, holders: 61, day: -0.44, }, { symbol: "BANKS", name: "Global Banks", creator: "@dewi", status: "live", aum: 7120, holders: 55, day: 0.12, }, { symbol: "BIO", name: "Biotech Leaders", creator: "@noah", status: "live", aum: 6410, holders: 47, day: -0.9, },];
const col = createColumnHelper<typeof features, Index>();
const columns = col.columns([ col.display({ id: "select", header: ({ table }) => ( <Checkbox checked={table.getIsAllPageRowsSelected()} indeterminate={ table.getIsSomePageRowsSelected() && !table.getIsAllPageRowsSelected() } onCheckedChange={(v) => table.toggleAllPageRowsSelected(!!v)} aria-label="Select all" /> ), cell: ({ row }) => ( <Checkbox checked={row.getIsSelected()} onCheckedChange={(v) => row.toggleSelected(!!v)} aria-label="Select row" /> ), enableSorting: false, enableHiding: false, }), col.accessor("symbol", { id: "index", header: ({ column }) => ( <DataTableColumnHeader column={column} title="Index" /> ), cell: ({ row }) => ( <div> <div className="font-semibold">{row.original.symbol}</div> <div className="text-[11.5px] text-muted-foreground"> {row.original.name} </div> </div> ), }), col.accessor("creator", { filterFn: "arrHas", header: ({ column }) => ( <DataTableColumnHeader column={column} title="Creator" /> ), cell: ({ getValue }) => ( <span className="text-muted-foreground">{getValue()}</span> ), }), col.accessor("status", { header: "Status", filterFn: "arrHas", cell: ({ getValue }) => <span className="capitalize">{getValue()}</span>, }), col.accessor("aum", { meta: { label: "AUM" }, header: ({ column }) => ( <DataTableColumnHeader column={column} title="AUM" numeric /> ), cell: ({ getValue }) => ( <div className="text-right font-mono tabular-nums"> ${getValue().toLocaleString("en-US")} </div> ), }), col.accessor("holders", { header: ({ column }) => ( <DataTableColumnHeader column={column} title="Holders" numeric /> ), cell: ({ getValue }) => ( <div className="text-right font-mono tabular-nums">{getValue()}</div> ), }), col.accessor("day", { id: "24h", header: ({ column }) => ( <DataTableColumnHeader column={column} title="24h" numeric /> ), cell: ({ getValue }) => { const v = getValue(); return ( <div className="text-right"> <Badge variant={v >= 0 ? "success" : "destructive"} className="font-mono" > {v >= 0 ? "+" : "−"} {Math.abs(v).toFixed(2)}% </Badge> </div> ); }, }), col.display({ id: "actions", enableHiding: false, cell: ({ row }) => ( <div className="text-right"> <DataTableRowActions> <DropdownMenuItem onClick={() => navigator.clipboard.writeText(row.original.symbol)} > Copy symbol </DropdownMenuItem> <DropdownMenuSeparator /> <DropdownMenuItem>View index</DropdownMenuItem> </DataTableRowActions> </div> ), }),]);
export default function Demo() { return ( <DataTable columns={columns} data={data} filterColumn="index" filterPlaceholder="Filter indexes…" facetedFilters={[ { column: "status", title: "Status" }, { column: "creator", title: "Creator" }, ]} pageSize={5} /> );}<script setup lang="ts">import { createColumnHelper } from "@tanstack/vue-table";import { h } from "vue";import { Badge } from "@/components/ui/badge";import { Checkbox } from "@/components/ui/checkbox";import { DataTable, DataTableColumnHeader, DataTableRowActions, type features,} from "@/components/ui/data-table";import { DropdownMenuItem, DropdownMenuSeparator } from "@/components/ui/dropdown-menu";
type Index = { symbol: string; name: string; creator: string; status: "live" | "paused"; aum: number; holders: number; day: number;};
const data: Index[] = [ { symbol: "MAG4", name: "Magnificent Four", creator: "@dewi", status: "live", aum: 49182, holders: 412, day: 0.53 }, { symbol: "PREIPO", name: "Pre-IPO Basket", creator: "@noah", status: "live", aum: 31770, holders: 265, day: 4.1 }, { symbol: "AIDX", name: "AI Infra", creator: "@sarah", status: "paused", aum: 18904, holders: 140, day: -1.2 }, { symbol: "CHIPS", name: "Semis Core", creator: "@emily", status: "live", aum: 12330, holders: 98, day: 0.21 }, { symbol: "ENRGY", name: "Energy Transition", creator: "@lucas", status: "paused", aum: 8412, holders: 61, day: -0.44 }, { symbol: "BANKS", name: "Global Banks", creator: "@dewi", status: "live", aum: 7120, holders: 55, day: 0.12 }, { symbol: "BIO", name: "Biotech Leaders", creator: "@noah", status: "live", aum: 6410, holders: 47, day: -0.9 },];
const col = createColumnHelper<typeof features, Index>();
const columns = col.columns([ col.display({ id: "select", header: ({ table }) => h(Checkbox, { modelValue: table.getIsAllPageRowsSelected() ? true : table.getIsSomePageRowsSelected() ? "indeterminate" : false, "onUpdate:modelValue": (v: boolean | "indeterminate") => table.toggleAllPageRowsSelected(!!v), ariaLabel: "Select all", }), cell: ({ row }) => h(Checkbox, { modelValue: row.getIsSelected(), "onUpdate:modelValue": (v: boolean | "indeterminate") => row.toggleSelected(!!v), ariaLabel: "Select row", }), enableSorting: false, enableHiding: false, }), col.accessor("symbol", { id: "index", header: ({ column }) => h(DataTableColumnHeader, { column, title: "Index" }), cell: ({ row }) => h("div", [ h("div", { class: "font-semibold" }, row.original.symbol), h("div", { class: "text-[11.5px] text-muted-foreground" }, row.original.name), ]), }), col.accessor("creator", { filterFn: "arrHas", header: ({ column }) => h(DataTableColumnHeader, { column, title: "Creator" }), cell: ({ getValue }) => h("span", { class: "text-muted-foreground" }, getValue()), }), col.accessor("status", { header: "Status", filterFn: "arrHas", cell: ({ getValue }) => h("span", { class: "capitalize" }, getValue()), }), col.accessor("aum", { meta: { label: "AUM" }, header: ({ column }) => h(DataTableColumnHeader, { column, title: "AUM", numeric: true }), cell: ({ getValue }) => h("div", { class: "text-right font-mono tabular-nums" }, `$${getValue().toLocaleString("en-US")}`), }), col.accessor("holders", { header: ({ column }) => h(DataTableColumnHeader, { column, title: "Holders", numeric: true }), cell: ({ getValue }) => h("div", { class: "text-right font-mono tabular-nums" }, getValue()), }), col.accessor("day", { id: "24h", header: ({ column }) => h(DataTableColumnHeader, { column, title: "24h", numeric: true }), cell: ({ getValue }) => { const v = getValue(); return h("div", { class: "text-right" }, [ h( Badge, { variant: v >= 0 ? "success" : "destructive", class: "font-mono" }, () => `${v >= 0 ? "+" : "−"}${Math.abs(v).toFixed(2)}%`, ), ]); }, }), col.display({ id: "actions", enableHiding: false, cell: ({ row }) => h("div", { class: "text-right" }, [ h(DataTableRowActions, null, { default: () => [ h( DropdownMenuItem, { onClick: () => navigator.clipboard.writeText(row.original.symbol) }, () => "Copy symbol", ), h(DropdownMenuSeparator), h(DropdownMenuItem, null, () => "View index"), ], }), ]), }),]);</script>
<template> <DataTable :columns="columns" :data="data" filter-column="index" filter-placeholder="Filter indexes…" :faceted-filters="[ { column: 'status', title: 'Status' }, { column: 'creator', title: 'Creator' }, ]" :page-size="5" /></template><script lang="ts"> import { createColumnHelper } from "@tanstack/svelte-table"; import { Badge } from "$lib/components/ui/badge"; import { Checkbox } from "$lib/components/ui/checkbox"; import { DataTable, DataTableColumnHeader, DataTableRowActions, renderComponent, renderSnippet, type features, } from "$lib/components/ui/data-table"; import * as DropdownMenu from "$lib/components/ui/dropdown-menu";
type Index = { symbol: string; name: string; creator: string; status: "live" | "paused"; aum: number; holders: number; day: number; };
const data: Index[] = [ { symbol: "MAG4", name: "Magnificent Four", creator: "@dewi", status: "live", aum: 49182, holders: 412, day: 0.53 }, { symbol: "PREIPO", name: "Pre-IPO Basket", creator: "@noah", status: "live", aum: 31770, holders: 265, day: 4.1 }, { symbol: "AIDX", name: "AI Infra", creator: "@sarah", status: "paused", aum: 18904, holders: 140, day: -1.2 }, { symbol: "CHIPS", name: "Semis Core", creator: "@emily", status: "live", aum: 12330, holders: 98, day: 0.21 }, { symbol: "ENRGY", name: "Energy Transition", creator: "@lucas", status: "paused", aum: 8412, holders: 61, day: -0.44 }, { symbol: "BANKS", name: "Global Banks", creator: "@dewi", status: "live", aum: 7120, holders: 55, day: 0.12 }, { symbol: "BIO", name: "Biotech Leaders", creator: "@noah", status: "live", aum: 6410, holders: 47, day: -0.9 }, ];
const col = createColumnHelper<typeof features, Index>();
const columns = col.columns([ col.display({ id: "select", header: ({ table }) => renderComponent(Checkbox, { checked: table.getIsAllPageRowsSelected(), indeterminate: table.getIsSomePageRowsSelected() && !table.getIsAllPageRowsSelected(), onCheckedChange: (v: boolean) => table.toggleAllPageRowsSelected(!!v), "aria-label": "Select all", }), cell: ({ row }) => renderComponent(Checkbox, { checked: row.getIsSelected(), onCheckedChange: (v: boolean) => row.toggleSelected(!!v), "aria-label": "Select row", }), enableSorting: false, enableHiding: false, }), col.accessor("symbol", { id: "index", header: ({ column }) => renderComponent(DataTableColumnHeader, { column, title: "Index" }), cell: ({ row }) => renderSnippet(indexCell, row.original), }), col.accessor("creator", { filterFn: "arrHas", header: ({ column }) => renderComponent(DataTableColumnHeader, { column, title: "Creator" }), cell: ({ getValue }) => renderSnippet(creatorCell, getValue()), }), col.accessor("status", { header: "Status", filterFn: "arrHas", cell: ({ getValue }) => renderSnippet(statusCell, getValue()), }), col.accessor("aum", { meta: { label: "AUM" }, header: ({ column }) => renderComponent(DataTableColumnHeader, { column, title: "AUM", numeric: true }), cell: ({ getValue }) => renderSnippet(aumCell, getValue()), }), col.accessor("holders", { header: ({ column }) => renderComponent(DataTableColumnHeader, { column, title: "Holders", numeric: true }), cell: ({ getValue }) => renderSnippet(holdersCell, getValue()), }), col.accessor("day", { id: "24h", header: ({ column }) => renderComponent(DataTableColumnHeader, { column, title: "24h", numeric: true }), cell: ({ getValue }) => renderSnippet(dayCell, getValue()), }), col.display({ id: "actions", enableHiding: false, cell: ({ row }) => renderSnippet(actionsCell, row.original), }), ]);</script>
{#snippet indexCell(row: Index)} <div> <div class="font-semibold">{row.symbol}</div> <div class="text-[11.5px] text-muted-foreground">{row.name}</div> </div>{/snippet}{#snippet creatorCell(v: string)} <span class="text-muted-foreground">{v}</span>{/snippet}{#snippet statusCell(v: string)} <span class="capitalize">{v}</span>{/snippet}{#snippet aumCell(v: number)} <div class="text-right font-mono tabular-nums">${v.toLocaleString("en-US")}</div>{/snippet}{#snippet holdersCell(v: number)} <div class="text-right font-mono tabular-nums">{v}</div>{/snippet}{#snippet dayCell(v: number)} <div class="text-right"> <Badge variant={v >= 0 ? "success" : "destructive"} class="font-mono"> {v >= 0 ? "+" : "−"}{Math.abs(v).toFixed(2)}% </Badge> </div>{/snippet}{#snippet actionsCell(row: Index)} <div class="text-right"> <DataTableRowActions> <DropdownMenu.Item onclick={() => navigator.clipboard.writeText(row.symbol)}> Copy symbol </DropdownMenu.Item> <DropdownMenu.Separator /> <DropdownMenu.Item>View index</DropdownMenu.Item> </DataTableRowActions> </div>{/snippet}
<DataTable {columns} {data} filterColumn="index" filterPlaceholder="Filter indexes…" facetedFilters={[ { column: "status", title: "Status" }, { column: "creator", title: "Creator" }, ]} pageSize={5}/>Installation
Section titled “Installation”npx shadcn@latest add @edmi-ui/data-tablepnpm dlx shadcn@latest add @edmi-ui/data-tableyarn dlx shadcn@latest add @edmi-ui/data-tablebunx shadcn@latest add @edmi-ui/data-tablePick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.
Install the dependencies:
Terminal window npm install @tanstack/react-tableTerminal window pnpm add @tanstack/react-tableTerminal window yarn add @tanstack/react-tableTerminal window bun add @tanstack/react-tableAdd the Edmi components this one builds on:
table,button,input,badge,checkbox,select,dropdown-menu,elevation.Copy the code from Component source into your project and update the import paths to match your aliases.
npx shadcn-vue@latest add @edmi-ui/data-tablepnpm dlx shadcn-vue@latest add @edmi-ui/data-tableyarn dlx shadcn-vue@latest add @edmi-ui/data-tablebunx shadcn-vue@latest add @edmi-ui/data-tablePick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.
Install the dependencies:
Terminal window npm install @tanstack/vue-tableTerminal window pnpm add @tanstack/vue-tableTerminal window yarn add @tanstack/vue-tableTerminal window bun add @tanstack/vue-tableAdd the Edmi components this one builds on:
table,button,input,badge,checkbox,select,dropdown-menu,elevation.Copy the code from Component source into your project and update the import paths to match your aliases.
npx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/data-table.jsonpnpm dlx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/data-table.jsonyarn dlx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/data-table.jsonbunx --bun shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/data-table.jsonPick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.
Add the Edmi components this one builds on:
table,button,input,badge,checkbox,select,dropdown-menu,elevation.Copy the code from Component source into your project and update the import paths to match your aliases.
import { DataTable } from "@/components/data-table/data-table";
<DataTable filterColumn="index" filterPlaceholder="Filter indexes…" facetedFilters={[ { column: "status", title: "Status" }, { column: "creator", title: "Creator" }, ]} pageSize={5}/><script setup lang="ts">import { DataTable } from "@/components/ui/data-table";</script>
<template> <DataTable filter-column="index" filter-placeholder="Filter indexes…" :faceted-filters="[ { column: 'status', title: 'Status' }, { column: 'creator', title: 'Creator' }, ]" :page-size="5" /></template><script lang="ts"> import { DataTable } from "$lib/components/ui/data-table";</script>
<DataTable
filterColumn="index" filterPlaceholder="Filter indexes…" facetedFilters={[ { column: "status", title: "Status" }, { column: "creator", title: "Creator" }, ]} pageSize={5}/>Component source
Section titled “Component source”import { type ColumnDef, type RowData, useTable } from "@tanstack/react-table";import { cn } from "cn";import { MagnifyingGlassIcon } from "@phosphor-icons/react";import type { Elevation } from "@/registry/edmi/ui/elevation";import { Input } from "@/registry/edmi/ui/input";import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow,} from "@/registry/edmi/ui/table";import { DataTableFacetedFilter } from "./data-table-faceted-filter";import { type DataTableFeatures, features } from "./data-table-features";import { DataTablePagination } from "./data-table-pagination";import { DataTableViewOptions } from "./data-table-view-options";
export type DataTableColumnDef<TData extends RowData> = ColumnDef< DataTableFeatures, TData, // biome-ignore lint/suspicious/noExplicitAny: column value types differ per column any>;
interface DataTableProps<TData extends RowData> { columns: DataTableColumnDef<TData>[]; data: TData[]; /** Column id the toolbar filter input searches. Omit to hide the input. */ filterColumn?: string; filterPlaceholder?: string; /** ✦ Faceted filter buttons; columns need `filterFn: "arrHas"`. */ facetedFilters?: { column: string; title: string; options?: { label: string; value: string }[]; }[]; pageSize?: number; className?: string; /** ✦ depth of the toolbar and pagination controls (the table container stays flat). */ elevation?: Elevation;}
function DataTable<TData extends RowData>({ columns, data, filterColumn, filterPlaceholder = "Filter…", facetedFilters, pageSize = 10, className, elevation,}: DataTableProps<TData>) { const table = useTable({ features, data, columns, initialState: { pagination: { pageIndex: 0, pageSize } }, });
const filterInput = filterColumn ? table.getColumn(filterColumn) : undefined;
return ( <div data-slot="data-table" className={cn("w-full space-y-3", className)}> <div className="flex flex-wrap items-center gap-2"> {filterInput && ( <div className="relative w-full max-w-[260px]"> <MagnifyingGlassIcon className="pointer-events-none absolute top-1/2 left-3 size-3.5 -translate-y-1/2 text-muted-foreground" /> <Input placeholder={filterPlaceholder} value={(filterInput.getFilterValue() as string) ?? ""} onChange={(event) => filterInput.setFilterValue(event.target.value) } className="pl-8" /> </div> )} {facetedFilters?.map((f) => ( <DataTableFacetedFilter key={f.column} column={table.getColumn(f.column)} title={f.title} options={f.options} elevation={elevation} /> ))} <DataTableViewOptions table={table} elevation={elevation} /> </div> <div className="overflow-hidden rounded-xl border border-border bg-card"> <Table> <TableHeader> {table.getHeaderGroups().map((headerGroup) => ( <TableRow key={headerGroup.id} className="hover:bg-transparent"> {headerGroup.headers.map((header) => ( <TableHead key={header.id} colSpan={header.colSpan}> {header.isPlaceholder ? null : ( <table.FlexRender header={header} /> )} </TableHead> ))} </TableRow> ))} </TableHeader> <TableBody> {table.getRowModel().rows.length ? ( table.getRowModel().rows.map((row) => ( <TableRow key={row.id} data-state={row.getIsSelected() ? "selected" : undefined} > {row.getVisibleCells().map((cell) => ( <TableCell key={cell.id}> <table.FlexRender cell={cell} /> </TableCell> ))} </TableRow> )) ) : ( <TableRow> <TableCell colSpan={columns.length} className="h-24 text-center text-muted-foreground" > No results. </TableCell> </TableRow> )} </TableBody> </Table> </div> <DataTablePagination table={table} elevation={elevation} /> </div> );}
export { DataTable };import { columnFacetingFeature, columnFilteringFeature, columnVisibilityFeature, createFacetedRowModel, createFacetedUniqueValues, createFilteredRowModel, createPaginatedRowModel, createSortedRowModel, filterFn_arrHas, filterFn_arrIncludesSome, filterFn_includesString, rowPaginationFeature, rowSelectionFeature, rowSortingFeature, sortFn_alphanumeric, sortFn_text, tableFeatures,} from "@tanstack/react-table";
// TanStack Table v9: declare the features this table uses. Anything you do not// register is tree-shaken out of the bundle.export const features = tableFeatures({ columnFacetingFeature, columnFilteringFeature, columnVisibilityFeature, rowPaginationFeature, rowSelectionFeature, rowSortingFeature, facetedRowModel: createFacetedRowModel(), facetedUniqueValues: createFacetedUniqueValues(), filteredRowModel: createFilteredRowModel(), paginatedRowModel: createPaginatedRowModel(), sortedRowModel: createSortedRowModel(), filterFns: { includesString: filterFn_includesString, arrHas: filterFn_arrHas, arrIncludesSome: filterFn_arrIncludesSome, }, sortFns: { alphanumeric: sortFn_alphanumeric, text: sortFn_text },});
// Pass this as the first generic argument to `ColumnDef`, `Column`, `Table`// and `Row` so each type knows which feature APIs are available.export type DataTableFeatures = typeof features;import type { Column, RowData } from "@tanstack/react-table";import { cn } from "cn";import type * as React from "react";import { ArrowDownIcon, ArrowUpIcon, CaretUpDownIcon, EyeSlashIcon } from "@phosphor-icons/react";import { Button } from "@/registry/edmi/ui/button";import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger,} from "@/registry/edmi/ui/dropdown-menu";import type { DataTableFeatures } from "./data-table-features";
interface DataTableColumnHeaderProps<TData extends RowData, TValue> extends React.HTMLAttributes<HTMLDivElement> { column: Column<DataTableFeatures, TData, TValue>; title: string; /** ✦ Right-align the title (numeric columns, DESIGN §4 rule 10). */ numeric?: boolean;}
export function DataTableColumnHeader<TData extends RowData, TValue>({ column, title, numeric, className,}: DataTableColumnHeaderProps<TData, TValue>) { if (!column.getCanSort()) { return ( <div className={cn(numeric && "text-right", className)}>{title}</div> ); }
const sorted = column.getIsSorted();
return ( <div className={cn( "flex items-center gap-2", numeric && "justify-end", className, )} > <DropdownMenu> <DropdownMenuTrigger render={ <Button variant="ghost" size="xs" className={cn( "-ml-2 text-[12.5px] font-medium text-muted-foreground data-[popup-open]:bg-accent", numeric && "-mr-2 ml-0", sorted && "text-foreground", )} /> } > <span>{title}</span> {sorted === "desc" ? ( <ArrowDownIcon /> ) : sorted === "asc" ? ( <ArrowUpIcon /> ) : ( <CaretUpDownIcon /> )} </DropdownMenuTrigger> <DropdownMenuContent align={numeric ? "end" : "start"}> <DropdownMenuItem onClick={() => column.toggleSorting(false)}> <ArrowUpIcon /> Asc </DropdownMenuItem> <DropdownMenuItem onClick={() => column.toggleSorting(true)}> <ArrowDownIcon /> Desc </DropdownMenuItem> {column.getCanHide() && ( <> <DropdownMenuSeparator /> <DropdownMenuItem onClick={() => column.toggleVisibility(false)}> <EyeSlashIcon /> Hide </DropdownMenuItem> </> )} </DropdownMenuContent> </DropdownMenu> </div> );}import type { ReactTable, RowData } from "@tanstack/react-table";import { ColumnsIcon, CaretDownIcon } from "@phosphor-icons/react";import { Button } from "@/registry/edmi/ui/button";import { DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuGroup, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger,} from "@/registry/edmi/ui/dropdown-menu";import type { Elevation } from "@/registry/edmi/ui/elevation";import type { DataTableFeatures } from "./data-table-features";
function columnLabel(column: { id: string; columnDef: { header?: unknown; meta?: unknown };}) { const label = (column.columnDef.meta as { label?: string } | undefined) ?.label; if (label) return label; const header = column.columnDef.header; return typeof header === "string" ? header : column.id;}
export function DataTableViewOptions<TData extends RowData>({ table, elevation,}: { table: ReactTable<DataTableFeatures, TData>; /** ✦ depth of the toolbar and pagination controls (the table container stays flat). */ elevation?: Elevation;}) { return ( <DropdownMenu> <DropdownMenuTrigger render={ <Button variant="outline" size="sm" elevation={elevation} className="ml-auto" /> } > <ColumnsIcon /> Columns <CaretDownIcon /> </DropdownMenuTrigger> <DropdownMenuContent align="end" className="w-44"> <DropdownMenuGroup> <DropdownMenuLabel>Toggle columns</DropdownMenuLabel> <DropdownMenuSeparator /> {table .getAllColumns() .filter( (column) => typeof column.accessorFn !== "undefined" && column.getCanHide(), ) .map((column) => ( <DropdownMenuCheckboxItem key={columnLabel(column)} className="capitalize" checked={column.getIsVisible()} onCheckedChange={(value) => column.toggleVisibility(!!value)} > {column.id} </DropdownMenuCheckboxItem> ))} </DropdownMenuGroup> </DropdownMenuContent> </DropdownMenu> );}import type { ReactTable, RowData } from "@tanstack/react-table";import { CaretDoubleLeftIcon, CaretLeftIcon, CaretRightIcon, CaretDoubleRightIcon } from "@phosphor-icons/react";import { Button } from "@/registry/edmi/ui/button";import type { Elevation } from "@/registry/edmi/ui/elevation";import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue,} from "@/registry/edmi/ui/select";import type { DataTableFeatures } from "./data-table-features";
interface DataTablePaginationProps<TData extends RowData> { table: ReactTable<DataTableFeatures, TData>; pageSizes?: number[]; /** ✦ depth of the toolbar and pagination controls (the table container stays flat). */ elevation?: Elevation;}
export function DataTablePagination<TData extends RowData>({ table, pageSizes = [10, 20, 30, 40, 50], elevation,}: DataTablePaginationProps<TData>) { const { pageIndex, pageSize } = table.state.pagination; // The current size (e.g. `DataTable pageSize={5}`) must be an option, or the trigger renders blank. const sizes = pageSizes.includes(pageSize) ? pageSizes : [...pageSizes, pageSize].sort((a, b) => a - b);
return ( <div className="flex items-center justify-between gap-4 text-[13px]"> <div className="flex-1 text-muted-foreground"> {table.getFilteredSelectedRowModel().rows.length} of{" "} {table.getFilteredRowModel().rows.length} row(s) selected. </div> <div className="flex items-center gap-4 lg:gap-6"> <div className="flex items-center gap-2"> <span className="text-muted-foreground">Rows per page</span> <Select value={`${pageSize}`} onValueChange={(value) => table.setPageSize(Number(value))} > <SelectTrigger size="sm" elevation={elevation} className="w-[72px]"> <SelectValue /> </SelectTrigger> <SelectContent side="top"> {sizes.map((size) => ( <SelectItem key={size} value={`${size}`}> {size} </SelectItem> ))} </SelectContent> </Select> </div> <div className="flex min-w-[88px] items-center justify-center font-medium"> Page {pageIndex + 1} of {table.getPageCount()} </div> <div className="flex items-center gap-1"> <Button variant="outline" size="icon-sm" elevation={elevation} className="hidden lg:inline-flex" onClick={() => table.setPageIndex(0)} disabled={!table.getCanPreviousPage()} > <span className="sr-only">Go to first page</span> <CaretDoubleLeftIcon /> </Button> <Button variant="outline" size="icon-sm" elevation={elevation} onClick={() => table.previousPage()} disabled={!table.getCanPreviousPage()} > <span className="sr-only">Go to previous page</span> <CaretLeftIcon /> </Button> <Button variant="outline" size="icon-sm" elevation={elevation} onClick={() => table.nextPage()} disabled={!table.getCanNextPage()} > <span className="sr-only">Go to next page</span> <CaretRightIcon /> </Button> <Button variant="outline" size="icon-sm" elevation={elevation} className="hidden lg:inline-flex" onClick={() => table.setPageIndex(table.getPageCount() - 1)} disabled={!table.getCanNextPage()} > <span className="sr-only">Go to last page</span> <CaretDoubleRightIcon /> </Button> </div> </div> </div> );}import type { Column, RowData } from "@tanstack/react-table";import { PlusIcon } from "@phosphor-icons/react";import { Badge } from "@/registry/edmi/ui/badge";import { Button } from "@/registry/edmi/ui/button";import { DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuGroup, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger,} from "@/registry/edmi/ui/dropdown-menu";import type { Elevation } from "@/registry/edmi/ui/elevation";import type { DataTableFeatures } from "./data-table-features";
interface DataTableFacetedFilterProps<TData extends RowData, TValue> { column?: Column<DataTableFeatures, TData, TValue>; title: string; /** Defaults to the unique values found in the column. */ options?: { label: string; value: string }[]; /** ✦ depth of the toolbar and pagination controls (the table container stays flat). */ elevation?: Elevation;}
/** * ✦ Dashed "+ Status" filter button with a checkbox menu. The column must set * `filterFn: "arrHas"`. */export function DataTableFacetedFilter<TData extends RowData, TValue>({ column, title, options, elevation,}: DataTableFacetedFilterProps<TData, TValue>) { if (!column) return null;
const facets = column.getFacetedUniqueValues(); const items = options ?? Array.from(facets.keys()).map((value) => ({ label: String(value), value: String(value), })); const selected = new Set((column.getFilterValue() as string[]) ?? []);
return ( <DropdownMenu> <DropdownMenuTrigger render={ <Button variant="outline" size="sm" elevation={elevation} className="border-dashed" /> } > <PlusIcon /> {title} {selected.size > 0 && ( <Badge variant="brand" shape="number"> {selected.size} </Badge> )} </DropdownMenuTrigger> <DropdownMenuContent align="start" className="w-48"> <DropdownMenuGroup> <DropdownMenuLabel>{title}</DropdownMenuLabel> <DropdownMenuSeparator /> {items.map((option) => ( <DropdownMenuCheckboxItem key={option.value} className="capitalize" checked={selected.has(option.value)} onCheckedChange={(checked) => { const next = new Set(selected); if (checked) next.add(option.value); else next.delete(option.value); column.setFilterValue(next.size ? Array.from(next) : undefined); }} > {option.label} <span className="ml-auto font-mono text-xs text-muted-foreground"> {facets.get(option.value) ?? 0} </span> </DropdownMenuCheckboxItem> ))} </DropdownMenuGroup> </DropdownMenuContent> </DropdownMenu> );}import type * as React from "react";import { DotsThreeOutlineIcon } from "@phosphor-icons/react";import { Button } from "@/registry/edmi/ui/button";import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger,} from "@/registry/edmi/ui/dropdown-menu";
/** * ✦ Row actions trigger: ghost icon button + DropdownMenu. Pass * `DropdownMenuItem`s (or groups) as children. */export function DataTableRowActions({ children, label = "Open menu",}: { children: React.ReactNode; label?: string;}) { return ( <DropdownMenu> <DropdownMenuTrigger render={<Button variant="ghost" size="icon-xs" />}> <span className="sr-only">{label}</span> <DotsThreeOutlineIcon /> </DropdownMenuTrigger> <DropdownMenuContent align="end" className="w-44"> {children} </DropdownMenuContent> </DropdownMenu> );}<script lang="ts">import type { ColumnDef, RowData } from "@tanstack/vue-table";import type { DataTableFeatures } from "./dataTableFeatures";
export type DataTableColumnDef<TData extends RowData> = ColumnDef< DataTableFeatures, TData, // biome-ignore lint/suspicious/noExplicitAny: column value types differ per column any>;</script>
<script setup lang="ts" generic="TData extends RowData">import type { HTMLAttributes } from "vue";import { FlexRender, useTable } from "@tanstack/vue-table";import { Search } from "@lucide/vue";import { computed } from "vue";import { cn } from "@/registry/edmi/lib/utils";import { Input } from "@/registry/edmi/ui/input";import type { Elevation } from "@/registry/edmi/ui/elevation";import { Table, TableBody, TableCell, TableEmpty, TableHead, TableHeader, TableRow,} from "@/registry/edmi/ui/table";import DataTableFacetedFilter from "./DataTableFacetedFilter.vue";import DataTablePagination from "./DataTablePagination.vue";import DataTableViewOptions from "./DataTableViewOptions.vue";import { features } from "./dataTableFeatures";
const props = withDefaults( defineProps<{ columns: DataTableColumnDef<TData>[]; data: TData[]; /** Column id the toolbar filter input searches. Omit to hide the input. */ filterColumn?: string; filterPlaceholder?: string; /** ✦ Faceted filter buttons; columns need `filterFn: "arrHas"`. */ facetedFilters?: { column: string; title: string; options?: { label: string; value: string }[]; }[]; pageSize?: number; /** ✦ depth of the toolbar and pagination controls (the table container stays flat). */ elevation?: Elevation; class?: HTMLAttributes["class"]; }>(), { filterPlaceholder: "Filter…", pageSize: 10 },);
const table = useTable({ features, get data() { return props.data; }, get columns() { return props.columns; }, initialState: { pagination: { pageIndex: 0, pageSize: props.pageSize } },});
const filterInput = computed(() => props.filterColumn ? table.getColumn(props.filterColumn) : undefined,);</script>
<template> <div data-slot="data-table" :class="cn('w-full space-y-3', props.class)"> <div class="flex flex-wrap items-center gap-2"> <div v-if="filterInput" class="relative w-full max-w-[260px]"> <Search class="pointer-events-none absolute top-1/2 left-3 size-3.5 -translate-y-1/2 text-muted-foreground" /> <Input :placeholder="filterPlaceholder" :model-value="(filterInput.getFilterValue() as string) ?? ''" class="h-9 pl-8" @update:model-value="(value) => filterInput?.setFilterValue(value)" /> </div> <DataTableFacetedFilter v-for="f in facetedFilters" :key="f.column" :column="table.getColumn(f.column)" :title="f.title" :options="f.options" :elevation="elevation" /> <DataTableViewOptions :table="table" :elevation="elevation" /> </div> <div class="overflow-hidden rounded-xl border border-border bg-card"> <Table> <TableHeader> <TableRow v-for="headerGroup in table.getHeaderGroups()" :key="headerGroup.id" class="hover:bg-transparent" > <TableHead v-for="header in headerGroup.headers" :key="header.id" :colspan="header.colSpan"> <FlexRender v-if="!header.isPlaceholder" :header="header" /> </TableHead> </TableRow> </TableHeader> <TableBody> <template v-if="table.getRowModel().rows.length"> <TableRow v-for="row in table.getRowModel().rows" :key="row.id" :data-state="row.getIsSelected() ? 'selected' : undefined" > <TableCell v-for="cell in row.getVisibleCells()" :key="cell.id"> <FlexRender :cell="cell" /> </TableCell> </TableRow> </template> <TableEmpty v-else :colspan="columns.length">No results.</TableEmpty> </TableBody> </Table> </div> <DataTablePagination :table="table" :elevation="elevation" /> </div></template><script setup lang="ts">import type { HTMLAttributes } from "vue";import { ArrowDownIcon, ArrowUpIcon, ChevronsUpDown, EyeIcon } from "@lucide/vue";import { cn } from "@/registry/edmi/lib/utils";import { Button } from "@/registry/edmi/ui/button";import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger,} from "@/registry/edmi/ui/dropdown-menu";
const props = defineProps<{ /** Any TanStack `Column`; typed structurally so `h(DataTableColumnHeader, { column })` needs no generics. */ column: { getCanSort: () => boolean; getCanHide: () => boolean; getIsSorted: () => false | "asc" | "desc"; toggleSorting: (desc?: boolean) => void; toggleVisibility: (value?: boolean) => void; }; title: string; /** ✦ Right-align the title (numeric columns, DESIGN §4 rule 10). */ numeric?: boolean; class?: HTMLAttributes["class"];}>();</script>
<template> <div v-if="!column.getCanSort()" :class="cn(numeric && 'text-right', props.class)" > {{ title }} </div> <div v-else :class="cn('flex items-center gap-2', numeric && 'justify-end', props.class)" > <DropdownMenu> <DropdownMenuTrigger as-child> <Button variant="ghost" size="xs" :class="cn( '-ml-2 text-[12.5px] font-medium text-muted-foreground data-[state=open]:bg-accent', numeric && '-mr-2 ml-0', column.getIsSorted() && 'text-foreground', )" > <span>{{ title }}</span> <ArrowDownIcon v-if="column.getIsSorted() === 'desc'" /> <ArrowUpIcon v-else-if="column.getIsSorted() === 'asc'" /> <ChevronsUpDown v-else /> </Button> </DropdownMenuTrigger> <DropdownMenuContent :align="numeric ? 'end' : 'start'"> <DropdownMenuItem @click="column.toggleSorting(false)"> <ArrowUpIcon /> Asc </DropdownMenuItem> <DropdownMenuItem @click="column.toggleSorting(true)"> <ArrowDownIcon /> Desc </DropdownMenuItem> <template v-if="column.getCanHide()"> <DropdownMenuSeparator /> <DropdownMenuItem @click="column.toggleVisibility(false)"> <EyeIcon /> Hide </DropdownMenuItem> </template> </DropdownMenuContent> </DropdownMenu> </div></template><script setup lang="ts" generic="TData extends RowData, TValue">import type { Column, RowData } from "@tanstack/vue-table";import { Plus } from "@lucide/vue";import { computed } from "vue";import { Badge } from "@/registry/edmi/ui/badge";import { Button } from "@/registry/edmi/ui/button";import type { Elevation } from "@/registry/edmi/ui/elevation";import { DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuGroup, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger,} from "@/registry/edmi/ui/dropdown-menu";import type { DataTableFeatures } from "./dataTableFeatures";
/** * ✦ Dashed "+ Status" filter button with a checkbox menu. The column must set * `filterFn: "arrHas"`. */const props = withDefaults(defineProps<{ column?: Column<DataTableFeatures, TData, TValue>; title: string; /** ✦ depth of the toolbar and pagination controls (the table container stays flat). */ elevation?: Elevation; /** Defaults to the unique values found in the column. */ options?: { label: string; value: string }[];}>(), { elevation: undefined });
const facets = computed(() => props.column?.getFacetedUniqueValues());const items = computed( () => props.options ?? Array.from(facets.value?.keys() ?? []).map((value) => ({ label: String(value), value: String(value), })),);const selected = computed( () => new Set((props.column?.getFilterValue() as string[] | undefined) ?? []),);
function toggle(value: string, checked: boolean) { const next = new Set(selected.value); if (checked) next.add(value); else next.delete(value); props.column?.setFilterValue(next.size ? Array.from(next) : undefined);}</script>
<template> <DropdownMenu v-if="column"> <DropdownMenuTrigger as-child> <Button variant="outline" size="sm" :elevation="elevation" class="border-dashed"> <Plus /> {{ title }} <Badge v-if="selected.size > 0" variant="brand" shape="number"> {{ selected.size }} </Badge> </Button> </DropdownMenuTrigger> <DropdownMenuContent align="start" class="w-48"> <DropdownMenuGroup> <DropdownMenuLabel>{{ title }}</DropdownMenuLabel> <DropdownMenuSeparator /> <DropdownMenuCheckboxItem v-for="option in items" :key="option.value" class="capitalize" :model-value="selected.has(option.value)" @update:model-value="(checked: boolean) => toggle(option.value, !!checked)" > {{ option.label }} <span class="ml-auto font-mono text-xs text-muted-foreground"> {{ facets?.get(option.value) ?? 0 }} </span> </DropdownMenuCheckboxItem> </DropdownMenuGroup> </DropdownMenuContent> </DropdownMenu></template><script setup lang="ts" generic="TData extends RowData">import type { RowData, Table } from "@tanstack/vue-table";import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from "@lucide/vue";import { computed } from "vue";import { Button } from "@/registry/edmi/ui/button";import type { Elevation } from "@/registry/edmi/ui/elevation";import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue,} from "@/registry/edmi/ui/select";import type { DataTableFeatures } from "./dataTableFeatures";
const props = withDefaults( defineProps<{ table: Table<DataTableFeatures, TData>; pageSizes?: number[]; /** ✦ depth of the toolbar and pagination controls (the table container stays flat). */ elevation?: Elevation; }>(), { pageSizes: () => [10, 20, 30, 40, 50] },);
// The current size (e.g. `DataTable :page-size="5"`) must be an option, or the trigger renders blank.const sizes = computed(() => { const current = props.table.atoms.pagination.get().pageSize; return props.pageSizes.includes(current) ? props.pageSizes : [...props.pageSizes, current].sort((a, b) => a - b);});</script>
<template> <div class="flex items-center justify-between gap-4 text-[13px]"> <div class="flex-1 text-muted-foreground"> {{ table.getFilteredSelectedRowModel().rows.length }} of {{ table.getFilteredRowModel().rows.length }} row(s) selected. </div> <div class="flex items-center gap-4 lg:gap-6"> <div class="flex items-center gap-2"> <span class="text-muted-foreground">Rows per page</span> <Select :model-value="`${table.atoms.pagination.get().pageSize}`" @update:model-value="(value) => table.setPageSize(Number(value))" > <SelectTrigger size="sm" :elevation="elevation" class="w-[72px]"> <SelectValue /> </SelectTrigger> <SelectContent side="top"> <SelectItem v-for="size in sizes" :key="size" :value="`${size}`"> {{ size }} </SelectItem> </SelectContent> </Select> </div> <div class="flex min-w-[88px] items-center justify-center font-medium"> Page {{ table.atoms.pagination.get().pageIndex + 1 }} of {{ table.getPageCount() }} </div> <div class="flex items-center gap-1"> <Button variant="outline" size="icon-sm" :elevation="elevation" class="hidden lg:inline-flex" :disabled="!table.getCanPreviousPage()" @click="table.setPageIndex(0)" > <span class="sr-only">Go to first page</span> <ChevronsLeft /> </Button> <Button variant="outline" size="icon-sm" :elevation="elevation" :disabled="!table.getCanPreviousPage()" @click="table.previousPage()" > <span class="sr-only">Go to previous page</span> <ChevronLeft /> </Button> <Button variant="outline" size="icon-sm" :elevation="elevation" :disabled="!table.getCanNextPage()" @click="table.nextPage()" > <span class="sr-only">Go to next page</span> <ChevronRight /> </Button> <Button variant="outline" size="icon-sm" :elevation="elevation" class="hidden lg:inline-flex" :disabled="!table.getCanNextPage()" @click="table.setPageIndex(table.getPageCount() - 1)" > <span class="sr-only">Go to last page</span> <ChevronsRight /> </Button> </div> </div> </div></template><script setup lang="ts">import { MoreHorizontal } from "@lucide/vue";import { Button } from "@/registry/edmi/ui/button";import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger,} from "@/registry/edmi/ui/dropdown-menu";
/** * ✦ Row actions trigger: ghost icon button + DropdownMenu. Pass * `DropdownMenuItem`s (or groups) in the default slot. */withDefaults(defineProps<{ label?: string }>(), { label: "Open menu" });</script>
<template> <DropdownMenu> <DropdownMenuTrigger as-child> <Button variant="ghost" size="icon-xs"> <span class="sr-only">{{ label }}</span> <MoreHorizontal /> </Button> </DropdownMenuTrigger> <DropdownMenuContent align="end" class="w-44"> <slot /> </DropdownMenuContent> </DropdownMenu></template><script setup lang="ts" generic="TData extends RowData">import type { RowData, Table } from "@tanstack/vue-table";import { ChevronDown, PanelLeft } from "@lucide/vue";import { Button } from "@/registry/edmi/ui/button";import type { Elevation } from "@/registry/edmi/ui/elevation";import { DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuGroup, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger,} from "@/registry/edmi/ui/dropdown-menu";import type { DataTableFeatures } from "./dataTableFeatures";
/** Column menu label: `meta.label`, else a string header, else the column id. */function columnLabel(column: { id: string; columnDef: { header?: unknown; meta?: unknown } }) { const label = (column.columnDef.meta as { label?: string } | undefined)?.label; if (label) return label; const header = column.columnDef.header; return typeof header === "string" ? header : column.id;}
withDefaults( defineProps<{ table: Table<DataTableFeatures, TData>; /** ✦ depth of the toolbar and pagination controls (the table container stays flat). */ elevation?: Elevation; }>(), { elevation: undefined },);</script>
<template> <DropdownMenu> <DropdownMenuTrigger as-child> <Button variant="outline" size="sm" :elevation="elevation" class="ml-auto"> <PanelLeft /> Columns <ChevronDown /> </Button> </DropdownMenuTrigger> <DropdownMenuContent align="end" class="w-44"> <DropdownMenuGroup> <DropdownMenuLabel>Toggle columns</DropdownMenuLabel> <DropdownMenuSeparator /> <DropdownMenuCheckboxItem v-for="column in table .getAllColumns() .filter((c) => typeof c.accessorFn !== 'undefined' && c.getCanHide())" :key="column.id" class="capitalize" :model-value="column.getIsVisible()" @update:model-value="(value: boolean) => column.toggleVisibility(!!value)" > {{ columnLabel(column) }} </DropdownMenuCheckboxItem> </DropdownMenuGroup> </DropdownMenuContent> </DropdownMenu></template>import { columnFacetingFeature, columnFilteringFeature, columnVisibilityFeature, createFacetedRowModel, createFacetedUniqueValues, createFilteredRowModel, createPaginatedRowModel, createSortedRowModel, filterFn_arrHas, filterFn_arrIncludesSome, filterFn_includesString, rowPaginationFeature, rowSelectionFeature, rowSortingFeature, sortFn_alphanumeric, sortFn_text, tableFeatures,} from "@tanstack/vue-table";
// TanStack Table v9: declare the features this table uses. Anything you do not// register is tree-shaken out of the bundle.export const features = tableFeatures({ columnFacetingFeature, columnFilteringFeature, columnVisibilityFeature, rowPaginationFeature, rowSelectionFeature, rowSortingFeature, facetedRowModel: createFacetedRowModel(), facetedUniqueValues: createFacetedUniqueValues(), filteredRowModel: createFilteredRowModel(), paginatedRowModel: createPaginatedRowModel(), sortedRowModel: createSortedRowModel(), filterFns: { includesString: filterFn_includesString, arrHas: filterFn_arrHas, arrIncludesSome: filterFn_arrIncludesSome, }, sortFns: { alphanumeric: sortFn_alphanumeric, text: sortFn_text },});
// Pass this as the first generic argument to `ColumnDef`, `Column`, `Table`// and `Row` so each type knows which feature APIs are available.export type DataTableFeatures = typeof features;export type { DataTableColumnDef } from "./DataTable.vue";export { default as DataTable } from "./DataTable.vue";export { default as DataTableColumnHeader } from "./DataTableColumnHeader.vue";export { default as DataTableFacetedFilter } from "./DataTableFacetedFilter.vue";export { default as DataTablePagination } from "./DataTablePagination.vue";export { default as DataTableRowActions } from "./DataTableRowActions.vue";export { default as DataTableViewOptions } from "./DataTableViewOptions.vue";export { type DataTableFeatures, features } from "./dataTableFeatures";<script lang="ts"> import { cn } from "$lib/utils.js"; import ArrowUpIcon from "phosphor-svelte/lib/ArrowUp"; import ArrowDownIcon from "phosphor-svelte/lib/ArrowDown"; import CaretUpDownIcon from "phosphor-svelte/lib/CaretUpDown"; import EyeSlashIcon from "phosphor-svelte/lib/EyeSlash"; import { Button } from "$lib/registry/ui/button/index.js"; import * as DropdownMenu from "$lib/registry/ui/dropdown-menu/index.js";
let { column, title, numeric = false, class: className, }: { /** Any TanStack `Column`; typed structurally so `renderComponent(DataTableColumnHeader, { column })` needs no generics. */ column: { getCanSort: () => boolean; getCanHide: () => boolean; getIsSorted: () => false | "asc" | "desc"; toggleSorting: (desc?: boolean) => void; toggleVisibility: (value?: boolean) => void; }; title: string; /** ✦ Right-align the title (numeric columns, DESIGN §4 rule 10). */ numeric?: boolean; class?: string; } = $props();
const sorted = $derived(column.getIsSorted());</script>
{#snippet ArrowUp()} <ArrowUpIcon />{/snippet}
{#snippet ArrowDown()} <ArrowDownIcon />{/snippet}
{#if !column.getCanSort()} <div class={cn(numeric && "text-right", className)}>{title}</div>{:else} <div class={cn("flex items-center gap-2", numeric && "justify-end", className)}> <DropdownMenu.Root> <DropdownMenu.Trigger> {#snippet child({ props })} <Button {...props} variant="ghost" size="xs" class={cn( "-ml-2 text-[12.5px] font-medium text-muted-foreground data-[state=open]:bg-accent", numeric && "-mr-2 ml-0", sorted && "text-foreground" )} > <span>{title}</span> {#if sorted === "desc"} {@render ArrowDown()} {:else if sorted === "asc"} {@render ArrowUp()} {:else} <CaretUpDownIcon /> {/if} </Button> {/snippet} </DropdownMenu.Trigger> <DropdownMenu.Content align={numeric ? "end" : "start"}> <DropdownMenu.Item onclick={() => column.toggleSorting(false)}> {@render ArrowUp()} Asc </DropdownMenu.Item> <DropdownMenu.Item onclick={() => column.toggleSorting(true)}> {@render ArrowDown()} Desc </DropdownMenu.Item> {#if column.getCanHide()} <DropdownMenu.Separator /> <DropdownMenu.Item onclick={() => column.toggleVisibility(false)}> <EyeSlashIcon /> Hide </DropdownMenu.Item> {/if} </DropdownMenu.Content> </DropdownMenu.Root> </div>{/if}<script lang="ts" generics="TData extends RowData, TValue"> import type { Column, RowData } from "@tanstack/svelte-table"; import PlusIcon from "phosphor-svelte/lib/Plus"; import { Badge } from "$lib/registry/ui/badge/index.js"; import { Button } from "$lib/registry/ui/button/index.js"; import type { Elevation } from "$lib/registry/ui/elevation/index.js"; import * as DropdownMenu from "$lib/registry/ui/dropdown-menu/index.js"; import type { DataTableFeatures } from "./data-table-features.js";
/** * ✦ Dashed "+ Status" filter button with a checkbox menu. The column must set * `filterFn: "arrHas"`. */ let { column, title, options, elevation = "auto", }: { column?: Column<DataTableFeatures, TData, TValue>; title: string; /** Defaults to the unique values found in the column. */ options?: { label: string; value: string }[]; /** ✦ depth of the toolbar and pagination controls (the table container stays flat). */ elevation?: Elevation; } = $props();
const facets = $derived(column?.getFacetedUniqueValues()); const items = $derived( options ?? Array.from(facets?.keys() ?? []).map((value) => ({ label: String(value), value: String(value), })) ); const selected = $derived(new Set((column?.getFilterValue() as string[] | undefined) ?? []));
function toggle(value: string, checked: boolean) { const next = new Set(selected); if (checked) next.add(value); else next.delete(value); column?.setFilterValue(next.size ? Array.from(next) : undefined); }</script>
{#if column} <DropdownMenu.Root> <DropdownMenu.Trigger> {#snippet child({ props })} <Button {...props} variant="outline" size="sm" {elevation} class="border-dashed"> <PlusIcon /> {title} {#if selected.size > 0} <Badge variant="brand" shape="number">{selected.size}</Badge> {/if} </Button> {/snippet} </DropdownMenu.Trigger> <DropdownMenu.Content align="start" class="w-48"> <DropdownMenu.Group> <DropdownMenu.Label>{title}</DropdownMenu.Label> <DropdownMenu.Separator /> {#each items as option (option.value)} <DropdownMenu.CheckboxItem class="capitalize" checked={selected.has(option.value)} onCheckedChange={(checked) => toggle(option.value, !!checked)} > {option.label} <span class="ml-auto font-mono text-xs text-muted-foreground"> {facets?.get(option.value) ?? 0} </span> </DropdownMenu.CheckboxItem> {/each} </DropdownMenu.Group> </DropdownMenu.Content> </DropdownMenu.Root>{/if}import { columnFacetingFeature, columnFilteringFeature, columnVisibilityFeature, createFacetedRowModel, createFacetedUniqueValues, createFilteredRowModel, createPaginatedRowModel, createSortedRowModel, filterFn_arrHas, filterFn_arrIncludesSome, filterFn_includesString, rowPaginationFeature, rowSelectionFeature, rowSortingFeature, sortFn_alphanumeric, sortFn_text, tableFeatures,} from "@tanstack/svelte-table";
// TanStack Table v9: declare the features this table uses. Anything you do not// register is tree-shaken out of the bundle.export const features = tableFeatures({ columnFacetingFeature, columnFilteringFeature, columnVisibilityFeature, rowPaginationFeature, rowSelectionFeature, rowSortingFeature, facetedRowModel: createFacetedRowModel(), facetedUniqueValues: createFacetedUniqueValues(), filteredRowModel: createFilteredRowModel(), paginatedRowModel: createPaginatedRowModel(), sortedRowModel: createSortedRowModel(), filterFns: { includesString: filterFn_includesString, arrHas: filterFn_arrHas, arrIncludesSome: filterFn_arrIncludesSome, }, sortFns: { alphanumeric: sortFn_alphanumeric, text: sortFn_text },});
// Pass this as the first generic argument to `ColumnDef`, `Column`, `Table`// and `Row` so each type knows which feature APIs are available.export type DataTableFeatures = typeof features;<script lang="ts" generics="TData extends RowData"> import type { RowData, Table } from "@tanstack/svelte-table"; import CaretDoubleLeftIcon from "phosphor-svelte/lib/CaretDoubleLeft"; import CaretLeftIcon from "phosphor-svelte/lib/CaretLeft"; import CaretRightIcon from "phosphor-svelte/lib/CaretRight"; import CaretDoubleRightIcon from "phosphor-svelte/lib/CaretDoubleRight"; import { Button } from "$lib/registry/ui/button/index.js"; import type { Elevation } from "$lib/registry/ui/elevation/index.js"; import * as Select from "$lib/registry/ui/select/index.js"; import type { DataTableFeatures } from "./data-table-features.js";
let { table, pageSizes = [10, 20, 30, 40, 50], elevation = "auto", }: { table: Table<DataTableFeatures, TData>; pageSizes?: number[]; /** ✦ depth of the toolbar and pagination controls (the table container stays flat). */ elevation?: Elevation; } = $props();
const pagination = $derived(table.atoms.pagination.get()); // The current size (e.g. `pageSize={5}`) must be an option, or the select shows blank. const sizes = $derived( pageSizes.includes(pagination.pageSize) ? pageSizes : [...pageSizes, pagination.pageSize].sort((a, b) => a - b) );</script>
<div class="flex items-center justify-between gap-4 text-[13px]"> <div class="flex-1 text-muted-foreground"> {table.getFilteredSelectedRowModel().rows.length} of {table.getFilteredRowModel().rows.length} row(s) selected. </div> <div class="flex items-center gap-4 lg:gap-6"> <div class="flex items-center gap-2"> <span class="text-muted-foreground">Rows per page</span> <Select.Root type="single" value={`${pagination.pageSize}`} onValueChange={(value) => table.setPageSize(Number(value))} > <Select.Trigger size="sm" {elevation} class="w-[72px]"> {pagination.pageSize} </Select.Trigger> <Select.Content side="top"> {#each sizes as size (size)} <Select.Item value={`${size}`} label={`${size}`}>{size}</Select.Item> {/each} </Select.Content> </Select.Root> </div> <div class="flex min-w-[88px] items-center justify-center font-medium"> Page {pagination.pageIndex + 1} of {table.getPageCount()} </div> <div class="flex items-center gap-1"> <Button variant="outline" {elevation} size="icon-sm" class="hidden lg:inline-flex" onclick={() => table.setPageIndex(0)} disabled={!table.getCanPreviousPage()} > <span class="sr-only">Go to first page</span> <CaretDoubleLeftIcon /> </Button> <Button variant="outline" {elevation} size="icon-sm" onclick={() => table.previousPage()} disabled={!table.getCanPreviousPage()} > <span class="sr-only">Go to previous page</span> <CaretLeftIcon /> </Button> <Button variant="outline" {elevation} size="icon-sm" onclick={() => table.nextPage()} disabled={!table.getCanNextPage()} > <span class="sr-only">Go to next page</span> <CaretRightIcon /> </Button> <Button variant="outline" {elevation} size="icon-sm" class="hidden lg:inline-flex" onclick={() => table.setPageIndex(table.getPageCount() - 1)} disabled={!table.getCanNextPage()} > <span class="sr-only">Go to last page</span> <CaretDoubleRightIcon /> </Button> </div> </div></div><script lang="ts"> import type { Snippet } from "svelte"; import DotsThreeOutlineIcon from "phosphor-svelte/lib/DotsThreeOutline"; import { Button } from "$lib/registry/ui/button/index.js"; import * as DropdownMenu from "$lib/registry/ui/dropdown-menu/index.js";
/** * ✦ Row actions trigger: ghost icon button + DropdownMenu. Pass * `DropdownMenu.Item`s (or groups) as children. */ let { children, label = "Open menu" }: { children?: Snippet; label?: string } = $props();</script>
<DropdownMenu.Root> <DropdownMenu.Trigger> {#snippet child({ props })} <Button {...props} variant="ghost" size="icon-xs"> <span class="sr-only">{label}</span> <DotsThreeOutlineIcon /> </Button> {/snippet} </DropdownMenu.Trigger> <DropdownMenu.Content align="end" class="w-44"> {@render children?.()} </DropdownMenu.Content></DropdownMenu.Root><script lang="ts" generics="TData extends RowData"> import type { RowData, Table } from "@tanstack/svelte-table"; import ColumnsIcon from "phosphor-svelte/lib/Columns"; import CaretDownIcon from "phosphor-svelte/lib/CaretDown"; import { Button } from "$lib/registry/ui/button/index.js"; import type { Elevation } from "$lib/registry/ui/elevation/index.js"; import * as DropdownMenu from "$lib/registry/ui/dropdown-menu/index.js"; import type { DataTableFeatures } from "./data-table-features.js";
let { table, elevation = "auto", }: { table: Table<DataTableFeatures, TData>; /** ✦ depth of the toolbar and pagination controls (the table container stays flat). */ elevation?: Elevation; } = $props();
/** Column menu label: `meta.label`, else a string header, else the column id. */ function columnLabel(column: { id: string; columnDef: { header?: unknown; meta?: unknown } }) { const label = (column.columnDef.meta as { label?: string } | undefined)?.label; if (label) return label; const header = column.columnDef.header; return typeof header === "string" ? header : column.id; }</script>
<DropdownMenu.Root> <DropdownMenu.Trigger> {#snippet child({ props })} <Button {...props} variant="outline" size="sm" {elevation} class="ml-auto"> <ColumnsIcon /> Columns <CaretDownIcon /> </Button> {/snippet} </DropdownMenu.Trigger> <DropdownMenu.Content align="end" class="w-44"> <DropdownMenu.Group> <DropdownMenu.Label>Toggle columns</DropdownMenu.Label> <DropdownMenu.Separator /> {#each table .getAllColumns() .filter((c) => typeof c.accessorFn !== "undefined" && c.getCanHide()) as column (column.id)} <DropdownMenu.CheckboxItem class="capitalize" checked={column.getIsVisible()} onCheckedChange={(value) => column.toggleVisibility(!!value)} > {columnLabel(column)} </DropdownMenu.CheckboxItem> {/each} </DropdownMenu.Group> </DropdownMenu.Content></DropdownMenu.Root><script lang="ts" module> import type { ColumnDef, RowData } from "@tanstack/svelte-table"; import type { DataTableFeatures } from "./data-table-features.js";
export type DataTableColumnDef<TData extends RowData> = ColumnDef< DataTableFeatures, TData, // eslint-disable-next-line @typescript-eslint/no-explicit-any any >;</script>
<script lang="ts" generics="TData extends RowData"> import { createTable, FlexRender } from "@tanstack/svelte-table"; import { cn } from "$lib/utils.js"; import MagnifyingGlassIcon from "phosphor-svelte/lib/MagnifyingGlass"; import type { Elevation } from "$lib/registry/ui/elevation/index.js"; import { Input } from "$lib/registry/ui/input/index.js"; import * as Table from "$lib/registry/ui/table/index.js"; import DataTableFacetedFilter from "./data-table-faceted-filter.svelte"; import DataTablePagination from "./data-table-pagination.svelte"; import DataTableViewOptions from "./data-table-view-options.svelte"; import { features } from "./data-table-features.js";
let { columns, data, filterColumn, filterPlaceholder = "Filter…", facetedFilters, pageSize = 10, elevation = "auto", class: className, }: { columns: DataTableColumnDef<TData>[]; data: TData[]; /** Column id the toolbar filter input searches. Omit to hide the input. */ filterColumn?: string; filterPlaceholder?: string; /** ✦ Faceted filter buttons; columns need `filterFn: "arrHas"`. */ facetedFilters?: { column: string; title: string; options?: { label: string; value: string }[]; }[]; pageSize?: number; /** ✦ depth of the toolbar and pagination controls (the table container stays flat). */ elevation?: Elevation; class?: string; } = $props();
const table = createTable<DataTableFeatures, TData>({ features, get data() { return data; }, get columns() { return columns; }, // svelte-ignore state_referenced_locally initialState: { pagination: { pageIndex: 0, pageSize } }, });
const filterInput = $derived(filterColumn ? table.getColumn(filterColumn) : undefined);</script>
<div data-slot="data-table" class={cn("w-full space-y-3", className)}> <div class="flex flex-wrap items-center gap-2"> {#if filterInput} <div class="relative w-full max-w-[260px]"> <MagnifyingGlassIcon class="pointer-events-none absolute top-1/2 left-3 size-3.5 -translate-y-1/2 text-muted-foreground" /> <Input placeholder={filterPlaceholder} value={(filterInput.getFilterValue() as string) ?? ""} oninput={(event) => filterInput.setFilterValue(event.currentTarget.value)} {elevation} class="h-[34px] pl-8" /> </div> {/if} {#each facetedFilters ?? [] as f (f.column)} <DataTableFacetedFilter column={table.getColumn(f.column)} title={f.title} options={f.options} {elevation} /> {/each} <DataTableViewOptions {table} {elevation} /> </div> <div class="overflow-hidden rounded-xl border border-border bg-card"> <Table.Root> <Table.Header> {#each table.getHeaderGroups() as headerGroup (headerGroup.id)} <Table.Row class="hover:bg-transparent"> {#each headerGroup.headers as header (header.id)} <Table.Head colspan={header.colSpan}> {#if !header.isPlaceholder} <FlexRender {header} /> {/if} </Table.Head> {/each} </Table.Row> {/each} </Table.Header> <Table.Body> {#each table.getRowModel().rows as row (row.id)} <Table.Row data-state={row.getIsSelected() ? "selected" : undefined}> {#each row.getVisibleCells() as cell (cell.id)} <Table.Cell> <FlexRender {cell} /> </Table.Cell> {/each} </Table.Row> {:else} <Table.Empty colspan={columns.length}>No results.</Table.Empty> {/each} </Table.Body> </Table.Root> </div> <DataTablePagination {table} {elevation} /></div>import Root from "./data-table.svelte";import ColumnHeader from "./data-table-column-header.svelte";import FacetedFilter from "./data-table-faceted-filter.svelte";import Pagination from "./data-table-pagination.svelte";import RowActions from "./data-table-row-actions.svelte";import ViewOptions from "./data-table-view-options.svelte";
export { FlexRender, renderComponent, renderSnippet,} from "@tanstack/svelte-table";export type { DataTableColumnDef } from "./data-table.svelte";export { type DataTableFeatures, features } from "./data-table-features.js";
export { ColumnHeader, ColumnHeader as DataTableColumnHeader, FacetedFilter, FacetedFilter as DataTableFacetedFilter, Pagination, Pagination as DataTablePagination, Root, // Root as DataTable, RowActions, RowActions as DataTableRowActions, ViewOptions, ViewOptions as DataTableViewOptions,};Anatomy
Section titled “Anatomy”const table = useTable({ features, data, columns }) // TanStack Table v9<DataTable columns data /> // toolbar + table + pagination <DataTableViewOptions /> // column visibility <DataTableColumnHeader /> // sort / hide <DataTablePagination /> // rows per page, page nav, selection count <DataTableFacetedFilter /> ✦ // dashed "+ Status" filters <DataTableRowActions /> ✦ // ghost "…" + DropdownMenuDeclare the features once in data-table-features.ts (v9 is feature-based, unused features are tree-shaken) and pass typeof features as the first generic to ColumnDef, Column and Table.
Elevation ✦
Section titled “Elevation ✦”See the Elevation guide for the levels, roles and layered mode.
The table container stays flat. elevation sets the depth of the toolbar and pagination controls: filter input, faceted filters, view options, page size select and page buttons (raised +1, floating +2, sunken -1 for the fields).
Flat (0)
| Status | |||||||
|---|---|---|---|---|---|---|---|
MAG4 Magnificent Four | @dewi | live | $49,182 | 412 | +0.53% | ||
PREIPO Pre-IPO Basket | @noah | live | $31,770 | 265 | +4.10% | ||
AIDX AI Infra | @sarah | paused | $18,904 | 140 | −1.20% | ||
CHIPS Semis Core | @emily | live | $12,330 | 98 | +0.21% | ||
ENRGY Energy Transition | @lucas | paused | $8,412 | 61 | −0.44% |
Raised (+1)
| Status | |||||||
|---|---|---|---|---|---|---|---|
MAG4 Magnificent Four | @dewi | live | $49,182 | 412 | +0.53% | ||
PREIPO Pre-IPO Basket | @noah | live | $31,770 | 265 | +4.10% | ||
AIDX AI Infra | @sarah | paused | $18,904 | 140 | −1.20% | ||
CHIPS Semis Core | @emily | live | $12,330 | 98 | +0.21% | ||
ENRGY Energy Transition | @lucas | paused | $8,412 | 61 | −0.44% |
Flat (0)
| Status | |||||||
|---|---|---|---|---|---|---|---|
MAG4 Magnificent Four | @dewi | live | $49,182 | 412 | +0.53% | ||
PREIPO Pre-IPO Basket | @noah | live | $31,770 | 265 | +4.10% | ||
AIDX AI Infra | @sarah | paused | $18,904 | 140 | −1.20% | ||
CHIPS Semis Core | @emily | live | $12,330 | 98 | +0.21% | ||
ENRGY Energy Transition | @lucas | paused | $8,412 | 61 | −0.44% |
Raised (+1)
| Status | |||||||
|---|---|---|---|---|---|---|---|
MAG4 Magnificent Four | @dewi | live | $49,182 | 412 | +0.53% | ||
PREIPO Pre-IPO Basket | @noah | live | $31,770 | 265 | +4.10% | ||
AIDX AI Infra | @sarah | paused | $18,904 | 140 | −1.20% | ||
CHIPS Semis Core | @emily | live | $12,330 | 98 | +0.21% | ||
ENRGY Energy Transition | @lucas | paused | $8,412 | 61 | −0.44% |
Flat (0)
| Status | |||||||
|---|---|---|---|---|---|---|---|
MAG4 Magnificent Four | @dewi | live | $49,182 | 412 | +0.53% | ||
PREIPO Pre-IPO Basket | @noah | live | $31,770 | 265 | +4.10% | ||
AIDX AI Infra | @sarah | paused | $18,904 | 140 | −1.20% | ||
CHIPS Semis Core | @emily | live | $12,330 | 98 | +0.21% | ||
ENRGY Energy Transition | @lucas | paused | $8,412 | 61 | −0.44% |
Raised (+1)
| Status | |||||||
|---|---|---|---|---|---|---|---|
MAG4 Magnificent Four | @dewi | live | $49,182 | 412 | +0.53% | ||
PREIPO Pre-IPO Basket | @noah | live | $31,770 | 265 | +4.10% | ||
AIDX AI Infra | @sarah | paused | $18,904 | 140 | −1.20% | ||
CHIPS Semis Core | @emily | live | $12,330 | 98 | +0.21% | ||
ENRGY Energy Transition | @lucas | paused | $8,412 | 61 | −0.44% |
import { DataTable } from "@/components/data-table/data-table";import { DataTableColumnHeader } from "@/components/data-table/data-table-column-header";import type { features } from "@/components/data-table/data-table-features";import { DataTableRowActions } from "@/components/data-table/data-table-row-actions";import { Badge } from "@/components/ui/badge";import { Checkbox } from "@/components/ui/checkbox";import { DropdownMenuItem, DropdownMenuSeparator,} from "@/components/ui/dropdown-menu";import type { Elevation } from "@/components/ui/elevation";import { createColumnHelper } from "@tanstack/react-table";
type Index = { symbol: string; name: string; creator: string; status: "live" | "paused"; aum: number; holders: number; day: number;};
const data: Index[] = [ { symbol: "MAG4", name: "Magnificent Four", creator: "@dewi", status: "live", aum: 49182, holders: 412, day: 0.53, }, { symbol: "PREIPO", name: "Pre-IPO Basket", creator: "@noah", status: "live", aum: 31770, holders: 265, day: 4.1, }, { symbol: "AIDX", name: "AI Infra", creator: "@sarah", status: "paused", aum: 18904, holders: 140, day: -1.2, }, { symbol: "CHIPS", name: "Semis Core", creator: "@emily", status: "live", aum: 12330, holders: 98, day: 0.21, }, { symbol: "ENRGY", name: "Energy Transition", creator: "@lucas", status: "paused", aum: 8412, holders: 61, day: -0.44, }, { symbol: "BANKS", name: "Global Banks", creator: "@dewi", status: "live", aum: 7120, holders: 55, day: 0.12, }, { symbol: "BIO", name: "Biotech Leaders", creator: "@noah", status: "live", aum: 6410, holders: 47, day: -0.9, },];
const col = createColumnHelper<typeof features, Index>();
const columns = col.columns([ col.display({ id: "select", header: ({ table }) => ( <Checkbox checked={table.getIsAllPageRowsSelected()} indeterminate={ table.getIsSomePageRowsSelected() && !table.getIsAllPageRowsSelected() } onCheckedChange={(v) => table.toggleAllPageRowsSelected(!!v)} aria-label="Select all" /> ), cell: ({ row }) => ( <Checkbox checked={row.getIsSelected()} onCheckedChange={(v) => row.toggleSelected(!!v)} aria-label="Select row" /> ), enableSorting: false, enableHiding: false, }), col.accessor("symbol", { id: "index", header: ({ column }) => ( <DataTableColumnHeader column={column} title="Index" /> ), cell: ({ row }) => ( <div> <div className="font-semibold">{row.original.symbol}</div> <div className="text-[11.5px] text-muted-foreground"> {row.original.name} </div> </div> ), }), col.accessor("creator", { filterFn: "arrHas", header: ({ column }) => ( <DataTableColumnHeader column={column} title="Creator" /> ), cell: ({ getValue }) => ( <span className="text-muted-foreground">{getValue()}</span> ), }), col.accessor("status", { header: "Status", filterFn: "arrHas", cell: ({ getValue }) => <span className="capitalize">{getValue()}</span>, }), col.accessor("aum", { meta: { label: "AUM" }, header: ({ column }) => ( <DataTableColumnHeader column={column} title="AUM" numeric /> ), cell: ({ getValue }) => ( <div className="text-right font-mono tabular-nums"> ${getValue().toLocaleString("en-US")} </div> ), }), col.accessor("holders", { header: ({ column }) => ( <DataTableColumnHeader column={column} title="Holders" numeric /> ), cell: ({ getValue }) => ( <div className="text-right font-mono tabular-nums">{getValue()}</div> ), }), col.accessor("day", { id: "24h", header: ({ column }) => ( <DataTableColumnHeader column={column} title="24h" numeric /> ), cell: ({ getValue }) => { const v = getValue(); return ( <div className="text-right"> <Badge variant={v >= 0 ? "success" : "destructive"} className="font-mono" > {v >= 0 ? "+" : "−"} {Math.abs(v).toFixed(2)}% </Badge> </div> ); }, }), col.display({ id: "actions", enableHiding: false, cell: ({ row }) => ( <div className="text-right"> <DataTableRowActions> <DropdownMenuItem onClick={() => navigator.clipboard.writeText(row.original.symbol)} > Copy symbol </DropdownMenuItem> <DropdownMenuSeparator /> <DropdownMenuItem>View index</DropdownMenuItem> </DataTableRowActions> </div> ), }),]);
function Sample({ elevation }: { elevation: Elevation }) { return ( <DataTable elevation={elevation} columns={columns} data={data} filterColumn="index" filterPlaceholder="Filter indexes…" facetedFilters={[ { column: "status", title: "Status" }, { column: "creator", title: "Creator" }, ]} pageSize={5} /> );}
const levels = [ { value: "flat", label: "Flat (0)" }, { value: "raised", label: "Raised (+1)" },] as const;
export default function Demo() { return ( <div className="flex flex-col gap-6"> {levels.map(({ value, label }) => ( <div key={value} className="flex flex-col gap-2"> <p className="text-xs font-medium text-muted-foreground">{label}</p> <Sample elevation={value} /> </div> ))} </div> );}<script setup lang="ts">import { createColumnHelper } from "@tanstack/vue-table";import { h } from "vue";import { Badge } from "@/components/ui/badge";import { Checkbox } from "@/components/ui/checkbox";import { DataTable, DataTableColumnHeader, DataTableRowActions, type features,} from "@/components/ui/data-table";import { DropdownMenuItem, DropdownMenuSeparator } from "@/components/ui/dropdown-menu";
type Index = { symbol: string; name: string; creator: string; status: "live" | "paused"; aum: number; holders: number; day: number;};
const data: Index[] = [ { symbol: "MAG4", name: "Magnificent Four", creator: "@dewi", status: "live", aum: 49182, holders: 412, day: 0.53 }, { symbol: "PREIPO", name: "Pre-IPO Basket", creator: "@noah", status: "live", aum: 31770, holders: 265, day: 4.1 }, { symbol: "AIDX", name: "AI Infra", creator: "@sarah", status: "paused", aum: 18904, holders: 140, day: -1.2 }, { symbol: "CHIPS", name: "Semis Core", creator: "@emily", status: "live", aum: 12330, holders: 98, day: 0.21 }, { symbol: "ENRGY", name: "Energy Transition", creator: "@lucas", status: "paused", aum: 8412, holders: 61, day: -0.44 }, { symbol: "BANKS", name: "Global Banks", creator: "@dewi", status: "live", aum: 7120, holders: 55, day: 0.12 }, { symbol: "BIO", name: "Biotech Leaders", creator: "@noah", status: "live", aum: 6410, holders: 47, day: -0.9 },];
const col = createColumnHelper<typeof features, Index>();
const columns = col.columns([ col.display({ id: "select", header: ({ table }) => h(Checkbox, { modelValue: table.getIsAllPageRowsSelected() ? true : table.getIsSomePageRowsSelected() ? "indeterminate" : false, "onUpdate:modelValue": (v: boolean | "indeterminate") => table.toggleAllPageRowsSelected(!!v), ariaLabel: "Select all", }), cell: ({ row }) => h(Checkbox, { modelValue: row.getIsSelected(), "onUpdate:modelValue": (v: boolean | "indeterminate") => row.toggleSelected(!!v), ariaLabel: "Select row", }), enableSorting: false, enableHiding: false, }), col.accessor("symbol", { id: "index", header: ({ column }) => h(DataTableColumnHeader, { column, title: "Index" }), cell: ({ row }) => h("div", [ h("div", { class: "font-semibold" }, row.original.symbol), h("div", { class: "text-[11.5px] text-muted-foreground" }, row.original.name), ]), }), col.accessor("creator", { filterFn: "arrHas", header: ({ column }) => h(DataTableColumnHeader, { column, title: "Creator" }), cell: ({ getValue }) => h("span", { class: "text-muted-foreground" }, getValue()), }), col.accessor("status", { header: "Status", filterFn: "arrHas", cell: ({ getValue }) => h("span", { class: "capitalize" }, getValue()), }), col.accessor("aum", { meta: { label: "AUM" }, header: ({ column }) => h(DataTableColumnHeader, { column, title: "AUM", numeric: true }), cell: ({ getValue }) => h("div", { class: "text-right font-mono tabular-nums" }, `$${getValue().toLocaleString("en-US")}`), }), col.accessor("holders", { header: ({ column }) => h(DataTableColumnHeader, { column, title: "Holders", numeric: true }), cell: ({ getValue }) => h("div", { class: "text-right font-mono tabular-nums" }, getValue()), }), col.accessor("day", { id: "24h", header: ({ column }) => h(DataTableColumnHeader, { column, title: "24h", numeric: true }), cell: ({ getValue }) => { const v = getValue(); return h("div", { class: "text-right" }, [ h( Badge, { variant: v >= 0 ? "success" : "destructive", class: "font-mono" }, () => `${v >= 0 ? "+" : "−"}${Math.abs(v).toFixed(2)}%`, ), ]); }, }), col.display({ id: "actions", enableHiding: false, cell: ({ row }) => h("div", { class: "text-right" }, [ h(DataTableRowActions, null, { default: () => [ h( DropdownMenuItem, { onClick: () => navigator.clipboard.writeText(row.original.symbol) }, () => "Copy symbol", ), h(DropdownMenuSeparator), h(DropdownMenuItem, null, () => "View index"), ], }), ]), }),]);
const levels = [ { value: "flat", label: "Flat (0)" } as const, { value: "raised", label: "Raised (+1)" } as const,];</script>
<template> <div class="flex flex-col gap-6"> <div v-for="l in levels" :key="l.value" class="flex flex-col gap-2"> <p class="text-xs font-medium text-muted-foreground">{{ l.label }}</p> <DataTable :elevation="l.value" :columns="columns" :data="data" filter-column="index" filter-placeholder="Filter indexes…" :faceted-filters="[ { column: 'status', title: 'Status' }, { column: 'creator', title: 'Creator' }, ]" :page-size="5" /> </div> </div></template><script lang="ts"> import { createColumnHelper } from "@tanstack/svelte-table"; import { Badge } from "$lib/components/ui/badge"; import { Checkbox } from "$lib/components/ui/checkbox"; import { DataTable, DataTableColumnHeader, DataTableRowActions, renderComponent, renderSnippet, type features, } from "$lib/components/ui/data-table"; import * as DropdownMenu from "$lib/components/ui/dropdown-menu";
type Index = { symbol: string; name: string; creator: string; status: "live" | "paused"; aum: number; holders: number; day: number; };
const data: Index[] = [ { symbol: "MAG4", name: "Magnificent Four", creator: "@dewi", status: "live", aum: 49182, holders: 412, day: 0.53 }, { symbol: "PREIPO", name: "Pre-IPO Basket", creator: "@noah", status: "live", aum: 31770, holders: 265, day: 4.1 }, { symbol: "AIDX", name: "AI Infra", creator: "@sarah", status: "paused", aum: 18904, holders: 140, day: -1.2 }, { symbol: "CHIPS", name: "Semis Core", creator: "@emily", status: "live", aum: 12330, holders: 98, day: 0.21 }, { symbol: "ENRGY", name: "Energy Transition", creator: "@lucas", status: "paused", aum: 8412, holders: 61, day: -0.44 }, { symbol: "BANKS", name: "Global Banks", creator: "@dewi", status: "live", aum: 7120, holders: 55, day: 0.12 }, { symbol: "BIO", name: "Biotech Leaders", creator: "@noah", status: "live", aum: 6410, holders: 47, day: -0.9 }, ];
const col = createColumnHelper<typeof features, Index>();
const columns = col.columns([ col.display({ id: "select", header: ({ table }) => renderComponent(Checkbox, { checked: table.getIsAllPageRowsSelected(), indeterminate: table.getIsSomePageRowsSelected() && !table.getIsAllPageRowsSelected(), onCheckedChange: (v: boolean) => table.toggleAllPageRowsSelected(!!v), "aria-label": "Select all", }), cell: ({ row }) => renderComponent(Checkbox, { checked: row.getIsSelected(), onCheckedChange: (v: boolean) => row.toggleSelected(!!v), "aria-label": "Select row", }), enableSorting: false, enableHiding: false, }), col.accessor("symbol", { id: "index", header: ({ column }) => renderComponent(DataTableColumnHeader, { column, title: "Index" }), cell: ({ row }) => renderSnippet(indexCell, row.original), }), col.accessor("creator", { filterFn: "arrHas", header: ({ column }) => renderComponent(DataTableColumnHeader, { column, title: "Creator" }), cell: ({ getValue }) => renderSnippet(creatorCell, getValue()), }), col.accessor("status", { header: "Status", filterFn: "arrHas", cell: ({ getValue }) => renderSnippet(statusCell, getValue()), }), col.accessor("aum", { meta: { label: "AUM" }, header: ({ column }) => renderComponent(DataTableColumnHeader, { column, title: "AUM", numeric: true }), cell: ({ getValue }) => renderSnippet(aumCell, getValue()), }), col.accessor("holders", { header: ({ column }) => renderComponent(DataTableColumnHeader, { column, title: "Holders", numeric: true }), cell: ({ getValue }) => renderSnippet(holdersCell, getValue()), }), col.accessor("day", { id: "24h", header: ({ column }) => renderComponent(DataTableColumnHeader, { column, title: "24h", numeric: true }), cell: ({ getValue }) => renderSnippet(dayCell, getValue()), }), col.display({ id: "actions", enableHiding: false, cell: ({ row }) => renderSnippet(actionsCell, row.original), }), ]);
const levels = [ { value: "flat", label: "Flat (0)" } as const, { value: "raised", label: "Raised (+1)" } as const, ];</script>
{#snippet indexCell(row: Index)} <div> <div class="font-semibold">{row.symbol}</div> <div class="text-[11.5px] text-muted-foreground">{row.name}</div> </div>{/snippet}{#snippet creatorCell(v: string)} <span class="text-muted-foreground">{v}</span>{/snippet}{#snippet statusCell(v: string)} <span class="capitalize">{v}</span>{/snippet}{#snippet aumCell(v: number)} <div class="text-right font-mono tabular-nums">${v.toLocaleString("en-US")}</div>{/snippet}{#snippet holdersCell(v: number)} <div class="text-right font-mono tabular-nums">{v}</div>{/snippet}{#snippet dayCell(v: number)} <div class="text-right"> <Badge variant={v >= 0 ? "success" : "destructive"} class="font-mono"> {v >= 0 ? "+" : "−"}{Math.abs(v).toFixed(2)}% </Badge> </div>{/snippet}{#snippet actionsCell(row: Index)} <div class="text-right"> <DataTableRowActions> <DropdownMenu.Item onclick={() => navigator.clipboard.writeText(row.symbol)}> Copy symbol </DropdownMenu.Item> <DropdownMenu.Separator /> <DropdownMenu.Item>View index</DropdownMenu.Item> </DataTableRowActions> </div>{/snippet}
<div class="flex flex-col gap-6"> {#each levels as l (l.value)} <div class="flex flex-col gap-2"> <p class="text-xs font-medium text-muted-foreground">{l.label}</p><DataTable elevation={l.value} {columns} {data} filterColumn="index" filterPlaceholder="Filter indexes…" facetedFilters={[ { column: "status", title: "Status" }, { column: "creator", title: "Creator" }, ]} pageSize={5}/> </div> {/each}</div>| Prop | Type | Default |
|---|---|---|
DataTable columns |
ColumnDef<DataTableFeatures, TData, any>[] |
required |
DataTable data |
TData[] |
required |
DataTable filterColumn |
string (column id) |
none (no input) |
DataTable facetedFilters ✦ |
{ column, title, options? }[] |
none |
DataTable pageSize |
number |
10 |
DataTableColumnHeader numeric ✦ |
boolean |
false |
DataTablePagination pageSizes |
number[] (the current page size is added if missing) |
[10, 20, 30, 40, 50] |
elevation |
"auto" | "sunken" | "flat" | "raised" | "floating" |
"auto" |
Requires @tanstack/react-table (v9), and the table, button, input, checkbox, select, dropdown-menu and badge items.
✦ Edmi additions
Section titled “✦ Edmi additions”- Table sits in a bordered card surface (always flat); selected rows get a soft brand tint.
- Numbers are mono and right-aligned (
numeric), deltas usesuccess/destructivebadges. - Dashed faceted filter buttons and
DataTableRowActionsmatch the kit board.
Vue / Svelte differences
Section titled “Vue / Svelte differences”- Vue and Svelte include an extra
TableEmptypart for the no-results row.