Changelog
diff --git a/apps/docs/src/pages/docs/getting-started.astro b/apps/docs/src/pages/docs/getting-started.astro
index a513e697..38672070 100644
--- a/apps/docs/src/pages/docs/getting-started.astro
+++ b/apps/docs/src/pages/docs/getting-started.astro
@@ -3,7 +3,7 @@ import DocsLayout from '../../layouts/DocsLayout.astro';
import CodeBlock from '../../components/CodeBlock.astro';
---
-
+
Getting started
@@ -55,7 +55,7 @@ export default function App() {
paginationbooleanEnable built-in pagination
selectableRowsbooleanEnable row checkboxes
expandableRowsbooleanEnable expandable row panels
- themestringNamed theme (e.g. "slate-dark")
+ themestringNamed theme (e.g. "material", "catppuccin", "crisp")
animateRowsbooleanStaggered row entrance + sort animation
columnSeparatorboolean | "full"Body column separators (headers always shown)
diff --git a/apps/docs/src/pages/docs/installation.astro b/apps/docs/src/pages/docs/installation.astro
index 637e0b03..c1ed58ec 100644
--- a/apps/docs/src/pages/docs/installation.astro
+++ b/apps/docs/src/pages/docs/installation.astro
@@ -3,7 +3,7 @@ import DocsLayout from '../../layouts/DocsLayout.astro';
import CodeBlock from '../../components/CodeBlock.astro';
---
-
+
Installation
npm
diff --git a/apps/docs/src/pages/docs/localization.astro b/apps/docs/src/pages/docs/localization.astro
index cfdcb1df..304c078a 100644
--- a/apps/docs/src/pages/docs/localization.astro
+++ b/apps/docs/src/pages/docs/localization.astro
@@ -3,7 +3,7 @@ import DocsLayout from '../../layouts/DocsLayout.astro';
import CodeBlock from '../../components/CodeBlock.astro';
---
-
+
Localization
diff --git a/apps/docs/src/pages/docs/migration.md b/apps/docs/src/pages/docs/migration.md
index 95af14ba..4e1c36ca 100644
--- a/apps/docs/src/pages/docs/migration.md
+++ b/apps/docs/src/pages/docs/migration.md
@@ -1,6 +1,7 @@
---
layout: '../../layouts/DocsLayout.astro'
title: 'Migration guide | react-data-table-component'
+description: 'Step-by-step upgrade instructions and breaking changes for each major version of react-data-table-component.'
---
# Migration guide
diff --git a/apps/docs/src/pages/docs/performance.astro b/apps/docs/src/pages/docs/performance.astro
index 7542634b..6b7788a3 100644
--- a/apps/docs/src/pages/docs/performance.astro
+++ b/apps/docs/src/pages/docs/performance.astro
@@ -3,7 +3,7 @@ import DocsLayout from '../../layouts/DocsLayout.astro';
import CodeBlock from '../../components/CodeBlock.astro';
---
-
+
Performance
diff --git a/apps/docs/src/pages/docs/recipes.astro b/apps/docs/src/pages/docs/recipes.astro
index 35deb0dd..71f004bb 100644
--- a/apps/docs/src/pages/docs/recipes.astro
+++ b/apps/docs/src/pages/docs/recipes.astro
@@ -1,355 +1,64 @@
---
import DocsLayout from '../../layouts/DocsLayout.astro';
-import CodeBlock from '../../components/CodeBlock.astro';
+
+const recipes = [
+ {
+ href: '/docs/recipes/bulk-action-toolbar',
+ title: 'Approval workflow',
+ desc: 'Bulk-approve or reject requests. Toolbar buttons adapt to the selected rows\' status — already-resolved rows are excluded automatically.',
+ },
+ {
+ href: '/docs/recipes/server-side',
+ title: 'Server-side sort, page & filter',
+ desc: 'Wire sorting, pagination, and column filters to your own API in a single useEffect.',
+ },
+ {
+ href: '/docs/recipes/editable-grid',
+ title: 'URL-synced table state',
+ desc: 'Sync sort, filters, and pagination to the URL — shareable links, reload-safe, back button works.',
+ },
+ {
+ href: '/docs/recipes/master-detail',
+ title: 'Dashboard with drill-down',
+ desc: 'Department overview with expandable rows, health-status row tinting, and inline budget-spend bars.',
+ },
+ {
+ href: '/docs/recipes/sticky-footer',
+ title: 'Audit log viewer',
+ desc: 'Fixed header, severity row tinting, and multi-field live search — all wired together.',
+ },
+ {
+ href: '/docs/recipes/persist-column-widths',
+ title: 'Persist column widths',
+ desc: 'Save and restore resizable column widths to localStorage or a back-end API.',
+ },
+ {
+ href: '/docs/recipes/row-grouping',
+ title: 'Inline row actions',
+ desc: 'Per-row ⋮ menu with edit, duplicate, and delete — confirmation modals included.',
+ },
+];
---
-
+
Recipes
- Working patterns for common product needs. Each recipe is self-contained. Copy into your
+ Working patterns for common product needs. Each recipe is self-contained — copy into your
project and adjust.
- Persist column widths
-
-
- Pass onColumnResize to receive the settled width after every drag, and
- initialColumnWidths to hydrate those widths on mount. The callback receives
- the resized column's id, its new width in px, and the full widths map. Write it
- to localStorage, a database, or anywhere else.
-
-
- localStorage
-
- {
- try { return JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '{}'); }
- catch { return {}; }
-}
-
-const columns: TableColumn[] = [
- { id: 'name', name: 'Name', selector: r => r.name },
- { id: 'salary', name: 'Salary', selector: r => r.salary, right: true },
-];
-
-export default function App() {
- const [initialWidths] = useState(loadWidths);
-
- return (
-
- localStorage.setItem(STORAGE_KEY, JSON.stringify(all))
- }
- />
- );
-}`} />
-
- Database / back-end
-
-
- Swap the callback body to call your API instead. Wrap with a debounce if you want to
- batch rapid resizes into a single request.
-
-
- >({});
-
- useEffect(() => {
- api.getColumnPrefs(userId).then(setInitialWidths);
- }, [userId]);
-
- const handleResize = useCallback(
- debounce((_id: string | number, _w: number, all: Record) => {
- api.saveColumnPrefs(userId, all);
- }, 500),
- [userId],
- );
-
- return (
-
- );
-}`} />
-
- Server-side sort, page, and filter
-
-
- Enable sortServer, paginationServer, and pass controlled
- filterValues. Refetch from a single effect that depends on every state knob:
-
-
- ([]);
- const [total, setTotal] = useState(0);
- const [page, setPage] = useState(1);
- const [perPage, setPerPage] = useState(20);
- const [sort, setSort] = useState<{ id?: string; dir: SortOrder }>({ dir: SortOrder.ASC });
- const [filters, setFilters] = useState>({});
- const [loading, setLoading] = useState(false);
-
- useEffect(() => {
- let cancelled = false;
- setLoading(true);
- fetchEmployees({ page, perPage, sort, filters }).then(res => {
- if (cancelled) return;
- setRows(res.rows);
- setTotal(res.total);
- setLoading(false);
- });
- return () => { cancelled = true; };
- }, [page, perPage, sort, filters]);
-
- const columns: TableColumn[] = [
- { id: 'name', name: 'Name', selector: r => r.name, sortable: true, filterable: true },
- { id: 'salary', name: 'Salary', selector: r => r.salary, sortable: true, filterable: true,
- filterType: 'number', right: true },
- ];
-
- return (
- setSort({ id: col.id as string, dir })}
-
- filterValues={filters}
- onFilterChange={(columnId, next) =>
- setFilters(prev => ({ ...prev, [columnId]: next }))
- }
- />
- );
-}`} />
-
-
-
-
- v8 removed the built-in contextMessage and contextActions props.
- The recommended replacement is a toolbar rendered outside the table, driven by
- onSelectedRowsChange. This gives you full control over layout, styling,
- and what actions appear.
-
-
-
- Show the toolbar only when rows are selected, display a count and names, and use the
- imperative ref to clear selection after the action completes.
-
-
- [] = [
- { name: 'Name', selector: r => r.name },
- { name: 'Department', selector: r => r.department },
-];
-
-export default function App() {
- const ref = useRef(null);
- const [selected, setSelected] = useState([]);
-
- async function handleArchive() {
- await api.archive(selected.map(r => r.id));
- ref.current?.clearSelectedRows();
- }
-
- async function handleExport() {
- await api.export(selected.map(r => r.id));
- ref.current?.clearSelectedRows();
- }
-
- return (
-
- {/* Toolbar — only visible when rows are selected */}
- {selected.length > 0 && (
-
-
- {selected.length} selected: {selected.map(r => r.name).join(', ')}
-
-
- Export
- Archive
- ref.current?.clearSelectedRows()}>Cancel
-
+
- );
-}`} />
-
-
Master/detail with a nested table
-
-
- Render a second DataTable inside expandableRowsComponent:
-
-
-
[] = [
- { name: 'SKU', selector: i => i.sku },
- { name: 'Qty', selector: i => i.qty, center: true, width: '80px' },
- { name: 'Price', selector: i => i.price, right: true, width: '110px',
- format: i => \`$\${i.price.toFixed(2)}\` },
-];
-
-function OrderDetail({ data }: ExpanderComponentProps) {
- return (
-
-
-
- );
-}
-
- `} />
-
- Sticky footer with totals
-
-
- Use the built-in footer — add a footer field on any
- column and the table renders an aligned totals row automatically. If you'd rather render
- your own summary outside the table, the pattern below still works:
-
-
- ({ salary: acc.salary + r.salary, headcount: acc.headcount + 1 }),
- { salary: 0, headcount: 0 },
-);
-
-<>
-
-
-
{totals.headcount} employees
-
Total: \${totals.salary.toLocaleString()}
+
{desc}
+
+ ))}
->`} />
-
- Excel-style editable grid
-
-
- Combine editable: true, onCellEdit, and an immer-style
- reducer for a spreadsheet feel. See the inline editing
- docs for the full pattern.
-
-
- [] = [
- { id: 'name', name: 'Name', selector: r => r.name, editable: true, onCellEdit },
- { id: 'quantity', name: 'Quantity', selector: r => r.quantity, editable: true, onCellEdit,
- right: true, width: '110px' },
- { id: 'price', name: 'Price', selector: r => r.price, editable: true, onCellEdit,
- right: true, width: '110px',
- format: r => \`$\${r.price.toFixed(2)}\` },
- { id: 'total', name: 'Total', selector: r => r.quantity * r.price,
- right: true, width: '110px',
- format: r => \`$\${(r.quantity * r.price).toFixed(2)}\` },
-];`} />
-
- Row grouping (manual)
-
-
- The library doesn't include row grouping, but you can fake it by pre-grouping your data
- and using conditionalRowStyles to highlight group headers:
-
-
- e.dept === 'Engineering'),
- { __group: 'Product' },
- ...employees.filter(e => e.dept === 'Product'),
-];
-
- '__group' in row
- ? {row.__group}
- : row.name,
- },
- // ... other columns ...
- ]}
- conditionalRowStyles={[
- { when: row => '__group' in row,
- style: { background: '#f3f4f6', fontWeight: 600 } },
- ]}
-/>`} />
-
- CSV export
-
-
- No built-in exporter. For typical sizes, a 20-line client-side function is fine:
-
-
- (rows: T[], columns: TableColumn[], filename = 'export.csv') {
- const header = columns.map(c => JSON.stringify(c.name)).join(',');
- const lines = rows.map(row =>
- columns
- .map(c => JSON.stringify(c.selector ? String(c.selector(row)) : ''))
- .join(','),
- );
- const csv = [header, ...lines].join('\\n');
- const blob = new Blob([csv], { type: 'text/csv' });
- const url = URL.createObjectURL(blob);
- const a = Object.assign(document.createElement('a'), { href: url, download: filename });
- a.click();
- URL.revokeObjectURL(url);
-}
-
- exportCsv(data, columns, 'employees.csv')}>Export `} />
diff --git a/apps/docs/src/pages/docs/ssr.astro b/apps/docs/src/pages/docs/ssr.astro
index 72630cac..41437f0c 100644
--- a/apps/docs/src/pages/docs/ssr.astro
+++ b/apps/docs/src/pages/docs/ssr.astro
@@ -3,7 +3,7 @@ import DocsLayout from '../../layouts/DocsLayout.astro';
import CodeBlock from '../../components/CodeBlock.astro';
---
-
+
Server-Side Rendering
diff --git a/apps/docs/src/pages/docs/typescript.astro b/apps/docs/src/pages/docs/typescript.astro
index cdd44989..6644b1f1 100644
--- a/apps/docs/src/pages/docs/typescript.astro
+++ b/apps/docs/src/pages/docs/typescript.astro
@@ -3,7 +3,7 @@ import DocsLayout from '../../layouts/DocsLayout.astro';
import CodeBlock from '../../components/CodeBlock.astro';
---
-
+
TypeScript
diff --git a/apps/docs/src/pages/docs/whats-new.astro b/apps/docs/src/pages/docs/whats-new.astro
index a0709447..8b81c3cc 100644
--- a/apps/docs/src/pages/docs/whats-new.astro
+++ b/apps/docs/src/pages/docs/whats-new.astro
@@ -3,7 +3,7 @@ import DocsLayout from '../../layouts/DocsLayout.astro';
import CodeBlock from '../../components/CodeBlock.astro';
---
-
+
What's new in v8
diff --git a/apps/docs/src/pages/index.astro b/apps/docs/src/pages/index.astro
index 4f8c7eb5..3e49d356 100644
--- a/apps/docs/src/pages/index.astro
+++ b/apps/docs/src/pages/index.astro
@@ -2,9 +2,29 @@
import { Code } from 'astro:components';
import Layout from '../layouts/Layout.astro';
import LiveDemo from '../components/LiveDemo.tsx';
+
+const [npmRes, ghRes] = await Promise.allSettled([
+ fetch('https://api.npmjs.org/downloads/point/last-week/react-data-table-component'),
+ fetch('https://api.github.com/repos/jbetancur/react-data-table-component'),
+]);
+
+const weeklyDownloads: number =
+ npmRes.status === 'fulfilled' && npmRes.value.ok
+ ? (await npmRes.value.json()).downloads
+ : 215000;
+
+const stars: number =
+ ghRes.status === 'fulfilled' && ghRes.value.ok
+ ? (await ghRes.value.json()).stargazers_count
+ : 2000;
+
+function fmt(n: number): string {
+ if (n >= 1000) return `${Math.floor(n / 1000)}k+`;
+ return String(n);
+}
---
-
+
@@ -49,16 +69,57 @@ import LiveDemo from '../components/LiveDemo.tsx';
-
-
$ npm install react-data-table-component
+
+
$ npm install react-data-table-component
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{fmt(weeklyDownloads)}
+
weekly downloads
+
+
+
+
+
+
2018
+
actively maintained since
+
-
-
Try it live: switch themes, toggle features
-
@@ -84,7 +145,7 @@ export default function App() {
lang="tsx"
theme="catppuccin-macchiato"
defaultColor={false}
- class="rounded-xl text-sm leading-relaxed overflow-x-auto"
+ class="rounded-xl text-sm leading-relaxed overflow-x-auto p-6"
/>
@@ -121,9 +182,9 @@ export default function App() {
desc: 'Render any component inside an expandable detail panel. Animate open/close with a single prop.',
},
{
- icon: '⚡',
- title: 'Animate rows',
- desc: 'Staggered row entrance and sort-pulse animations. Respects prefers-reduced-motion.',
+ icon: '⌕',
+ title: 'Column filtering',
+ desc: 'Per-column filter inputs for text, number, and select. Client-side or hook into your own server search.',
},
{
icon: '⊞',
@@ -136,9 +197,9 @@ export default function App() {
desc: 'Drag column edges to resize. Widths preserved in state, serializable for persistence.',
},
{
- icon: '○',
- title: 'Zero peer deps',
- desc: 'styled-components removed in v8. CSS custom properties handle all theming at zero runtime cost.',
+ icon: '▲',
+ title: 'SSR & Next.js ready',
+ desc: 'Works in Next.js App Router, Remix, and Astro. No useLayoutEffect warnings. Ships "use client" automatically.',
},
].map(({ icon, title, desc }) => (
@@ -197,7 +258,7 @@ export default function App() {
Sponsors
- This library is maintained by one person. If your team ships products with it, consider sponsoring. It funds bug
+ Actively maintained since 2018. If your team ships products with it, consider sponsoring — it funds bug
fixes and keeps pace with the React ecosystem.
diff --git a/apps/docs/src/pages/support.astro b/apps/docs/src/pages/support.astro
index fdf56b53..849cac95 100644
--- a/apps/docs/src/pages/support.astro
+++ b/apps/docs/src/pages/support.astro
@@ -13,8 +13,8 @@ const OC_BASE = 'https://opencollective.com/react-data-table-component';
Support & Sponsorship
- This library is maintained by one person. Your support funds bug fixes, new features, and keeps
- pace with the React ecosystem.
+ Actively maintained since 2018 and downloaded ~215k times a week. Your sponsorship funds bug fixes,
+ new features, and keeps pace with the React ecosystem.
diff --git a/package.json b/package.json
index b2e61b89..cd615b20 100644
--- a/package.json
+++ b/package.json
@@ -26,8 +26,19 @@
"data",
"table",
"react-table",
- "react-data-table-component"
+ "react-data-table-component",
+ "pagination",
+ "sorting",
+ "expandable-rows",
+ "row-selection",
+ "typescript",
+ "accessible",
+ "themeable",
+ "nextjs",
+ "dashboard",
+ "admin"
],
+ "homepage": "https://reactdatatable.com",
"repository": "https://github.com/jbetancur/react-data-table-component",
"author": "jbetancur",
"license": "Apache-2.0",
From 9cb5053a6def006fc97404b9a5b558443de551b2 Mon Sep 17 00:00:00 2001
From: John Betancur <1385932+jbetancur@users.noreply.github.com>
Date: Fri, 22 May 2026 22:01:22 -0400
Subject: [PATCH 2/2] feat: add new recipes and demos for data table
functionalities
- Implemented PersistColumnWidthsDemo to demonstrate persistent column widths using localStorage.
- Created ServerSideRecipeDemo for server-side sorting, pagination, and filtering.
- Developed UrlSyncDemo to sync table state with URL parameters.
- Updated DocsLayout to include new recipe links for better navigation.
- Added bulk action toolbar recipe with multi-step approval workflow.
- Introduced editable grid recipe with URL-synced state.
- Created master-detail recipe showcasing dashboard drill-down functionality.
- Added persist column widths recipe demonstrating localStorage and API integration.
- Implemented inline row actions recipe with confirmation modals and optimistic updates.
- Developed server-side recipe for sorting, pagination, and filtering.
- Created sticky footer recipe for an audit log viewer with fixed headers and multi-field search.
---
apps/docs/src/components/LiveDemo.tsx | 69 +++---
.../components/demos/ApprovalWorkflowDemo.tsx | 158 +++++++++++++
.../src/components/demos/AuditLogDemo.tsx | 121 ++++++++++
.../src/components/demos/BulkActionDemo.tsx | 97 ++++++++
.../demos/DashboardDrilldownDemo.tsx | 164 ++++++++++++++
.../src/components/demos/EditableGridDemo.tsx | 91 ++++++++
.../components/demos/InlineRowActionsDemo.tsx | 208 ++++++++++++++++++
.../demos/PersistColumnWidthsDemo.tsx | 80 +++++++
.../components/demos/ServerSideRecipeDemo.tsx | 120 ++++++++++
.../docs/src/components/demos/UrlSyncDemo.tsx | 128 +++++++++++
apps/docs/src/layouts/DocsLayout.astro | 49 +++--
.../docs/recipes/bulk-action-toolbar.astro | 77 +++++++
.../pages/docs/recipes/editable-grid.astro | 99 +++++++++
.../pages/docs/recipes/master-detail.astro | 80 +++++++
.../docs/recipes/persist-column-widths.astro | 89 ++++++++
.../src/pages/docs/recipes/row-grouping.astro | 90 ++++++++
.../src/pages/docs/recipes/server-side.astro | 72 ++++++
.../pages/docs/recipes/sticky-footer.astro | 62 ++++++
18 files changed, 1810 insertions(+), 44 deletions(-)
create mode 100644 apps/docs/src/components/demos/ApprovalWorkflowDemo.tsx
create mode 100644 apps/docs/src/components/demos/AuditLogDemo.tsx
create mode 100644 apps/docs/src/components/demos/BulkActionDemo.tsx
create mode 100644 apps/docs/src/components/demos/DashboardDrilldownDemo.tsx
create mode 100644 apps/docs/src/components/demos/EditableGridDemo.tsx
create mode 100644 apps/docs/src/components/demos/InlineRowActionsDemo.tsx
create mode 100644 apps/docs/src/components/demos/PersistColumnWidthsDemo.tsx
create mode 100644 apps/docs/src/components/demos/ServerSideRecipeDemo.tsx
create mode 100644 apps/docs/src/components/demos/UrlSyncDemo.tsx
create mode 100644 apps/docs/src/pages/docs/recipes/bulk-action-toolbar.astro
create mode 100644 apps/docs/src/pages/docs/recipes/editable-grid.astro
create mode 100644 apps/docs/src/pages/docs/recipes/master-detail.astro
create mode 100644 apps/docs/src/pages/docs/recipes/persist-column-widths.astro
create mode 100644 apps/docs/src/pages/docs/recipes/row-grouping.astro
create mode 100644 apps/docs/src/pages/docs/recipes/server-side.astro
create mode 100644 apps/docs/src/pages/docs/recipes/sticky-footer.astro
diff --git a/apps/docs/src/components/LiveDemo.tsx b/apps/docs/src/components/LiveDemo.tsx
index 7e8036ef..4e4c1f57 100644
--- a/apps/docs/src/components/LiveDemo.tsx
+++ b/apps/docs/src/components/LiveDemo.tsx
@@ -90,9 +90,21 @@ const columns = [
},
];
+function ExpandedRow({ data }: { data: Row }) {
+ return (
+
+
Department: {data.department}
+
Status: {data.status}
+
Salary: ${data.salary.toLocaleString()}
+
Role: {data.role}
+
+ );
+}
+
export default function LiveDemo() {
const [theme, setTheme] = useState('default');
const [selectable, setSelectable] = useState(true);
+ const [expandable, setExpandable] = useState(false);
const [striped, setStriped] = useState(false);
const [animateRows, setAnimateRows] = useState(true);
const [selectedCount, setSelectedCount] = useState(0);
@@ -108,10 +120,16 @@ export default function LiveDemo() {
: 'bg-white text-gray-600 border-gray-200 hover:border-gray-300'
}`;
+ const toggleClass = (active: boolean) =>
+ `relative inline-flex h-4 w-7 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors ${
+ active ? 'bg-brand-600' : 'bg-gray-200'
+ }`;
+
return (
{/* Toolbar */}
-
+
+ {/* Theme row */}
Theme
{THEMES.map(t => (
@@ -121,34 +139,29 @@ export default function LiveDemo() {
))}
-
-
- setSelectable(e.target.checked)}
- className="rounded"
- />
- Selectable
-
-
-
- setStriped(e.target.checked)} className="rounded" />
- Striped
-
-
-
- setAnimateRows(e.target.checked)}
- className="rounded"
- />
- Animate
-
+ {/* Toggles row */}
+
+ {([
+ ['Selectable', selectable, setSelectable],
+ ['Expandable', expandable, setExpandable],
+ ['Striped', striped, setStriped],
+ ['Animate', animateRows, setAnimateRows],
+ ] as [string, boolean, (v: boolean) => void][]).map(([label, value, setter]) => (
+
+ setter(!value)}
+ className={toggleClass(value)}
+ >
+
+
+ {label}
+
+ ))}
{selectable && selectedCount > 0 && (
- {selectedCount} selected
+ {selectedCount} selected
)}
@@ -163,6 +176,8 @@ export default function LiveDemo() {
highlightOnHover
selectableRows={selectable}
onSelectedRowsChange={handleSelectedChange}
+ expandableRows={expandable}
+ expandableRowsComponent={ExpandedRow}
animateRows={animateRows}
resizable
pagination
diff --git a/apps/docs/src/components/demos/ApprovalWorkflowDemo.tsx b/apps/docs/src/components/demos/ApprovalWorkflowDemo.tsx
new file mode 100644
index 00000000..f7783497
--- /dev/null
+++ b/apps/docs/src/components/demos/ApprovalWorkflowDemo.tsx
@@ -0,0 +1,158 @@
+import React, { useRef, useState } from 'react';
+import DataTable, { type DataTableHandle, type TableColumn } from 'react-data-table-component';
+
+type Status = 'pending' | 'approved' | 'rejected' | 'needs-info';
+
+interface Request {
+ id: number;
+ title: string;
+ requester: string;
+ department: string;
+ amount: number;
+ status: Status;
+ submittedAt: string;
+}
+
+const STATUS_LABEL: Record
= {
+ 'pending': 'Pending',
+ 'approved': 'Approved',
+ 'rejected': 'Rejected',
+ 'needs-info': 'Needs info',
+};
+
+const STATUS_CLASS: Record = {
+ 'pending': 'bg-yellow-50 text-yellow-700 border border-yellow-200',
+ 'approved': 'bg-green-50 text-green-700 border border-green-200',
+ 'rejected': 'bg-red-50 text-red-700 border border-red-200',
+ 'needs-info': 'bg-blue-50 text-blue-700 border border-blue-200',
+};
+
+const initialData: Request[] = [
+ { id: 1, title: 'New MacBook Pro', requester: 'Sam Rivera', department: 'Engineering', amount: 2499, status: 'pending', submittedAt: '2024-05-14' },
+ { id: 2, title: 'Figma Teams plan', requester: 'Priya Kapoor', department: 'Design', amount: 720, status: 'pending', submittedAt: '2024-05-14' },
+ { id: 3, title: 'AWS reserved instance', requester: 'Aria Chen', department: 'Engineering', amount: 8400, status: 'needs-info', submittedAt: '2024-05-13' },
+ { id: 4, title: 'Office chairs (x4)', requester: 'Marcus Webb', department: 'Product', amount: 1200, status: 'pending', submittedAt: '2024-05-12' },
+ { id: 5, title: 'Tableau license', requester: 'Jordan Ellis', department: 'Analytics', amount: 1800, status: 'approved', submittedAt: '2024-05-10' },
+ { id: 6, title: 'Conference travel', requester: 'Taylor Brooks', department: 'Sales', amount: 950, status: 'rejected', submittedAt: '2024-05-09' },
+ { id: 7, title: 'Slack Enterprise', requester: 'Casey Morgan', department: 'Engineering', amount: 3600, status: 'pending', submittedAt: '2024-05-08' },
+];
+
+const ACTIONABLE: Status[] = ['pending', 'needs-info'];
+
+export default function ApprovalWorkflowDemo() {
+ const ref = useRef(null);
+ const [data, setData] = useState(initialData);
+ const [selected, setSelected] = useState([]);
+ const [toast, setToast] = useState('');
+
+ function showToast(msg: string) {
+ setToast(msg);
+ setTimeout(() => setToast(''), 2500);
+ }
+
+ function applyStatus(ids: number[], next: Status) {
+ setData(prev => prev.map(r => ids.includes(r.id) ? { ...r, status: next } : r));
+ ref.current?.clearSelectedRows();
+ showToast(`${ids.length} request${ids.length !== 1 ? 's' : ''} marked as "${STATUS_LABEL[next]}"`);
+ }
+
+ const actionable = selected.filter(r => ACTIONABLE.includes(r.status));
+ const selectedIds = actionable.map(r => r.id);
+
+ const columns: TableColumn[] = [
+ { id: 'title', name: 'Request', selector: r => r.title, grow: 2 },
+ { id: 'requester', name: 'Requester', selector: r => r.requester, sortable: true },
+ { id: 'department', name: 'Dept', selector: r => r.department, sortable: true, width: '120px' },
+ {
+ id: 'amount',
+ name: 'Amount',
+ selector: r => r.amount,
+ sortable: true,
+ right: true,
+ width: '110px',
+ format: r => `$${r.amount.toLocaleString()}`,
+ },
+ {
+ id: 'status',
+ name: 'Status',
+ selector: r => r.status,
+ sortable: true,
+ width: '130px',
+ cell: r => (
+
+ {STATUS_LABEL[r.status]}
+
+ ),
+ },
+ { id: 'submittedAt', name: 'Submitted', selector: r => r.submittedAt, sortable: true, width: '115px' },
+ ];
+
+ return (
+
+ {/* Bulk toolbar — actions change based on what's selected */}
+ {selected.length > 0 && (
+
+
+ {selected.length} selected
+ {actionable.length < selected.length && (
+
+ ({selected.length - actionable.length} already resolved, excluded)
+
+ )}
+
+
+ {actionable.length > 0 && (
+ <>
+ applyStatus(selectedIds, 'approved')}
+ className="px-3 py-1 rounded-md bg-green-50 border border-green-200 text-green-700 hover:bg-green-100 text-xs font-medium"
+ >
+ Approve {actionable.length > 1 ? `(${actionable.length})` : ''}
+
+ applyStatus(selectedIds, 'needs-info')}
+ className="px-3 py-1 rounded-md bg-blue-50 border border-blue-200 text-blue-700 hover:bg-blue-100 text-xs font-medium"
+ >
+ Needs info
+
+ applyStatus(selectedIds, 'rejected')}
+ className="px-3 py-1 rounded-md bg-red-50 border border-red-200 text-red-700 hover:bg-red-100 text-xs font-medium"
+ >
+ Reject
+
+ >
+ )}
+ ref.current?.clearSelectedRows()}
+ className="px-3 py-1 rounded-md text-gray-400 hover:text-gray-600 text-xs"
+ >
+ Cancel
+
+
+
+ )}
+
+
+ !ACTIONABLE.includes(r.status)}
+ onSelectedRowsChange={({ selectedRows }) => setSelected(selectedRows)}
+ highlightOnHover
+ defaultSortFieldId="submittedAt"
+ defaultSortAsc={false}
+ />
+
+
+ {toast && (
+
+ {toast}
+
+ )}
+
+ );
+}
diff --git a/apps/docs/src/components/demos/AuditLogDemo.tsx b/apps/docs/src/components/demos/AuditLogDemo.tsx
new file mode 100644
index 00000000..e5a2ed61
--- /dev/null
+++ b/apps/docs/src/components/demos/AuditLogDemo.tsx
@@ -0,0 +1,121 @@
+import React, { useState } from 'react';
+import DataTable, { type ConditionalStyles, type TableColumn } from 'react-data-table-component';
+
+type Severity = 'info' | 'warning' | 'error' | 'critical';
+
+interface LogEntry {
+ id: number;
+ timestamp: string;
+ severity: Severity;
+ user: string;
+ action: string;
+ resource: string;
+ ip: string;
+}
+
+const SEVERITY_STYLE: Record = {
+ info: 'bg-blue-50 text-blue-700 border border-blue-100',
+ warning: 'bg-yellow-50 text-yellow-700 border border-yellow-100',
+ error: 'bg-red-50 text-red-700 border border-red-100',
+ critical: 'bg-red-100 text-red-900 border border-red-300 font-bold',
+};
+
+const ALL_LOGS: LogEntry[] = [
+ { id: 1, timestamp: '2024-05-16 09:01:12', severity: 'info', user: 'aria.chen', action: 'LOGIN', resource: '/dashboard', ip: '10.0.1.42' },
+ { id: 2, timestamp: '2024-05-16 09:03:44', severity: 'info', user: 'marcus.webb', action: 'VIEW', resource: '/reports/q1', ip: '10.0.1.18' },
+ { id: 3, timestamp: '2024-05-16 09:12:05', severity: 'warning', user: 'aria.chen', action: 'EXPORT', resource: '/users/all', ip: '10.0.1.42' },
+ { id: 4, timestamp: '2024-05-16 09:15:30', severity: 'error', user: 'unknown', action: 'LOGIN_FAILED', resource: '/auth', ip: '203.0.113.7' },
+ { id: 5, timestamp: '2024-05-16 09:15:31', severity: 'error', user: 'unknown', action: 'LOGIN_FAILED', resource: '/auth', ip: '203.0.113.7' },
+ { id: 6, timestamp: '2024-05-16 09:15:32', severity: 'critical', user: 'unknown', action: 'BRUTE_FORCE', resource: '/auth', ip: '203.0.113.7' },
+ { id: 7, timestamp: '2024-05-16 09:22:18', severity: 'info', user: 'priya.kapoor', action: 'UPDATE', resource: '/settings/theme', ip: '10.0.1.55' },
+ { id: 8, timestamp: '2024-05-16 09:31:00', severity: 'warning', user: 'jordan.ellis', action: 'DELETE', resource: '/reports/draft-4', ip: '10.0.2.11' },
+ { id: 9, timestamp: '2024-05-16 09:45:09', severity: 'info', user: 'sam.rivera', action: 'DEPLOY', resource: '/infra/staging', ip: '10.0.1.99' },
+ { id: 10, timestamp: '2024-05-16 10:02:44', severity: 'critical', user: 'system', action: 'DB_CONN_LOST', resource: '/db/primary', ip: '10.0.0.1' },
+ { id: 11, timestamp: '2024-05-16 10:03:01', severity: 'critical', user: 'system', action: 'FAILOVER', resource: '/db/replica', ip: '10.0.0.2' },
+ { id: 12, timestamp: '2024-05-16 10:15:22', severity: 'info', user: 'aria.chen', action: 'LOGOUT', resource: '/auth', ip: '10.0.1.42' },
+ { id: 13, timestamp: '2024-05-16 10:28:33', severity: 'warning', user: 'taylor.brooks',action: 'PERMISSION_DENY', resource: '/admin/users', ip: '10.0.1.77' },
+ { id: 14, timestamp: '2024-05-16 10:55:50', severity: 'error', user: 'system', action: 'DISK_FULL', resource: '/storage/logs', ip: '10.0.0.5' },
+ { id: 15, timestamp: '2024-05-16 11:10:04', severity: 'info', user: 'marcus.webb', action: 'LOGIN', resource: '/dashboard', ip: '10.0.1.18' },
+];
+
+const SEVERITIES: ('all' | Severity)[] = ['all', 'info', 'warning', 'error', 'critical'];
+
+const conditionalRowStyles: ConditionalStyles[] = [
+ { when: r => r.severity === 'critical', style: { backgroundColor: '#fef2f2' } },
+ { when: r => r.severity === 'error', style: { backgroundColor: '#fff7f7' } },
+];
+
+const columns: TableColumn[] = [
+ { id: 'timestamp', name: 'Timestamp', selector: r => r.timestamp, sortable: true, width: '175px' },
+ {
+ id: 'severity',
+ name: 'Severity',
+ selector: r => r.severity,
+ sortable: true,
+ width: '110px',
+ cell: r => (
+
+ {r.severity}
+
+ ),
+ },
+ { id: 'user', name: 'User', selector: r => r.user, sortable: true, width: '145px' },
+ { id: 'action', name: 'Action', selector: r => r.action, sortable: true, width: '145px', style: { fontFamily: 'monospace', fontSize: 12 } },
+ { id: 'resource', name: 'Resource', selector: r => r.resource, grow: 1, style: { fontFamily: 'monospace', fontSize: 12 } },
+ { id: 'ip', name: 'IP', selector: r => r.ip, width: '130px', style: { fontFamily: 'monospace', fontSize: 12 } },
+];
+
+export default function AuditLogDemo() {
+ const [severity, setSeverity] = useState<'all' | Severity>('all');
+ const [search, setSearch] = useState('');
+
+ const filtered = ALL_LOGS.filter(r => {
+ if (severity !== 'all' && r.severity !== severity) return false;
+ if (search) {
+ const q = search.toLowerCase();
+ return r.user.includes(q) || r.action.includes(q) || r.resource.includes(q) || r.ip.includes(q);
+ }
+ return true;
+ });
+
+ return (
+
+
+
setSearch(e.target.value)}
+ placeholder="Search user, action, resource, IP…"
+ className="px-3 py-1.5 text-xs border border-gray-200 rounded-md w-56 focus:outline-none focus:border-gray-400"
+ />
+
+ {SEVERITIES.map(s => (
+ setSeverity(s)}
+ className={`px-2.5 py-1 text-xs rounded-full border transition-colors ${
+ severity === s
+ ? 'bg-gray-800 border-gray-800 text-white'
+ : 'border-gray-200 text-gray-500 hover:border-gray-300'
+ }`}
+ >
+ {s}
+
+ ))}
+
+
+
+ No matching log entries
}
+ />
+
+
+ );
+}
diff --git a/apps/docs/src/components/demos/BulkActionDemo.tsx b/apps/docs/src/components/demos/BulkActionDemo.tsx
new file mode 100644
index 00000000..0e7aa394
--- /dev/null
+++ b/apps/docs/src/components/demos/BulkActionDemo.tsx
@@ -0,0 +1,97 @@
+import React, { useRef, useState } from 'react';
+import DataTable, { type DataTableHandle, type TableColumn } from 'react-data-table-component';
+
+interface Employee {
+ id: number;
+ name: string;
+ department: string;
+ role: string;
+}
+
+const data: Employee[] = [
+ { id: 1, name: 'Aria Chen', department: 'Engineering', role: 'Engineering Lead' },
+ { id: 2, name: 'Marcus Webb', department: 'Product', role: 'Product Manager' },
+ { id: 3, name: 'Priya Kapoor', department: 'Design', role: 'Senior Designer' },
+ { id: 4, name: 'Jordan Ellis', department: 'Analytics', role: 'Data Scientist' },
+ { id: 5, name: 'Sam Rivera', department: 'Engineering', role: 'DevOps Engineer' },
+ { id: 6, name: 'Taylor Brooks', department: 'Sales', role: 'Account Manager' },
+];
+
+const columns: TableColumn
[] = [
+ { name: 'Name', selector: r => r.name, sortable: true },
+ { name: 'Department', selector: r => r.department, sortable: true },
+ { name: 'Role', selector: r => r.role },
+];
+
+export default function BulkActionDemo() {
+ const ref = useRef(null);
+ const [selected, setSelected] = useState([]);
+ const [toast, setToast] = useState('');
+
+ function showToast(msg: string) {
+ setToast(msg);
+ setTimeout(() => setToast(''), 2500);
+ }
+
+ function handleExport() {
+ showToast(`Exported ${selected.length} row${selected.length !== 1 ? 's' : ''}`);
+ ref.current?.clearSelectedRows();
+ }
+
+ function handleArchive() {
+ showToast(`Archived: ${selected.map(r => r.name).join(', ')}`);
+ ref.current?.clearSelectedRows();
+ }
+
+ return (
+
+ {/* Toolbar */}
+ {selected.length > 0 && (
+
+
{selected.length} selected
+
+
+ Export
+
+
+ Archive
+
+ ref.current?.clearSelectedRows()}
+ className="px-3 py-1 rounded-md text-gray-400 hover:text-gray-600 text-xs"
+ >
+ Cancel
+
+
+
+ )}
+
+
+ setSelected(selectedRows)}
+ pagination
+ paginationPerPage={5}
+ />
+
+
+ {/* Toast */}
+ {toast && (
+
+ {toast}
+
+ )}
+
+ );
+}
diff --git a/apps/docs/src/components/demos/DashboardDrilldownDemo.tsx b/apps/docs/src/components/demos/DashboardDrilldownDemo.tsx
new file mode 100644
index 00000000..18bbb160
--- /dev/null
+++ b/apps/docs/src/components/demos/DashboardDrilldownDemo.tsx
@@ -0,0 +1,164 @@
+import React from 'react';
+import DataTable, { type ConditionalStyles, type ExpanderComponentProps, type TableColumn } from 'react-data-table-component';
+
+interface TeamMember {
+ name: string;
+ role: string;
+ tickets: number;
+ utilization: number;
+}
+
+interface Department {
+ id: number;
+ name: string;
+ headcount: number;
+ budget: number;
+ spent: number;
+ openTickets: number;
+ health: 'good' | 'at-risk' | 'critical';
+ members: TeamMember[];
+}
+
+const departments: Department[] = [
+ {
+ id: 1, name: 'Engineering', headcount: 12, budget: 1800000, spent: 1240000, openTickets: 8, health: 'good',
+ members: [
+ { name: 'Aria Chen', role: 'Engineering Lead', tickets: 3, utilization: 82 },
+ { name: 'Sam Rivera', role: 'DevOps Engineer', tickets: 2, utilization: 91 },
+ { name: 'Taylor Kim', role: 'Frontend Eng', tickets: 2, utilization: 74 },
+ { name: 'Casey Morgan', role: 'Backend Eng', tickets: 1, utilization: 68 },
+ ],
+ },
+ {
+ id: 2, name: 'Product', headcount: 5, budget: 750000, spent: 680000, openTickets: 14, health: 'at-risk',
+ members: [
+ { name: 'Marcus Webb', role: 'Product Manager', tickets: 7, utilization: 98 },
+ { name: 'Dana Park', role: 'Product Designer', tickets: 7, utilization: 95 },
+ ],
+ },
+ {
+ id: 3, name: 'Design', headcount: 4, budget: 480000, spent: 195000, openTickets: 3, health: 'good',
+ members: [
+ { name: 'Priya Kapoor', role: 'Senior Designer', tickets: 2, utilization: 65 },
+ { name: 'Alex Kim', role: 'UX Researcher', tickets: 1, utilization: 55 },
+ ],
+ },
+ {
+ id: 4, name: 'Analytics', headcount: 3, budget: 420000, spent: 410000, openTickets: 21, health: 'critical',
+ members: [
+ { name: 'Jordan Ellis', role: 'Data Scientist', tickets: 12, utilization: 100 },
+ { name: 'Quinn Adams', role: 'Data Engineer', tickets: 9, utilization: 100 },
+ ],
+ },
+ {
+ id: 5, name: 'Sales', headcount: 6, budget: 600000, spent: 320000, openTickets: 5, health: 'good',
+ members: [
+ { name: 'Taylor Brooks', role: 'Account Manager', tickets: 3, utilization: 72 },
+ { name: 'Riley Stone', role: 'Sales Rep', tickets: 2, utilization: 60 },
+ ],
+ },
+];
+
+const HEALTH_CLASS = {
+ good: 'bg-green-50 text-green-700 border border-green-200',
+ 'at-risk':'bg-yellow-50 text-yellow-700 border border-yellow-200',
+ critical: 'bg-red-50 text-red-700 border border-red-200',
+};
+
+function UtilBar({ pct }: { pct: number }) {
+ const color = pct >= 95 ? '#ef4444' : pct >= 80 ? '#f59e0b' : '#22c55e';
+ return (
+
+ );
+}
+
+function SpendBar({ budget, spent }: { budget: number; spent: number }) {
+ const pct = Math.min(100, Math.round((spent / budget) * 100));
+ const color = pct >= 95 ? '#ef4444' : pct >= 80 ? '#f59e0b' : '#6366f1';
+ return (
+
+ );
+}
+
+const memberColumns: TableColumn[] = [
+ { name: 'Name', selector: m => m.name, grow: 1 },
+ { name: 'Role', selector: m => m.role, grow: 1 },
+ { name: 'Open tickets',selector: m => m.tickets, width: '110px', right: true },
+ { name: 'Utilization', selector: m => m.utilization, width: '160px',
+ cell: m => ,
+ },
+];
+
+function DepartmentDetail({ data: dept }: ExpanderComponentProps) {
+ return (
+
+ );
+}
+
+const conditionalRowStyles: ConditionalStyles[] = [
+ { when: r => r.health === 'critical', style: { backgroundColor: '#fff7f7' } },
+ { when: r => r.health === 'at-risk', style: { backgroundColor: '#fffdf0' } },
+];
+
+const columns: TableColumn[] = [
+ {
+ id: 'name', name: 'Department', selector: r => r.name, sortable: true, grow: 1,
+ },
+ {
+ id: 'headcount', name: 'Headcount', selector: r => r.headcount, sortable: true, right: true, width: '110px',
+ },
+ {
+ id: 'spend', name: 'Budget spend', selector: r => r.spent, sortable: true, width: '200px',
+ cell: r => (
+
+
+ ${r.spent.toLocaleString()} / ${r.budget.toLocaleString()}
+
+
+
+ ),
+ },
+ {
+ id: 'openTickets', name: 'Open tickets', selector: r => r.openTickets, sortable: true, right: true, width: '120px',
+ },
+ {
+ id: 'health', name: 'Health', selector: r => r.health, sortable: true, width: '110px',
+ cell: r => (
+
+ {r.health}
+
+ ),
+ },
+];
+
+export default function DashboardDrilldownDemo() {
+ return (
+
+
Expand any row to see team member utilization.
+
+
+
+
+ );
+}
diff --git a/apps/docs/src/components/demos/EditableGridDemo.tsx b/apps/docs/src/components/demos/EditableGridDemo.tsx
new file mode 100644
index 00000000..3b846e00
--- /dev/null
+++ b/apps/docs/src/components/demos/EditableGridDemo.tsx
@@ -0,0 +1,91 @@
+import React, { useState } from 'react';
+import DataTable, { type TableColumn } from 'react-data-table-component';
+
+interface LineItem {
+ id: number;
+ product: string;
+ quantity: number;
+ price: number;
+}
+
+const initialData: LineItem[] = [
+ { id: 1, product: 'Widget A', quantity: 4, price: 12.5 },
+ { id: 2, product: 'Widget B', quantity: 2, price: 34.0 },
+ { id: 3, product: 'Gadget Pro', quantity: 1, price: 89.99 },
+ { id: 4, product: 'Connector X', quantity: 10, price: 5.25 },
+ { id: 5, product: 'Cable Pack', quantity: 3, price: 18.0 },
+];
+
+export default function EditableGridDemo() {
+ const [data, setData] = useState(initialData);
+
+ const handleCellEdit = (row: LineItem, value: string, column: TableColumn) => {
+ const field = column.id as keyof LineItem;
+ setData(prev =>
+ prev.map(r => {
+ if (r.id !== row.id) return r;
+ if (field === 'quantity') return { ...r, quantity: Math.max(0, parseInt(value) || 0) };
+ if (field === 'price') return { ...r, price: Math.max(0, parseFloat(value) || 0) };
+ return { ...r, [field]: value };
+ }),
+ );
+ };
+
+ const columns: TableColumn[] = [
+ {
+ id: 'product',
+ name: 'Product',
+ selector: r => r.product,
+ editable: true,
+ onCellEdit: handleCellEdit,
+ width: '160px',
+ },
+ {
+ id: 'quantity',
+ name: 'Qty',
+ selector: r => r.quantity,
+ editable: true,
+ editor: { type: 'number' },
+ onCellEdit: handleCellEdit,
+ right: true,
+ width: '100px',
+ },
+ {
+ id: 'price',
+ name: 'Unit price',
+ selector: r => r.price,
+ editable: true,
+ editor: { type: 'number' },
+ onCellEdit: handleCellEdit,
+ format: r => `$${r.price.toFixed(2)}`,
+ right: true,
+ width: '120px',
+ },
+ {
+ id: 'total',
+ name: 'Total',
+ selector: r => r.quantity * r.price,
+ format: r => `$${(r.quantity * r.price).toFixed(2)}`,
+ right: true,
+ width: '120px',
+ style: { fontWeight: 600, color: '#1d4ed8' },
+ },
+ ];
+
+ const grandTotal = data.reduce((sum, r) => sum + r.quantity * r.price, 0);
+
+ return (
+
+
+
+ {data.length} items
+ Grand total: ${grandTotal.toFixed(2)}
+
+
+ );
+}
diff --git a/apps/docs/src/components/demos/InlineRowActionsDemo.tsx b/apps/docs/src/components/demos/InlineRowActionsDemo.tsx
new file mode 100644
index 00000000..1d721adf
--- /dev/null
+++ b/apps/docs/src/components/demos/InlineRowActionsDemo.tsx
@@ -0,0 +1,208 @@
+import React, { useEffect, useRef, useState } from 'react';
+import DataTable, { type TableColumn } from 'react-data-table-component';
+
+interface Employee {
+ id: number;
+ name: string;
+ department: string;
+ role: string;
+ email: string;
+}
+
+const seed: Employee[] = [
+ { id: 1, name: 'Aria Chen', department: 'Engineering', role: 'Engineering Lead', email: 'aria@example.com' },
+ { id: 2, name: 'Marcus Webb', department: 'Product', role: 'Product Manager', email: 'marcus@example.com' },
+ { id: 3, name: 'Priya Kapoor', department: 'Design', role: 'Senior Designer', email: 'priya@example.com' },
+ { id: 4, name: 'Jordan Ellis', department: 'Analytics', role: 'Data Scientist', email: 'jordan@example.com' },
+ { id: 5, name: 'Sam Rivera', department: 'Engineering', role: 'DevOps Engineer', email: 'sam@example.com' },
+ { id: 6, name: 'Taylor Brooks', department: 'Sales', role: 'Account Manager', email: 'taylor@example.com' },
+];
+
+type ModalState =
+ | { type: 'none' }
+ | { type: 'edit'; row: Employee }
+ | { type: 'delete'; row: Employee }
+ | { type: 'duplicate'; row: Employee };
+
+function DropdownMenu({ row, onEdit, onDuplicate, onDelete }: {
+ row: Employee;
+ onEdit: (r: Employee) => void;
+ onDuplicate: (r: Employee) => void;
+ onDelete: (r: Employee) => void;
+}) {
+ const [open, setOpen] = useState(false);
+ const ref = useRef(null);
+
+ useEffect(() => {
+ if (!open) return;
+ function handle(e: MouseEvent) {
+ if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
+ }
+ document.addEventListener('mousedown', handle);
+ return () => document.removeEventListener('mousedown', handle);
+ }, [open]);
+
+ return (
+
+
{ e.stopPropagation(); setOpen(o => !o); }}
+ className="p-1 rounded hover:bg-gray-100 text-gray-400 hover:text-gray-600"
+ aria-label="Row actions"
+ >
+
+
+
+
+ {open && (
+
+
{ e.stopPropagation(); setOpen(false); onEdit(row); }}
+ className="w-full text-left px-3 py-1.5 hover:bg-gray-50 text-gray-700"
+ >
+ Edit
+
+
{ e.stopPropagation(); setOpen(false); onDuplicate(row); }}
+ className="w-full text-left px-3 py-1.5 hover:bg-gray-50 text-gray-700"
+ >
+ Duplicate
+
+
+
{ e.stopPropagation(); setOpen(false); onDelete(row); }}
+ className="w-full text-left px-3 py-1.5 hover:bg-red-50 text-red-600"
+ >
+ Delete
+
+
+ )}
+
+ );
+}
+
+export default function InlineRowActionsDemo() {
+ const [data, setData] = useState(seed);
+ const [modal, setModal] = useState({ type: 'none' });
+ const [editDraft, setEditDraft] = useState(null);
+ const [toast, setToast] = useState('');
+ const nextId = useRef(seed.length + 1);
+
+ function showToast(msg: string) {
+ setToast(msg);
+ setTimeout(() => setToast(''), 2000);
+ }
+
+ function openEdit(row: Employee) {
+ setEditDraft({ ...row });
+ setModal({ type: 'edit', row });
+ }
+
+ function commitEdit() {
+ if (!editDraft) return;
+ setData(prev => prev.map(r => r.id === editDraft.id ? editDraft : r));
+ setModal({ type: 'none' });
+ showToast(`Saved ${editDraft.name}`);
+ }
+
+ function commitDuplicate(row: Employee) {
+ const copy = { ...row, id: nextId.current++, name: `${row.name} (copy)` };
+ setData(prev => [...prev, copy]);
+ setModal({ type: 'none' });
+ showToast(`Duplicated ${row.name}`);
+ }
+
+ function commitDelete(row: Employee) {
+ setData(prev => prev.filter(r => r.id !== row.id));
+ setModal({ type: 'none' });
+ showToast(`Deleted ${row.name}`);
+ }
+
+ const columns: TableColumn[] = [
+ { name: 'Name', selector: r => r.name, sortable: true, grow: 1 },
+ { name: 'Department', selector: r => r.department, sortable: true },
+ { name: 'Role', selector: r => r.role, grow: 1 },
+ {
+ name: '',
+ button: true,
+ width: '48px',
+ cell: row => (
+ setModal({ type: 'duplicate', row: r })}
+ onDelete={r => setModal({ type: 'delete', row: r })}
+ />
+ ),
+ },
+ ];
+
+ return (
+
+
+ {/* Edit modal */}
+ {modal.type === 'edit' && editDraft && (
+ setModal({ type: 'none' })}>
+
e.stopPropagation()}>
+
Edit employee
+ {(['name', 'role', 'email'] as const).map(field => (
+
+ {field}
+ setEditDraft(d => d ? { ...d, [field]: e.target.value } : d)}
+ className="w-full px-3 py-1.5 text-sm border border-gray-200 rounded-md focus:outline-none focus:border-brand-400"
+ />
+
+ ))}
+
+ setModal({ type: 'none' })} className="px-3 py-1.5 text-sm text-gray-500 hover:text-gray-700">Cancel
+ Save
+
+
+
+ )}
+
+ {/* Duplicate confirmation */}
+ {modal.type === 'duplicate' && (
+ setModal({ type: 'none' })}>
+
e.stopPropagation()}>
+
Duplicate row?
+
A copy of {modal.row.name} will be added to the list.
+
+ setModal({ type: 'none' })} className="px-3 py-1.5 text-sm text-gray-500 hover:text-gray-700">Cancel
+ commitDuplicate(modal.row)} className="px-3 py-1.5 text-sm bg-brand-600 text-white rounded-md hover:bg-brand-700">Duplicate
+
+
+
+ )}
+
+ {/* Delete confirmation */}
+ {modal.type === 'delete' && (
+ setModal({ type: 'none' })}>
+
e.stopPropagation()}>
+
Delete employee?
+
This will permanently remove {modal.row.name} .
+
+ setModal({ type: 'none' })} className="px-3 py-1.5 text-sm text-gray-500 hover:text-gray-700">Cancel
+ commitDelete(modal.row)} className="px-3 py-1.5 text-sm bg-red-600 text-white rounded-md hover:bg-red-700">Delete
+
+
+
+ )}
+
+ {toast && (
+
+ {toast}
+
+ )}
+
+ );
+}
diff --git a/apps/docs/src/components/demos/PersistColumnWidthsDemo.tsx b/apps/docs/src/components/demos/PersistColumnWidthsDemo.tsx
new file mode 100644
index 00000000..c0ac1f5f
--- /dev/null
+++ b/apps/docs/src/components/demos/PersistColumnWidthsDemo.tsx
@@ -0,0 +1,80 @@
+import React, { useState } from 'react';
+import DataTable, { type TableColumn } from 'react-data-table-component';
+
+interface Employee {
+ id: number;
+ name: string;
+ department: string;
+ salary: number;
+ role: string;
+}
+
+const data: Employee[] = [
+ { id: 1, name: 'Aria Chen', department: 'Engineering', salary: 155000, role: 'Engineering Lead' },
+ { id: 2, name: 'Marcus Webb', department: 'Product', salary: 132000, role: 'Product Manager' },
+ { id: 3, name: 'Priya Kapoor', department: 'Design', salary: 118000, role: 'Senior Designer' },
+ { id: 4, name: 'Jordan Ellis', department: 'Analytics', salary: 143000, role: 'Data Scientist' },
+ { id: 5, name: 'Sam Rivera', department: 'Engineering', salary: 128000, role: 'DevOps Engineer' },
+];
+
+const STORAGE_KEY = 'recipe-demo-column-widths';
+
+function loadWidths(): Record
{
+ try { return JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '{}'); }
+ catch { return {}; }
+}
+
+const columnDefs: TableColumn[] = [
+ { id: 'name', name: 'Name', selector: r => r.name, sortable: true },
+ { id: 'department', name: 'Department', selector: r => r.department, sortable: true },
+ { id: 'role', name: 'Role', selector: r => r.role },
+ { id: 'salary', name: 'Salary', selector: r => r.salary, right: true, format: r => `$${r.salary.toLocaleString()}` },
+];
+
+export default function PersistColumnWidthsDemo() {
+ const [initialWidths, setInitialWidths] = useState | null>(null);
+ const [saved, setSaved] = useState(false);
+
+ React.useEffect(() => {
+ setInitialWidths(loadWidths());
+ }, []);
+
+ function handleResize(_id: string | number, _w: number, all: Record) {
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(all));
+ setSaved(true);
+ setTimeout(() => setSaved(false), 1500);
+ }
+
+ function handleReset() {
+ localStorage.removeItem(STORAGE_KEY);
+ setInitialWidths({});
+ }
+
+ return (
+
+
+ Drag column edges to resize — widths persist across sessions.
+
+ Reset widths
+
+ {saved && Saved! }
+
+
+ {initialWidths !== null && (
+
+ )}
+
+
+ );
+}
diff --git a/apps/docs/src/components/demos/ServerSideRecipeDemo.tsx b/apps/docs/src/components/demos/ServerSideRecipeDemo.tsx
new file mode 100644
index 00000000..beb86b51
--- /dev/null
+++ b/apps/docs/src/components/demos/ServerSideRecipeDemo.tsx
@@ -0,0 +1,120 @@
+import React, { useEffect, useState } from 'react';
+import DataTable, { type FilterState, SortOrder, type TableColumn } from 'react-data-table-component';
+
+interface Employee {
+ id: number;
+ name: string;
+ department: string;
+ salary: number;
+}
+
+const ALL_DATA: Employee[] = [
+ { id: 1, name: 'Aria Chen', department: 'Engineering', salary: 155000 },
+ { id: 2, name: 'Marcus Webb', department: 'Product', salary: 132000 },
+ { id: 3, name: 'Priya Kapoor', department: 'Design', salary: 118000 },
+ { id: 4, name: 'Jordan Ellis', department: 'Analytics', salary: 143000 },
+ { id: 5, name: 'Sam Rivera', department: 'Engineering', salary: 128000 },
+ { id: 6, name: 'Taylor Brooks', department: 'Sales', salary: 97000 },
+ { id: 7, name: 'Casey Morgan', department: 'Engineering', salary: 138000 },
+ { id: 8, name: 'Alex Kim', department: 'Design', salary: 112000 },
+ { id: 9, name: 'Dana Park', department: 'Product', salary: 125000 },
+ { id: 10, name: 'Riley Stone', department: 'Sales', salary: 104000 },
+ { id: 11, name: 'Quinn Adams', department: 'Analytics', salary: 137000 },
+ { id: 12, name: 'Morgan Lee', department: 'Engineering', salary: 149000 },
+];
+
+function simulateFetch({
+ page, perPage, sortId, sortDir, filters,
+}: {
+ page: number;
+ perPage: number;
+ sortId: string;
+ sortDir: SortOrder;
+ filters: Record;
+}): Promise<{ rows: Employee[]; total: number }> {
+ return new Promise(resolve => {
+ setTimeout(() => {
+ let rows = [...ALL_DATA];
+
+ // filter
+ const nameFilter = filters['name'];
+ if (nameFilter?.value) {
+ rows = rows.filter(r => r.name.toLowerCase().includes(String(nameFilter.value).toLowerCase()));
+ }
+ const deptFilter = filters['department'];
+ if (deptFilter?.value) {
+ rows = rows.filter(r => r.department.toLowerCase().includes(String(deptFilter.value).toLowerCase()));
+ }
+
+ // sort
+ if (sortId) {
+ rows.sort((a, b) => {
+ const av = a[sortId as keyof Employee];
+ const bv = b[sortId as keyof Employee];
+ return sortDir === SortOrder.ASC
+ ? av < bv ? -1 : av > bv ? 1 : 0
+ : av > bv ? -1 : av < bv ? 1 : 0;
+ });
+ }
+
+ const total = rows.length;
+ rows = rows.slice((page - 1) * perPage, page * perPage);
+ resolve({ rows, total });
+ }, 400);
+ });
+}
+
+export default function ServerSideRecipeDemo() {
+ const [rows, setRows] = useState([]);
+ const [total, setTotal] = useState(0);
+ const [page, setPage] = useState(1);
+ const [perPage, setPerPage] = useState(5);
+ const [sortId, setSortId] = useState('');
+ const [sortDir, setSortDir] = useState(SortOrder.ASC);
+ const [filters, setFilters] = useState>({});
+ const [loading, setLoading] = useState(false);
+
+ useEffect(() => {
+ let cancelled = false;
+ setLoading(true);
+ simulateFetch({ page, perPage, sortId, sortDir, filters }).then(res => {
+ if (cancelled) return;
+ setRows(res.rows);
+ setTotal(res.total);
+ setLoading(false);
+ });
+ return () => { cancelled = true; };
+ }, [page, perPage, sortId, sortDir, filters]);
+
+ const columns: TableColumn[] = [
+ { id: 'name', name: 'Name', selector: r => r.name, sortable: true, filterable: true },
+ { id: 'department', name: 'Department', selector: r => r.department, sortable: true, filterable: true },
+ {
+ id: 'salary', name: 'Salary', selector: r => r.salary, sortable: true,
+ right: true, format: r => `$${r.salary.toLocaleString()}`,
+ },
+ ];
+
+ return (
+
+ setPage(p)}
+ onChangeRowsPerPage={(pp, p) => { setPerPage(pp); setPage(p); }}
+ sortServer
+ onSort={(col, dir) => { setSortId(col.id as string); setSortDir(dir); setPage(1); }}
+ filterValues={filters}
+ onFilterChange={(columnId, next) =>
+ setFilters(prev => ({ ...prev, [columnId]: next }))
+ }
+ highlightOnHover
+ />
+
+ );
+}
diff --git a/apps/docs/src/components/demos/UrlSyncDemo.tsx b/apps/docs/src/components/demos/UrlSyncDemo.tsx
new file mode 100644
index 00000000..b13f4b6a
--- /dev/null
+++ b/apps/docs/src/components/demos/UrlSyncDemo.tsx
@@ -0,0 +1,128 @@
+import React, { useCallback, useEffect, useMemo, useState } from 'react';
+import DataTable, { type FilterState, SortOrder, type TableColumn } from 'react-data-table-component';
+
+interface Employee {
+ id: number;
+ name: string;
+ department: string;
+ role: string;
+ salary: number;
+}
+
+const ALL_DATA: Employee[] = [
+ { id: 1, name: 'Aria Chen', department: 'Engineering', role: 'Engineering Lead', salary: 155000 },
+ { id: 2, name: 'Marcus Webb', department: 'Product', role: 'Product Manager', salary: 132000 },
+ { id: 3, name: 'Priya Kapoor', department: 'Design', role: 'Senior Designer', salary: 118000 },
+ { id: 4, name: 'Jordan Ellis', department: 'Analytics', role: 'Data Scientist', salary: 143000 },
+ { id: 5, name: 'Sam Rivera', department: 'Engineering', role: 'DevOps Engineer', salary: 128000 },
+ { id: 6, name: 'Taylor Brooks', department: 'Sales', role: 'Account Manager', salary: 97000 },
+ { id: 7, name: 'Casey Morgan', department: 'Engineering', role: 'Software Engineer', salary: 138000 },
+ { id: 8, name: 'Alex Kim', department: 'Design', role: 'UX Researcher', salary: 112000 },
+ { id: 9, name: 'Dana Park', department: 'Product', role: 'Product Designer', salary: 125000 },
+ { id: 10, name: 'Riley Stone', department: 'Sales', role: 'Sales Rep', salary: 104000 },
+ { id: 11, name: 'Quinn Adams', department: 'Analytics', role: 'Data Engineer', salary: 137000 },
+ { id: 12, name: 'Morgan Lee', department: 'Engineering', role: 'Backend Engineer', salary: 149000 },
+];
+
+// --- URL <-> state helpers ---
+
+function readParams(): { sortId: string; sortDir: SortOrder; page: number; perPage: number; filters: Record } {
+ const p = new URLSearchParams(typeof window !== 'undefined' ? window.location.search : '');
+ return {
+ sortId: p.get('sort') ?? '',
+ sortDir: p.get('dir') === 'desc' ? SortOrder.DESC : SortOrder.ASC,
+ page: parseInt(p.get('page') ?? '1', 10),
+ perPage: parseInt(p.get('per') ?? '5', 10),
+ filters: Object.fromEntries(
+ [...p.entries()]
+ .filter(([k]) => k.startsWith('f_'))
+ .map(([k, v]) => [k.slice(2), v]),
+ ),
+ };
+}
+
+function writeParams(state: { sortId: string; sortDir: SortOrder; page: number; perPage: number; filters: Record }) {
+ const p = new URLSearchParams();
+ if (state.sortId) { p.set('sort', state.sortId); p.set('dir', state.sortDir === SortOrder.DESC ? 'desc' : 'asc'); }
+ if (state.page > 1) p.set('page', String(state.page));
+ if (state.perPage !== 5) p.set('per', String(state.perPage));
+ Object.entries(state.filters).forEach(([k, v]) => { if (v) p.set(`f_${k}`, v); });
+ const qs = p.toString();
+ history.replaceState(null, '', qs ? `?${qs}` : window.location.pathname);
+}
+
+// --- Component ---
+
+export default function UrlSyncDemo() {
+ const init = useMemo(() => readParams(), []);
+
+ const [sortId, setSortId] = useState(init.sortId);
+ const [sortDir, setSortDir] = useState(init.sortDir);
+ const [page, setPage] = useState(init.page);
+ const [perPage, setPerPage] = useState(init.perPage);
+ const [filters, setFilters] = useState>(() =>
+ Object.fromEntries(
+ Object.entries(init.filters).map(([k, v]) => [k, { value: v, operator: 'contains' as const, condition2: null, join: 'and' as const }]),
+ ),
+ );
+
+ // Sync every state change to the URL
+ useEffect(() => {
+ writeParams({
+ sortId, sortDir, page, perPage,
+ filters: Object.fromEntries(
+ Object.entries(filters).map(([k, f]) => [k, String(f.value ?? '')]).filter(([, v]) => v),
+ ),
+ });
+ }, [sortId, sortDir, page, perPage, filters]);
+
+ const handleSort = useCallback((col: TableColumn, dir: SortOrder) => {
+ setSortId(col.id as string);
+ setSortDir(dir);
+ setPage(1);
+ }, []);
+
+ const handleFilterChange = useCallback((columnId: string | number, next: FilterState) => {
+ setFilters(prev => ({ ...prev, [columnId]: next }));
+ setPage(1);
+ }, []);
+
+ const columns: TableColumn[] = [
+ { id: 'name', name: 'Name', selector: r => r.name, sortable: true, filterable: true, grow: 1 },
+ { id: 'department', name: 'Department', selector: r => r.department, sortable: true, filterable: true },
+ { id: 'role', name: 'Role', selector: r => r.role, sortable: true, grow: 1 },
+ { id: 'salary', name: 'Salary', selector: r => r.salary, sortable: true, right: true, width: '110px',
+ format: r => `$${r.salary.toLocaleString()}` },
+ ];
+
+ // Show current URL params so the demo is self-explanatory
+ const [urlDisplay, setUrlDisplay] = useState('');
+ useEffect(() => {
+ setUrlDisplay(window.location.search || '(no params — default state)');
+ }, [sortId, sortDir, page, perPage, filters]);
+
+ return (
+
+
+ URL params:
+ {urlDisplay}
+
+
setPage(p)}
+ onChangeRowsPerPage={(pp, p) => { setPerPage(pp); setPage(p); }}
+ highlightOnHover
+ />
+
+ );
+}
diff --git a/apps/docs/src/layouts/DocsLayout.astro b/apps/docs/src/layouts/DocsLayout.astro
index 57e88215..00404c1c 100644
--- a/apps/docs/src/layouts/DocsLayout.astro
+++ b/apps/docs/src/layouts/DocsLayout.astro
@@ -84,7 +84,18 @@ const nav = [
{ label: 'TypeScript', href: '/docs/typescript' },
{ label: 'SSR', href: '/docs/ssr' },
{ label: 'Performance', href: '/docs/performance' },
- { label: 'Recipes', href: '/docs/recipes' },
+ ],
+ },
+ {
+ group: 'Recipes',
+ links: [
+ { label: 'Approval workflow', href: '/docs/recipes/bulk-action-toolbar' },
+ { label: 'Server-side sort & filter', href: '/docs/recipes/server-side' },
+ { label: 'URL-synced table state', href: '/docs/recipes/editable-grid' },
+ { label: 'Dashboard drill-down', href: '/docs/recipes/master-detail' },
+ { label: 'Audit log viewer', href: '/docs/recipes/sticky-footer' },
+ { label: 'Persist column widths', href: '/docs/recipes/persist-column-widths' },
+ { label: 'Inline row actions', href: '/docs/recipes/row-grouping' },
],
},
{
@@ -109,7 +120,7 @@ const nextLink = currentIndex < allLinks.length - 1 ? allLinks[currentIndex + 1]
-