From 52277a121157cc3e875f4dfb46bdb20d607dafed Mon Sep 17 00:00:00 2001 From: John Betancur <1385932+jbetancur@users.noreply.github.com> Date: Mon, 15 Jun 2026 15:25:20 -0400 Subject: [PATCH 1/2] feat: implement multi-column sorting functionality (#1325) - Added support for multi-column sorting in the DataTable component. - Updated the sorting logic to handle multiple sort columns and their respective directions. - Enhanced the table reducer to manage sort states for multiple columns. - Introduced a new `SortColumn` type to represent individual sort configurations. - Modified the `onSort` callback to include the current sort configuration. - Updated tests to cover new multi-sort behavior and edge cases. - Enhanced styling for sort priority indicators in the table headers. --- .github/workflows/release.yml | 17 +- .markdownlint.json | 4 + CHANGELOG.md | 120 +++++++++ CLAUDE.md | 116 ++++++++ apps/docs/astro.config.mjs | 8 + .../src/components/demos/MultiSortDemo.tsx | 63 +++++ apps/docs/src/layouts/DocsLayout.astro | 3 +- apps/docs/src/pages/docs/api.md | 3 +- apps/docs/src/pages/docs/changelog.astro | 236 +---------------- apps/docs/src/pages/docs/sorting.astro | 146 ++++++++++- apps/docs/src/pages/docs/whats-new.astro | 248 ------------------ apps/docs/tsconfig.json | 3 +- src/DataTable.css | 17 ++ src/__tests__/DataTable.test.tsx | 62 ++++- src/__tests__/tableReducer.test.ts | 154 ++++++++++- src/__tests__/util.test.ts | 49 ++++ src/components/DataTable.tsx | 8 +- src/components/DataTableHead.tsx | 8 +- src/components/TableCol.tsx | 69 +++-- src/context/HeadContext.tsx | 4 + src/defaultProps.tsx | 1 + src/hooks/useHeadContextValue.ts | 3 + src/hooks/useTableData.ts | 36 ++- src/hooks/useTableState.ts | 11 +- src/index.ts | 1 + src/tableReducer.ts | 52 +++- src/types.ts | 27 +- src/util.ts | 46 ++++ 28 files changed, 957 insertions(+), 558 deletions(-) create mode 100644 .markdownlint.json create mode 100644 CHANGELOG.md create mode 100644 CLAUDE.md create mode 100644 apps/docs/src/components/demos/MultiSortDemo.tsx delete mode 100644 apps/docs/src/pages/docs/whats-new.astro diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index df085109..79f7cb45 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -77,20 +77,17 @@ jobs: - name: Commit and tag run: | - git add package.json + git add package.json CHANGELOG.md git commit -m "chore: release v${{ steps.version.outputs.value }} [skip ci]" git tag "v${{ steps.version.outputs.value }}" git push origin HEAD:master --follow-tags - - name: Generate release notes + - name: Extract release notes from CHANGELOG.md id: notes run: | - PREV_TAG=$(git describe --tags --abbrev=0 HEAD~1 2>/dev/null || echo "") - if [ -z "$PREV_TAG" ]; then - NOTES=$(git log --pretty=format:"- %s" | head -30) - else - NOTES=$(git log "$PREV_TAG"..HEAD~1 --pretty=format:"- %s") - fi + VERSION="${{ steps.version.outputs.value }}" + # Extract everything between the ## X.Y.Z heading and the next --- or ## heading + NOTES=$(awk "/^## ${VERSION}$/{found=1; next} found && /^(---|## )/{exit} found{print}" CHANGELOG.md) echo "content<> $GITHUB_OUTPUT echo "$NOTES" >> $GITHUB_OUTPUT echo "EOF" >> $GITHUB_OUTPUT @@ -101,9 +98,7 @@ jobs: tag_name: v${{ steps.version.outputs.value }} name: v${{ steps.version.outputs.value }} body: | - ## What's Changed - ${{ steps.notes.outputs.content }} - **Full changelog**: https://github.com/${{ github.repository }}/compare/${{ steps.version.outputs.value }}...v${{ steps.version.outputs.value }} + **Full changelog**: https://reactdatatable.com/docs/changelog generate_release_notes: false diff --git a/.markdownlint.json b/.markdownlint.json new file mode 100644 index 00000000..60cdd147 --- /dev/null +++ b/.markdownlint.json @@ -0,0 +1,4 @@ +{ + "MD013": false, + "MD024": { "siblings_only": true } +} diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..7603ea01 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,120 @@ +# Changelog + +A summary of notable changes per release. For the full commit history see the [repository on GitHub](https://github.com/jbetancur/react-data-table-component/commits/master). + +## 8.4.0 + +### New features + +- **Removable sorting** — clicking a sorted header now cycles asc → desc → unsorted, so a sort can be cleared without reloading the page. → [Sorting docs](/docs/sorting#removable-sorting) +- **Multi-column sorting** — new `sortMulti` prop. Ctrl/⌘-click headers to build a sort stack; priority follows click order and a numbered badge marks each sorted column. → [Sorting docs](/docs/sorting#multi-column-sorting) ([#1325](https://github.com/jbetancur/react-data-table-component/pulls/1325)) +- `SortColumn` type exported — represents a single entry in the sort stack (`{ column, sortDirection }`). +- `onSort` gains a fourth `sortColumns: SortColumn[]` argument with the full sort config. Existing three-argument handlers are unaffected. + +### Behavior changes + +- A third click on a sorted header now removes the sort (previously it stayed on descending). Server-side `onSort` handlers should treat an empty `sortColumns` array as "no sort" and drop their `ORDER BY`. + +--- + +## 8.3.0 + +### New features + +- **Localization** — new `localization` prop replaces the three separate option props (`columnFilterOptions`, `expandableRowsOptions`, and pagination aria-label fields on `paginationComponentOptions`). Pass a single object to translate every string and aria-label in the table — filter panel, pagination navigation, and expand/collapse buttons. → [Localization docs](/docs/localization) +- **Built-in locales** — import pre-built translations from the `react-data-table-component/locales` subpath. Ships with: English (`en`), French (`fr`), Spanish (`es`), German (`de`), Brazilian Portuguese (`ptBR`), Arabic — Modern Standard (`ar`), Egyptian (`arEG`), Levantine (`arLV`), Hebrew (`he`), Chinese Simplified (`zhCN`), Chinese Traditional (`zhTW`), Japanese (`ja`), Korean (`ko`), Ukrainian (`uk`). Each locale is individually tree-shakeable. +- New utility exports: `emptyFilterState(type)` and `isFilterActive(filter)`. → [Filtering docs](/docs/filtering#utility-exports) +- **Removable sorting** — clicking a sortable header now cycles ascending → descending → unsorted, so a sort can be cleared directly from the header. → [Sorting docs](/docs/sorting#removable-sorting) +- **Multi-column sorting** — new `sortMulti` prop. Ctrl/⌘-click a header to add it to the existing sort; priority follows click order and a numbered badge marks each sorted column. `onSort` gains a fourth `sortColumns` argument with the full sort config, and the new `SortColumn` type is exported. → [Sorting docs](/docs/sorting#multi-column-sorting) + +### Behavior changes + +- A third click on a sorted header now *removes* the sort (previously it stayed descending). When the sort is cleared, `onSort` fires with an empty primary column and an empty `sortColumns` array — server-side handlers should treat this as "no sort" and drop their `ORDER BY`. The existing three-argument `onSort` usage is unaffected; the fourth argument is additive. + +### Deprecations + +The following will continue to work in 8.x but will be removed in v9. TypeScript will show a deprecation hint. + +- `columnFilterOptions` prop — use `localization` with a `filter` key instead. +- `expandableRowsOptions` prop — use `localization` with an `expandable` key instead. +- Pagination aria-label fields on `paginationComponentOptions` (`navigationAriaLabel`, `firstPageAriaLabel`, `previousPageAriaLabel`, `nextPageAriaLabel`, `lastPageAriaLabel`) — use `localization` with a `pagination` key instead. +- `ColumnFilterOptions` and `ExpandableRowsOptions` types — use `Localization['filter']` and `Localization['expandable']` instead. + +--- + +## 8.2.0 + +### New features + +- `paginationPosition` — controls where the pagination bar renders. Accepts `'bottom'` (default), `'top'`, or `'both'`. → [Pagination docs](/docs/pagination#pagination-position) +- `paginationPage` — controlled active-page prop. Set it to navigate programmatically (e.g. reset to page 1 after a filter change). Use with `onChangePage` to keep in sync. → [API reference](/docs/api#pagination) +- 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 `footerComponent`. Footer cells respect column widths, alignment, and pinning automatically. → [Footer docs](/docs/footer) +- **Pagination button aria-labels** — "First Page", "Previous Page", "Next Page", and "Last Page" are now configurable via `paginationComponentOptions`, enabling proper i18n for screen readers. +- `ref.clearSort()` — new `DataTableHandle` method to programmatically reset sort back to its default state, or unsorted if no defaults are set. → [Sorting docs](/docs/sorting#resetting-sort-programmatically) +- **Sortable column indicator** — sortable columns now show a faint sort icon at reduced opacity so users can discover which columns are sortable before clicking. The inactive opacity is themable via `--rdt-sort-icon-inactive-opacity` (default `0.3`). +- `onScroll` — new prop that fires whenever the table's scroll wrapper scrolls. Receives the native `React.UIEvent`. + +### Bug fixes + +- Fixed column reordering bypassing `reorder={false}` when a cell's text was selected via double-click and then dragged. + +--- + +## 8.1.0 + +### New features + +- Inline editing now supports `number`, `date`, `checkbox`, and `custom` editor types. New column-level `validate` hook gates the edit before `onCellEdit` fires. → [Inline editing](/docs/inline-editing) +- Shift-click range selection on row checkboxes. Enabled by default — opt out with `selectableRowsRange={false}`. New `selectedRows` prop drives controlled selection. → [Row selection](/docs/selection) +- New headless export hook `useTableExport`: build CSV/JSON, trigger a download, or copy to clipboard. → [Export](/docs/export) + +### Bug fixes & polish + +- Expandable row open/close animation now works correctly. Switched from a `max-height` tween to the CSS grid `grid-template-rows: 0fr → 1fr` trick. Close animation added — the row stays mounted while animating out, then unmounts. Both directions respect `animateRows` and `prefers-reduced-motion`. +- Fixed `useLayoutEffect` SSR warning in Next.js App Router and Astro SSR modes. +- Inline editing: added CSS for `checkbox` and `custom` editor types, and validation error tooltip styles (`.rdt_cellEditError`, `.rdt_editErrorTip`). + +--- + +## v8 + +v8 is a full rewrite around a headless hook architecture. Every major feature is composable and usable independently of ``. See the [migration guide](/docs/migration) for breaking changes from v7. + +### Headline features + +- Column pinning (`pinned: 'left' | 'right'`) with cascading sticky offsets, custom pinned scrollbar, and full compatibility with resize/reorder/fixed-header. +- Inline cell editing (`editable` + `onCellEdit`). +- Per-column filtering with structured operators (`filterable`, `filterType`, controlled `filterValues`). +- Column groups (`columnGroups`): span labels across adjacent columns. Drag-to-reorder entire groups as a unit. +- Drag-to-reorder columns and column groups (`reorder: true`, `onColumnOrderChange`). +- Column visibility hook (`useColumnVisibility`) for show/hide pickers. +- Resizable columns (`resizable`): handle straddles the column boundary, 6 px hit area, 40 px hard floor. +- Column separators: `columnSeparator` and `headerSeparator` with `"subtle"` / `"full"` variants. +- Row animations (`animateRows`): staggered entrance + sort transitions, respects `prefers-reduced-motion`. +- Improved loading state: skeleton on first load, dimmed overlay + spinner on refetch. +- Imperative ref API: `ref.current?.clearSelectedRows()`. The `clearSelectedRows` prop is deprecated. +- New row events: `onRowMiddleClicked`, `onRowMouseEnter`, `onRowMouseLeave`. +- Dark mode support via `colorMode` ("light", "dark", "auto"). +- Headless hooks exported: `useColumns`, `useTableState`, `useTableData`, `useColumnFilter`, `useColumnVisibility`. +- New theme: `crisp`. Refactored theme system around CSS variables (`createTheme`). + +### Architecture changes + +- Replaced styled-components with CSS variables and a single stylesheet. No more runtime CS -in-JS. Smaller bundle, faster first render. +- All visual defaults live in `DataTable.css` as `--rdt-*` custom properties. +- Row separators are drawn at the cell level (`.rdt_cellBase`) instead of the row container. Fixes a long-standing issue where the separator scrolled with content past pinned columns. +- System column width (checkbox/expander) is now controlled by `--rdt-system-col-width`. Themes can override it and pinning offsets stay aligned. + +### Breaking changes from v7 + +- v8 ships its own CSS. No `import 'react-data-table-component/dist/index.css'` required, and styled-components overrides will not apply. Use the new theme system or `customStyles`. +- `clearSelectedRows` prop deprecated in favor of `ref.current.clearSelectedRows()`. +- Several renamed props and removed legacy options. See [migration guide](/docs/migration). + +--- + +## v7 (legacy) + +v7 is no longer actively developed. Docs remain at [v7.reactdatatable.com](https://v7.reactdatatable.com). + +If you're upgrading, the [migration guide](/docs/migration) covers the breaking changes and rename mappings. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..b2839229 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,116 @@ +# react-data-table-component — Claude instructions + +## Project layout + +- `src/` — library source (TypeScript). Built with `tsup` → `dist/`. +- `apps/docs/` — Astro docs site served at reactdatatable.com. Imports the library directly from `src/` in dev via a Vite alias. +- `CHANGELOG.md` — single source of truth for release notes. Feeds both the docs changelog page and GitHub Release bodies. + +## Commands + +```sh +npm run lint # eslint src/ +npm run typecheck # tsc --noEmit +npm test # vitest run +npm run build # tsup (library only) +npm run docs # tsup --watch + astro dev (concurrent) +npm run docs:build # astro build + pagefind index +``` + +Always run `npm run lint && npm run typecheck && npm test` before considering a change complete. + +--- + +## Adding a new feature + +1. Implement in `src/`. Export any new public types from `src/index.ts`. +2. Add or update unit tests — reducer logic in `src/__tests__/tableReducer.test.ts`, util functions in `src/__tests__/util.test.ts`, component behaviour in `src/__tests__/DataTable.test.tsx`. +3. Update the docs (see below). +4. Add a changelog entry (see below). + +--- + +## Updating the docs + +The docs live in `apps/docs/src/pages/docs/`. Each feature has its own `.astro` page. + +**Structure of a docs page:** + +- Start with a plain-language explanation of what the feature does and when to use it. +- Show a live demo via a `` component wrapping a `.tsx` island in `apps/docs/src/components/demos/`. +- Follow with reference code blocks for common patterns. +- End with a prop reference table. + +**Demo components** (`apps/docs/src/components/demos/`): + +- Import `DataTable` from `../ThemedDataTable` (not directly from the library) so the demo respects the docs theme switcher. +- Keep demo data self-contained in the file. +- Show a live readout of relevant state (e.g. current sort, selected rows) so the demo is self-explanatory without running it. + +**When adding a new prop or changing an existing one:** + +- Update the prop reference table on the relevant docs page. +- Update the type signature in the API reference at `apps/docs/src/pages/docs/api.md`. + +**Nav and routing:** + +- Add new pages to the sidebar in `apps/docs/src/layouts/DocsLayout.astro`. +- If removing a page, add a `[[redirects]]` entry in `netlify.toml` pointing the old URL to its replacement. + +--- + +## Updating CHANGELOG.md + +`CHANGELOG.md` at the repo root is the single source of truth. Do not edit `apps/docs/src/pages/docs/changelog.astro` — it just renders the root file. + +**Format for a new release section** (add above the previous release, before the `---` divider): + +```markdown +## X.Y.Z + +### New features + +- **Feature name** — one-sentence description. → [Relevant docs](/docs/page) + +### Behavior changes + +- Description of anything that changes existing behaviour, and what consumers need to do. + +### Bug fixes + +- Description of what was broken and what changed. + +### Deprecations + +- `propName` — what to use instead. + +--- +``` + +Rules: + +- One `## X.Y.Z` heading per release. The release workflow's `awk` extractor keys on this exact format — `##` followed by the bare version number, nothing else on the line. +- Keep entries terse — one bullet per item. Link to the relevant docs page where useful. +- Add the section **before** triggering the release workflow. The workflow reads it at release time; if the section is missing the GitHub Release body will be empty. + +--- + +## Release process + +Releases are triggered manually via the **Release** GitHub Actions workflow (`workflow_dispatch`). Before triggering: + +1. Make sure `CHANGELOG.md` has a `## X.Y.Z` section for the new version (the version the bump will produce — patch/minor/major of the current `package.json` version). +2. Ensure all changes are committed and CI is green on master. +3. Go to **Actions → Release → Run workflow**, choose the bump type (patch / minor / major), and run. + +The workflow will: + +- Lint, typecheck, test, and build the library. +- Bump `package.json` version. +- Build the library dist. +- Publish to npm. +- Commit `package.json` + `CHANGELOG.md` and push a version tag to master. +- Extract the matching `## X.Y.Z` section from `CHANGELOG.md` and post it as the GitHub Release body. +- The master push triggers Netlify, which rebuilds and redeploys the docs automatically. + +**Common mistake:** triggering the release before writing the changelog entry. The workflow does not write changelog entries — that is always a human (or AI-assisted) step done on master beforehand. diff --git a/apps/docs/astro.config.mjs b/apps/docs/astro.config.mjs index a5d32c16..b08bc89d 100644 --- a/apps/docs/astro.config.mjs +++ b/apps/docs/astro.config.mjs @@ -14,6 +14,14 @@ export default defineConfig({ alias: { // In dev, resolve the library from local source so edits are instant 'react-data-table-component': new URL('../../src/index.ts', import.meta.url).pathname, + // Allow importing the root CHANGELOG.md as an Astro markdown module + '~changelog': new URL('../../CHANGELOG.md', import.meta.url).pathname, + }, + }, + server: { + fs: { + // Allow Vite to serve files from the monorepo root + allow: [new URL('../..', import.meta.url).pathname], }, }, }, diff --git a/apps/docs/src/components/demos/MultiSortDemo.tsx b/apps/docs/src/components/demos/MultiSortDemo.tsx new file mode 100644 index 00000000..9071d507 --- /dev/null +++ b/apps/docs/src/components/demos/MultiSortDemo.tsx @@ -0,0 +1,63 @@ +import React, { useState } from 'react'; +import DataTable from '../ThemedDataTable'; +import { type TableColumn, type SortColumn, SortOrder } from 'react-data-table-component'; + +interface Employee { + id: number; + name: string; + department: string; + salary: number; + hired: string; +} + +const data: Employee[] = [ + { id: 1, name: 'Aria Chen', department: 'Engineering', salary: 155000, hired: '2019-03-12' }, + { id: 2, name: 'Marcus Webb', department: 'Product', salary: 132000, hired: '2020-07-01' }, + { id: 3, name: 'Priya Kapoor', department: 'Design', salary: 118000, hired: '2021-01-15' }, + { id: 4, name: 'Jordan Ellis', department: 'Engineering', salary: 143000, hired: '2018-11-30' }, + { id: 5, name: 'Sam Rivera', department: 'Engineering', salary: 128000, hired: '2022-04-22' }, + { id: 6, name: 'Taylor Brooks', department: 'Sales', salary: 97000, hired: '2023-02-08' }, + { id: 7, name: 'Morgan Lee', department: 'Engineering', salary: 162000, hired: '2017-09-05' }, + { id: 8, name: 'Casey Park', department: 'Design', salary: 109000, hired: '2022-11-19' }, +]; + +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, + format: r => `$${r.salary.toLocaleString()}`, + right: true, + sortable: true, + }, + { id: 'hired', name: 'Hired', selector: r => r.hired, sortable: true }, +]; + +export default function MultiSortDemo() { + const [sortColumns, setSortColumns] = useState[]>([]); + + const summary = + sortColumns.length === 0 + ? 'No sort — click a header to sort, click a third time to remove it' + : sortColumns.map((s, i) => `${i + 1}. ${String(s.column.id)} ${s.sortDirection}`).join(' · '); + + return ( +
+

+ Ctrl-click (⌘-click on macOS) a second column header to add it to the sort. Cycle each column + ascending → descending → off. +

+ setSortColumns(next as SortColumn[])} + highlightOnHover + /> + {summary} +
+ ); +} diff --git a/apps/docs/src/layouts/DocsLayout.astro b/apps/docs/src/layouts/DocsLayout.astro index 00404c1c..adafaf6d 100644 --- a/apps/docs/src/layouts/DocsLayout.astro +++ b/apps/docs/src/layouts/DocsLayout.astro @@ -16,7 +16,7 @@ const nav = [ links: [ { label: 'Getting Started', href: '/docs/getting-started' }, { label: 'Installation', href: '/docs/installation' }, - { label: "What's New in v8", href: '/docs/whats-new' }, + { label: 'Changelog', href: '/docs/changelog' }, ], }, { @@ -103,7 +103,6 @@ const nav = [ links: [ { label: 'API Reference', href: '/docs/api' }, { label: 'Migration Guide', href: '/docs/migration' }, - { label: 'Changelog', href: '/docs/changelog' }, ], }, ]; diff --git a/apps/docs/src/pages/docs/api.md b/apps/docs/src/pages/docs/api.md index 9733d6af..de5c32d7 100644 --- a/apps/docs/src/pages/docs/api.md +++ b/apps/docs/src/pages/docs/api.md @@ -76,9 +76,10 @@ Complete reference for every prop, type, and export in `react-data-table-compone | `defaultSortFieldId` | `string \| number` | - | Column `id` to sort by on initial render. | | `defaultSortAsc` | `boolean` | `true` | Initial sort direction. | | `sortServer` | `boolean` | `false` | Disable client-side sorting; fire `onSort` and let the server sort. | +| `sortMulti` | `boolean` | `false` | Enable multi-column sorting via Ctrl/⌘-click. Clicking still cycles asc → desc → off per column. | | `sortFunction` | `SortFunction \| null` | - | Global custom sort function applied to all sortable columns. | | `sortIcon` | `ReactNode` | built-in chevron | Custom sort direction indicator. | -| `onSort` | `(column, direction, sortedRows) => void` | - | Called whenever the sort column or direction changes. | +| `onSort` | `(column, direction, sortedRows, sortColumns) => void` | - | Called whenever the sort changes. `sortColumns` is the full sort config in priority order; an empty array means the sort was cleared. | | `ref.clearSort()` | `DataTableHandle` | - | Imperatively reset sort to the default state. See [DataTableHandle](#datatablehandle-ref). | ### Pagination diff --git a/apps/docs/src/pages/docs/changelog.astro b/apps/docs/src/pages/docs/changelog.astro index d582e3ae..6c338470 100644 --- a/apps/docs/src/pages/docs/changelog.astro +++ b/apps/docs/src/pages/docs/changelog.astro @@ -1,240 +1,8 @@ --- import DocsLayout from '../../layouts/DocsLayout.astro'; -import CodeBlock from '../../components/CodeBlock.astro'; +import { Content } from '~changelog'; --- -

Changelog

- -

- A summary of notable changes per release. For the full commit history, see the - repository on GitHub. -

- -

8.3.0

- -

New features

-
    -
  • - Localization — new localization prop on DataTable - replaces the three separate option props (columnFilterOptions, - expandableRowsOptions, and pagination aria-label fields on - paginationComponentOptions). Pass a single object to translate every string and - aria-label in the table — filter panel, pagination navigation, and expand/collapse buttons. - → Localization docs -
  • -
  • - Built-in locales — import pre-built translations from the - react-data-table-component/locales subpath. Ships with: - English (en), French (fr), Spanish (es), - German (de), Brazilian Portuguese (ptBR), - Arabic — Modern Standard (ar), Egyptian (arEG), Levantine (arLV), - Hebrew (he), - Chinese Simplified (zhCN), Chinese Traditional (zhTW), - Japanese (ja), Korean (ko), Ukrainian (uk). - Each locale is individually tree-shakeable. -
  • -
  • - New utility exports: emptyFilterState(type) and isFilterActive(filter). - → Filtering docs -
  • -
- -

Deprecations

-

The following will continue to work in 8.x but will be removed in v9. TypeScript will show a deprecation hint.

-
    -
  • - columnFilterOptions prop — use localization with a filter key instead. -
  • -
  • - expandableRowsOptions prop — use localization with an expandable key instead. -
  • -
  • - Pagination aria-label fields on paginationComponentOptions - (navigationAriaLabel, firstPageAriaLabel, previousPageAriaLabel, - nextPageAriaLabel, lastPageAriaLabel) — - use localization with a pagination key instead. -
  • -
  • - ColumnFilterOptions and ExpandableRowsOptions types — - use Localization['filter'] and Localization['expandable'] instead. -
  • -
- -

8.2.0

- -

New features

-
    -
  • - paginationPosition — controls where the pagination bar renders relative to the - table. Accepts 'bottom' (default), 'top', or 'both'. - → Pagination docs -
  • -
  • - paginationPage — controlled active-page prop. Set it to navigate the table - programmatically (e.g. reset to page 1 after a filter change). Use together with - 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. -
  • -
  • - ref.clearSort() — new DataTableHandle method to programmatically - reset sort back to its default state (defaultSortFieldId / defaultSortAsc), - or unsorted if no defaults are set. - → Sorting docs -
  • -
  • - Sortable column indicator — sortable columns now show a faint sort icon at - reduced opacity so users can discover which columns are sortable before clicking. The inactive - opacity is themable via the --rdt-sort-icon-inactive-opacity CSS custom property - (default 0.3). -
  • -
  • - onScroll — new prop that fires whenever the table's scroll wrapper scrolls. - Receives the native React.UIEvent<HTMLDivElement>. -
  • -
- -

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

- -

- Additive feature release. No breaking changes. See - what's new for narrative coverage. -

- -

New features

-
    -
  • - Inline editing now supports number, date, checkbox, - and custom editor types. New column-level validate hook gates - the edit before onCellEdit fires. - → Inline editing -
  • -
  • - Shift-click range selection on row checkboxes. Enabled by default — opt out with - selectableRowsRange={false}. New selectedRows prop - drives controlled selection. - → Row selection -
  • -
  • - New headless export hook useTableExport: build CSV/JSON, trigger a - download, or copy to clipboard. Import directly from the package: - import { useTableExport } from 'react-data-table-component'. - → Export -
  • -
- -

Bug fixes & polish

-
    -
  • - Expandable row open/close animation now works correctly. Switched from a - max-height tween (which appeared instant at realistic content heights) to - the CSS grid grid-template-rows: 0fr → 1fr trick, giving a true - height transition. Close animation added — the row stays mounted while animating - out, then unmounts. Both directions respect animateRows and - prefers-reduced-motion. -
  • -
  • - Fixed useLayoutEffect SSR warning in Next.js App Router and Astro SSR - modes. The effect now falls back to useEffect on the server. - → SSR -
  • -
  • - Inline editing: added CSS for checkbox and custom editor - types, and validation error tooltip styles (.rdt_cellEditError, - .rdt_editErrorTip). -
  • -
- -

v8

- -

- v8 is a full rewrite around a headless hook architecture. Every major feature is - composable and usable independently of <DataTable>. See - what's new in v8 for narrative coverage and the - migration guide for breaking changes from v7. -

- -

Headline features

-
    -
  • Column pinning (pinned: 'left' | 'right') with cascading sticky offsets, custom pinned scrollbar, and full compatibility with resize/reorder/fixed-header.
  • -
  • Inline cell editing (editable + onCellEdit).
  • -
  • Per-column filtering with structured operators (filterable, filterType, controlled filterValues).
  • -
  • Column groups (columnGroups): span labels across adjacent columns. Drag-to-reorder entire groups as a unit.
  • -
  • Drag-to-reorder columns and column groups (reorder: true, onColumnOrderChange).
  • -
  • Column visibility hook (useColumnVisibility) for show/hide pickers.
  • -
  • Resizable columns (resizable): handle straddles the column boundary, 6 px hit area, 40 px hard floor.
  • -
  • Column separators: columnSeparator and headerSeparator with "subtle" / "full" variants.
  • -
  • Row animations (animateRows): staggered entrance + sort transitions, respects prefers-reduced-motion.
  • -
  • Improved loading state: skeleton on first load, dimmed overlay + spinner on refetch.
  • -
  • Imperative ref API: ref.current?.clearSelectedRows(). The clearSelectedRows prop is deprecated.
  • -
  • New row events: onRowMiddleClicked, onRowMouseEnter, onRowMouseLeave.
  • -
  • Dark mode support via colorMode ("light", "dark", "auto").
  • -
  • Headless hooks exported: useColumns, useTableState, useTableData, useColumnFilter, useColumnVisibility.
  • -
  • New theme: crisp. Refactored theme system around CSS variables (createTheme).
  • -
- -

Architecture changes

-
    -
  • Replaced styled-components with CSS variables and a single stylesheet. No more runtime CSS-in-JS. Smaller bundle, faster first render.
  • -
  • All visual defaults live in DataTable.css as --rdt-* custom properties.
  • -
  • Row separators are drawn at the cell level (.rdt_cellBase) instead of the row container. Fixes a long-standing issue where the separator scrolled with content past pinned columns.
  • -
  • Pinned cell metadata centralized in getPinnedCellMeta so the header (TableCol) and body (TableCell) compute pin styles from a single source.
  • -
  • System column width (checkbox/expander) is now controlled by --rdt-system-col-width. Themes can override it and pinning offsets stay aligned.
  • -
- -

Quality of life

-
    -
  • Horizontal overscroll bounce is contained to DataTable. No more whole-page rubber-band at scroll edges.
  • -
  • Resize handle visual indicator is a 2 px center line that aligns with the separator instead of a translucent block offset to one side.
  • -
  • Pinning silently strips when used with columnGroups and warns once in dev. No more confusing layout glitches.
  • -
  • 426 tests in CI, including pin-meta edge cases and grid template column rendering.
  • -
- -

Breaking changes from v7

-
    -
  • v8 ships its own CSS. No import 'react-data-table-component/dist/index.css' required, and your existing styled-components overrides will not apply. Use the new theme system or customStyles.
  • -
  • clearSelectedRows prop deprecated in favor of ref.current.clearSelectedRows().
  • -
  • Several renamed props and removed legacy options. See migration guide.
  • -
- -

v7 (legacy)

- -

- v7 is no longer actively developed. Docs remain at - v7.reactdatatable.com. - The major v7 milestones, for context: -

- -
    -
  • Built on styled-components.
  • -
  • Class-component themes.
  • -
  • No column pinning, no column groups, no inline editing, no column filtering API.
  • -
- -

- If you're upgrading, the migration guide covers the - breaking changes and rename mappings. -

+
diff --git a/apps/docs/src/pages/docs/sorting.astro b/apps/docs/src/pages/docs/sorting.astro index 808cda5c..b03f6f0c 100644 --- a/apps/docs/src/pages/docs/sorting.astro +++ b/apps/docs/src/pages/docs/sorting.astro @@ -7,6 +7,7 @@ import PrioritySortDemo from '../../components/demos/PrioritySortDemo.tsx'; import ServerSideSortingDemo from '../../components/demos/ServerSideSortingDemo.tsx'; import ServerSideSortPaginationDemo from '../../components/demos/ServerSideSortPaginationDemo.tsx'; import SortResetDemo from '../../components/demos/SortResetDemo.tsx'; +import MultiSortDemo from '../../components/demos/MultiSortDemo.tsx'; --- @@ -14,9 +15,15 @@ import SortResetDemo from '../../components/demos/SortResetDemo.tsx';

Add sortable: true to any column to enable client-side sorting on that column. - Click a header to sort; click again to reverse direction. + Clicking a header cycles through three states: ascending → descending → unsorted. A third click + removes the sort without resetting any other table state. Columns without sortable are not interactive and are excluded from the tab order.

+

+ Enable sortMulti to let users sort by several columns at once — hold + Ctrl (or on macOS) while clicking additional headers. See + Multi-column sorting below. +

[] = [ +

Removable sorting

+

+ Sorting is removable by default. Clicking a sortable header walks through three states so the + user can clear a sort directly from the header instead of resetting the whole table: +

+
    +
  1. First click — sort ascending (or the direction set by defaultSortAsc).
  2. +
  3. Second click — sort descending.
  4. +
  5. Third click — remove the sort.
  6. +
+

+ When the sort is removed, onSort fires with an empty column ({`{}`}), + the default direction, and an empty sortColumns array. The table returns to its + original (unsorted) row order. This is also the signal a server-side handler should use to drop + its ORDER BY clause — see Server-side sorting. +

+ +

Multi-column sorting

+

+ Set sortMulti to let the user sort by more than one column. A plain click still + replaces the entire sort. Holding Ctrl ( on macOS) while clicking a header + adds that column to the existing sort instead. Sort priority follows the order columns + are added, and a small numbered badge appears on each participating header. +

+

+ Each column still cycles ascending → descending → off independently. Ctrl-clicking a column + already in the sort flips its direction, then removes it on the next Ctrl-click. Removing the + primary column promotes the next one to primary. +

+ + [] = [ + { 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 }, + { id: 'hired', name: 'Hired', selector: r => r.hired, sortable: true }, +]; + +export default function App() { + const [sortColumns, setSortColumns] = useState[]>([]); + + return ( + setSortColumns(sortColumns)} + highlightOnHover + /> + ); +}`} + > + + + +

