Skip to content
edmi-ui

Data Table

Table + TanStack Table v9 — filter, faceted filters, sortable columns, column visibility, row selection, row actions and pagination.

StackBlitz ↗StackBlitz ↗StackBlitz ↗
Status
MAG4
Magnificent Four
@dewilive
$49,182
412
+0.53%
PREIPO
Pre-IPO Basket
@noahlive
$31,770
265
+4.10%
AIDX
AI Infra
@sarahpaused
$18,904
140
−1.20%
CHIPS
Semis Core
@emilylive
$12,330
98
+0.21%
ENRGY
Energy Transition
@lucaspaused
$8,412
61
−0.44%
0 of 7 row(s) selected.
Rows per page
Page 1 of 2
Status
MAG4
Magnificent Four
@dewilive
$49,182
412
+0.53%
PREIPO
Pre-IPO Basket
@noahlive
$31,770
265
+4.10%
AIDX
AI Infra
@sarahpaused
$18,904
140
−1.20%
CHIPS
Semis Core
@emilylive
$12,330
98
+0.21%
ENRGY
Energy Transition
@lucaspaused
$8,412
61
−0.44%
0 of 7 row(s) selected.
Rows per page
Page 1 of 2
Status
MAG4
Magnificent Four
@dewilive
$49,182
412
+0.53%
PREIPO
Pre-IPO Basket
@noahlive
$31,770
265
+4.10%
AIDX
AI Infra
@sarahpaused
$18,904
140
−1.20%
CHIPS
Semis Core
@emilylive
$12,330
98
+0.21%
ENRGY
Energy Transition
@lucaspaused
$8,412
61
−0.44%
0 of 7 row(s) selected.
Rows per page
Page 1 of 2
Terminal window
npx shadcn@latest add @edmi-ui/data-table
Terminal window
pnpm dlx shadcn@latest add @edmi-ui/data-table
Terminal window
yarn dlx shadcn@latest add @edmi-ui/data-table
Terminal window
bunx shadcn@latest add @edmi-ui/data-table

Pick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.

Terminal window
npx shadcn-vue@latest add @edmi-ui/data-table
Terminal window
pnpm dlx shadcn-vue@latest add @edmi-ui/data-table
Terminal window
yarn dlx shadcn-vue@latest add @edmi-ui/data-table
Terminal window
bunx shadcn-vue@latest add @edmi-ui/data-table

Pick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.

Terminal window
npx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/data-table.json
Terminal window
pnpm dlx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/data-table.json
Terminal window
yarn dlx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/data-table.json
Terminal window
bunx --bun shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/data-table.json

Pick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.

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}
/>
registry/blocks/data-table/data-table.tsx
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 };
registry/blocks/data-table/data-table-features.ts
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;
registry/blocks/data-table/data-table-column-header.tsx
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>
);
}
registry/blocks/data-table/data-table-view-options.tsx
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>
);
}
registry/blocks/data-table/data-table-pagination.tsx
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>
);
}
registry/blocks/data-table/data-table-faceted-filter.tsx
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>
);
}
registry/blocks/data-table/data-table-row-actions.tsx
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>
);
}
registry/ui/data-table/DataTable.vue
<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>
registry/ui/data-table/DataTableColumnHeader.vue
<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>
registry/ui/data-table/DataTableFacetedFilter.vue
<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>
registry/ui/data-table/DataTablePagination.vue
<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>
registry/ui/data-table/DataTableRowActions.vue
<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>
registry/ui/data-table/DataTableViewOptions.vue
<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>
registry/ui/data-table/dataTableFeatures.ts
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;
registry/ui/data-table/index.ts
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";
src/lib/registry/ui/data-table/data-table-column-header.svelte
<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}
src/lib/registry/ui/data-table/data-table-faceted-filter.svelte
<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}
src/lib/registry/ui/data-table/data-table-features.ts
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;
src/lib/registry/ui/data-table/data-table-pagination.svelte
<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>
src/lib/registry/ui/data-table/data-table-row-actions.svelte
<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>
src/lib/registry/ui/data-table/data-table-view-options.svelte
<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>
src/lib/registry/ui/data-table/data-table.svelte
<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>
src/lib/registry/ui/data-table/index.ts
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,
};
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 "…" + DropdownMenu

