import type { Meta, StoryObj } from "@storybook/react";
|
import { useState } from "react";
|
import { DataTable } from "./data-table";
|
import type { ColumnDef, SortingState } from "@tanstack/react-table";
|
import { Badge } from "../badge/badge";
|
import { Button } from "../button/button";
|
import { IconEdit, IconTrash } from "@humansignal/icons";
|
|
const meta: Meta<typeof DataTable> = {
|
component: DataTable,
|
title: "UI/DataTable",
|
argTypes: {
|
selectable: { control: "boolean" },
|
enableSorting: { control: "boolean" },
|
},
|
};
|
|
export default meta;
|
type Story = StoryObj<typeof DataTable>;
|
|
// Sample data
|
type User = {
|
id: number;
|
name: string;
|
email: string;
|
role: string;
|
status: "active" | "inactive";
|
lastActive: string;
|
};
|
|
const sampleData: User[] = [
|
{ id: 1, name: "John Doe", email: "john@example.com", role: "Admin", status: "active", lastActive: "2024-01-15" },
|
{ id: 2, name: "Jane Smith", email: "jane@example.com", role: "Editor", status: "active", lastActive: "2024-01-14" },
|
{
|
id: 3,
|
name: "Bob Johnson",
|
email: "bob@example.com",
|
role: "Viewer",
|
status: "inactive",
|
lastActive: "2024-01-10",
|
},
|
{
|
id: 4,
|
name: "Alice Brown",
|
email: "alice@example.com",
|
role: "Editor",
|
status: "active",
|
lastActive: "2024-01-15",
|
},
|
{
|
id: 5,
|
name: "Charlie Wilson",
|
email: "charlie@example.com",
|
role: "Viewer",
|
status: "active",
|
lastActive: "2024-01-13",
|
},
|
];
|
|
const baseColumns: ColumnDef<User>[] = [
|
{
|
accessorKey: "name",
|
header: "Name",
|
enableSorting: true,
|
},
|
{
|
accessorKey: "email",
|
header: "Email",
|
enableSorting: true,
|
},
|
{
|
accessorKey: "role",
|
header: "Role",
|
cell: ({ getValue }) => {
|
const role = getValue() as string;
|
return (
|
<Badge variant={role === "Admin" ? "primary" : role === "Editor" ? "success" : "info"} size="small">
|
{role}
|
</Badge>
|
);
|
},
|
},
|
{
|
accessorKey: "status",
|
header: "Status",
|
cell: ({ getValue }) => {
|
const status = getValue() as string;
|
return (
|
<Badge variant={status === "active" ? "success" : "default"} size="small">
|
{status}
|
</Badge>
|
);
|
},
|
},
|
{
|
accessorKey: "lastActive",
|
header: "Last Active",
|
enableSorting: true,
|
},
|
];
|
|
/**
|
* Basic DataTable
|
*
|
* Shows a simple table with data and columns.
|
* Sorting is enabled with controlled state starting as empty (no sort applied).
|
* Only columns with explicit `enableSorting: true` will be sortable (Name, Email, Last Active).
|
*/
|
export const Default: Story = {
|
render: () => {
|
const [sorting, setSorting] = useState<SortingState>([]);
|
|
return (
|
<DataTable data={sampleData} columns={baseColumns} enableSorting sorting={sorting} onSortingChange={setSorting} />
|
);
|
},
|
};
|
|
/**
|
* Table with Sorting
|
*
|
* Default sorted by "name" ascending.
|
* Click on sortable column headers to change sort direction.
|
*/
|
export const WithSorting: Story = {
|
render: () => {
|
const [sorting, setSorting] = useState<SortingState>([{ id: "name", desc: false }]);
|
|
return (
|
<div className="flex flex-col gap-4">
|
<div className="p-4 bg-neutral-surface rounded-md">
|
<p className="text-sm text-neutral-content-subtle">
|
Click on column headers to sort. Current sort:{" "}
|
{sorting.length > 0 ? `${sorting[0].id} (${sorting[0].desc ? "desc" : "asc"})` : "none"}
|
</p>
|
</div>
|
<DataTable
|
data={sampleData}
|
columns={baseColumns}
|
enableSorting
|
sorting={sorting}
|
onSortingChange={setSorting}
|
/>
|
</div>
|
);
|
},
|
};
|
|
export const WithSelection: Story = {
|
render: () => {
|
const [rowSelection, setRowSelection] = useState<Record<string, boolean>>({});
|
const selectedCount = Object.keys(rowSelection).length;
|
|
return (
|
<div className="flex flex-col gap-4">
|
<div className="p-4 bg-neutral-surface rounded-md">
|
<p className="text-sm text-neutral-content-subtle">Selected rows: {selectedCount}</p>
|
</div>
|
<DataTable
|
data={sampleData}
|
columns={baseColumns}
|
selectable
|
rowSelection={rowSelection}
|
onRowSelectionChange={setRowSelection}
|
/>
|
</div>
|
);
|
},
|
};
|
|
export const WithRowClick: Story = {
|
render: () => {
|
const [clickedUser, setClickedUser] = useState<User | null>(null);
|
const [rowSelection, setRowSelection] = useState<Record<string, boolean>>({});
|
const selectedCount = Object.keys(rowSelection).length;
|
|
return (
|
<div className="flex flex-col gap-4">
|
<div className="p-4 bg-neutral-surface rounded-md flex justify-between items-center">
|
<p className="text-sm text-neutral-content-subtle">
|
{clickedUser ? `Last clicked: ${clickedUser.name}` : "Click on a row"}
|
</p>
|
<p className="text-sm text-neutral-content-subtle">Selected: {selectedCount}</p>
|
</div>
|
<DataTable
|
data={sampleData}
|
columns={baseColumns}
|
selectable
|
rowSelection={rowSelection}
|
onRowSelectionChange={setRowSelection}
|
onRowClick={(row) => setClickedUser(row ? row.original : null)}
|
/>
|
</div>
|
);
|
},
|
};
|
|
export const WithActions: Story = {
|
render: () => {
|
const columnsWithActions: ColumnDef<User>[] = [
|
...baseColumns,
|
{
|
id: "actions",
|
header: "Actions",
|
cell: ({ row }) => (
|
<div className="flex gap-2">
|
<Button
|
size="small"
|
variant="neutral"
|
look="outlined"
|
leading={<IconEdit />}
|
onClick={() => alert(`Edit ${row.original.name}`)}
|
>
|
Edit
|
</Button>
|
<Button
|
size="small"
|
variant="negative"
|
look="outlined"
|
leading={<IconTrash />}
|
onClick={() => alert(`Delete ${row.original.name}`)}
|
>
|
Delete
|
</Button>
|
</div>
|
),
|
meta: {
|
noDivider: true,
|
},
|
},
|
];
|
|
return <DataTable data={sampleData} columns={columnsWithActions} />;
|
},
|
};
|
|
export const WithSortingAndSelection: Story = {
|
render: () => {
|
const [sorting, setSorting] = useState<SortingState>([]);
|
const [rowSelection, setRowSelection] = useState<Record<string, boolean>>({});
|
const selectedCount = Object.keys(rowSelection).length;
|
|
return (
|
<div className="flex flex-col gap-4">
|
<div className="p-4 bg-neutral-surface rounded-md flex justify-between items-center">
|
<p className="text-sm text-neutral-content-subtle">
|
Sort: {sorting.length > 0 ? `${sorting[0].id} (${sorting[0].desc ? "desc" : "asc"})` : "none"}
|
</p>
|
<p className="text-sm text-neutral-content-subtle">Selected: {selectedCount}</p>
|
</div>
|
<DataTable
|
data={sampleData}
|
columns={baseColumns}
|
enableSorting
|
selectable
|
sorting={sorting}
|
onSortingChange={setSorting}
|
rowSelection={rowSelection}
|
onRowSelectionChange={setRowSelection}
|
/>
|
</div>
|
);
|
},
|
};
|
|
/**
|
* Example with empty state when no data
|
* Shows the default empty state with search icon and default messaging
|
*/
|
export const EmptyState: Story = {
|
args: {
|
data: [],
|
columns: baseColumns,
|
emptyState: {},
|
},
|
};
|
|
/**
|
* Custom Empty State with Actions
|
*
|
* Example showing a custom empty state with custom title, description, and action buttons.
|
* Demonstrates how to provide interactive elements in the empty state.
|
*/
|
export const CustomEmptyStateWithActions: Story = {
|
render: () => {
|
return (
|
<DataTable
|
data={[]}
|
columns={baseColumns}
|
emptyState={{
|
title: "No users found",
|
description:
|
"Get started by adding your first user to the system. You can import users or create them manually.",
|
actions: (
|
<div className="flex gap-2">
|
<Button variant="primary" size="small" onClick={() => alert("Create user clicked")}>
|
Create User
|
</Button>
|
<Button variant="neutral" look="outlined" size="small" onClick={() => alert("Import users clicked")}>
|
Import Users
|
</Button>
|
</div>
|
),
|
}}
|
/>
|
);
|
},
|
};
|
|
/**
|
* Loading State
|
*
|
* Shows skeleton rows while data is loading.
|
* Displays 5 skeleton rows by default (configurable with loadingRows prop).
|
*/
|
export const Loading: Story = {
|
args: {
|
data: [],
|
columns: baseColumns,
|
isLoading: true,
|
loadingRows: 5,
|
},
|
};
|
|
export const LargeDataset: Story = {
|
render: () => {
|
const [sorting, setSorting] = useState<SortingState>([]);
|
|
// Generate 100 users
|
const largeDataset = Array.from({ length: 100 }, (_, i) => ({
|
id: i + 1,
|
name: `User ${i + 1}`,
|
email: `user${i + 1}@example.com`,
|
role: ["Admin", "Editor", "Viewer"][i % 3],
|
status: i % 3 === 0 ? ("inactive" as const) : ("active" as const),
|
lastActive: new Date(2024, 0, (i % 30) + 1).toISOString().split("T")[0],
|
}));
|
|
return (
|
<div className="flex flex-col gap-4">
|
<div className="p-4 bg-neutral-surface rounded-md">
|
<p className="text-sm text-neutral-content-subtle">
|
Showing 100 users. Note: In production, you should use pagination or virtualization for large datasets.
|
</p>
|
</div>
|
<div className="max-h-[500px] overflow-auto">
|
<DataTable
|
data={largeDataset}
|
columns={baseColumns}
|
enableSorting
|
sorting={sorting}
|
onSortingChange={setSorting}
|
selectable
|
/>
|
</div>
|
</div>
|
);
|
},
|
};
|
|
export const CustomRowClassName: Story = {
|
render: () => {
|
return (
|
<DataTable
|
data={sampleData}
|
columns={baseColumns}
|
rowClassName={(row) => (row.original.status === "inactive" ? "opacity-50" : undefined)}
|
/>
|
);
|
},
|
};
|
|
export const WithColumnResizing: Story = {
|
args: {
|
data: sampleData,
|
columns: baseColumns,
|
cellSizesStorageKey: "storybook-table",
|
},
|
};
|
|
export const FullFeatured: Story = {
|
render: () => {
|
const [sorting, setSorting] = useState<SortingState>([]);
|
const [rowSelection, setRowSelection] = useState<Record<string, boolean>>({});
|
const selectedCount = Object.keys(rowSelection).length;
|
|
const columnsWithActions: ColumnDef<User>[] = [
|
...baseColumns,
|
{
|
id: "actions",
|
header: "Actions",
|
cell: ({ row }) => (
|
<div className="flex gap-2">
|
<Button
|
size="smaller"
|
variant="neutral"
|
look="outlined"
|
leading={<IconEdit />}
|
onClick={(e) => {
|
e.stopPropagation();
|
alert(`Edit ${row.original.name}`);
|
}}
|
>
|
Edit
|
</Button>
|
</div>
|
),
|
meta: {
|
noDivider: true,
|
},
|
},
|
];
|
|
return (
|
<div className="flex flex-col gap-4">
|
<div className="p-4 bg-neutral-surface rounded-md flex justify-between items-center">
|
<p className="text-sm text-neutral-content-subtle">
|
Sort: {sorting.length > 0 ? `${sorting[0].id} (${sorting[0].desc ? "desc" : "asc"})` : "none"}
|
</p>
|
<p className="text-sm text-neutral-content-subtle">Selected: {selectedCount}</p>
|
</div>
|
<DataTable
|
data={sampleData}
|
columns={columnsWithActions}
|
enableSorting
|
selectable
|
sorting={sorting}
|
onSortingChange={setSorting}
|
rowSelection={rowSelection}
|
onRowSelectionChange={setRowSelection}
|
onRowClick={(row) => {
|
if (row) {
|
console.log("Row clicked:", row.original);
|
}
|
}}
|
rowClassName={(row) => (row.original.status === "inactive" ? "opacity-50" : undefined)}
|
cellSizesStorageKey="storybook-full-featured-table"
|
/>
|
</div>
|
);
|
},
|
};
|
|
/**
|
* Conditional Row Selection
|
*
|
* Use `isRowSelectable` to control which rows can be selected.
|
* The checkbox will be hidden for non-selectable rows.
|
*
|
* In this example, inactive users cannot be selected.
|
*/
|
export const ConditionalRowSelection: Story = {
|
render: () => {
|
const [rowSelection, setRowSelection] = useState<Record<string, boolean>>({});
|
const selectedCount = Object.keys(rowSelection).length;
|
|
return (
|
<div className="flex flex-col gap-4">
|
<div className="p-4 bg-neutral-surface rounded-md">
|
<p className="text-sm text-neutral-content-subtle mb-2">
|
Selected: <strong>{selectedCount}</strong> user{selectedCount !== 1 ? "s" : ""}
|
</p>
|
<p className="text-sm text-neutral-content-subtle">
|
💡 Inactive users (Bob Johnson) cannot be selected - their checkbox is hidden
|
</p>
|
</div>
|
<DataTable
|
data={sampleData}
|
columns={baseColumns}
|
selectable
|
rowSelection={rowSelection}
|
onRowSelectionChange={setRowSelection}
|
isRowSelectable={(row) => row.original.status === "active"}
|
/>
|
</div>
|
);
|
},
|
};
|