From 2c473fc37e850fcf2d878f1d6f92beb693c8ddf5 Mon Sep 17 00:00:00 2001 From: noahisdai Date: Thu, 20 Aug 2026 01:56:57 +0300 Subject: [PATCH 1/4] fix(selection): use the controlled selectedRows prop as the delta base (#1375) The reducer computed selection changes from internal state, which is never reconciled with the controlled selectedRows prop, so a selection set from outside the table was dropped on the next in-table toggle. Wrap the reducer to substitute the prop into the state it computes from when controlled. --- src/__tests__/DataTable.test.tsx | 36 ++++++++++++++++++++++++++++++++ src/hooks/useTableState.ts | 12 ++++++++++- 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/src/__tests__/DataTable.test.tsx b/src/__tests__/DataTable.test.tsx index bdd3f805..0d3e9336 100644 --- a/src/__tests__/DataTable.test.tsx +++ b/src/__tests__/DataTable.test.tsx @@ -255,6 +255,42 @@ describe('DataTable::onSelectedRowsChange', () => { }); }); +describe('DataTable::controlled selectedRows', () => { + test('should call onSelectedRowsChange with the correct values when a row is toggled while another is selected via the controlled selectedRows prop', () => { + const mock = dataMock(); + const onSelectedRowsChange = vi.fn(); + + const { container, rerender } = render( + , + ); + + rerender( + , + ); + + fireEvent.click(container.querySelector('input[name="Select row 2"]') as HTMLInputElement); + + const calls = onSelectedRowsChange.mock.calls; + const lastCall = calls[calls.length - 1][0]; + expect(lastCall.selectedCount).toBe(2); + expect(lastCall.selectedRows.map((row: { id: number }) => row.id).sort()).toEqual([1, 2]); + }); +}); + describe('data prop changes', () => { test('should update state if the data prop changes', () => { const mock = dataMock(); diff --git a/src/hooks/useTableState.ts b/src/hooks/useTableState.ts index c1842095..9de7dc08 100644 --- a/src/hooks/useTableState.ts +++ b/src/hooks/useTableState.ts @@ -93,7 +93,17 @@ export default function useTableState(props: UseTableStateProps): UseTable const hasDefaultSort = defaultSortColumn.id != null || !!defaultSortColumn.selector; - const [tableState, dispatch] = React.useReducer, Action>>(tableReducer, { + const reducer = React.useCallback( + (state: TableState, action: Action): TableState => { + const calculatedState = + controlledSelectedRows !== undefined ? { ...state, selectedRows: controlledSelectedRows } : state; + + return tableReducer(calculatedState, action); + }, + [controlledSelectedRows], + ); + + const [tableState, dispatch] = React.useReducer(reducer, { allSelected: false, selectedCount: 0, selectedRows: [], From 118c093a9c5d77731c77810c3252902184ab36a7 Mon Sep 17 00:00:00 2001 From: John Betancur <1385932+jbetancur@users.noreply.github.com> Date: Wed, 19 Aug 2026 19:19:15 -0400 Subject: [PATCH 2/4] docs: add more row selection demos | selection demo cleanup (#1376) --- .../demos/ControlledSelectionDemo.tsx | 114 ++++++++++++ .../components/demos/CustomCheckboxDemo.tsx | 171 +++++++++++++++++ .../src/components/demos/VisibleOnlyDemo.tsx | 78 ++++++++ apps/docs/src/pages/docs/export.astro | 4 +- apps/docs/src/pages/docs/inline-editing.astro | 5 - apps/docs/src/pages/docs/selection.astro | 175 +++++++++++++++--- 6 files changed, 510 insertions(+), 37 deletions(-) create mode 100644 apps/docs/src/components/demos/ControlledSelectionDemo.tsx create mode 100644 apps/docs/src/components/demos/CustomCheckboxDemo.tsx create mode 100644 apps/docs/src/components/demos/VisibleOnlyDemo.tsx diff --git a/apps/docs/src/components/demos/ControlledSelectionDemo.tsx b/apps/docs/src/components/demos/ControlledSelectionDemo.tsx new file mode 100644 index 00000000..86337f06 --- /dev/null +++ b/apps/docs/src/components/demos/ControlledSelectionDemo.tsx @@ -0,0 +1,114 @@ +import React, { useState } from 'react'; +import DataTable from '../ThemedDataTable'; +import { type TableColumn } from 'react-data-table-component'; + +interface Row { + id: number; + name: string; + role: string; + status: 'Active' | 'On Leave'; +} + +const data: Row[] = [ + { id: 1, name: 'Aria Chen', role: 'Engineering Lead', status: 'Active' }, + { id: 2, name: 'Marcus Webb', role: 'Product Manager', status: 'Active' }, + { id: 3, name: 'Priya Kapoor', role: 'Senior Designer', status: 'On Leave' }, + { id: 4, name: 'Jordan Ellis', role: 'Data Scientist', status: 'Active' }, + { id: 5, name: 'Sam Rivera', role: 'DevOps Engineer', status: 'On Leave' }, +]; + +const columns: TableColumn[] = [ + { name: 'Name', selector: r => r.name, sortable: true }, + { name: 'Role', selector: r => r.role }, + { name: 'Status', selector: r => r.status }, +]; + +export default function ControlledSelectionDemo() { + const [selected, setSelected] = useState([]); + const [events, setEvents] = useState([]); + + const setExternally = (rows: Row[], label: string) => { + setSelected(rows); + setEvents(prev => [`external: ${label}`, ...prev].slice(0, 6)); + }; + + const selectWhere = (label: string, predicate: (row: Row) => boolean) => + setExternally(data.filter(predicate), label); + + const invert = () => + setExternally( + data.filter(row => !selected.some(s => s.id === row.id)), + 'invert selection', + ); + + return ( +
+
+ + + + +
+ +

+ The buttons write straight to the selectedRows state. Checking a box in the table + calls onSelectedRowsChange, which writes back to that same state. Both paths stay in sync. +

+ + { + setSelected(selectedRows); + setEvents(prev => [`table: [${selectedRows.map(r => r.id).join(', ')}]`, ...prev].slice(0, 6)); + }} + highlightOnHover + /> + +
+
+ selectedRows state +
+ {selected.length > 0 ? `[${selected.map(r => r.id).join(', ')}]` : '[]'} + {selected.length > 0 && ( + — {selected.map(r => r.name).join(', ')} + )} +
+
+
+ recent updates +
    + {events.length === 0 ? ( +
  • none yet
  • + ) : ( + events.map((e, i) =>
  • {e}
  • ) + )} +
