diff --git a/README.md b/README.md
index 64890ea5..308eb44d 100644
--- a/README.md
+++ b/README.md
@@ -42,11 +42,11 @@ The documentation contains information about installation, usage and contributio
# Supporting React Data Table Component
-React Data Table Component is maintained by one person and downloaded ~200k times a week. If your team ships products with it, your support keeps it maintained, bug-free, and moving forward.
+React Data Table Component has been actively maintained since 2018 and is downloaded ~215k times a week. If your team ships products with it, your support keeps it maintained, bug-free, and moving forward.
## Sponsor the project
-Sponsoring puts your company logo in front of ~200k developers a week: in the README, the docs site, and every release. It's the right move if your team depends on this library and you want it to keep improving.
+Sponsoring puts your company logo in front of ~215k developers a week: in the README, the docs site, and every release. It's the right move if your team depends on this library and you want it to keep improving.
| Tier | Price/month | Perk |
| --- | --- | --- |
diff --git a/apps/docs/astro.config.mjs b/apps/docs/astro.config.mjs
index 36abf2c6..a5d32c16 100644
--- a/apps/docs/astro.config.mjs
+++ b/apps/docs/astro.config.mjs
@@ -1,12 +1,14 @@
import { defineConfig } from 'astro/config';
import react from '@astrojs/react';
import tailwind from '@astrojs/tailwind';
+import sitemap from '@astrojs/sitemap';
export default defineConfig({
+ site: 'https://reactdatatable.com',
markdown: {
shikiConfig: { theme: 'catppuccin-macchiato' },
},
- integrations: [react(), tailwind({ applyBaseStyles: false })],
+ integrations: [react(), tailwind({ applyBaseStyles: false }), sitemap()],
vite: {
resolve: {
alias: {
diff --git a/apps/docs/package-lock.json b/apps/docs/package-lock.json
index de6b984b..34fba527 100644
--- a/apps/docs/package-lock.json
+++ b/apps/docs/package-lock.json
@@ -9,6 +9,7 @@
"version": "0.0.1",
"dependencies": {
"@astrojs/react": "^4.2.0",
+ "@astrojs/sitemap": "^3.7.2",
"@astrojs/tailwind": "^5.1.4",
"@shikijs/langs": "^4.1.0",
"astro": "^5.7.0",
@@ -109,6 +110,26 @@
"react-dom": "^17.0.2 || ^18.0.0 || ^19.0.0"
}
},
+ "node_modules/@astrojs/sitemap": {
+ "version": "3.7.2",
+ "resolved": "https://registry.npmjs.org/@astrojs/sitemap/-/sitemap-3.7.2.tgz",
+ "integrity": "sha512-PqkzkcZTb5ICiyIR8VoKbIAP/laNRXi5tw616N1Ckk+40oNB8Can1AzVV56lrbC5GKSZFCyJYUVYqVivMisvpA==",
+ "license": "MIT",
+ "dependencies": {
+ "sitemap": "^9.0.0",
+ "stream-replace-string": "^2.0.0",
+ "zod": "^4.3.6"
+ }
+ },
+ "node_modules/@astrojs/sitemap/node_modules/zod": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
+ "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/colinhacks"
+ }
+ },
"node_modules/@astrojs/tailwind": {
"version": "5.1.5",
"resolved": "https://registry.npmjs.org/@astrojs/tailwind/-/tailwind-5.1.5.tgz",
@@ -2128,6 +2149,15 @@
"@types/unist": "*"
}
},
+ "node_modules/@types/node": {
+ "version": "24.12.4",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.4.tgz",
+ "integrity": "sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA==",
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~7.16.0"
+ }
+ },
"node_modules/@types/prop-types": {
"version": "15.7.15",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
@@ -2153,6 +2183,15 @@
"@types/react": "^18.0.0"
}
},
+ "node_modules/@types/sax": {
+ "version": "1.2.7",
+ "resolved": "https://registry.npmjs.org/@types/sax/-/sax-1.2.7.tgz",
+ "integrity": "sha512-rO73L89PJxeYM3s3pPPjiPgVVcymqU490g0YO5n5By0k2Erzj6tay/4lr1CHAAU4JyOWd1rpQ8bCf6cZfHU96A==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
"node_modules/@types/unist": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
@@ -6175,6 +6214,25 @@
"integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==",
"license": "MIT"
},
+ "node_modules/sitemap": {
+ "version": "9.0.1",
+ "resolved": "https://registry.npmjs.org/sitemap/-/sitemap-9.0.1.tgz",
+ "integrity": "sha512-S6hzjGJSG3d6if0YoF5kTyeRJvia6FSTBroE5fQ0bu1QNxyJqhhinfUsXi9fH3MgtXODWvwo2BDyQSnhPQ88uQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "^24.9.2",
+ "@types/sax": "^1.2.1",
+ "arg": "^5.0.0",
+ "sax": "^1.4.1"
+ },
+ "bin": {
+ "sitemap": "dist/esm/cli.js"
+ },
+ "engines": {
+ "node": ">=20.19.5",
+ "npm": ">=10.8.2"
+ }
+ },
"node_modules/smol-toml": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.6.1.tgz",
@@ -6206,6 +6264,12 @@
"url": "https://github.com/sponsors/wooorm"
}
},
+ "node_modules/stream-replace-string": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/stream-replace-string/-/stream-replace-string-2.0.0.tgz",
+ "integrity": "sha512-TlnjJ1C0QrmxRNrON00JvaFFlNh5TTG00APw23j74ET7gkQpTASi6/L2fuiav8pzK715HXtUeClpBTw2NPSn6w==",
+ "license": "MIT"
+ },
"node_modules/string-width": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz",
@@ -6588,6 +6652,12 @@
"integrity": "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==",
"license": "MIT"
},
+ "node_modules/undici-types": {
+ "version": "7.16.0",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
+ "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
+ "license": "MIT"
+ },
"node_modules/unified": {
"version": "11.0.5",
"resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz",
diff --git a/apps/docs/package.json b/apps/docs/package.json
index 4217ad81..ebfd2ad3 100644
--- a/apps/docs/package.json
+++ b/apps/docs/package.json
@@ -10,6 +10,7 @@
},
"dependencies": {
"@astrojs/react": "^4.2.0",
+ "@astrojs/sitemap": "^3.7.2",
"@astrojs/tailwind": "^5.1.4",
"@shikijs/langs": "^4.1.0",
"astro": "^5.7.0",
diff --git a/apps/docs/public/robots.txt b/apps/docs/public/robots.txt
new file mode 100644
index 00000000..4257a012
--- /dev/null
+++ b/apps/docs/public/robots.txt
@@ -0,0 +1,4 @@
+User-agent: *
+Allow: /
+
+Sitemap: https://reactdatatable.com/sitemap-index.xml
diff --git a/apps/docs/src/components/CodeBlock.astro b/apps/docs/src/components/CodeBlock.astro
index dffaf75f..38a93dff 100644
--- a/apps/docs/src/components/CodeBlock.astro
+++ b/apps/docs/src/components/CodeBlock.astro
@@ -13,7 +13,7 @@ const { code, lang = 'tsx' } = Astro.props;
@@ -35,14 +34,11 @@ const { code, lang = 'tsx' } = Astro.props;
await navigator.clipboard.writeText(code);
const iconCopy = btn.querySelector('.icon-copy');
const iconCheck = btn.querySelector('.icon-check');
- const label = btn.querySelector('.label');
iconCopy?.classList.add('hidden');
iconCheck?.classList.remove('hidden');
- if (label) label.textContent = 'Copied!';
setTimeout(() => {
iconCopy?.classList.remove('hidden');
iconCheck?.classList.add('hidden');
- if (label) label.textContent = 'Copy';
}, 2000);
});
});
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() {
))}
-
-
-
-
-
-
+ {/* Toggles row */}
+
+ {([
+ ['Selectable', selectable, setSelectable],
+ ['Expandable', expandable, setExpandable],
+ ['Striped', striped, setStriped],
+ ['Animate', animateRows, setAnimateRows],
+ ] as [string, boolean, (v: boolean) => void][]).map(([label, value, setter]) => (
+
+ ))}
{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 && (
+ <>
+
+
+
+ >
+ )}
+
+
+
+ )}
+
+
+ !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 => (
+
+ ))}
+
+
+
+ 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
+
+
+
+
+
+
+ )}
+
+
+ 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 (
+
+
+ {open && (
+
+
+
+
+
+
+ )}
+
+ );
+}
+
+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 => (
+
+
+ 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"
+ />
+
+ ))}
+
+
+
+
+
+
+ )}
+
+ {/* Duplicate confirmation */}
+ {modal.type === 'duplicate' && (
+ setModal({ type: 'none' })}>
+
e.stopPropagation()}>
+
Duplicate row?
+
A copy of {modal.row.name} will be added to the list.
+
+
+
+
+
+
+ )}
+
+ {/* Delete confirmation */}
+ {modal.type === 'delete' && (
+ setModal({ type: 'none' })}>
+
e.stopPropagation()}>
+
Delete employee?
+
This will permanently remove {modal.row.name}.
+
+
+
+
+
+
+ )}
+
+ {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.
+
+ {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]
-