+ Multi-column sorting is stable: when every active sort column ties, rows keep their original + relative order. Each column honors its own sortFunction when one is set; otherwise + its selector value is compared. +

+

Default sort

defaultSortFieldId sets which column is sorted on first render. @@ -62,18 +136,30 @@ const columns: TableColumn[] = [

Listening to sort events

onSort fires on every sort change, whether triggered by clicking a header or by - a defaultSortFieldId change. It receives the sorted column, the new direction, and - the fully sorted row array. + a defaultSortFieldId change. It receives four arguments: the primary sorted column, + its direction, the fully sorted row array, and sortColumns — the complete sort + configuration in priority order. +

+

+ For single-column sorting the first two arguments are all you need. The fourth argument is what + you read when sortMulti is enabled, and it is also how you detect a cleared sort + (the array is empty and the primary column is {`{}`}).

- { - console.log('sorted by', column.id, direction); - // sortedRows is the full sorted array after the sort is applied + onSort={(column, direction, sortedRows, sortColumns) => { + if (sortColumns.length === 0) { + console.log('sort cleared'); + return; + } + console.log('primary sort:', column.id, direction); + // sortedRows is the full sorted array after the sort is applied. + // sortColumns holds every active sort column in priority order: + // [{ column, sortDirection }, ...] }} />`} /> @@ -206,7 +292,7 @@ const SortIcon = () => ( } />`} /> -

Server-side sorting

+

Server-side sorting

Set sortServer to stop the table from sorting rows locally. DataTable still updates its visual sort indicator and fires onSort. Your handler @@ -218,6 +304,40 @@ const SortIcon = () => ( The value is passed to onSort via the column object so you can forward it directly to your query.

+

+ The removable and multi-column behaviors apply to server-side sorting too — the table sends the + intent, your handler turns it into a query. Two cases to handle: +

+
    +
  • + Cleared sort: the three-state cycle means a header can now resolve to + no sort. When that happens onSort fires with an empty + sortColumns array and an empty primary column — drop your ORDER BY and + fetch the default order. +
  • +
  • + Multi-column sort: with sortMulti enabled, read the fourth + sortColumns argument and build an ordered list of sort keys rather than relying on + the single primary column. +
  • +
+ + ({ + field: s.column.sortField ?? String(s.column.id), + dir: s.sortDirection, + })); + + load({ orderBy }); // e.g. ORDER BY department ASC, salary DESC +} + +`} /> onSort - (column, direction, sortedRows) => void + (column, direction, sortedRows, sortColumns) => void - - Called on every sort change with the active column, direction, and sorted rows. + Called on every sort change with the primary column, its direction, the sorted rows, and the full sortColumns config in priority order. An empty sortColumns array means the sort was cleared. + + + sortMulti + boolean + false + Enable multi-column sorting. Ctrl/⌘-click a header to add it to the existing sort. sortServer diff --git a/apps/docs/src/pages/docs/whats-new.astro b/apps/docs/src/pages/docs/whats-new.astro deleted file mode 100644 index 8b81c3cc..00000000 --- a/apps/docs/src/pages/docs/whats-new.astro +++ /dev/null @@ -1,248 +0,0 @@ ---- -import DocsLayout from '../../layouts/DocsLayout.astro'; -import CodeBlock from '../../components/CodeBlock.astro'; ---- - - -

What's new in v8

- -

- v8 is a full rewrite built around a new headless hook architecture. Every major feature is now - composable and usable independently of <DataTable>. Here's what changed. -

- -

8.1.0 — additive feature release

- -

- 8.1.0 builds on the v8 foundation with three additive features. Nothing is breaking; existing - code keeps working. -

- -

Expanded inline editors + validation

- -

- The editor column option now supports number, date, - checkbox, and custom on top of the original text and - select. A new column-level validate function gates the edit before - onCellEdit fires — return true to accept, false to - reject silently, or a string error to keep the editor open with an inline tooltip. -

- - r.salary, - editor: { type: 'number', min: 0, step: 1000 }, - validate: value => { - const n = Number(value); - if (Number.isNaN(n) || n < 0) return 'Must be a positive number'; - return true; - }, - onCellEdit, -}`} /> - -

Inline editing docs

- -

Range selection + controlled selectedRows

- -

- Selection got two upgrades. Click a row's checkbox, then Shift-click another to toggle every - row in between — the same gesture native file pickers and spreadsheets use. Opt out with - selectableRowsRange={false}. The new selectedRows prop - accepts an array to drive selection from outside the table, mirroring the controlled - filterValues pattern. -

- - ([]); - return ( - setSelected(selectedRows)} - columns={columns} - data={data} - /> - ); -}`} /> - -