Declare 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.

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).

StackBlitz ↗StackBlitz ↗StackBlitz ↗

Flat (0)

Status
MAG4
Magnificent Four
@dewilive
$49,182
412
+0.53%
PREIPO
Pre-IPO Basket
@noahlive
$31,770
265
+4.10%
AIDX
AI Infra
@sarahpaused
$18,904
140
−1.20%
CHIPS
Semis Core
@emilylive
$12,330
98
+0.21%
ENRGY
Energy Transition
@lucaspaused
$8,412
61
−0.44%
0 of 7 row(s) selected.
Rows per page
Page 1 of 2

Raised (+1)

Status
MAG4
Magnificent Four
@dewilive
$49,182
412
+0.53%
PREIPO
Pre-IPO Basket
@noahlive
$31,770
265
+4.10%
AIDX
AI Infra
@sarahpaused
$18,904
140
−1.20%
CHIPS
Semis Core
@emilylive
$12,330
98
+0.21%
ENRGY
Energy Transition
@lucaspaused
$8,412
61
−0.44%
0 of 7 row(s) selected.
Rows per page
Page 1 of 2

Flat (0)

Status
MAG4
Magnificent Four
@dewilive
$49,182
412
+0.53%
PREIPO
Pre-IPO Basket
@noahlive
$31,770
265
+4.10%
AIDX
AI Infra
@sarahpaused
$18,904
140
−1.20%
CHIPS
Semis Core
@emilylive
$12,330
98
+0.21%
ENRGY
Energy Transition
@lucaspaused
$8,412
61
−0.44%
0 of 7 row(s) selected.
Rows per page
Page 1 of 2

Raised (+1)

Status
MAG4
Magnificent Four
@dewilive
$49,182
412
+0.53%
PREIPO
Pre-IPO Basket
@noahlive
$31,770
265
+4.10%
AIDX
AI Infra
@sarahpaused
$18,904
140
−1.20%
CHIPS
Semis Core
@emilylive
$12,330
98
+0.21%
ENRGY
Energy Transition
@lucaspaused
$8,412
61
−0.44%
0 of 7 row(s) selected.
Rows per page
Page 1 of 2

Flat (0)

Status
MAG4
Magnificent Four
@dewilive
$49,182
412
+0.53%
PREIPO
Pre-IPO Basket
@noahlive
$31,770
265
+4.10%
AIDX
AI Infra
@sarahpaused
$18,904
140
−1.20%
CHIPS
Semis Core
@emilylive
$12,330
98
+0.21%
ENRGY
Energy Transition
@lucaspaused
$8,412
61
−0.44%
0 of 7 row(s) selected.
Rows per page
Page 1 of 2

Raised (+1)

Status
MAG4
Magnificent Four
@dewilive
$49,182
412
+0.53%
PREIPO
Pre-IPO Basket
@noahlive
$31,770
265
+4.10%
AIDX
AI Infra
@sarahpaused
$18,904
140
−1.20%
CHIPS
Semis Core
@emilylive
$12,330
98
+0.21%
ENRGY
Energy Transition
@lucaspaused
$8,412
61
−0.44%
0 of 7 row(s) selected.
Rows per page
Page 1 of 2
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.

  • Table sits in a bordered card surface (always flat); selected rows get a soft brand tint.
  • Numbers are mono and right-aligned (numeric), deltas use success/destructive badges.
  • Dashed faceted filter buttons and DataTableRowActions match the kit board.
  • Vue and Svelte include an extra TableEmpty part for the no-results row.