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 ( +
+
+
+
+ {pct}% +
+ ); +} + +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 ( +
+
+
+
+ {pct}% +
+ ); +} + +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 ( +
+

Team members

+ +
+ ); +} + +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 ( +
+
+ No employees
} + /> +
+ + {/* 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]
-