+
+
+
+ ); +} diff --git a/apps/docs/src/components/demos/CustomCheckboxDemo.tsx b/apps/docs/src/components/demos/CustomCheckboxDemo.tsx new file mode 100644 index 00000000..26f7b09a --- /dev/null +++ b/apps/docs/src/components/demos/CustomCheckboxDemo.tsx @@ -0,0 +1,171 @@ +import React, { useState, forwardRef } from 'react'; +import DataTable from '../ThemedDataTable'; +import { type TableColumn } from 'react-data-table-component'; + +interface Row { + id: number; + track: string; + artist: string; + length: string; +} + +const data: Row[] = [ + { id: 1, track: 'Midnight Static', artist: 'Vela Nine', length: '3:42' }, + { id: 2, track: 'Paper Ghosts', artist: 'The Longwave', length: '4:15' }, + { id: 3, track: 'Neon Orchard', artist: 'Kite Parade', length: '2:58' }, + { id: 4, track: 'Slow Cartography', artist: 'Ansel Frame', length: '5:07' }, +]; + +const columns: TableColumn[] = [ + { name: 'Track', selector: r => r.track, sortable: true }, + { name: 'Artist', selector: r => r.artist }, + { name: 'Length', selector: r => r.length, right: true }, +]; + +type CheckboxProps = React.InputHTMLAttributes & { + accent?: string; +}; + +/** + * The ref must land on a real : DataTable sets `.indeterminate` on it directly. + * Everything visual is a sibling overlay driven by :checked, so the input itself stays + * a functioning checkbox for keyboard and assistive tech. + */ +const VinylCheckbox = forwardRef( + ({ accent = '#7c3aed', checked, disabled, ...rest }, ref) => ( + + ), +); + +VinylCheckbox.displayName = 'VinylCheckbox'; + +const styles = ` +.vinyl-wrap { + position: relative; + display: inline-flex; + width: 22px; + height: 22px; + align-items: center; + justify-content: center; +} +.vinyl-wrap input { + position: absolute; + inset: 0; + margin: 0; + opacity: 0; + width: 100%; + height: 100%; + cursor: inherit; +} +.vinyl-disc { + width: 20px; + height: 20px; + border-radius: 999px; + background: repeating-radial-gradient(circle at 50% 50%, #1f2937 0 2px, #111827 2px 3px); + box-shadow: 0 0 0 1px rgba(148, 163, 184, 0.5); + display: grid; + place-items: center; + transition: transform 0.35s ease, box-shadow 0.2s ease; +} +.vinyl-label { + width: 7px; + height: 7px; + border-radius: 999px; + background: #9ca3af; + transition: background 0.2s ease, transform 0.2s ease; +} +.vinyl-wrap input:checked ~ .vinyl-disc { + transform: rotate(180deg); + box-shadow: 0 0 0 2px var(--vinyl-accent), 0 0 10px -1px var(--vinyl-accent); +} +.vinyl-wrap input:checked ~ .vinyl-disc .vinyl-label { + background: var(--vinyl-accent); + transform: scale(1.25); +} +/* Indeterminate: DataTable sets this directly on the input for the header checkbox. */ +.vinyl-wrap input:indeterminate ~ .vinyl-disc { + box-shadow: 0 0 0 2px #f59e0b; +} +.vinyl-wrap input:indeterminate ~ .vinyl-disc .vinyl-label { + background: #f59e0b; + transform: scale(0.6); +} +.vinyl-wrap input:focus-visible ~ .vinyl-disc { + outline: 2px solid var(--vinyl-accent); + outline-offset: 2px; +} +@media (prefers-reduced-motion: reduce) { + .vinyl-disc { transition: none; } +} +`; + +export default function CustomCheckboxDemo() { + const [selected, setSelected] = useState([]); + const [accent, setAccent] = useState('#7c3aed'); + + return ( +
+