Row selection docs

- -

CSV / JSON export hook

- -

- A new useTableExport headless hook produces CSV or JSON for any subset of rows - you pass it, plus helpers to trigger a browser download or copy to the clipboard. Pair it - with useTableData + useColumnFilter to export the current filtered - view; pair it with raw data to dump everything. -

- - download('view.csv')}>Export CSV`} /> - -

Export docs

- -

Column filtering

- -

- A new built-in filter popup per column. Set filterable: true on any column to add a - filter icon to its header. The filter type ("text", "number", "date") - controls the available operators and input widget. -

- - [] = [ - { name: 'Name', selector: r => r.name, filterable: true, filterType: 'text' }, - { name: 'Salary', selector: r => r.salary, filterable: true, filterType: 'number' }, - { name: 'Hired', selector: r => r.hired, filterable: true, filterType: 'date' }, -];`} /> - -

- Filters use a structured FilterState with two independent conditions joined by - AND or OR. Supply a custom filterFunction on a column - to override the built-in operator logic. - → Column filtering docs -

- -

Column groups

- -

- Span a label across multiple adjacent columns using the new columnGroups prop. - Groups render as a second header row above the regular column headers. -

- - `} /> - -

Column groups docs

- -

Drag-to-reorder columns and groups

- -

- Users can drag column headers to reorder them. Set reorder: true on each column - that should be draggable. Column groups can be dragged as a unit too. Set reorder: true - on the group and the entire block moves together. -

- - r.name }, - { id: 'dept', name: 'Dept', reorder: true, selector: r => r.dept }, -]; - - setColumns(cols)} - data={data} -/>`} /> - -

