This commit is contained in:
228
src/components/Table.stories.tsx
Normal file
228
src/components/Table.stories.tsx
Normal file
@@ -0,0 +1,228 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import type { Meta, StoryObj } from '@storybook/react';
|
||||
import type { SortState } from '../types/sort';
|
||||
import { Chip } from './Chip';
|
||||
import { Table, type TableHeader } from './Table';
|
||||
|
||||
type UserRow = {
|
||||
id: string;
|
||||
name: string;
|
||||
role: 'ADMIN' | 'EDITOR' | 'AUTHOR';
|
||||
status: 'Active' | 'Pending';
|
||||
posts: number;
|
||||
};
|
||||
|
||||
const rows: UserRow[] = [
|
||||
{ id: '1', name: 'Beatrice Rosa', role: 'ADMIN', status: 'Active', posts: 48 },
|
||||
{ id: '2', name: 'Luca Valli', role: 'EDITOR', status: 'Active', posts: 26 },
|
||||
{ id: '3', name: 'Marta Bellini', role: 'AUTHOR', status: 'Pending', posts: 4 },
|
||||
{ id: '4', name: 'Giulia Fontana', role: 'AUTHOR', status: 'Active', posts: 12 },
|
||||
{ id: '5', name: 'Andrea Pini', role: 'EDITOR', status: 'Pending', posts: 9 },
|
||||
{ id: '6', name: 'Sofia Denti', role: 'AUTHOR', status: 'Active', posts: 7 },
|
||||
{ id: '7', name: 'Marco Serra', role: 'AUTHOR', status: 'Active', posts: 18 },
|
||||
{ id: '8', name: 'Elena Neri', role: 'EDITOR', status: 'Active', posts: 31 }
|
||||
];
|
||||
|
||||
const headers: TableHeader<UserRow>[] = [
|
||||
{
|
||||
id: 'name',
|
||||
label: 'Name',
|
||||
value: (row) => row.name,
|
||||
sortable: true,
|
||||
sortField: 'name',
|
||||
cellClassName: 'table-cell-primary'
|
||||
},
|
||||
{
|
||||
id: 'role',
|
||||
label: 'Role',
|
||||
value: (row) => row.role,
|
||||
sortable: true,
|
||||
sortField: 'role'
|
||||
},
|
||||
{
|
||||
id: 'status',
|
||||
label: 'Status',
|
||||
value: (row) => (
|
||||
<Chip variant="outlined" tone={row.status === 'Active' ? 'indigo-700' : 'cyan-700'}>
|
||||
{row.status}
|
||||
</Chip>
|
||||
)
|
||||
},
|
||||
{
|
||||
id: 'posts',
|
||||
label: 'Posts',
|
||||
value: (row) => row.posts,
|
||||
sortable: true,
|
||||
sortField: 'posts'
|
||||
}
|
||||
];
|
||||
|
||||
type UsersTableProps = {
|
||||
data: UserRow[];
|
||||
isLoading?: boolean;
|
||||
emptyMessage?: string;
|
||||
sorting?: SortState | null;
|
||||
onSortChange?: (field: string) => void;
|
||||
pagination?: {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
total: number;
|
||||
totalPages: number;
|
||||
onPageChange: (page: number) => void;
|
||||
onPageSizeChange?: (pageSize: number) => void;
|
||||
};
|
||||
};
|
||||
|
||||
function UsersTable(props: Readonly<UsersTableProps>) {
|
||||
return (
|
||||
<Table<UserRow>
|
||||
headers={headers}
|
||||
data={props.data}
|
||||
rowKey={(row) => row.id}
|
||||
isLoading={props.isLoading}
|
||||
emptyMessage={props.emptyMessage}
|
||||
sorting={props.sorting}
|
||||
onSortChange={props.onSortChange}
|
||||
pagination={props.pagination}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function sortRows(data: UserRow[], sorting: SortState | null): UserRow[] {
|
||||
if (!sorting) {
|
||||
return data;
|
||||
}
|
||||
|
||||
const sorted = [...data];
|
||||
sorted.sort((a, b) => {
|
||||
const left = a[sorting.field as keyof UserRow];
|
||||
const right = b[sorting.field as keyof UserRow];
|
||||
if (left === right) {
|
||||
return 0;
|
||||
}
|
||||
if (typeof left === 'number' && typeof right === 'number') {
|
||||
return sorting.direction === 'asc' ? left - right : right - left;
|
||||
}
|
||||
return sorting.direction === 'asc'
|
||||
? String(left).localeCompare(String(right))
|
||||
: String(right).localeCompare(String(left));
|
||||
});
|
||||
return sorted;
|
||||
}
|
||||
|
||||
const meta = {
|
||||
title: 'Components/Table',
|
||||
component: UsersTable,
|
||||
tags: ['autodocs'],
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Generic data table with loading/empty states, optional sorting controls, and optional pagination footer.'
|
||||
}
|
||||
}
|
||||
},
|
||||
argTypes: {
|
||||
data: {
|
||||
description: 'Rows rendered in the table body.',
|
||||
control: 'object',
|
||||
table: { type: { summary: 'UserRow[]' } }
|
||||
},
|
||||
isLoading: {
|
||||
description: 'When true, shows the loading indicator row.',
|
||||
control: 'boolean',
|
||||
table: { type: { summary: 'boolean' } }
|
||||
},
|
||||
emptyMessage: {
|
||||
description: 'Message shown when `data` is empty and `isLoading` is false.',
|
||||
control: 'text',
|
||||
table: { type: { summary: 'string' } }
|
||||
},
|
||||
sorting: {
|
||||
description: "Current sort state object. Use `null` for no active sorting.",
|
||||
control: 'object',
|
||||
table: { type: { summary: "{ field: string; direction: 'asc' | 'desc' } | null" } }
|
||||
},
|
||||
onSortChange: {
|
||||
description: 'Callback fired when a sortable header is clicked.',
|
||||
action: 'sort changed',
|
||||
table: { type: { summary: '(field: string) => void' } }
|
||||
},
|
||||
pagination: {
|
||||
description: 'Pagination config object. When omitted, pagination footer is hidden.',
|
||||
control: 'object',
|
||||
table: {
|
||||
type: {
|
||||
summary: '{ page; pageSize; total; totalPages; onPageChange; onPageSizeChange? }'
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
args: {
|
||||
data: rows
|
||||
}
|
||||
} satisfies Meta<typeof UsersTable>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const WithRows: Story = {};
|
||||
|
||||
export const Loading: Story = {
|
||||
args: {
|
||||
isLoading: true
|
||||
}
|
||||
};
|
||||
|
||||
export const Empty: Story = {
|
||||
args: {
|
||||
data: [],
|
||||
emptyMessage: 'No users found'
|
||||
}
|
||||
};
|
||||
|
||||
export const InteractiveSortingAndPagination: Story = {
|
||||
render: () => {
|
||||
const [sorting, setSorting] = useState<SortState | null>({
|
||||
field: 'name',
|
||||
direction: 'asc'
|
||||
});
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(5);
|
||||
|
||||
const sorted = useMemo(() => sortRows(rows, sorting), [sorting]);
|
||||
const totalPages = Math.max(1, Math.ceil(sorted.length / pageSize));
|
||||
const safePage = Math.min(page, totalPages);
|
||||
const start = (safePage - 1) * pageSize;
|
||||
const pagedRows = sorted.slice(start, start + pageSize);
|
||||
|
||||
return (
|
||||
<UsersTable
|
||||
data={pagedRows}
|
||||
sorting={sorting}
|
||||
onSortChange={(field) => {
|
||||
setPage(1);
|
||||
setSorting((prev) => {
|
||||
if (!prev || prev.field !== field) {
|
||||
return { field, direction: 'asc' };
|
||||
}
|
||||
if (prev.direction === 'asc') {
|
||||
return { field, direction: 'desc' };
|
||||
}
|
||||
return null;
|
||||
});
|
||||
}}
|
||||
pagination={{
|
||||
page: safePage,
|
||||
pageSize,
|
||||
total: sorted.length,
|
||||
totalPages,
|
||||
onPageChange: setPage,
|
||||
onPageSizeChange: (next) => {
|
||||
setPage(1);
|
||||
setPageSize(next);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user