From 41735f786896075f70d162e0e9b888ae39a319fa Mon Sep 17 00:00:00 2001 From: John Betancur <1385932+jbetancur@users.noreply.github.com> Date: Sun, 17 May 2026 16:30:01 -0400 Subject: [PATCH 1/3] chore: update issue templates and add contributing guidelines (#1315) --- .github/ISSUE_TEMPLATE/bug_report.md | 54 ++++++++++++----------- .github/ISSUE_TEMPLATE/config.yml | 7 +++ .github/ISSUE_TEMPLATE/feature_request.md | 26 +++++------ .github/PULL_REQUEST_TEMPLATE.md | 34 ++++++++++++++ .github/stale.yml | 9 ++-- .github/workflows/ci.yml | 32 ++++++++++++++ CONTRIBUTING.md | 46 +++++++++++++++++++ 7 files changed, 166 insertions(+), 42 deletions(-) create mode 100644 .github/PULL_REQUEST_TEMPLATE.md create mode 100644 .github/workflows/ci.yml create mode 100644 CONTRIBUTING.md diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index ce999ab7..b911be50 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -1,45 +1,49 @@ --- name: Bug report about: Create a report to help us improve -title: "[DESCRIPTION]" -labels: '' +title: "[BUG]: " +labels: 'bug' assignees: '' --- -## Issue Check list -- [ ] Agree to the [Code of Conduct](https://github.com/jbetancur/react-data-table-component/blob/master/CODE-OF-CONDUCT.md) -- [ ] Read the README -- [ ] You are using React 16.8.0+ -- [ ] You installed `styled-components` -- [ ] Include relevant code or preferably a [code sandbox](https://codesandbox.io/embed/react-data-table-sandbox-ccyuu -) +## Checklist + +- [ ] I agree to the [Code of Conduct](https://github.com/jbetancur/react-data-table-component/blob/master/CODE-OF-CONDUCT.md) +- [ ] I searched [existing issues](https://github.com/jbetancur/react-data-table-component/issues?q=is%3Aissue) and this is not a duplicate +- [ ] I read the [documentation](https://reactdatatable.com) +- [ ] I am using React 18+ +- [ ] I can reproduce this with a minimal example (sandbox link below) ## Describe the bug + A clear and concise description of what the bug is. ## To Reproduce + Steps to reproduce the behavior: -1. Go to '...' -2. Click on '....' -3. Scroll down to '....' -4. See error + +1. +2. +3. ## Expected behavior -A clear and concise description of what you expected to happen. -## Code Sandbox, Screenshots, or Relevant Code -Please include a codesandbox to help **expedite** troublshooting. +What you expected to happen. + +## Actual behavior + +What actually happens. + +## Minimal reproduction -https://codesandbox.io/embed/react-data-table-sandbox-ccyuu +**A minimal reproduction is required — issues without one may be closed.** -Otherwise, add screenshots and/or complete sample code to help explain your problem. + -## Versions (please complete the following information) - - React (RDT requires 16.8.0+) - - Styled Components - - OS: [e.g. iOS] - - Browser [e.g. chrome, safari, firefox] +## Versions -## Additional context -Add any other context about the problem here. +- react-data-table-component: +- React: +- Browser: +- OS: diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 3ba13e0c..fabeab1c 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1 +1,8 @@ blank_issues_enabled: false +contact_links: + - name: Documentation & Recipes + url: https://reactdatatable.com + about: Check the docs and recipes — most questions are already answered here. + - name: GitHub Discussions + url: https://github.com/jbetancur/react-data-table-component/discussions + about: Ask usage questions, share ideas, or get help from the community. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md index e2ebfab9..74916eeb 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -1,32 +1,32 @@ --- name: Feature request about: Suggest an idea for this project -title: "[FEATURE]: [SHORT DESCRIPTION]" -labels: '' +title: "[FEATURE]: " +labels: 'enhancement' assignees: '' --- -## Feature Check list +## Checklist -- [ ] Agree to the [Code of Conduct](https://github.com/jbetancur/react-data-table-component/blob/master/CODE-OF-CONDUCT.md) -- [ ] Read the README to ensure the feature is not already present -- [ ] You read [Creating Issues, Features and Pull Requests](https://github.com/jbetancur/react-data-table-component/issues/387) -- [ ] Considered the value versus complexity for all users of the library as well as library maintenance -- [ ] Considered if this can be a storybook or documentation example +- [ ] I agree to the [Code of Conduct](https://github.com/jbetancur/react-data-table-component/blob/master/CODE-OF-CONDUCT.md) +- [ ] I searched [existing issues](https://github.com/jbetancur/react-data-table-component/issues?q=is%3Aissue) and this has not been requested before +- [ ] I read the [documentation](https://reactdatatable.com) and this feature does not already exist +- [ ] I considered whether this could be solved with a documentation example or recipe instead +- [ ] I considered the maintenance burden and value for all library users -## Is your feature request related to a problem? Please describe +## Is your feature request related to a problem? -A clear and concise description of what the feature is. +A clear and concise description of what the problem is. ## Describe the solution you'd like A clear and concise description of what you want to happen. -## Describe alternatives you've considered +## Alternatives considered -A clear and concise description of any alternative solutions or features you've considered. +Any alternative solutions or workarounds you've considered. ## Additional context -Add any other context or screenshots about the feature request here. +Screenshots, examples, or any other context about the feature request. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 00000000..63302f0a --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,34 @@ + + +## Summary + + + +Fixes # + +## Type of change + +- [ ] Bug fix +- [ ] New feature +- [ ] Breaking change +- [ ] Documentation / example update +- [ ] Refactor / internal cleanup + +## Checklist + +- [ ] I read [CONTRIBUTING.md](https://github.com/jbetancur/react-data-table-component/blob/master/CONTRIBUTING.md) +- [ ] I agree to the [Code of Conduct](https://github.com/jbetancur/react-data-table-component/blob/master/CODE-OF-CONDUCT.md) +- [ ] Tests pass (`npm test`) +- [ ] No TypeScript errors (`npm run typecheck`) +- [ ] I have updated docs or examples if needed + +## Breaking changes + + + +## How to test + + diff --git a/.github/stale.yml b/.github/stale.yml index b0ad5155..ac2c9ea0 100644 --- a/.github/stale.yml +++ b/.github/stale.yml @@ -8,12 +8,13 @@ exemptLabels: - security - enhancement - feature + - bug # Label to use when marking an issue as stale -staleLabel: wontfix +staleLabel: stale # Comment to post when marking an issue as stale. Set to `false` to disable markComment: > - This issue has been automatically marked as stale because it has not had - recent activity. It will be closed if no further activity occurs. Thank you - for your contributions. + This issue has been automatically marked as stale due to inactivity. + It will be closed in 7 days if there is no further activity. + If this is still relevant, please leave a comment or update the issue. # Comment to post when closing a stale issue. Set to `false` to disable closeComment: false diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..c7b1e562 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,32 @@ +name: CI + +on: + pull_request: + branches: [master] + +jobs: + ci: + name: Lint, typecheck, test, build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Lint + run: npm run lint + + - name: Typecheck + run: npm run typecheck + + - name: Test + run: npm test + + - name: Build + run: npm run build diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..0d0def84 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,46 @@ +# Contributing + +Thanks for your interest in contributing. Please read this before opening a PR. + +## What gets merged + +This is a focused, stable library. The bar for new features is high — complexity must be justified by broad utility. Most things that feel like features are better solved with a documentation example. + +PRs that get merged quickly: + +- Bug fixes with a clear reproduction +- TypeScript or accessibility improvements +- Documentation or example improvements + +PRs that get closed: + +- No linked issue or description +- Adds niche features better handled in userland +- Breaks existing API without strong justification +- Fails CI (lint, typecheck, tests, build) + +## Before you open a PR + +1. **Open an issue first** for anything non-trivial. Alignment before code saves everyone time. +2. **Keep the scope small.** One concern per PR. +3. **Check CI passes** locally before pushing: `npm run lint && npm run typecheck && npm test && npm run build` + +## Development setup + +```bash +npm install +npm test # run tests +npm run typecheck # check types +npm run lint # check lint +npm run build # build the library +``` + +## Code style + +- TypeScript, no `any` +- Prettier + ESLint enforced — run `npm run format` if needed +- No new dependencies without discussion + +## Questions + +Usage questions belong in [Discussions](https://github.com/jbetancur/react-data-table-component/discussions) or the [docs](https://reactdatatable.com), not issues or PRs. From 52125148ebb4dd48869bcd9a5549647dfd718da8 Mon Sep 17 00:00:00 2001 From: John Betancur <1385932+jbetancur@users.noreply.github.com> Date: Sun, 17 May 2026 16:39:01 -0400 Subject: [PATCH 2/3] feat: add controlled paginationPage prop and update API documentation (#1316) --- apps/docs/src/pages/docs/api.md | 3 +- apps/docs/src/pages/docs/changelog.astro | 14 +++++- src/__tests__/DataTable.test.tsx | 57 ++++++++++++++++++++++++ src/components/DataTable.tsx | 2 + src/hooks/useTableState.ts | 9 ++++ src/types.ts | 1 + 6 files changed, 84 insertions(+), 2 deletions(-) diff --git a/apps/docs/src/pages/docs/api.md b/apps/docs/src/pages/docs/api.md index 5d511143..fcef6f95 100644 --- a/apps/docs/src/pages/docs/api.md +++ b/apps/docs/src/pages/docs/api.md @@ -86,7 +86,8 @@ Complete reference for every prop, type, and export in `react-data-table-compone | `paginationPerPage` | `number` | `10` | Rows shown per page. | | `paginationRowsPerPageOptions` | `number[]` | `[10,15,20,25,30]` | Options in the rows-per-page dropdown. | | `paginationDefaultPage` | `number` | `1` | Initial active page. | -| `paginationResetDefaultPage` | `boolean` | `false` | Toggle to reset to page 1 (e.g. after a filter change). | +| `paginationPage` | `number` | - | Controlled active page. When provided, the table navigates to this page whenever the value changes. Use together with `onChangePage` to keep them in sync. | +| `paginationResetDefaultPage` | `boolean` | `false` | Toggle to reset to page 1 (e.g. after a filter change). Prefer `paginationPage` for new code. | | `paginationTotalRows` | `number` | - | Total row count for server-side pagination. | | `paginationServer` | `boolean` | `false` | Delegate page changes to `onChangePage` / `onChangeRowsPerPage`. | | `paginationServerOptions` | `PaginationServerOptions` | - | Selection-persistence options for server-side mode. | diff --git a/apps/docs/src/pages/docs/changelog.astro b/apps/docs/src/pages/docs/changelog.astro index c3c6c238..2d14af75 100644 --- a/apps/docs/src/pages/docs/changelog.astro +++ b/apps/docs/src/pages/docs/changelog.astro @@ -11,7 +11,19 @@ import CodeBlock from '../../components/CodeBlock.astro'; repository on GitHub.

-

8.1.0 (current)

+

8.2.0 (unreleased)

+ +

New features

+ + +

8.1.0

Additive feature release. No breaking changes. See diff --git a/src/__tests__/DataTable.test.tsx b/src/__tests__/DataTable.test.tsx index 6a18ffb4..344cc889 100644 --- a/src/__tests__/DataTable.test.tsx +++ b/src/__tests__/DataTable.test.tsx @@ -1921,6 +1921,63 @@ describe('DataTable::Pagination', () => { expect(getByText('Todos')).not.toBeNull(); }); + test('should navigate to the specified page when paginationPage changes', () => { + const mock = dataMock(); + const { container, rerender } = render( + , + ); + + rerender( + , + ); + + expect(container.querySelector('div[id="row-2"]')).not.toBeNull(); + }); + + test('should call onChangePage when paginationPage changes', () => { + const mock = dataMock(); + const onChangePage = vi.fn(); + const { rerender } = render( + , + ); + + rerender( + , + ); + + expect(onChangePage).toHaveBeenCalledWith(2, mock.data.length); + }); + test('should render correctly when paginationResetDefaultPage is toggled', () => { const mock = dataMock(); const { container, rerender } = render( diff --git a/src/components/DataTable.tsx b/src/components/DataTable.tsx index b72c24cb..20086a6d 100644 --- a/src/components/DataTable.tsx +++ b/src/components/DataTable.tsx @@ -57,6 +57,7 @@ function DataTableInner(props: TableProps, ref: React.ForwardedRef(props: TableProps, ref: React.ForwardedRef { selectableRowsVisibleOnly: boolean; selectableRowSelected: ((row: T) => boolean) | null; clearSelectedRows: boolean; + paginationPage?: number; paginationResetDefaultPage: boolean; /** Controlled selection. When provided, internal selection state is overridden. */ controlledSelectedRows?: T[]; @@ -72,6 +73,7 @@ export default function useTableState(props: UseTableStateProps): UseTable selectableRowsVisibleOnly, selectableRowSelected, clearSelectedRows, + paginationPage, paginationResetDefaultPage, controlledSelectedRows, onSelectedRowsChange, @@ -173,6 +175,13 @@ export default function useTableState(props: UseTableStateProps): UseTable handleChangePage(paginationDefaultPage); }, [paginationDefaultPage, paginationResetDefaultPage]); + // Effect: Handle controlled page prop + useDidUpdateEffect(() => { + if (paginationPage !== undefined) { + handleChangePage(paginationPage); + } + }, [paginationPage]); + // Effect: Recalculate page when total rows change (server pagination) useDidUpdateEffect(() => { if (pagination && paginationServer && paginationTotalRows > 0) { diff --git a/src/types.ts b/src/types.ts index e13dd40f..0267aa76 100644 --- a/src/types.ts +++ b/src/types.ts @@ -122,6 +122,7 @@ type PaginationProps = { */ paginationIcons?: PaginationIcons; paginationPerPage?: number; + paginationPage?: number; paginationResetDefaultPage?: boolean; paginationRowsPerPageOptions?: number[]; paginationServer?: boolean; From b379a636784b10655d008c4d6265e225305ee285 Mon Sep 17 00:00:00 2001 From: John Betancur <1385932+jbetancur@users.noreply.github.com> Date: Sun, 17 May 2026 17:13:16 -0400 Subject: [PATCH 3/3] feat: add footer row support with customizable content (#1317) - Introduced a built-in footer row for totals, averages, and summaries in the DataTable component. - Added support for per-column footer definitions using a new `footer` field and a `footerComponent` prop for custom footer rendering. - Enhanced pagination button aria-labels for better accessibility. - Updated documentation to reflect new footer features and usage examples. - Implemented styling for the footer to align with existing table layouts. - Added tests to ensure footer functionality works as expected, including rendering conditions and custom components. --- apps/docs/src/components/demos/ExportDemo.tsx | 63 +++++ .../src/components/demos/FooterBasicDemo.tsx | 96 ++++++++ .../src/components/demos/FooterCustomDemo.tsx | 121 ++++++++++ .../src/components/demos/SelectionDemo.tsx | 4 +- apps/docs/src/layouts/DocsLayout.astro | 1 + apps/docs/src/pages/docs/api.md | 12 + apps/docs/src/pages/docs/changelog.astro | 25 +- apps/docs/src/pages/docs/export.astro | 42 ++++ apps/docs/src/pages/docs/footer.astro | 227 ++++++++++++++++++ apps/docs/src/pages/docs/recipes.astro | 52 +++- apps/docs/src/pages/docs/selection.astro | 8 + src/DataTable.css | 33 +++ src/__tests__/DataTable.test.tsx | 96 ++++++++ src/components/DataTable.tsx | 24 ++ src/components/TableCell.tsx | 11 +- src/components/TableFooter.tsx | 101 ++++++++ src/index.ts | 3 + src/themes/base.ts | 3 +- src/themes/catppuccin.ts | 4 +- src/themes/crisp.ts | 4 +- src/themes/index.ts | 1 + src/themes/material.ts | 4 +- src/themes/rounded.ts | 4 +- src/types.ts | 42 ++++ 24 files changed, 953 insertions(+), 28 deletions(-) create mode 100644 apps/docs/src/components/demos/ExportDemo.tsx create mode 100644 apps/docs/src/components/demos/FooterBasicDemo.tsx create mode 100644 apps/docs/src/components/demos/FooterCustomDemo.tsx create mode 100644 apps/docs/src/pages/docs/footer.astro create mode 100644 src/components/TableFooter.tsx diff --git a/apps/docs/src/components/demos/ExportDemo.tsx b/apps/docs/src/components/demos/ExportDemo.tsx new file mode 100644 index 00000000..30f2a24f --- /dev/null +++ b/apps/docs/src/components/demos/ExportDemo.tsx @@ -0,0 +1,63 @@ +import React, { useState } from 'react'; +import DataTable from '../ThemedDataTable'; +import { useTableExport, type TableColumn } from 'react-data-table-component'; + +interface Employee { + id: number; + name: string; + department: string; + salary: number; +} + +const 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 }, +]; + +const columns: TableColumn[] = [ + { id: 'name', name: 'Name', selector: r => r.name, sortable: true }, + { id: 'department', name: 'Department', selector: r => r.department, sortable: true }, + { id: 'salary', name: 'Salary', selector: r => r.salary, right: true, + format: r => `$${r.salary.toLocaleString()}` }, +]; + +export default function ExportDemo() { + const [copied, setCopied] = useState(false); + const { download, copy } = useTableExport({ columns, rows: data, valueSource: 'format' }); + + async function handleCopy(format: 'csv' | 'json') { + await copy(format); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } + + return ( +

+
+ + + +
+ +
+ ); +} diff --git a/apps/docs/src/components/demos/FooterBasicDemo.tsx b/apps/docs/src/components/demos/FooterBasicDemo.tsx new file mode 100644 index 00000000..20e6b31e --- /dev/null +++ b/apps/docs/src/components/demos/FooterBasicDemo.tsx @@ -0,0 +1,96 @@ +import { useState } from 'react'; +import DataTable from '../ThemedDataTable'; +import { type TableColumn } from 'react-data-table-component'; + +interface Row { + id: number; + name: string; + department: string; + salary: number; + bonus: number; +} + +const ALL_DATA: Row[] = [ + { id: 1, name: 'Aria Chen', department: 'Engineering', salary: 155000, bonus: 12000 }, + { id: 2, name: 'Marcus Webb', department: 'Product', salary: 132000, bonus: 9500 }, + { id: 3, name: 'Priya Kapoor', department: 'Design', salary: 118000, bonus: 7800 }, + { id: 4, name: 'Jordan Ellis', department: 'Analytics', salary: 143000, bonus: 11200 }, + { id: 5, name: 'Sam Rivera', department: 'Engineering', salary: 128000, bonus: 9000 }, + { id: 6, name: 'Taylor Brooks', department: 'Engineering', salary: 122000, bonus: 8400 }, + { id: 7, name: 'Casey Morgan', department: 'Product', salary: 108000, bonus: 6600 }, + { id: 8, name: 'Alex Kim', department: 'Analytics', salary: 137000, bonus: 10100 }, + { id: 9, name: 'Morgan Lee', department: 'Design', salary: 114000, bonus: 7200 }, + { id: 10, name: 'Drew Park', department: 'Engineering', salary: 141000, bonus: 10800 }, +]; + +export default function FooterBasicDemo() { + const [deptFilter, setDeptFilter] = useState('All'); + const departments = ['All', 'Engineering', 'Product', 'Design', 'Analytics']; + const data = deptFilter === 'All' ? ALL_DATA : ALL_DATA.filter(r => r.department === deptFilter); + + const columns: TableColumn[] = [ + { + id: 'name', + name: 'Name', + selector: r => r.name, + sortable: true, + footer: rows => `${rows.length} employee${rows.length !== 1 ? 's' : ''}`, + }, + { + id: 'department', + name: 'Department', + selector: r => r.department, + sortable: true, + }, + { + id: 'salary', + name: 'Salary', + selector: r => r.salary, + sortable: true, + right: true, + format: r => `$${r.salary.toLocaleString()}`, + footer: rows => `$${rows.reduce((s, r) => s + r.salary, 0).toLocaleString()}`, + }, + { + id: 'bonus', + name: 'Bonus', + selector: r => r.bonus, + sortable: true, + right: true, + format: r => `$${r.bonus.toLocaleString()}`, + footer: rows => `$${rows.reduce((s, r) => s + r.bonus, 0).toLocaleString()}`, + }, + ]; + + return ( +
+
+ Filter by department: + {departments.map(d => ( + + ))} +
+ +
+ ); +} diff --git a/apps/docs/src/components/demos/FooterCustomDemo.tsx b/apps/docs/src/components/demos/FooterCustomDemo.tsx new file mode 100644 index 00000000..89267bb4 --- /dev/null +++ b/apps/docs/src/components/demos/FooterCustomDemo.tsx @@ -0,0 +1,121 @@ +import { useState } from 'react'; +import DataTable from '../ThemedDataTable'; +import { type TableColumn, type FooterComponentProps } from 'react-data-table-component'; + +interface Row { + id: number; + name: string; + department: string; + salary: number; + bonus: number; +} + +const ALL_DATA: Row[] = [ + { id: 1, name: 'Aria Chen', department: 'Engineering', salary: 155000, bonus: 12000 }, + { id: 2, name: 'Marcus Webb', department: 'Product', salary: 132000, bonus: 9500 }, + { id: 3, name: 'Priya Kapoor', department: 'Design', salary: 118000, bonus: 7800 }, + { id: 4, name: 'Jordan Ellis', department: 'Analytics', salary: 143000, bonus: 11200 }, + { id: 5, name: 'Sam Rivera', department: 'Engineering', salary: 128000, bonus: 9000 }, + { id: 6, name: 'Taylor Brooks', department: 'Engineering', salary: 122000, bonus: 8400 }, + { id: 7, name: 'Casey Morgan', department: 'Product', salary: 108000, bonus: 6600 }, + { id: 8, name: 'Alex Kim', department: 'Analytics', salary: 137000, bonus: 10100 }, + { id: 9, name: 'Morgan Lee', department: 'Design', salary: 114000, bonus: 7200 }, + { id: 10, name: 'Drew Park', department: 'Engineering', salary: 141000, bonus: 10800 }, +]; + +function SummaryFooter({ rows }: FooterComponentProps) { + const totalSalary = rows.reduce((s, r) => s + r.salary, 0); + const totalBonus = rows.reduce((s, r) => s + r.bonus, 0); + const avgSalary = rows.length ? Math.round(totalSalary / rows.length) : 0; + + return ( +
+ + {rows.length} {rows.length === 1 ? 'employee' : 'employees'} + + + Salary total: ${totalSalary.toLocaleString()} + + + Salary avg: ${avgSalary.toLocaleString()} + + + Bonus total: ${totalBonus.toLocaleString()} + + + Total comp: ${(totalSalary + totalBonus).toLocaleString()} + +
+ ); +} + +const columns: TableColumn[] = [ + { id: 'name', name: 'Name', selector: r => r.name, sortable: true }, + { id: 'department', name: 'Department', selector: r => r.department, sortable: true }, + { + id: 'salary', + name: 'Salary', + selector: r => r.salary, + sortable: true, + right: true, + format: r => `$${r.salary.toLocaleString()}`, + }, + { + id: 'bonus', + name: 'Bonus', + selector: r => r.bonus, + sortable: true, + right: true, + format: r => `$${r.bonus.toLocaleString()}`, + }, +]; + +export default function FooterCustomDemo() { + const [deptFilter, setDeptFilter] = useState('All'); + const departments = ['All', 'Engineering', 'Product', 'Design', 'Analytics']; + const data = deptFilter === 'All' ? ALL_DATA : ALL_DATA.filter(r => r.department === deptFilter); + + return ( +
+
+ Filter by department: + {departments.map(d => ( + + ))} +
+ +
+ ); +} diff --git a/apps/docs/src/components/demos/SelectionDemo.tsx b/apps/docs/src/components/demos/SelectionDemo.tsx index 6ceb1644..81dbd20e 100644 --- a/apps/docs/src/components/demos/SelectionDemo.tsx +++ b/apps/docs/src/components/demos/SelectionDemo.tsx @@ -57,8 +57,10 @@ export default function SelectionDemo() { > Clear selection + +
{selectedRows.length > 0 && ( - + {selectedRows.length} selected: {selectedRows.map(r => r.name).join(', ')} )} diff --git a/apps/docs/src/layouts/DocsLayout.astro b/apps/docs/src/layouts/DocsLayout.astro index d19f8fe7..b7a0d3da 100644 --- a/apps/docs/src/layouts/DocsLayout.astro +++ b/apps/docs/src/layouts/DocsLayout.astro @@ -50,6 +50,7 @@ const nav = [ { label: 'Conditional Styles', href: '/docs/conditional-styles' }, { label: 'Fixed Header', href: '/docs/fixed-header' }, { label: 'Pagination', href: '/docs/pagination' }, + { label: 'Footer', href: '/docs/footer' }, { label: 'Loading State', href: '/docs/loading' }, ], }, diff --git a/apps/docs/src/pages/docs/api.md b/apps/docs/src/pages/docs/api.md index fcef6f95..1e852b8a 100644 --- a/apps/docs/src/pages/docs/api.md +++ b/apps/docs/src/pages/docs/api.md @@ -97,6 +97,15 @@ Complete reference for every prop, type, and export in `react-data-table-compone | `onChangePage` | `(page, totalRows) => void` | - | Called when the active page changes. | | `onChangeRowsPerPage` | `(rowsPerPage, page) => void` | - | Called when rows-per-page selection changes. | +### Footer + +| Prop | Type | Default | Description | +|---|---|---|---| +| `footerComponent` | `ComponentType>` | - | Replace the footer row with a custom component. Receives `{ rows, columns }`. Takes precedence over column-level `footer` fields. | +| `showFooter` | `boolean` | - | Force the footer row on or off. By default the footer renders when `footerComponent` is set or any visible column declares a `footer`. Set to `false` to suppress, `true` to render an empty footer row. | + +Column-level footers live on each [`TableColumn`](#tablecolumnt) as the `footer` field. See [Footer](/docs/footer) for the full walkthrough. + ### Row selection | Prop | Type | Default | Description | @@ -219,6 +228,7 @@ const columns: TableColumn[] = [ | `reorder` | `boolean` | Allow drag-to-reorder for this column (requires `reorder` on at least two columns). | | `style` | `CSSProperties` | Inline styles applied to every cell in this column. | | `conditionalCellStyles` | `ConditionalStyles[]` | Per-cell conditional styles. | +| `footer` | `ReactNode \| (rows: T[]) => ReactNode` | Footer cell for this column. Static node or a function receiving the filtered+sorted rows (typically used to render aggregates like sums or averages). When any visible column has a `footer`, a footer row renders below the body. See [Footer](/docs/footer). | ### Inline editing @@ -361,6 +371,8 @@ const customStyles: TableStyles = { | `expanderCell` | - | Cell containing the expand/collapse button. | | `expanderButton` | - | The expand/collapse button itself. | | `pagination` | `pageButtonsStyle` | Pagination bar and page buttons. | +| `footer` | - | The footer row container. | +| `footerCells` | - | Individual footer cells. | | `noData` | - | Empty-state container. | | `progress` | - | Loading indicator container. | diff --git a/apps/docs/src/pages/docs/changelog.astro b/apps/docs/src/pages/docs/changelog.astro index 2d14af75..a7dd5109 100644 --- a/apps/docs/src/pages/docs/changelog.astro +++ b/apps/docs/src/pages/docs/changelog.astro @@ -21,6 +21,28 @@ import CodeBlock from '../../components/CodeBlock.astro'; onChangePage to keep them in sync. → API reference +
  • + Built-in footer row for totals, averages, and other summary cells. + Declare per-column with the new footer field + (ReactNode or (rows) => ReactNode) or replace the whole + row with the footerComponent prop. Footer cells respect column widths, + alignment, and pinning automatically. + → Footer docs +
  • +
  • + Pagination button aria-labels — "First Page", "Previous Page", "Next Page", + and "Last Page" are now configurable via paginationComponentOptions, enabling + proper i18n for screen readers. +
  • + + +

    Bug fixes

    +
      +
    • + Fixed column reordering bypassing reorder={false} when a cell's text + was selected via double-click and then dragged. The cell now correctly gates all drag + handlers on column.reorder. +

    8.1.0

    @@ -46,7 +68,8 @@ import CodeBlock from '../../components/CodeBlock.astro';
  • New headless export hook useTableExport: build CSV/JSON, trigger a - download, or copy to clipboard. + download, or copy to clipboard. Import directly from the package: + import { useTableExport } from 'react-data-table-component'. → Export
  • diff --git a/apps/docs/src/pages/docs/export.astro b/apps/docs/src/pages/docs/export.astro index ca471c21..a4730348 100644 --- a/apps/docs/src/pages/docs/export.astro +++ b/apps/docs/src/pages/docs/export.astro @@ -1,6 +1,8 @@ --- import DocsLayout from '../../layouts/DocsLayout.astro'; +import Demo from '../../components/Demo.astro'; import CodeBlock from '../../components/CodeBlock.astro'; +import ExportDemo from '../../components/demos/ExportDemo.tsx'; --- @@ -18,6 +20,46 @@ import CodeBlock from '../../components/CodeBlock.astro'; headless surface alongside useTableState, useTableData, and friends.

    + [] = [ + { id: 'name', name: 'Name', selector: r => r.name }, + { id: 'department', name: 'Department', selector: r => r.department }, + { id: 'salary', name: 'Salary', selector: r => r.salary, right: true, + format: r => \`$\${r.salary.toLocaleString()}\` }, +]; + +export default function App() { + const [copied, setCopied] = useState(false); + const { download, copy } = useTableExport({ columns, rows: data, valueSource: 'format' }); + + async function handleCopy(format: 'csv' | 'json') { + await copy(format); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } + + return ( +
    +
    + + + +
    + +
    + ); +}`} + > + +
    +

    Basic usage

    +

    Footer

    + +

    + A footer row renders below the body and above the pagination controls. Use it for totals, + averages, counts, or any other summary that should always be visible alongside the data. +

    + +

    + There are two ways to declare a footer: +

    + +
      +
    • + Column-level footer — the 80% case. Add a footer field + to any TableColumn. The footer row appears automatically when at least one + visible column has one. +
    • +
    • + footerComponent prop — escape hatch. Replace the entire footer + row with a custom component when you need multi-row summaries, custom layouts, or aggregates + that span columns. +
    • +
    + +

    Column-level footers

    + +

    + Pass footer as either a static ReactNode or a function that receives + the filtered+sorted rows and returns a node. Use the function form to compute aggregates — + it sees exactly the rows the user sees in the body, so totals update automatically when you + sort or filter. +

    + + [] = [ + { + id: 'name', + name: 'Name', + selector: r => r.name, + sortable: true, + // static label in the footer + footer: rows => \`\${rows.length} employees\`, + }, + { + id: 'department', + name: 'Department', + selector: r => r.department, + sortable: true, + }, + { + id: 'salary', + name: 'Salary', + selector: r => r.salary, + right: true, + format: r => \`$\${r.salary.toLocaleString()}\`, + // aggregate function — receives the current visible rows + footer: rows => \`$\${rows.reduce((s, r) => s + r.salary, 0).toLocaleString()}\`, + }, + { + id: 'bonus', + name: 'Bonus', + selector: r => r.bonus, + right: true, + format: r => \`$\${r.bonus.toLocaleString()}\`, + footer: rows => \`$\${rows.reduce((s, r) => s + r.bonus, 0).toLocaleString()}\`, + }, +]; + +`} + > + + + +

    + Each footer cell inherits its column's right, center, + width, minWidth, and maxWidth — so totals line up + under their column automatically, including after a resize or sort. +

    + +
    + Server-side pagination: the rows argument to a footer function + contains only the rows currently in the table (the current page). If you need true cross-page + totals, use footerComponent and supply server-computed aggregates yourself. +
    + +

    Custom footer component

    + +

    + Pass footerComponent to replace the footer row entirely. The component receives + {`{ rows, columns }`} and can render whatever it likes — multi-stat summaries, + action buttons, contextual copy, anything. +

    + + ) { + const totalSalary = rows.reduce((s, r) => s + r.salary, 0); + const totalBonus = rows.reduce((s, r) => s + r.bonus, 0); + const avgSalary = rows.length ? Math.round(totalSalary / rows.length) : 0; + + return ( +
    + {rows.length} employees + Salary total: \${totalSalary.toLocaleString()} + Salary avg: \${avgSalary.toLocaleString()} + Bonus total: \${totalBonus.toLocaleString()} + Total comp: \${(totalSalary + totalBonus).toLocaleString()} +
    + ); +} + +`} + > + +
    + +

    + When footerComponent is set it takes precedence over any column-level + footer fields. +

    + +

    Controlling footer visibility

    + +

    + The showFooter prop overrides auto-detection: +

    + +
      +
    • showFooter — force the footer row on even if no column defines a footer.
    • +
    • showFooter={`{false}`} — suppress the footer entirely, overriding column footers and footerComponent. Useful for toggling the summary on and off.
    • +
    • Omitted (default) — auto: render when any visible column has footer or footerComponent is provided.
    • +
    + +

    + The footer is also automatically hidden during progressPending. +

    + +

    Styling

    + +

    + Use customStyles to override footer appearance: +

    + + `} /> + +

    + All built-in themes (including their dark modes) set a background.footer value + that gives the footer a distinct surface color matching the header. When building a custom + theme with createTheme, set background.footer to control it: +

    + + + +

    + The CSS variable --rdt-color-footer-bg is emitted automatically when + background.footer is set. You can also set it directly in a raw CSS-variable + theme object. It falls back to --rdt-color-header-bg, then + --rdt-color-bg. +

    + +

    Pinned columns

    + +

    + Footer cells in pinned columns stick to the table edge exactly like their body cells — no + extra config required. Pinning offsets are computed from the same source as the rest of the + table, so they stay aligned after resizing. +

    + +

    Tips

    + +
      +
    • + Match the footer's display format to the column's format function. If salaries + render as $45,000, format the total the same way. +
    • +
    • + Use a static label (footer: 'Total') on the leftmost column and aggregate + functions on numeric columns. +
    • +
    • + For multi-row footers, group subtotals, or any layout that doesn't map 1:1 to columns, + reach for footerComponent — it renders inside a role="rowgroup" + container for accessibility and has no constraints on its internal structure. +
    • +
    +
    diff --git a/apps/docs/src/pages/docs/recipes.astro b/apps/docs/src/pages/docs/recipes.astro index c94e20fd..35deb0dd 100644 --- a/apps/docs/src/pages/docs/recipes.astro +++ b/apps/docs/src/pages/docs/recipes.astro @@ -149,15 +149,29 @@ export default function App() { ); }`} /> -

    Bulk-action toolbar

    +

    Bulk-action toolbar

    - Render a toolbar above the table that appears when one or more rows are selected. Use the - imperative ref to clear the selection after the action completes. + 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); @@ -168,13 +182,25 @@ export default function App() { 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.length} selected: {selected.map(r => r.name).join(', ')} + +
    + + + +
    )} @@ -182,10 +208,11 @@ export default function App() { ref={ref} columns={columns} data={data} + keyField="id" selectableRows onSelectedRowsChange={({ selectedRows }) => setSelected(selectedRows)} /> - +
    ); }`} /> @@ -225,8 +252,9 @@ function OrderDetail({ data }: ExpanderComponentProps) {

    Sticky footer with totals

    - There's no built-in footer slot. The simplest pattern is to render a totals row outside - the table that aligns to the same column layout: + 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:

    +

    Custom selection toolbar

    +

    + v8 removed the built-in contextMessage and contextActions props. + Use onSelectedRowsChange to drive your own toolbar rendered outside the table — + you get full control over layout, copy, and actions. See the + bulk-action toolbar recipe for a complete example. +

    +

    Range selection (Shift-click)

    Click one row's checkbox, then Shift-click another to toggle every row in between to match diff --git a/src/DataTable.css b/src/DataTable.css index f11ecbdc..7160dbe4 100644 --- a/src/DataTable.css +++ b/src/DataTable.css @@ -1087,6 +1087,39 @@ pointer-events: none; } +/* ─── Footer ───────────────────────────────────────────────────────────────── + * Lives inside .rdt_table so it shares the same flex layout as body rows — + * column widths align automatically with the rest of the table. + * The row gets a top border to visually separate the totals from the data. + */ +.rdt_footer { + display: flex; + flex-direction: column; + background-color: var(--rdt-color-footer-bg, var(--rdt-color-header-bg, var(--rdt-color-bg, #fff))); +} + +.rdt_footerRow { + display: flex; + align-items: stretch; + width: 100%; + box-sizing: border-box; + min-height: var(--rdt-row-height, 48px); + font-size: var(--rdt-font-size, 13px); + font-weight: 600; + color: var(--rdt-color-text-primary, rgba(0, 0, 0, 0.87)); + border-top: 1px solid var(--rdt-color-divider, rgba(0, 0, 0, 0.12)); +} + +.rdt_footerCell { + background-color: inherit; +} + +.rdt_footerSystemCell { + flex: 0 0 var(--rdt-system-col-width, 40px); + min-width: 0; + max-width: var(--rdt-system-col-width, 40px); +} + /* ─── Pagination ────────────────────────────────────────────────────────────── */ .rdt_pagination { display: flex; diff --git a/src/__tests__/DataTable.test.tsx b/src/__tests__/DataTable.test.tsx index 344cc889..cf884629 100644 --- a/src/__tests__/DataTable.test.tsx +++ b/src/__tests__/DataTable.test.tsx @@ -2631,3 +2631,99 @@ describe('DataTable::columnGroups', () => { expect(getByText('Personal Info')).not.toBeNull(); }); }); + +describe('DataTable::footer', () => { + test('does not render a footer row by default', () => { + const mock = dataMock(); + const { container } = render(); + + expect(container.querySelector('.rdt_footer')).toBeNull(); + }); + + test('renders a footer row when a column declares a static footer', () => { + const columns = [{ id: 'name', name: 'Name', selector: (r: { name: string }) => r.name, footer: 'Total' }]; + const data = [ + { id: 1, name: 'Apple' }, + { id: 2, name: 'Banana' }, + ]; + const { container, getByText } = render(); + + expect(container.querySelector('.rdt_footer')).not.toBeNull(); + expect(container.querySelector('.rdt_footerRow')).not.toBeNull(); + expect(getByText('Total')).not.toBeNull(); + }); + + test('invokes a column footer function with filtered+sorted rows', () => { + const footerFn = vi.fn((rows: { value: number }[]) => `Sum: ${rows.reduce((s, r) => s + r.value, 0)}`); + const columns = [{ id: 'value', name: 'Value', selector: (r: { value: number }) => r.value, footer: footerFn }]; + const data = [ + { id: 1, value: 10 }, + { id: 2, value: 25 }, + { id: 3, value: 7 }, + ]; + const { getByText } = render(); + + expect(footerFn).toHaveBeenCalledWith(data); + expect(getByText('Sum: 42')).not.toBeNull(); + }); + + test('renders a footerCell per visible column (omit excluded)', () => { + const columns = [ + { id: 'a', name: 'A', selector: (r: { a: string }) => r.a, footer: 'A-foot' }, + { id: 'b', name: 'B', selector: (r: { b: string }) => r.b, footer: 'B-foot', omit: true }, + { id: 'c', name: 'C', selector: (r: { c: string }) => r.c, footer: 'C-foot' }, + ]; + const data = [{ id: 1, a: 'a1', b: 'b1', c: 'c1' }]; + const { container } = render(); + + const footerCells = container.querySelectorAll('.rdt_footerCell'); + expect(footerCells.length).toBe(2); + }); + + test('renders a custom footerComponent and passes rows + columns', () => { + const FooterComp = vi.fn(({ rows }: { rows: { id: number }[] }) => ( +

    Rows: {rows.length}
    + )); + const mock = dataMock(); + const { getByTestId } = render(); + + expect(getByTestId('custom-footer').textContent).toBe('Rows: 2'); + expect(FooterComp).toHaveBeenCalled(); + const call = FooterComp.mock.calls[0][0] as { rows: unknown[]; columns: unknown[] }; + expect(call.rows.length).toBe(2); + expect(call.columns.length).toBe(1); + }); + + test('footerComponent takes precedence over column footers', () => { + const FooterComp = () =>
    Custom
    ; + const columns = [{ id: 'name', name: 'Name', selector: (r: { name: string }) => r.name, footer: 'Column-Footer' }]; + const data = [{ id: 1, name: 'Apple' }]; + const { container, queryByText } = render(); + + expect(container.querySelector('.custom-footer-marker')).not.toBeNull(); + expect(queryByText('Column-Footer')).toBeNull(); + }); + + test('showFooter=false suppresses the footer even when columns declare a footer', () => { + const columns = [{ id: 'name', name: 'Name', selector: (r: { name: string }) => r.name, footer: 'Total' }]; + const data = [{ id: 1, name: 'Apple' }]; + const { container } = render(); + + expect(container.querySelector('.rdt_footer')).toBeNull(); + }); + + test('showFooter=true renders the footer row even with no column footers', () => { + const mock = dataMock(); + const { container } = render(); + + expect(container.querySelector('.rdt_footer')).not.toBeNull(); + }); + + test('does not render the footer while progressPending is true', () => { + const columns = [{ id: 'name', name: 'Name', selector: (r: { name: string }) => r.name, footer: 'Total' }]; + const data = [{ id: 1, name: 'Apple' }]; + const { container } = render(); + + expect(container.querySelector('.rdt_footer')).toBeNull(); + }); +}); diff --git a/src/components/DataTable.tsx b/src/components/DataTable.tsx index 20086a6d..4cb20111 100644 --- a/src/components/DataTable.tsx +++ b/src/components/DataTable.tsx @@ -8,6 +8,7 @@ import NativePagination from './Pagination'; import DataTableHead from './DataTableHead'; import DataTableBody from './DataTableBody'; import TablePaginationFooter from './TablePaginationFooter'; +import TableFooter from './TableFooter'; import { getNumberOfPages, recalculatePage, getPinnedOffsets, getPinnedTotalWidths } from '../util'; import PinnedScrollbar from './PinnedScrollbar'; import { defaultProps, DEFAULT_EXPANDABLE_ICON, DEFAULT_PAGINATION_ICONS } from '../defaultProps'; @@ -112,6 +113,8 @@ function DataTableInner(props: TableProps, ref: React.ForwardedRef(props: TableProps, ref: React.ForwardedRef 0); const showHeader = !noHeader && !!(title || actions); + // Footer renders when explicitly enabled, when a footerComponent is provided, + // or when at least one visible column declares a `footer`. `showFooter={false}` + // suppresses the row entirely (overrides both column footers and footerComponent). + const hasColumnFooter = React.useMemo( + () => effectiveColumns.some(c => !c.omit && c.footer !== undefined), + [effectiveColumns], + ); + const showFooterRow = + showFooter !== false && !progressPending && (showFooter === true || !!footerComponent || hasColumnFooter); + if (pagination && !paginationServer && filteredSortedData.length > 0 && filteredTableRows.length === 0) { handleChangePage(recalculatePage(currentPage, getNumberOfPages(filteredSortedData.length, rowsPerPage))); } @@ -491,6 +504,17 @@ function DataTableInner(props: TableProps, ref: React.ForwardedRef + + {showFooterRow && ( + + )} diff --git a/src/components/TableCell.tsx b/src/components/TableCell.tsx index f483634c..ddcd8bde 100644 --- a/src/components/TableCell.tsx +++ b/src/components/TableCell.tsx @@ -122,11 +122,12 @@ function Cell({ id, column, row, rowIndex, dataTag, isDragging }: CellProps e.preventDefault()} - onDragOver={onDragOver} - onDragEnd={onDragEnd} - onDragEnter={onDragEnter} - onDragLeave={onDragLeave} + draggable={column.reorder || undefined} + onDragStart={column.reorder ? onDragStart : undefined} + onDragOver={column.reorder ? onDragOver : undefined} + onDragEnd={column.reorder ? onDragEnd : undefined} + onDragEnter={column.reorder ? onDragEnter : undefined} + onDragLeave={column.reorder ? onDragLeave : undefined} onClick={editor && !editing && editor.type !== 'checkbox' ? startEdit : undefined} > {editing && editor?.type === 'text' && ( diff --git a/src/components/TableFooter.tsx b/src/components/TableFooter.tsx new file mode 100644 index 00000000..d7ceceb0 --- /dev/null +++ b/src/components/TableFooter.tsx @@ -0,0 +1,101 @@ +import * as React from 'react'; +import '../DataTable.css'; +import { useStyles } from '../context/StylesContext'; +import { useRowContext } from '../context/RowContext'; +import { CellExtended } from './Cell'; +import RightPinSpacer from './RightPinSpacer'; +import { getPinnedCellMeta } from '../util'; +import type { ColumnFooter, FooterComponent, TableColumn } from '../types'; + +interface TableFooterProps { + columns: TableColumn[]; + rows: T[]; + selectableRows: boolean; + expandableRows: boolean; + expandableRowsHideExpander: boolean; + footerComponent?: FooterComponent; +} + +function resolveFooter(footer: ColumnFooter | undefined, rows: T[]): React.ReactNode { + if (footer == null) return null; + if (typeof footer === 'function') return (footer as (rows: T[]) => React.ReactNode)(rows); + return footer; +} + +function TableFooter({ + columns, + rows, + selectableRows, + expandableRows, + expandableRowsHideExpander, + footerComponent: FooterComponentProp, +}: TableFooterProps): JSX.Element { + const customStyles = useStyles(); + const { columnWidths, pinnedOffsets } = useRowContext(); + + // First (leftmost) right-pinned column id — a spacer is injected before it so + // non-pinned footer cells fill the gap to the right pins, matching TableRow. + const firstRightPinnedId = React.useMemo(() => { + for (const col of columns) { + if (!col.omit && col.pinned === 'right') return col.id; + } + return null; + }, [columns]); + + const style: React.CSSProperties = { + ...(customStyles.footer?.style as React.CSSProperties | undefined), + }; + + if (FooterComponentProp) { + return ( +
    + +
    + ); + } + + return ( +
    +
    + {selectableRows && + ); +} + +export default TableFooter; diff --git a/src/index.ts b/src/index.ts index c64680a9..d2bdde8f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -44,6 +44,9 @@ export type { CellEditCallback, CellValidateResult, CustomCellEditorContext, + ColumnFooter, + FooterComponent, + FooterComponentProps, } from './types'; export { emptyFilterState, isFilterActive } from './hooks/useColumnFilter'; diff --git a/src/themes/base.ts b/src/themes/base.ts index 8dbca6e7..c4a7f8b8 100644 --- a/src/themes/base.ts +++ b/src/themes/base.ts @@ -7,6 +7,7 @@ export const defaultTheme = { }, background: { default: '#FFFFFF', + footer: '#FAFAFA', }, context: { background: '#e3f2fd', @@ -38,7 +39,7 @@ export const defaultTheme = { export const defaultDarkMode = { primary: '#90CAF9', text: { primary: '#FFFFFF', secondary: 'rgba(255,255,255,0.7)', disabled: 'rgba(0,0,0,.12)' }, - background: { default: '#424242' }, + background: { default: '#424242', footer: '#373737' }, context: { background: '#E91E63', text: '#FFFFFF' }, divider: { default: 'rgba(81,81,81,1)' }, button: { diff --git a/src/themes/catppuccin.ts b/src/themes/catppuccin.ts index 536e2281..dda53082 100644 --- a/src/themes/catppuccin.ts +++ b/src/themes/catppuccin.ts @@ -12,7 +12,7 @@ export const catppuccinTheme: Theme = { secondary: '#5c5f77', // latte: subtext1 disabled: '#9ca0b0', // latte: overlay0 }, - background: { default: '#eff1f5', header: '#e6e9ef' }, // base / mantle + background: { default: '#eff1f5', header: '#e6e9ef', footer: '#e6e9ef' }, // base / mantle divider: { default: '#bcc0cc' }, // surface1 highlightOnHover: { default: '#ccd0da', text: '#4c4f69' }, // surface0 striped: { default: '#e6e9ef', text: '#4c4f69' }, // mantle @@ -36,7 +36,7 @@ export const catppuccinTheme: Theme = { secondary: '#bac2de', // mocha: subtext1 disabled: '#6c7086', // mocha: overlay0 }, - background: { default: '#1e1e2e', header: '#181825' }, // base / mantle + background: { default: '#1e1e2e', header: '#181825', footer: '#181825' }, // base / mantle divider: { default: '#45475a' }, // surface1 highlightOnHover: { default: '#313244', text: '#cdd6f4' }, // surface0 striped: { default: '#181825', text: '#cdd6f4' }, // mantle diff --git a/src/themes/crisp.ts b/src/themes/crisp.ts index 8b2e5645..2b30cd46 100644 --- a/src/themes/crisp.ts +++ b/src/themes/crisp.ts @@ -9,7 +9,7 @@ export const crispTheme: Theme = { secondary: 'rgba(24,29,31,0.7)', disabled: 'rgba(24,29,31,0.5)', }, - background: { default: '#ffffff', header: '#fafafa' }, + background: { default: '#ffffff', header: '#fafafa', footer: '#fafafa' }, divider: { default: 'rgba(24,29,31,0.15)' }, highlightOnHover: { default: 'rgba(33,150,243,0.12)', text: '#181d1f' }, striped: { default: '#fafafa', text: '#181d1f' }, @@ -37,7 +37,7 @@ export const crispTheme: Theme = { secondary: 'rgba(255,255,255,0.7)', disabled: 'rgba(255,255,255,0.5)', }, - background: { default: '#1f2936', header: '#28313e' }, + background: { default: '#1f2936', header: '#28313e', footer: '#28313e' }, divider: { default: 'rgba(255,255,255,0.16)' }, highlightOnHover: { default: 'rgba(33,150,243,0.2)', text: '#ffffff' }, striped: { default: '#242d3a', text: '#ffffff' }, diff --git a/src/themes/index.ts b/src/themes/index.ts index cbd87c60..cbc7cc19 100644 --- a/src/themes/index.ts +++ b/src/themes/index.ts @@ -30,6 +30,7 @@ export function themeToVars(theme: Theme): Record { '--rdt-color-bg': theme.background.default, '--rdt-color-context-bg': theme.context.background, ...(theme.background.header ? { '--rdt-color-header-bg': theme.background.header } : {}), + ...(theme.background.footer ? { '--rdt-color-footer-bg': theme.background.footer } : {}), '--rdt-color-context-text': theme.context.text, '--rdt-color-divider': theme.divider.default, '--rdt-color-btn': theme.button.default, diff --git a/src/themes/material.ts b/src/themes/material.ts index 126061ec..d292154f 100644 --- a/src/themes/material.ts +++ b/src/themes/material.ts @@ -16,7 +16,7 @@ export const materialTheme: Theme = { secondary: 'rgba(0,0,0,0.6)', disabled: 'rgba(0,0,0,0.38)', }, - background: { default: '#ffffff' }, + background: { default: '#ffffff', footer: '#fafafa' }, divider: { default: '#e0e0e0' }, highlightOnHover: { default: 'rgba(0,0,0,0.04)', text: 'rgba(0,0,0,0.87)' }, striped: { default: 'rgba(0,0,0,0.02)', text: 'rgba(0,0,0,0.87)' }, @@ -41,7 +41,7 @@ export const materialTheme: Theme = { darkMode: { primary: '#90caf9', text: { primary: '#ffffff', secondary: 'rgba(255,255,255,0.7)', disabled: 'rgba(255,255,255,0.5)' }, - background: { default: '#121212' }, + background: { default: '#121212', footer: '#1a1a1a' }, divider: { default: 'rgba(255,255,255,0.12)' }, highlightOnHover: { default: 'rgba(255,255,255,0.08)', text: '#ffffff' }, striped: { default: 'rgba(255,255,255,0.04)', text: '#ffffff' }, diff --git a/src/themes/rounded.ts b/src/themes/rounded.ts index 43aadb1d..55fb8323 100644 --- a/src/themes/rounded.ts +++ b/src/themes/rounded.ts @@ -10,7 +10,7 @@ export const roundedTheme: Theme = { secondary: '#64748b', disabled: '#cbd5e1', }, - background: { default: '#ffffff', header: '#f8fafc' }, + background: { default: '#ffffff', header: '#f8fafc', footer: '#f8fafc' }, divider: { default: '#e2e8f0' }, highlightOnHover: { default: '#f1f5f9', text: '#0f172a' }, striped: { default: '#f8fafc', text: '#0f172a' }, @@ -30,7 +30,7 @@ export const roundedTheme: Theme = { darkMode: { primary: '#818cf8', text: { primary: '#f1f5f9', secondary: '#94a3b8', disabled: '#334155' }, - background: { default: '#0f172a', header: '#1e293b' }, + background: { default: '#0f172a', header: '#1e293b', footer: '#1e293b' }, divider: { default: '#1e293b' }, highlightOnHover: { default: '#1e293b', text: '#f1f5f9' }, striped: { default: '#162032', text: '#f1f5f9' }, diff --git a/src/types.ts b/src/types.ts index 0267aa76..2b60a4c8 100644 --- a/src/types.ts +++ b/src/types.ts @@ -68,6 +68,22 @@ export type PaginationComponentProps = { }; export type PaginationComponent = React.ComponentType; +/** Renders a column's footer cell. Either a static node, or a function that + * receives the filtered+sorted rows for the current view and returns a node + * (typically an aggregate such as a sum or average). */ +export type ColumnFooter = React.ReactNode | ((rows: T[]) => React.ReactNode); + +/** Props passed to a custom `footerComponent`. */ +export type FooterComponentProps = { + /** Filtered + sorted rows in the current view. With server-side pagination + * this is whatever data the parent passed in — page-bound, not the full dataset. */ + rows: T[]; + /** The column definitions in their current visible order. */ + columns: TableColumn[]; +}; + +export type FooterComponent = React.ComponentType>; + export type DataTableHandle = { clearSelectedRows: () => void; }; @@ -243,6 +259,18 @@ type BaseTableProps = { * Shows and displays a header with a title * */ title?: string | React.ReactNode; + /** + * Replace the built-in footer row with a custom component. Receives `rows` + * (filtered + sorted) and `columns`. When omitted, the footer row is built + * from each column's `footer` field. + */ + footerComponent?: FooterComponent; + /** + * Force the footer row to render. By default the footer renders only when + * `footerComponent` is set or at least one visible column defines a `footer`. + * Set to `false` to suppress the footer entirely. + */ + showFooter?: boolean; }; export type TableProps = BaseTableProps & SelectionProps & PaginationProps & ExpandableProps & SortProps; @@ -345,6 +373,12 @@ export interface TableColumn extends TableColumnBase { * Return `true` to accept, `false` to reject silently, or a string error to display. */ validate?: (value: string, row: T, column: TableColumn) => CellValidateResult; + /** + * Footer cell for this column. Pass a `ReactNode` for a static value, or a function + * `(rows) => ReactNode` to compute an aggregate from the filtered+sorted rows. + * When any visible column defines a `footer`, a footer row renders below the body. + */ + footer?: ColumnFooter; } /** A column group renders as a spanning header row above the regular header row. */ @@ -418,6 +452,12 @@ export interface TableStyles { style?: CSSObject; pageButtonsStyle?: CSSObject; }; + footer?: { + style?: CSSObject; + }; + footerCells?: { + style?: CSSObject; + }; noData?: { style: CSSObject; }; @@ -483,6 +523,8 @@ type ThemeBackground = { default: string; /** Optional separate background for column header rows. Falls back to `default`. */ header?: string; + /** Optional separate background for the footer row. Falls back to `header`, then `default`. */ + footer?: string; }; type ThemeContext = {