Column reordering docs

- -

Column visibility hook

- -

- useColumnVisibility is a new headless hook for managing a show/hide column picker. - It returns a columns array with omit pre-set. Pass it directly to <DataTable>. -

- - - -

Column visibility docs

- -

Improved loading state

- -

- progressPending now distinguishes between initial load and re-fetch: -

- -
    -
  • Initial load (no data yet): renders shimmer skeleton rows that match your column layout.
  • -
  • Re-fetch (data already showing): dims existing rows and overlays a centered spinner, preserving table structure.
  • -
- -

The column header always stays visible in both states. → Loading state docs

- -

Row animations

- -

- Set animateRows to stagger row entrances and animate sort transitions. - Automatically suppressed when the user has prefers-reduced-motion enabled. -

- - `} /> - -

Animations docs

- -

Column separators

- -

- Two new props control vertical lines between columns independently for body rows and headers. -

- - `} /> - -

Column separators docs

- -

Headless hooks

- -

- All internal logic is now exposed as composable hooks. Use them to build fully custom table - markup while keeping the library's sort, pagination, and filter engines. -

- - - - - - - - - - -
HookPurpose
useColumnsResolve column definitions and defaults
useTableStateManage sort, page, and selection state
useTableDataSort, paginate, and slice rows
useColumnFilterPer-column filter values and predicate application
useColumnVisibilityShow/hide column state
- -

