Repository: jbetancur/react-data-table-component (ts)

Issue:
Radio selection for selectableRows
## Feature Check list

- [x] Agree to the [Code of Conduct](https://github.com/jbetancur/react-data-table-component/blob/master/CODE-OF-CONDUCT.md)
- [x] Read the README to ensure the feature is not already present
- [x] You read [Creating Issues, Features and Pull Requests](https://github.com/jbetancur/react-data-table-component/issues/387)
- [x] Considered the value versus complexity for all users of the library as well as library maintenance
- [x] Considered if this can be a storybook or documentation example

## Is your feature request related to a problem? Please describe

Not a problem

## Describe the solution you'd like

Make it possible to enable radio selectable rows. Something similar to this https://www.lightningdesignsystem.com/components/data-tables/#Single-row-selection

## Describe alternatives you've considered

Could probably work something with selectableRowsComponent, but I think this should be easier then that.

## Additional context

No


Pull request description:
implement single row selection
closes #497 

Patch:
diff --git a/README.md b/README.md
index 650162a..d789ba2 100644
--- a/README.md
+++ b/README.md
@@ -177,6 +177,7 @@ When the breakpoint is reached the column will be hidden. These are the built-in
 | selectableRows | bool | no | false | Whether to show selectable checkboxes |
 | selectableRowsVisibleOnly | bool | no | false | When using using `pagination` and `selectableRows` all rows in `data` are selected by default if you check the select all rows checkbox. For example, if you have 20 rows and with `pagination` 10 rows per page clicking on the select all checkbox results in 20 rows being selected (i.e. rows are selected that are not in the view). However, with `selectableRowsVisibleOnly` only the 10 rows that are visible (those that are on the current page) are allowed to be selected using select all. <br /><br />Things to note when using `selectableRowsVisibleOnly`: <br /> 1. When you sort then selected items will be cleared <br />2. When using `sortServer` for server side sorting you do not need to set `selectableRowsVisibleOnly` as the behavior is implicit |
 | selectableRowsHighlight | bool | no | false | Highlight a row when it is selected |
+| selectableRowsSingle | bool | no | false | Switches to single row selection mode. `onSelectedRowsChange` will still return an array of `selectedRows`, but it will only be a single array item |
 | selectableRowsNoSelectAll | bool | no | false | Whether to show the select all rows checkbox |
 | clearSelectedRows | bool | no | false | Toggling this property clears the selectedRows. If you use redux or react state you need to make sure that you pass a toggled value or the component will not update. See [Clearing Selected Rows](#clearing-selected-rows)|
 | onSelectedRowsChange | func | no |  | Callback that fires anytime the rows selected state changes. Returns ({ allSelected, selectedCount, selectedRows }).<br /><br />**Note** It's highly recommended that you memoize the callback that you pass to `onSelectedRowsChange` if it updates the state of your parent component. This prevents `DataTable` from unnecessary re-renders every time your parent component is re-rendered |
diff --git a/package.json b/package.json
index 36093d3..f989566 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
 {
   "name": "react-data-table-component",
-  "version": "7.0.0-alpha-13",
+  "version": "7.0.0-alpha-14",
   "description": "A declarative react based data table",
   "main": "dist/index.cjs.js",
   "module": "dist/index.es.js",
diff --git a/src/DataTable/DataTable.tsx b/src/DataTable/DataTable.tsx
index db6fee1..3019d9e 100644
--- a/src/DataTable/DataTable.tsx
+++ b/src/DataTable/DataTable.tsx
@@ -44,6 +44,7 @@ function DataTable<T extends RowRecord>(props: TableProps<T>): JSX.Element {
 		pointerOnHover = defaultProps.pointerOnHover,
 		dense = defaultProps.dense,
 		selectableRows = defaultProps.selectableRows,
+		selectableRowsSingle = defaultProps.selectableRowsSingle,
 		selectableRowsHighlight = defaultProps.selectableRowsHighlight,
 		selectableRowsNoSelectAll = defaultProps.selectableRowsNoSelectAll,
 		selectableRowsVisibleOnly = defaultProps.selectableRowsVisibleOnly,
@@ -284,10 +285,10 @@ function DataTable<T extends RowRecord>(props: TableProps<T>): JSX.Element {
 
 	React.useEffect(() => {
 		dispatch({ type: 'CLEAR_SELECTED_ROWS', selectedRowsFlag: clearSelectedRows });
-	}, [clearSelectedRows]);
+	}, [selectableRowsSingle, clearSelectedRows]);
 
 	React.useEffect(() => {
-		if (selectableRowSelected) {
+		if (selectableRowSelected && !selectableRowsSingle) {
 			const preSelectedRows = rows.filter(row => selectableRowSelected(row));
 
 			dispatch({ type: 'SELECT_MULTIPLE_ROWS', keyField, selectedRows: preSelectedRows, rows: rows, mergeSelections });
@@ -297,7 +298,7 @@ function DataTable<T extends RowRecord>(props: TableProps<T>): JSX.Element {
 	}, [rows]);
 
 	const rowData = selectableRowsVisibleOnly ? calculatedRows : rows;
-	const showSelectAll = persistSelectedOnPageChange || selectableRowsNoSelectAll;
+	const showSelectAll = persistSelectedOnPageChange || selectableRowsSingle || selectableRowsNoSelectAll;
 
 	return (
 		<ThemeProvider theme={currentTheme}>
@@ -420,6 +421,7 @@ function DataTable<T extends RowRecord>(props: TableProps<T>): JSX.Element {
 											selectableRowsComponent={selectableRowsComponent}
 											selectableRowsComponentProps={selectableRowsComponentProps}
 											selectableRowDisabled={selectableRowDisabled}
+											selectableRowsSingle={selectableRowsSingle}
 											striped={striped}
 											onRowExpandToggled={onRowExpandToggled}
 											onRowClicked={handleRowClicked}
diff --git a/src/DataTable/TableCellCheckbox.tsx b/src/DataTable/TableCellCheckbox.tsx
index c197aa5..f21333e 100644
--- a/src/DataTable/TableCellCheckbox.tsx
+++ b/src/DataTable/TableCellCheckbox.tsx
@@ -2,7 +2,7 @@ import * as React from 'react';
 import styled from 'styled-components';
 import { CellBase } from './Cell';
 import Checkbox from './Checkbox';
-import { RowRecord, RowState, SingleRowAction } from './types';
+import { RowRecord, RowState, SingleRowAction, ComponentProps } from './types';
 
 const TableCellCheckboxStyle = styled(CellBase)`
 	flex: 0 0 48px;
@@ -19,7 +19,8 @@ type TableCellCheckboxProps<T> = {
 	rowCount: number;
 	selected: boolean;
 	selectableRowsComponent: 'input' | React.ReactNode;
-	selectableRowsComponentProps: Record<string, unknown>;
+	selectableRowsComponentProps: ComponentProps;
+	selectableRowsSingle: boolean;
 	selectableRowDisabled: RowState<T>;
 	onSelectedRow: (action: SingleRowAction<T>) => void;
 };
@@ -31,6 +32,7 @@ function TableCellCheckbox<T extends RowRecord>({
 	selected,
 	selectableRowsComponent,
 	selectableRowsComponentProps,
+	selectableRowsSingle,
 	selectableRowDisabled,
 	onSelectedRow,
 }: TableCellCheckboxProps<T>): JSX.Element {
@@ -43,6 +45,7 @@ function TableCellCheckbox<T extends RowRecord>({
 			isSelected: selected,
 			keyField,
 			rowCount,
+			singleSelect: selectableRowsSingle,
 		});
 	};
 
diff --git a/src/DataTable/TableRow.tsx b/src/DataTable/TableRow.tsx
index fe33a6d..ba04be2 100644
--- a/src/DataTable/TableRow.tsx
+++ b/src/DataTable/TableRow.tsx
@@ -68,6 +68,7 @@ type DProps<T> = Pick<
 	| 'selectableRowsComponent'
 	| 'selectableRowsComponentProps'
 	| 'selectableRowsHighlight'
+	| 'selectableRowsSingle'
 	| 'striped'
 >;
 
@@ -113,6 +114,7 @@ function TableRow<T extends RowRecord>({
 	selectableRowsComponent,
 	selectableRowsComponentProps,
 	selectableRowsHighlight = false,
+	selectableRowsSingle = false,
 	selected,
 	striped = false,
 }: TableRowProps<T>): JSX.Element {
@@ -184,6 +186,7 @@ function TableRow<T extends RowRecord>({
 						selectableRowsComponent={selectableRowsComponent}
 						selectableRowsComponentProps={selectableRowsComponentProps}
 						selectableRowDisabled={selectableRowDisabled}
+						selectableRowsSingle={selectableRowsSingle}
 						onSelectedRow={onSelectedRow}
 					/>
 				)}
diff --git a/src/DataTable/defaultProps.tsx b/src/DataTable/defaultProps.tsx
index d40b223..942036f 100644
--- a/src/DataTable/defaultProps.tsx
+++ b/src/DataTable/defaultProps.tsx
@@ -21,6 +21,7 @@ export const defaultProps = {
 	selectableRowsComponent: 'input' as const,
 	selectableRowsComponentProps: {},
 	selectableRowsVisibleOnly: false,
+	selectableRowsSingle: false,
 	clearSelectedRows: false,
 	expandableRows: false,
 	expandableRowDisabled: null,
diff --git a/src/DataTable/tableReducer.ts b/src/DataTable/tableReducer.ts
index c2782e2..a4d6e0b 100644
--- a/src/DataTable/tableReducer.ts
+++ b/src/DataTable/tableReducer.ts
@@ -38,7 +38,17 @@ export function tableReducer<T extends RowRecord>(state: TableState<T>, action:
 		}
 
 		case 'SELECT_SINGLE_ROW': {
-			const { keyField, row, isSelected, rowCount } = action;
+			const { keyField, row, isSelected, rowCount, singleSelect } = action;
+
+			//  single select mode allows only 1 row to be selected at a time
+			if (singleSelect) {
+				return {
+					...state,
+					selectedCount: 0, // this also has the effect of disabling the context menu
+					allSelected: false,
+					selectedRows: insertItem([], row),
+				};
+			}
 
 			if (isSelected) {
 				return {
diff --git a/src/DataTable/types.ts b/src/DataTable/types.ts
index 1bb74a1..c5845f0 100644
--- a/src/DataTable/types.ts
+++ b/src/DataTable/types.ts
@@ -90,6 +90,7 @@ export type TableProps<T = RowRecord> = {
 	selectableRowsHighlight?: boolean;
 	selectableRowsNoSelectAll?: boolean;
 	selectableRowsVisibleOnly?: boolean;
+	selectableRowsSingle?: boolean;
 	sortFunction?: SortFunction<T> | null;
 	sortIcon?: React.ReactNode;
 	sortServer?: boolean;
@@ -324,6 +325,7 @@ export interface SingleRowAction<T> {
 	row: T;
 	isSelected: boolean;
 	rowCount: number;
+	singleSelect: boolean;
 }
 
 export interface MultiRowAction<T> {
diff --git a/stories/DataTable/Basic/1BasicKitchenSink.stories.js b/stories/DataTable/Basic/1BasicKitchenSink.stories.js
index 5209e5a..27c01b2 100644
--- a/stories/DataTable/Basic/1BasicKitchenSink.stories.js
+++ b/stories/DataTable/Basic/1BasicKitchenSink.stories.js
@@ -36,6 +36,7 @@ const KitchenSink = () => {
 	const [noSelectAll, setNoSelectAll] = React.useState(false);
 	const [selectableRowsVisibleOnly, setSelectableRowsVisibleOnly] = React.useState(false);
 	const [selectableRowsHighlight, setSelectableRowsHighlight] = React.useState(false);
+	const [selectableRowsSingle, setSelectableRowsSingle] = React.useState(false);
 	const [expandableRows, setExpandableRows] = React.useState(false);
 	const [expandOnRowClick, setExpandOnRowClick] = React.useState(false);
 	const [pagination, setPagination] = React.useState(true);
@@ -96,6 +97,17 @@ const KitchenSink = () => {
 							label="Highlight Selected Rows"
 						/>
 
+						<FormControlLabel
+							control={
+								<Checkbox
+									size="small"
+									checked={selectableRowsSingle}
+									onChange={() => setSelectableRowsSingle(!selectableRowsSingle)}
+								/>
+							}
+							label="Single Select Mode"
+						/>
+
 						<FormControlLabel
 							control={<Checkbox size="small" checked={tableHead} onChange={() => setNoHead(!tableHead)} />}
 							label="No Table Head"
@@ -228,8 +240,10 @@ const KitchenSink = () => {
 				data={data}
 				defaultSortField="title"
 				selectableRows={selectableRows}
+				selectableRowsComponentProps={{ type: 'radio' }}
 				selectableRowsNoSelectAll={noSelectAll}
 				selectableRowsHighlight={selectableRowsHighlight}
+				selectableRowsSingle={selectableRowsSingle}
 				selectableRowsVisibleOnly={selectableRowsVisibleOnly}
 				expandableRows={expandableRows}
 				expandOnRowClicked={expandOnRowClick}


Test patch:
diff --git a/src/DataTable/__tests__/DataTable.test.tsx b/src/DataTable/__tests__/DataTable.test.tsx
index cb2685f..a038eb8 100644
--- a/src/DataTable/__tests__/DataTable.test.tsx
+++ b/src/DataTable/__tests__/DataTable.test.tsx
@@ -844,6 +844,38 @@ describe('DataTable::selectableRows', () => {
 		expect(container.firstChild).toMatchSnapshot();
 	});
 
+	test('should only select a single row when selectableRowsSingle is true and a row is selected', () => {
+		const mock = dataMock();
+		const { container } = render(
+			<DataTable data={mock.data} columns={mock.columns} selectableRows selectableRowsSingle />,
+		);
+
+		const rowCheck1 = container.querySelector('input[name="select-row-1"]') as HTMLInputElement;
+		const rowCheck2 = container.querySelector('input[name="select-row-2"]') as HTMLInputElement;
+
+		fireEvent.click(rowCheck1);
+		fireEvent.click(rowCheck2);
+
+		expect(rowCheck1.checked).toBe(false);
+		expect(rowCheck2.checked).toBe(true);
+		expect(container.firstChild).toMatchSnapshot();
+	});
+
+	test('should clear all rows selectableRowsSingle is changed', () => {
+		const mock = dataMock();
+		const { container, rerender } = render(<DataTable data={mock.data} columns={mock.columns} selectableRows />);
+		const rowCheck1 = container.querySelector('input[name="select-row-1"]') as HTMLInputElement;
+		const rowCheck2 = container.querySelector('input[name="select-row-2"]') as HTMLInputElement;
+
+		fireEvent.click(rowCheck1);
+		fireEvent.click(rowCheck2);
+
+		rerender(<DataTable data={mock.data} columns={mock.columns} selectableRows selectableRowsSingle />);
+
+		expect(rowCheck1.checked).toBe(false);
+		expect(rowCheck2.checked).toBe(false);
+	});
+
 	test('should not render a select all checkbox when selectableRowsNoSelectAll is true', () => {
 		const mock = dataMock();
 		const { container } = render(
diff --git a/src/DataTable/__tests__/__snapshots__/DataTable.test.tsx.snap b/src/DataTable/__tests__/__snapshots__/DataTable.test.tsx.snap
index e88e704..08ec446 100644
--- a/src/DataTable/__tests__/__snapshots__/DataTable.test.tsx.snap
+++ b/src/DataTable/__tests__/__snapshots__/DataTable.test.tsx.snap
@@ -20847,6 +20847,332 @@ exports[`DataTable::selectableRows should not render a select all checkbox when
 </div>
 `;
 
+exports[`DataTable::selectableRows should only select a single row when selectableRowsSingle is true and a row is selected 1`] = `
+.c2 {
+  position: relative;
+  box-sizing: border-box;
+  display: -webkit-box;
+  display: -webkit-flex;
+  display: -ms-flexbox;
+  display: flex;
+  -webkit-flex-direction: column;
+  -ms-flex-direction: column;
+  flex-direction: column;
+  width: 100%;
+  height: 100%;
+  max-width: 100%;
+  color: rgba(0,0,0,0.87);
+  background-color: #FFFFFF;
+}
+
+.c3 {
+  display: -webkit-box;
+  display: -webkit-flex;
+  display: -ms-flexbox;
+  display: flex;
+  text-align: left;
+}
+
+.c4 {
+  display: -webkit-box;
+  display: -webkit-flex;
+  display: -ms-flexbox;
+  display: flex;
+  -webkit-align-items: stretch;
+  -webkit-box-align: stretch;
+  -ms-flex-align: stretch;
+  align-items: stretch;
+  width: 100%;
+  background-color: #FFFFFF;
+  min-height: 56px;
+  border-bottom-width: 1px;
+  border-bottom-color: rgba(0,0,0,.12);
+  border-bottom-style: solid;
+}
+
+.c5 {
+  position: relative;
+  display: -webkit-box;
+  display: -webkit-flex;
+  display: -ms-flexbox;
+  display: flex;
+  -webkit-align-items: center;
+  -webkit-box-align: center;
+  -ms-flex-align: center;
+  align-items: center;
+  box-sizing: border-box;
+  line-height: normal;
+  padding-left: 16px;
+  padding-right: 16px;
+  word-break: break-word;
+}
+
+.c6 {
+  position: relative;
+  display: -webkit-box;
+  display: -webkit-flex;
+  display: -ms-flexbox;
+  display: flex;
+  -webkit-align-items: center;
+  -webkit-box-align: center;
+  -ms-flex-align: center;
+  align-items: center;
+  box-sizing: border-box;
+  line-height: normal;
+  font-size: 12px;
+  font-weight: 500;
+  color: rgba(0,0,0,0.87);
+  padding-left: 16px;
+  padding-right: 16px;
+}
+
+.c12 {
+  position: relative;
+  display: -webkit-box;
+  display: -webkit-flex;
+  display: -ms-flexbox;
+  display: flex;
+  -webkit-align-items: center;
+  -webkit-box-align: center;
+  -ms-flex-align: center;
+  align-items: center;
+  box-sizing: border-box;
+  line-height: normal;
+  padding-left: 16px;
+  padding-right: 16px;
+  word-break: break-word;
+  padding: 0;
+}
+
+.c7 {
+  -webkit-box-flex: 1;
+  -webkit-flex-grow: 1;
+  -ms-flex-positive: 1;
+  flex-grow: 1;
+  -webkit-flex-shrink: 0;
+  -ms-flex-negative: 0;
+  flex-shrink: 0;
+  -webkit-flex-basis: 0;
+  -ms-flex-preferred-size: 0;
+  flex-basis: 0;
+  max-width: 100%;
+  min-width: 100px;
+}
+
+.c14 {
+  font-weight: 400;
+}
+
+.c14 div:first-child {
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+
+.c13 {
+  -webkit-flex: 0 0 48px;
+  -ms-flex: 0 0 48px;
+  flex: 0 0 48px;
+  min-width: 48px;
+  -webkit-box-pack: center;
+  -webkit-justify-content: center;
+  -ms-flex-pack: center;
+  justify-content: center;
+  -webkit-align-items: center;
+  -webkit-box-align: center;
+  -ms-flex-align: center;
+  align-items: center;
+  -webkit-user-select: none;
+  -moz-user-select: none;
+  -ms-user-select: none;
+  user-select: none;
+  white-space: nowrap;
+}
+
+.c11 {
+  display: -webkit-box;
+  display: -webkit-flex;
+  display: -ms-flexbox;
+  display: flex;
+  -webkit-align-items: stretch;
+  -webkit-box-align: stretch;
+  -ms-flex-align: stretch;
+  align-items: stretch;
+  -webkit-align-content: stretch;
+  -ms-flex-line-pack: stretch;
+  align-content: stretch;
+  width: 100%;
+  box-sizing: border-box;
+  font-size: 13px;
+  color: rgba(0,0,0,0.87);
+  background-color: #FFFFFF;
+  min-height: 48px;
+}
+
+.c11:not(:last-of-type) {
+  border-bottom-style: solid;
+  border-bottom-width: 1px;
+  border-bottom-color: rgba(0,0,0,.12);
+}
+
+.c8 {
+  -webkit-align-items: center;
+  -webkit-box-align: center;
+  -ms-flex-align: center;
+  align-items: center;
+  height: 100%;
+  line-height: 1;
+  outline: none;
+  -webkit-user-select: none;
+  -moz-user-select: none;
+  -ms-user-select: none;
+  user-select: none;
+  display: -webkit-inline-box;
+  display: -webkit-inline-flex;
+  display: -ms-inline-flexbox;
+  display: inline-flex;
+  overflow: hidden;
+}
+
+.c9 {
+  overflow: hidden;
+  font-weight: 500;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+}
+
+.c10 {
+  display: -webkit-box;
+  display: -webkit-flex;
+  display: -ms-flexbox;
+  display: flex;
+  -webkit-flex-direction: column;
+  -ms-flex-direction: column;
+  flex-direction: column;
+}
+
+.c0 {
+  position: relative;
+  width: 100%;
+  border-radius: inherit;
+  overflow-x: auto;
+  overflow-y: hidden;
+  min-height: 0;
+}
+
+.c1 {
+  position: relative;
+  width: 100%;
+  display: table;
+}
+
+<div
+  class="c0"
+>
+  <div
+    class="c1"
+  >
+    <div
+      class="c2 rdt_Table"
+      role="table"
+    >
+      <div
+        class="c3 rdt_TableHead"
+        role="rowgroup"
+      >
+        <div
+          class="c4 rdt_TableHeadRow"
+          role="row"
+        >
+          <div
+            class="c5"
+            style="flex: 0 0 48px;"
+          />
+          <div
+            class="c6 c7 rdt_TableCol"
+          >
+            <div
+              class="c8 rdt_TableCol_Sortable"
+              id="column-1"
+              role="columnheader"
+              tabindex="0"
+            >
+              <div
+                class="c9"
+              >
+                Test
+              </div>
+            </div>
+          </div>
+        </div>
+      </div>
+      <div
+        class="c10 rdt_TableBody"
+        offset="250px"
+        role="rowgroup"
+      >
+        <div
+          class="c11 rdt_TableRow"
+          id="row-1"
+          role="row"
+        >
+          <div
+            class="c12 c13 rdt_TableCell"
+          >
+            <input
+              aria-label="select-row-1"
+              name="select-row-1"
+              style="font-size: 18px; cursor: pointer; padding: 0px; margin-top: 1px; vertical-align: middle; position: relative;"
+              type="checkbox"
+            />
+          </div>
+          <div
+            class="c5 c7 c14 rdt_TableCell"
+            data-tag="allowRowEvents"
+            id="cell-1-1"
+            role="gridcell"
+          >
+            <div
+              data-tag="allowRowEvents"
+            >
+              Apple
+            </div>
+          </div>
+        </div>
+        <div
+          class="c11 rdt_TableRow"
+          id="row-2"
+          role="row"
+        >
+          <div
+            class="c12 c13 rdt_TableCell"
+          >
+            <input
+              aria-label="select-row-2"
+              name="select-row-2"
+              style="font-size: 18px; cursor: pointer; padding: 0px; margin-top: 1px; vertical-align: middle; position: relative;"
+              type="checkbox"
+            />
+          </div>
+          <div
+            class="c5 c7 c14 rdt_TableCell"
+            data-tag="allowRowEvents"
+            id="cell-1-2"
+            role="gridcell"
+          >
+            <div
+              data-tag="allowRowEvents"
+            >
+              Zuchinni
+            </div>
+          </div>
+        </div>
+      </div>
+    </div>
+  </div>
+</div>
+`;
+
 exports[`DataTable::selectableRows should render correctly when selectableRows is true 1`] = `
 .c2 {
   position: relative;