Headless hooks docs

- -

Imperative ref API

- -

- Attach a ref to <DataTable> to call imperative methods. - The clearSelectedRows prop is now deprecated in favour of this API. -

- - (null); -ref.current?.clearSelectedRows(); - -`} /> - -

New row events

- -

- onRowMiddleClicked fires on scroll-click. Use it with onRowClicked - to implement open-in-new-tab behavior. -

- - navigate(\`/users/\${row.id}\`)} - onRowMiddleClicked={row => window.open(\`/users/\${row.id}\`, '_blank')} - columns={columns} - data={data} -/>`} /> - - -
- -

Upgrading from v7?

- -

- See the Migration guide for a full list of breaking changes and how to update your code. -

-
diff --git a/apps/docs/tsconfig.json b/apps/docs/tsconfig.json index f01ba595..ecb97a7a 100644 --- a/apps/docs/tsconfig.json +++ b/apps/docs/tsconfig.json @@ -5,7 +5,8 @@ "jsxImportSource": "react", "paths": { "react-data-table-component": ["../../src/index.ts"], - "react-data-table-component/locales": ["../../src/locales/index.ts"] + "react-data-table-component/locales": ["../../src/locales/index.ts"], + "~changelog": ["../../CHANGELOG.md"] } } } diff --git a/src/DataTable.css b/src/DataTable.css index 5dd5a6cf..1337d901 100644 --- a/src/DataTable.css +++ b/src/DataTable.css @@ -915,6 +915,23 @@ transform: rotate(180deg); } +/* Multi-column sort priority badge */ +.rdt_sortPriority { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 14px; + height: 14px; + margin: 0 2px; + padding: 0 3px; + border-radius: 7px; + font-size: 10px; + line-height: 1; + font-weight: 600; + color: var(--rdt-color-bg, #fff); + background-color: var(--rdt-color-text-secondary, rgba(0, 0, 0, 0.54)); +} + /* ─── Expander ──────────────────────────────────────────────────────────────── */ .rdt_expanderRow { width: 100%; diff --git a/src/__tests__/DataTable.test.tsx b/src/__tests__/DataTable.test.tsx index 40324a7e..dac981ad 100644 --- a/src/__tests__/DataTable.test.tsx +++ b/src/__tests__/DataTable.test.tsx @@ -711,7 +711,10 @@ describe('DataTable::sorting', () => { fireEvent.click(container.querySelector('div[data-sort-id="1"]') as HTMLElement); - expect(onSortMock).toBeCalledWith({ id: 1, ...mock.columns[0] }, SortOrder.ASC, mock.data.slice(0).sort()); + const sortedColumn = { id: 1, ...mock.columns[0] }; + expect(onSortMock).toBeCalledWith(sortedColumn, SortOrder.ASC, mock.data.slice(0).sort(), [ + { column: sortedColumn, sortDirection: SortOrder.ASC }, + ]); }); test('should call onSort with the correct params if the sort is clicked twice', () => { @@ -719,11 +722,64 @@ describe('DataTable::sorting', () => { const mock = dataMock({ sortable: true }); const { container } = render(); + const sortedColumn = { id: 1, ...mock.columns[0] }; + fireEvent.click(container.querySelector('div[data-sort-id="1"]') as HTMLElement); - expect(onSortMock).toBeCalledWith({ id: 1, ...mock.columns[0] }, SortOrder.ASC, mock.data.slice(0).sort()); + expect(onSortMock).toBeCalledWith(sortedColumn, SortOrder.ASC, mock.data.slice(0).sort(), [ + { column: sortedColumn, sortDirection: SortOrder.ASC }, + ]); fireEvent.click(container.querySelector('div[data-sort-id="1"]') as HTMLElement); - expect(onSortMock).toBeCalledWith({ id: 1, ...mock.columns[0] }, SortOrder.DESC, mock.data.slice(0).reverse()); + expect(onSortMock).toBeCalledWith(sortedColumn, SortOrder.DESC, mock.data.slice(0).reverse(), [ + { column: sortedColumn, sortDirection: SortOrder.DESC }, + ]); + }); + + test('a third plain click removes sorting and restores the original row order', () => { + const onSortMock = vi.fn(); + const mock = dataMock({ sortable: true }); + const { container } = render(); + + const target = () => container.querySelector('div[data-sort-id="1"]') as HTMLElement; + + fireEvent.click(target()); // asc + fireEvent.click(target()); // desc + fireEvent.click(target()); // removed + + const lastCall = onSortMock.mock.calls[onSortMock.mock.calls.length - 1]; + expect(lastCall[0]).toEqual({}); + expect(lastCall[3]).toEqual([]); + expect(target().getAttribute('aria-sort')).toBe('none'); + }); + + test('Ctrl+click adds a second sort column when sortMulti is enabled', () => { + const onSortMock = vi.fn(); + const mock = dataMock({ sortable: true }); + const columns = [mock.columns[0], { ...mock.columns[0], id: 2, name: 'Second', sortable: true }]; + const { container } = render(); + + fireEvent.click(container.querySelector('div[data-sort-id="1"]') as HTMLElement); + fireEvent.click(container.querySelector('div[data-sort-id="2"]') as HTMLElement, { ctrlKey: true }); + + const lastCall = onSortMock.mock.calls[onSortMock.mock.calls.length - 1]; + const sortColumns = lastCall[3]; + expect(sortColumns).toHaveLength(2); + expect(sortColumns[0].column.id).toBe(1); + expect(sortColumns[1].column.id).toBe(2); + }); + + test('Ctrl+click is ignored (treated as a replace) when sortMulti is disabled', () => { + const onSortMock = vi.fn(); + const mock = dataMock({ sortable: true }); + const columns = [mock.columns[0], { ...mock.columns[0], id: 2, name: 'Second', sortable: true }]; + const { container } = render(); + + fireEvent.click(container.querySelector('div[data-sort-id="1"]') as HTMLElement); + fireEvent.click(container.querySelector('div[data-sort-id="2"]') as HTMLElement, { ctrlKey: true }); + + const lastCall = onSortMock.mock.calls[onSortMock.mock.calls.length - 1]; + expect(lastCall[3]).toHaveLength(1); + expect(lastCall[3][0].column.id).toBe(2); }); test('should render correctly with a custom sortIcon', () => { diff --git a/src/__tests__/tableReducer.test.ts b/src/__tests__/tableReducer.test.ts index 70aed29c..b5fa43e5 100644 --- a/src/__tests__/tableReducer.test.ts +++ b/src/__tests__/tableReducer.test.ts @@ -12,6 +12,7 @@ const baseState = (overrides: Partial> = {}): TableState => selectedRows: [], selectedColumn: column, sortDirection: SortOrder.ASC, + sortColumns: [{ column, sortDirection: SortOrder.ASC }], currentPage: 1, rowsPerPage: 10, selectedRowsFlag: false, @@ -297,6 +298,18 @@ describe('tableReducer:CLEAR_SORT', () => { expect(next.selectedColumn).toBe(defaultColumn); expect(next.sortDirection).toBe(SortOrder.ASC); + expect(next.sortColumns).toEqual([{ column: defaultColumn, sortDirection: SortOrder.ASC }]); + }); + + test('clears sortColumns to empty when the default column has no id or selector', () => { + const next = tableReducer(baseState(), { + type: 'CLEAR_SORT', + defaultSortColumn: {}, + defaultSortDirection: SortOrder.ASC, + }); + + expect(next.sortColumns).toEqual([]); + expect(next.selectedColumn).toEqual({}); }); test('preserves all other state fields', () => { @@ -314,25 +327,153 @@ describe('tableReducer:CLEAR_SORT', () => { }); describe('tableReducer:SORT_CHANGE', () => { - test('updates sort column / direction and resets to page 1', () => { - const next = tableReducer(baseState({ currentPage: 4 }), { + const colB: TableColumn = { id: 2, name: 'idCol', selector: r => r.id }; + const empty = (): TableState => baseState({ selectedColumn: {}, sortColumns: [] }); + + test('first click on an unsorted column sorts it ascending and resets to page 1', () => { + const next = tableReducer(empty(), { type: 'SORT_CHANGE', - sortDirection: SortOrder.DESC, selectedColumn: column, + additive: false, + defaultSortDirection: SortOrder.ASC, clearSelectedOnSort: false, }); expect(next.selectedColumn).toBe(column); - expect(next.sortDirection).toBe(SortOrder.DESC); + expect(next.sortDirection).toBe(SortOrder.ASC); + expect(next.sortColumns).toEqual([{ column, sortDirection: SortOrder.ASC }]); expect(next.currentPage).toBe(1); expect(next.sortTriggeredPageReset).toBe(true); }); + test('second click on the sorted column flips to descending', () => { + const next = tableReducer(baseState(), { + type: 'SORT_CHANGE', + selectedColumn: column, + additive: false, + defaultSortDirection: SortOrder.ASC, + clearSelectedOnSort: false, + }); + + expect(next.sortDirection).toBe(SortOrder.DESC); + expect(next.sortColumns).toEqual([{ column, sortDirection: SortOrder.DESC }]); + }); + + test('third click on the sorted column removes the sort', () => { + const desc = baseState({ sortDirection: SortOrder.DESC, sortColumns: [{ column, sortDirection: SortOrder.DESC }] }); + const next = tableReducer(desc, { + type: 'SORT_CHANGE', + selectedColumn: column, + additive: false, + defaultSortDirection: SortOrder.ASC, + clearSelectedOnSort: false, + }); + + expect(next.sortColumns).toEqual([]); + expect(next.selectedColumn).toEqual({}); + expect(next.sortDirection).toBe(SortOrder.ASC); + }); + + test('plain click on a different column replaces the existing sort', () => { + const next = tableReducer(baseState(), { + type: 'SORT_CHANGE', + selectedColumn: colB, + additive: false, + defaultSortDirection: SortOrder.ASC, + clearSelectedOnSort: false, + }); + + expect(next.sortColumns).toEqual([{ column: colB, sortDirection: SortOrder.ASC }]); + expect(next.selectedColumn).toBe(colB); + }); + + test('respects defaultSortDirection=desc as the first direction', () => { + const next = tableReducer(empty(), { + type: 'SORT_CHANGE', + selectedColumn: column, + additive: false, + defaultSortDirection: SortOrder.DESC, + clearSelectedOnSort: false, + }); + + expect(next.sortDirection).toBe(SortOrder.DESC); + expect(next.sortColumns).toEqual([{ column, sortDirection: SortOrder.DESC }]); + }); + + test('additive click appends a new column, preserving priority order', () => { + const next = tableReducer(baseState(), { + type: 'SORT_CHANGE', + selectedColumn: colB, + additive: true, + defaultSortDirection: SortOrder.ASC, + clearSelectedOnSort: false, + }); + + expect(next.sortColumns).toEqual([ + { column, sortDirection: SortOrder.ASC }, + { column: colB, sortDirection: SortOrder.ASC }, + ]); + // Primary remains the first column. + expect(next.selectedColumn).toBe(column); + }); + + test('additive click cycles an already-sorted secondary column asc -> desc -> removed', () => { + const twoCols = baseState({ + sortColumns: [ + { column, sortDirection: SortOrder.ASC }, + { column: colB, sortDirection: SortOrder.ASC }, + ], + }); + + const flipped = tableReducer(twoCols, { + type: 'SORT_CHANGE', + selectedColumn: colB, + additive: true, + defaultSortDirection: SortOrder.ASC, + clearSelectedOnSort: false, + }); + expect(flipped.sortColumns).toEqual([ + { column, sortDirection: SortOrder.ASC }, + { column: colB, sortDirection: SortOrder.DESC }, + ]); + + const removed = tableReducer(flipped, { + type: 'SORT_CHANGE', + selectedColumn: colB, + additive: true, + defaultSortDirection: SortOrder.ASC, + clearSelectedOnSort: false, + }); + expect(removed.sortColumns).toEqual([{ column, sortDirection: SortOrder.ASC }]); + }); + + test('removing the primary in a multi-sort promotes the next column to primary', () => { + const twoCols = baseState({ + sortColumns: [ + { column, sortDirection: SortOrder.DESC }, + { column: colB, sortDirection: SortOrder.ASC }, + ], + }); + + const next = tableReducer(twoCols, { + type: 'SORT_CHANGE', + selectedColumn: column, + additive: true, + defaultSortDirection: SortOrder.ASC, + clearSelectedOnSort: false, + }); + + expect(next.sortColumns).toEqual([{ column: colB, sortDirection: SortOrder.ASC }]); + expect(next.selectedColumn).toBe(colB); + expect(next.sortDirection).toBe(SortOrder.ASC); + }); + test('clears the selection when clearSelectedOnSort=true', () => { const next = tableReducer(baseState({ selectedRows: [r1], selectedCount: 1, allSelected: true }), { type: 'SORT_CHANGE', - sortDirection: SortOrder.DESC, selectedColumn: column, + additive: false, + defaultSortDirection: SortOrder.ASC, clearSelectedOnSort: true, }); @@ -344,8 +485,9 @@ describe('tableReducer:SORT_CHANGE', () => { test('keeps the selection when clearSelectedOnSort=false', () => { const next = tableReducer(baseState({ selectedRows: [r1], selectedCount: 1, allSelected: true }), { type: 'SORT_CHANGE', - sortDirection: SortOrder.DESC, selectedColumn: column, + additive: false, + defaultSortDirection: SortOrder.ASC, clearSelectedOnSort: false, }); diff --git a/src/__tests__/util.test.ts b/src/__tests__/util.test.ts index 2c2bf6c4..345438e9 100644 --- a/src/__tests__/util.test.ts +++ b/src/__tests__/util.test.ts @@ -1,6 +1,7 @@ import { isEmpty, sort, + multiSort, getProperty, insertItem, removeItem, @@ -124,6 +125,54 @@ describe('sort', () => { }); }); +describe('multiSort', () => { + type Person = { dept: string; name: string; age: number }; + const people: Person[] = [ + { dept: 'eng', name: 'Bob', age: 40 }, + { dept: 'eng', name: 'Alice', age: 30 }, + { dept: 'sales', name: 'Carol', age: 25 }, + { dept: 'eng', name: 'Alice', age: 22 }, + ]; + + test('returns rows unchanged when there are no sort columns', () => { + expect(multiSort(people, [])).toBe(people); + }); + + test('sorts by the primary column, then breaks ties with the secondary column', () => { + const sorted = multiSort(people, [ + { column: { id: 1, selector: r => r.dept }, sortDirection: SortOrder.ASC }, + { column: { id: 2, selector: r => r.name }, sortDirection: SortOrder.ASC }, + ]); + + expect(sorted.map(p => `${p.dept}:${p.name}`)).toEqual(['eng:Alice', 'eng:Alice', 'eng:Bob', 'sales:Carol']); + }); + + test('honors per-column direction independently', () => { + const sorted = multiSort(people, [ + { column: { id: 1, selector: r => r.dept }, sortDirection: SortOrder.ASC }, + { column: { id: 2, selector: r => r.age }, sortDirection: SortOrder.DESC }, + ]); + + expect(sorted.map(p => `${p.dept}:${p.age}`)).toEqual(['eng:40', 'eng:30', 'eng:22', 'sales:25']); + }); + + test('is stable — equal rows keep their original relative order', () => { + const sorted = multiSort(people, [{ column: { id: 1, selector: r => r.dept }, sortDirection: SortOrder.ASC }]); + const engNames = sorted.filter(p => p.dept === 'eng').map(p => `${p.name}:${p.age}`); + + expect(engNames).toEqual(['Bob:40', 'Alice:30', 'Alice:22']); + }); + + test('uses a column sortFunction when provided, flipping its result for desc', () => { + const byAge = { id: 1, selector: (r: Person) => r.name, sortFunction: (a: Person, b: Person) => a.age - b.age }; + const asc = multiSort(people, [{ column: byAge, sortDirection: SortOrder.ASC }]); + const desc = multiSort(people, [{ column: byAge, sortDirection: SortOrder.DESC }]); + + expect(asc.map(p => p.age)).toEqual([22, 25, 30, 40]); + expect(desc.map(p => p.age)).toEqual([40, 30, 25, 22]); + }); +}); + describe('getProperty', () => { test('getProperty return null if a selector is null or undefined', () => { const property = getProperty(row, null, null, 0); diff --git a/src/components/DataTable.tsx b/src/components/DataTable.tsx index 54eb9019..d5047af5 100644 --- a/src/components/DataTable.tsx +++ b/src/components/DataTable.tsx @@ -89,6 +89,7 @@ function DataTableInner(props: TableProps, ref: React.ForwardedRef(props: TableProps, ref: React.ForwardedRef(props: TableProps, ref: React.ForwardedRef({ selectedColumn, sortDirection, + sortColumns, + sortMulti, + defaultSortDirection, sortIcon, sortServer, pagination, diff --git a/src/components/DataTableHead.tsx b/src/components/DataTableHead.tsx index bd57ca2f..45508199 100644 --- a/src/components/DataTableHead.tsx +++ b/src/components/DataTableHead.tsx @@ -28,8 +28,10 @@ function DataTableHead({ expandableRowsHideExpander, }: DataTableHeadProps): JSX.Element { const { - selectedColumn, sortDirection, + sortColumns, + sortMulti, + defaultSortDirection, sortIcon, sortServer, pagination, @@ -147,13 +149,15 @@ function DataTableHead({ // ── Shared column props ────────────────────────────────────────────────── const colProps = (column: TableColumn) => ({ column, - selectedColumn, disabled: progressPending || sortedData.length === 0, pagination, paginationServer, persistSelectedOnSort, selectableRowsVisibleOnly, sortDirection, + sortColumns, + sortMulti, + defaultSortDirection, sortIcon, sortServer, filterValue: filterValues[column.id!] ?? emptyFilterState(column.filterType), diff --git a/src/components/TableCol.tsx b/src/components/TableCol.tsx index 0a8ddc4e..601c18a4 100644 --- a/src/components/TableCol.tsx +++ b/src/components/TableCol.tsx @@ -7,7 +7,7 @@ import ColumnFilter from './ColumnFilter'; import { equalizeId, getPinnedCellMeta } from '../util'; import type { PinnedOffsets } from '../util'; import { SortOrder } from '../types'; -import type { TableColumn, SortAction, FilterState, Localization } from '../types'; +import type { TableColumn, SortAction, SortColumn, FilterState, Localization } from '../types'; type FilterLocalization = NonNullable; @@ -19,8 +19,10 @@ type TableColProps = { pagination: boolean; paginationServer: boolean; persistSelectedOnSort: boolean; - selectedColumn: TableColumn; sortDirection: SortOrder; + sortColumns: SortColumn[]; + sortMulti: boolean; + defaultSortDirection: SortOrder; sortServer: boolean; selectableRowsVisibleOnly: boolean; filterValue: FilterState; @@ -44,8 +46,10 @@ function TableCol({ column, disabled, draggingColumnId, - selectedColumn = {}, sortDirection, + sortColumns, + sortMulti, + defaultSortDirection, sortIcon, sortServer, pagination, @@ -81,34 +85,37 @@ function TableCol({ return null; } - const handleSortChange = () => { + const handleSortChange = (additive: boolean) => { if (!column.sortable && !column.selector) { return; } - let direction = sortDirection; - - if (equalizeId(selectedColumn.id, column.id)) { - direction = sortDirection === SortOrder.ASC ? SortOrder.DESC : SortOrder.ASC; - } - onSort({ type: 'SORT_CHANGE', - sortDirection: direction, selectedColumn: column, + additive: sortMulti && additive, + defaultSortDirection, clearSelectedOnSort: (pagination && paginationServer && !persistSelectedOnSort) || sortServer || selectableRowsVisibleOnly, }); }; + const handleClick = (event: React.MouseEvent) => { + handleSortChange(event.ctrlKey || event.metaKey); + }; + const handleKeyDown = (event: React.KeyboardEvent) => { if (event.key === 'Enter') { - handleSortChange(); + handleSortChange(event.ctrlKey || event.metaKey); } }; + const sortIndex = sortColumns.findIndex(s => equalizeId(s.column.id, column.id)); + const sortEntry = sortIndex === -1 ? undefined : sortColumns[sortIndex]; + const columnSortDirection = sortEntry ? sortEntry.sortDirection : sortDirection; + const renderNativeSortIcon = (sortActive: boolean) => ( - + ); const renderCustomSortIcon = () => ( @@ -116,7 +123,7 @@ function TableCol({ className={[ 'rdt_sortIcon', sortActive ? 'rdt_sortIconActive' : 'rdt_sortIconInactive', - sortDirection === SortOrder.ASC && 'rdt_sortIconAsc', + columnSortDirection === SortOrder.ASC && 'rdt_sortIconAsc', '__rdt_custom_sort_icon__', ] .filter(Boolean) @@ -126,7 +133,14 @@ function TableCol({ ); - const sortActive = !!(column.sortable && equalizeId(selectedColumn.id, column.id)); + const renderSortPriority = () => + sortMulti && sortColumns.length > 1 && sortIndex !== -1 ? ( + + ) : null; + + const sortActive = !!(column.sortable && sortIndex !== -1); const disableSort = !column.sortable || disabled; const tabIndex = disableSort ? -1 : 0; const nativeSortIconLeft = column.sortable && !sortIcon && !column.right; @@ -209,12 +223,12 @@ function TableCol({ ] .filter(Boolean) .join(' ')} - onClick={!disableSort ? handleSortChange : undefined} + onClick={!disableSort ? handleClick : undefined} onKeyDown={!disableSort ? handleKeyDown : undefined} aria-sort={ !disableSort ? sortActive - ? sortDirection === SortOrder.ASC + ? columnSortDirection === SortOrder.ASC ? 'ascending' : 'descending' : 'none' @@ -223,6 +237,7 @@ function TableCol({ > {!disableSort && customSortIconRight && renderCustomSortIcon()} {!disableSort && nativeSortIconRight && renderNativeSortIcon(sortActive)} + {!disableSort && column.right && renderSortPriority()} {typeof column.name === 'string' ? (
({ column.name )} + {!disableSort && !column.right && renderSortPriority()} {!disableSort && customSortIconLeft && renderCustomSortIcon()} {!disableSort && nativeSortIconLeft && renderNativeSortIcon(sortActive)}
@@ -259,10 +275,21 @@ function TableCol({ function areColPropsEqual(prevProps: TableColProps, nextProps: TableColProps): boolean { if (prevProps.column !== nextProps.column) return false; - const prevIsSelected = equalizeId(prevProps.selectedColumn.id, prevProps.column.id); - const nextIsSelected = equalizeId(nextProps.selectedColumn.id, nextProps.column.id); - if (prevIsSelected !== nextIsSelected) return false; - if (prevIsSelected && nextIsSelected && prevProps.sortDirection !== nextProps.sortDirection) return false; + if (prevProps.sortMulti !== nextProps.sortMulti) return false; + // Compare this column's slice of the sort config (position + direction). A change to + // either flips its active state, arrow direction, or multi-sort priority badge. + const prevIdx = prevProps.sortColumns.findIndex(s => equalizeId(s.column.id, prevProps.column.id)); + const nextIdx = nextProps.sortColumns.findIndex(s => equalizeId(s.column.id, nextProps.column.id)); + if (prevIdx !== nextIdx) return false; + if (prevIdx !== -1 && prevProps.sortColumns[prevIdx].sortDirection !== nextProps.sortColumns[nextIdx].sortDirection) + return false; + // The priority badge shows only when more than one column is sorted; a flip across that + // threshold changes whether the badge renders even when this column's index is unchanged. + if (prevProps.sortMulti && prevProps.sortColumns.length !== nextProps.sortColumns.length) { + const prevMulti = prevProps.sortColumns.length > 1; + const nextMulti = nextProps.sortColumns.length > 1; + if (prevMulti !== nextMulti) return false; + } if (prevProps.draggingColumnId !== nextProps.draggingColumnId) { const prevIsDragging = equalizeId(prevProps.column.id, prevProps.draggingColumnId); const nextIsDragging = equalizeId(nextProps.column.id, nextProps.draggingColumnId); diff --git a/src/context/HeadContext.tsx b/src/context/HeadContext.tsx index 2e7d1afc..57a7709c 100644 --- a/src/context/HeadContext.tsx +++ b/src/context/HeadContext.tsx @@ -3,6 +3,7 @@ import { SortOrder } from '../types'; import type { TableColumn, SortAction, + SortColumn, AllRowsAction, RowState, ComponentProps, @@ -15,6 +16,9 @@ export interface HeadContextValue { // Sort state selectedColumn: TableColumn; sortDirection: SortOrder; + sortColumns: SortColumn[]; + sortMulti: boolean; + defaultSortDirection: SortOrder; sortIcon?: React.ReactNode; sortServer: boolean; // Pagination config (affects sort-reset behaviour) diff --git a/src/defaultProps.tsx b/src/defaultProps.tsx index ad34dd28..29de9d5e 100644 --- a/src/defaultProps.tsx +++ b/src/defaultProps.tsx @@ -70,6 +70,7 @@ export const defaultProps = { sortIcon: null, sortFunction: null, sortServer: false, + sortMulti: false, striped: false, highlightOnHover: false, pointerOnHover: false, diff --git a/src/hooks/useHeadContextValue.ts b/src/hooks/useHeadContextValue.ts index ad13056d..16a64d6a 100644 --- a/src/hooks/useHeadContextValue.ts +++ b/src/hooks/useHeadContextValue.ts @@ -14,6 +14,9 @@ export default function useHeadContextValue(options: HeadContextValue): He [ options.selectedColumn, options.sortDirection, + options.sortColumns, + options.sortMulti, + options.defaultSortDirection, options.sortIcon, options.sortServer, options.pagination, diff --git a/src/hooks/useTableData.ts b/src/hooks/useTableData.ts index fd19f44b..0e3ed895 100644 --- a/src/hooks/useTableData.ts +++ b/src/hooks/useTableData.ts @@ -1,20 +1,26 @@ import * as React from 'react'; -import { sort } from '../util'; +import { sort, multiSort } from '../util'; import { SortOrder } from '../types'; -import type { TableColumn, SortFunction, Selector } from '../types'; +import type { TableColumn, SortColumn, SortFunction, Selector } from '../types'; interface UseTableDataProps { data: T[]; columns: TableColumn[]; selectedColumn: TableColumn; sortDirection: SortOrder; + sortColumns: SortColumn[]; currentPage: number; rowsPerPage: number; pagination: boolean; paginationServer: boolean; sortServer: boolean; sortFunction: SortFunction | null; - onSort: (selectedColumn: TableColumn, sortDirection: SortOrder, sortedRows: T[]) => void; + onSort: ( + selectedColumn: TableColumn, + sortDirection: SortOrder, + sortedRows: T[], + sortColumns: SortColumn[], + ) => void; } interface UseTableDataReturn { @@ -30,6 +36,7 @@ export default function useTableData(props: UseTableDataProps): UseTableDa data, selectedColumn, sortDirection, + sortColumns, currentPage, rowsPerPage, pagination, @@ -46,6 +53,11 @@ export default function useTableData(props: UseTableDataProps): UseTableDa return data; } + // Multi-column sort: stable comparison across all sort columns in priority order. + if (sortColumns.length > 1) { + return multiSort(data, sortColumns); + } + // Use column-specific sort function if available if (selectedColumn?.sortFunction && typeof selectedColumn.sortFunction === 'function') { const sortFn = selectedColumn.sortFunction; @@ -57,7 +69,7 @@ export default function useTableData(props: UseTableDataProps): UseTableDa // Use default sort utility — cast selector to Primitive-returning variant required by sort(). // Columns with ReactNode selectors should supply a sortFunction instead. return sort(data, selectedColumn?.selector as Selector | undefined, sortDirection, sortFunction); - }, [sortServer, selectedColumn, sortDirection, data, sortFunction]); + }, [sortServer, selectedColumn, sortDirection, sortColumns, data, sortFunction]); // Memoize paginated table rows const tableRows = React.useMemo(() => { @@ -75,19 +87,23 @@ export default function useTableData(props: UseTableDataProps): UseTableDa // Notify parent when sort changes (but not on sortedData changes to avoid loops) const sortCallbackRef = React.useRef(onSort); - const prevSortRef = React.useRef({ selectedColumn, sortDirection }); + const prevSortRef = React.useRef({ selectedColumn, sortDirection, sortColumns }); React.useEffect(() => { sortCallbackRef.current = onSort; }, [onSort]); React.useEffect(() => { - // Only call onSort if column or direction actually changed - if (prevSortRef.current.selectedColumn !== selectedColumn || prevSortRef.current.sortDirection !== sortDirection) { - prevSortRef.current = { selectedColumn, sortDirection }; - sortCallbackRef.current(selectedColumn, sortDirection, sortedData.slice(0)); + // Only call onSort if column, direction, or the multi-column config actually changed + if ( + prevSortRef.current.selectedColumn !== selectedColumn || + prevSortRef.current.sortDirection !== sortDirection || + prevSortRef.current.sortColumns !== sortColumns + ) { + prevSortRef.current = { selectedColumn, sortDirection, sortColumns }; + sortCallbackRef.current(selectedColumn, sortDirection, sortedData.slice(0), sortColumns); } - }, [selectedColumn, sortDirection, sortedData]); + }, [selectedColumn, sortDirection, sortColumns, sortedData]); return { sortedData, diff --git a/src/hooks/useTableState.ts b/src/hooks/useTableState.ts index fab9d8ed..c1842095 100644 --- a/src/hooks/useTableState.ts +++ b/src/hooks/useTableState.ts @@ -11,6 +11,7 @@ import type { SingleRowAction, RangeRowAction, SortAction, + SortColumn, } from '../types'; interface UseTableStateProps { @@ -36,7 +37,12 @@ interface UseTableStateProps { /** Controlled selection. When provided, internal selection state is overridden. */ controlledSelectedRows?: T[]; onSelectedRowsChange: (state: { allSelected: boolean; selectedCount: number; selectedRows: T[] }) => void; - onSort: (selectedColumn: TableColumn, sortDirection: SortOrder, sortedRows: T[]) => void; + onSort: ( + selectedColumn: TableColumn, + sortDirection: SortOrder, + sortedRows: T[], + sortColumns: SortColumn[], + ) => void; onChangePage: (page: number, totalRows: number) => void; onChangeRowsPerPage: (currentRowsPerPage: number, currentPage: number) => void; } @@ -85,6 +91,8 @@ export default function useTableState(props: UseTableStateProps): UseTable const { persistSelectedOnSort = false, persistSelectedOnPageChange = false } = paginationServerOptions; const mergeSelections = paginationServer && (persistSelectedOnPageChange || persistSelectedOnSort); + const hasDefaultSort = defaultSortColumn.id != null || !!defaultSortColumn.selector; + const [tableState, dispatch] = React.useReducer, Action>>(tableReducer, { allSelected: false, selectedCount: 0, @@ -92,6 +100,7 @@ export default function useTableState(props: UseTableStateProps): UseTable selectedColumn: defaultSortColumn, toggleOnSelectedRowsChange: false, sortDirection: defaultSortDirection, + sortColumns: hasDefaultSort ? [{ column: defaultSortColumn, sortDirection: defaultSortDirection }] : [], currentPage: paginationDefaultPage, rowsPerPage: paginationPerPage, selectedRowsFlag: false, diff --git a/src/index.ts b/src/index.ts index cadfee0f..764e9738 100644 --- a/src/index.ts +++ b/src/index.ts @@ -40,6 +40,7 @@ export type { /** @deprecated Use `Localization['expandable']` instead. Will be removed in v9. */ ExpandableRowsOptions, SortFunction, + SortColumn, Selector, FilterType, FilterOperator, diff --git a/src/tableReducer.ts b/src/tableReducer.ts index c3f085a9..aedd2524 100644 --- a/src/tableReducer.ts +++ b/src/tableReducer.ts @@ -1,5 +1,6 @@ -import { insertItem, isRowSelected, removeItem } from './util'; -import type { Action, TableState } from './types'; +import { insertItem, isRowSelected, removeItem, equalizeId } from './util'; +import { SortOrder } from './types'; +import type { Action, TableState, SortColumn } from './types'; export function tableReducer(state: TableState, action: Action): TableState { const toggleOnSelectedRowsChange = !state.toggleOnSelectedRowsChange; @@ -141,21 +142,62 @@ export function tableReducer(state: TableState, action: Action): TableS case 'CLEAR_SORT': { const { defaultSortColumn, defaultSortDirection } = action; + const hasDefault = defaultSortColumn.id != null || !!defaultSortColumn.selector; return { ...state, selectedColumn: defaultSortColumn, sortDirection: defaultSortDirection, + sortColumns: hasDefault ? [{ column: defaultSortColumn, sortDirection: defaultSortDirection }] : [], }; } case 'SORT_CHANGE': { - const { sortDirection, selectedColumn, clearSelectedOnSort } = action; + const { selectedColumn, clearSelectedOnSort, additive, defaultSortDirection } = action; + const firstDirection = defaultSortDirection; + const secondDirection = firstDirection === SortOrder.ASC ? SortOrder.DESC : SortOrder.ASC; + + const existingIndex = state.sortColumns.findIndex(s => equalizeId(s.column.id, selectedColumn.id)); + + let nextSortColumns: SortColumn[]; + + if (additive) { + // Ctrl/⌘+click: add or cycle the clicked column within the existing sort. + if (existingIndex === -1) { + nextSortColumns = [...state.sortColumns, { column: selectedColumn, sortDirection: firstDirection }]; + } else { + const current = state.sortColumns[existingIndex]; + if (current.sortDirection === firstDirection) { + nextSortColumns = state.sortColumns.map((s, i) => + i === existingIndex ? { column: selectedColumn, sortDirection: secondDirection } : s, + ); + } else { + // Second click on a column already in the second direction removes it from the sort. + nextSortColumns = state.sortColumns.filter((_, i) => i !== existingIndex); + } + } + } else { + // Plain click: cycle the clicked column through asc → desc → none, replacing any other sort. + const isOnlyColumn = state.sortColumns.length === 1 && existingIndex === 0; + + if (isOnlyColumn) { + const current = state.sortColumns[0]; + nextSortColumns = + current.sortDirection === firstDirection + ? [{ column: selectedColumn, sortDirection: secondDirection }] + : []; + } else { + nextSortColumns = [{ column: selectedColumn, sortDirection: firstDirection }]; + } + } + + const primary = nextSortColumns[0]; return { ...state, - selectedColumn, - sortDirection, + selectedColumn: primary ? primary.column : {}, + sortDirection: primary ? primary.sortDirection : defaultSortDirection, + sortColumns: nextSortColumns, currentPage: 1, sortTriggeredPageReset: true, // when using server-side paging reset selected row counts when sorting diff --git a/src/types.ts b/src/types.ts index f9b635bc..bd514400 100644 --- a/src/types.ts +++ b/src/types.ts @@ -43,6 +43,12 @@ export enum SortOrder { DESC = 'desc', } +/** A single column participating in a (possibly multi-column) sort, in priority order. */ +export type SortColumn = { + column: TableColumn; + sortDirection: SortOrder; +}; + export type Primitive = string | number | boolean; export type ColumnSortFunction = (a: T, b: T) => number; export type ExpandRowToggled = (expanded: boolean, row: T) => void; @@ -174,13 +180,24 @@ type ExpandableProps = { type SortProps = { defaultSortAsc?: boolean; defaultSortFieldId?: string | number | null | undefined; - onSort?: (selectedColumn: TableColumn, sortDirection: SortOrder, sortedRows: T[]) => void; + onSort?: ( + selectedColumn: TableColumn, + sortDirection: SortOrder, + sortedRows: T[], + sortColumns: SortColumn[], + ) => void; sortFunction?: SortFunction | null; /** * @deprecated Pass via the theme instead: `createTheme('t', { icons: { sort: } })` */ sortIcon?: React.ReactNode; sortServer?: boolean; + /** + * Enable multi-column sorting. Hold Ctrl (or ⌘ on macOS) while clicking a column + * header to add it to the existing sort instead of replacing it. Sort priority follows + * the order columns are added. Defaults to `false`. + */ + sortMulti?: boolean; }; type BaseTableProps = { @@ -599,6 +616,9 @@ export type TableState = { selectedRows: T[]; selectedColumn: TableColumn; sortDirection: SortOrder; + /** Full sort configuration in priority order. The primary entry mirrors + * `selectedColumn`/`sortDirection`. Empty when nothing is sorted. */ + sortColumns: SortColumn[]; currentPage: number; rowsPerPage: number; selectedRowsFlag: boolean; @@ -781,9 +801,12 @@ export interface MultiRowAction { export interface SortAction { type: 'SORT_CHANGE'; - sortDirection: SortOrder; selectedColumn: TableColumn; clearSelectedOnSort: boolean; + /** When true, add/update this column in the existing sort instead of replacing it. */ + additive: boolean; + /** Direction a freshly clicked column sorts in first (from `defaultSortAsc`). */ + defaultSortDirection: SortOrder; } export interface PaginationPageAction { diff --git a/src/util.ts b/src/util.ts index 4a8b8ab6..0b2ae622 100644 --- a/src/util.ts +++ b/src/util.ts @@ -75,6 +75,52 @@ export function sort( }); } +/** + * Stable multi-column sort. Compares rows by each sort column in priority order, + * falling back to the next column when the current one ties. Honors each column's + * `sortFunction` when provided, otherwise compares its `selector` value. + */ +export function multiSort(rows: T[], sortColumns: { column: TableColumn; sortDirection: SortOrder }[]): T[] { + if (sortColumns.length === 0) { + return rows; + } + + return rows + .map((row, index) => ({ row, index })) + .sort((a, b) => { + for (const { column, sortDirection } of sortColumns) { + const flip = sortDirection === SortOrder.ASC ? 1 : -1; + + if (column.sortFunction && typeof column.sortFunction === 'function') { + const result = column.sortFunction(a.row, b.row); + if (result !== 0) { + return result * flip; + } + continue; + } + + const selector = column.selector as Selector | undefined; + if (!selector) { + continue; + } + + const aValue = selector(a.row); + const bValue = selector(b.row); + + if (aValue < bValue) { + return -1 * flip; + } + if (aValue > bValue) { + return 1 * flip; + } + } + + // Stable tiebreaker: preserve the original order. + return a.index - b.index; + }) + .map(({ row }) => row); +} + export function getProperty( row: T, selector: ((row: T, rowIndex?: number) => React.ReactNode) | undefined | null, From 2c1e65464cdc07889b128a3e2652942294fcd872 Mon Sep 17 00:00:00 2001 From: John Betancur <1385932+jbetancur@users.noreply.github.com> Date: Mon, 15 Jun 2026 15:37:54 -0400 Subject: [PATCH 2/2] feat: update version to 8.3.0 and add version check before publishing --- .github/workflows/release.yml | 13 ++++++++++++- package-lock.json | 2 +- package.json | 2 +- 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 79f7cb45..6a59f72b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -67,11 +67,22 @@ jobs: - name: Get new version id: version - run: echo "value=$(node -p "require('./package.json').version")" >> $GITHUB_OUTPUT + run: | + VERSION=$(node -p "require('./package.json').version") + echo "value=$VERSION" >> $GITHUB_OUTPUT + echo "Bumped to $VERSION" - name: Build run: npm run build + - name: Check version not already published + run: | + VERSION="${{ steps.version.outputs.value }}" + if npm view react-data-table-component@$VERSION version 2>/dev/null; then + echo "Error: v$VERSION is already published to npm" + exit 1 + fi + - name: Publish to npm run: npm publish --provenance --access public --ignore-scripts diff --git a/package-lock.json b/package-lock.json index f985a324..0a4d15e0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,7 +6,7 @@ "packages": { "": { "name": "react-data-table-component", - "version": "8.2.0", + "version": "8.3.0", "license": "Apache-2.0", "devDependencies": { "@testing-library/dom": "^10.4.1", diff --git a/package.json b/package.json index cd615b20..adad5861 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "react-data-table-component", - "version": "8.2.0", + "version": "8.3.0", "description": "A fast, feature-rich React data table. Working table in 10 lines.", "main": "dist/index.js", "module": "dist/index.mjs",