Skip to content

Commit 5f17696

Browse files
authored
Table: fix selectable mode (#641)
* fix(Table): properly report selected rows * fix(Table): disable checkbox for disabled/deleted rows * refactor(Table): separate component for select-all checkbox * fix(Table): ignore disabled rows for select-all * feat(Table): use indeterminate state for select-all checkbox * docs(Table): improve storybook * docs(Table): add story for selectable table
1 parent ddccb7b commit 5f17696

File tree

2 files changed

+135
-35
lines changed

2 files changed

+135
-35
lines changed

src/components/Table/Table.stories.tsx

Lines changed: 34 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
1-
import { Table } from "./Table";
1+
import { useEffect, useState } from "react";
2+
3+
import { Meta, StoryObj } from "@storybook/react";
4+
5+
import { Table, TableRowType } from "./Table";
26

37
const headers = [{ label: "Company" }, { label: "Contact" }, { label: "Country" }];
4-
const rows = [
8+
const rows: TableRowType[] = [
59
{
610
id: "row-1",
711
items: [
@@ -38,24 +42,43 @@ const rows = [
3842
},
3943
];
4044

41-
export default {
45+
const meta: Meta<typeof Table> = {
4246
component: Table,
4347
title: "Display/Table",
4448
tags: ["table", "autodocs"],
45-
argTypes: {
46-
selectedIds: {
47-
control: { type: "object" },
48-
if: { arg: "isSelectable", exists: true },
49-
},
50-
message: { control: "string" },
49+
};
50+
51+
export default meta;
52+
53+
export const Playground: StoryObj<typeof Table> = {
54+
args: {
55+
headers,
56+
rows,
5157
},
5258
};
5359

54-
export const Playground = {
60+
export const Selectable: StoryObj<typeof Table> = {
5561
args: {
5662
headers,
5763
rows,
64+
isSelectable: true,
5865
selectedIds: [],
59-
loading: false,
66+
},
67+
render: ({ selectedIds, ...props }) => {
68+
const [selectedRows, setSelectedRows] = useState(selectedIds);
69+
70+
useEffect(() => {
71+
setSelectedRows(selectedIds);
72+
}, [selectedIds]);
73+
74+
return (
75+
<Table
76+
{...props}
77+
selectedIds={selectedRows}
78+
onSelect={selectedItems =>
79+
setSelectedRows(selectedItems.map(({ item: { id } }) => id))
80+
}
81+
/>
82+
);
6083
},
6184
};

src/components/Table/Table.tsx

Lines changed: 101 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,22 @@
1+
import { FC, HTMLAttributes, MouseEvent, ReactNode, forwardRef, useMemo } from "react";
2+
import { styled } from "styled-components";
3+
4+
import { CheckedState } from "@radix-ui/react-checkbox";
5+
16
import {
27
Checkbox,
8+
CheckboxProps,
39
EllipsisContent,
410
HorizontalDirection,
511
Icon,
612
IconButton,
713
Text,
814
} from "@/components";
9-
import { HTMLAttributes, MouseEvent, ReactNode, forwardRef } from "react";
10-
import { styled } from "styled-components";
15+
1116
type SortDir = "asc" | "desc";
1217
type SortFn = (sortDir: SortDir, header: TableHeaderType, index: number) => void;
1318
type TableSize = "sm" | "md";
19+
1420
export interface TableHeaderType extends HTMLAttributes<HTMLTableCellElement> {
1521
label: ReactNode;
1622
isSortable?: boolean;
@@ -87,11 +93,12 @@ const TableHeader = ({
8793
interface TheadProps {
8894
headers: Array<TableHeaderType>;
8995
isSelectable?: boolean;
90-
onSelectAll: (checked: boolean) => void;
96+
onSelectAll?: (selectedValues: SelectReturnValue[]) => void;
9197
actionsList: Array<string>;
9298
onSort?: SortFn;
93-
hasRows: boolean;
9499
size: TableSize;
100+
rows: TableRowType[];
101+
selectedIds: (number | string)[];
95102
}
96103

97104
const Thead = ({
@@ -100,8 +107,9 @@ const Thead = ({
100107
onSelectAll,
101108
actionsList,
102109
onSort: onSortProp,
103-
hasRows,
104110
size,
111+
rows,
112+
selectedIds,
105113
}: TheadProps) => {
106114
const onSort = (header: TableHeaderType, headerIndex: number) => () => {
107115
if (typeof onSortProp === "function" && header.isSortable) {
@@ -127,9 +135,10 @@ const Thead = ({
127135
$size={size}
128136
aria-label="Select column"
129137
>
130-
<Checkbox
138+
<SelectAllCheckbox
131139
onCheckedChange={onSelectAll}
132-
disabled={!hasRows}
140+
rows={rows}
141+
selectedIds={selectedIds}
133142
/>
134143
</StyledHeader>
135144
)}
@@ -451,6 +460,7 @@ const TableBodyRow = ({
451460
<Checkbox
452461
checked={isSelected}
453462
onCheckedChange={onSelect}
463+
disabled={isDisabled || isDeleted}
454464
/>
455465
</SelectData>
456466
)}
@@ -561,17 +571,6 @@ const Table = forwardRef<HTMLTableElement, TableProps>(
561571
) => {
562572
const isDeletable = typeof onDelete === "function";
563573
const isEditable = typeof onEdit === "function";
564-
const onSelectAll = (checked: boolean): void => {
565-
if (typeof onSelect === "function") {
566-
const ids = checked
567-
? rows.map((row, index) => ({
568-
item: row,
569-
index,
570-
}))
571-
: [];
572-
onSelect(ids);
573-
}
574-
};
575574

576575
const onRowSelect =
577576
(id: number | string) =>
@@ -580,7 +579,7 @@ const Table = forwardRef<HTMLTableElement, TableProps>(
580579
const selectedItems = rows.flatMap((row, index) => {
581580
if (
582581
(id === row.id && checked) ||
583-
(selectedIds.includes(id) && id !== row.id)
582+
(selectedIds.includes(row.id) && id !== row.id)
584583
) {
585584
return {
586585
item: row,
@@ -607,10 +606,11 @@ const Table = forwardRef<HTMLTableElement, TableProps>(
607606
{hasRows && showHeader && (
608607
<MobileActions>
609608
{isSelectable && (
610-
<Checkbox
609+
<SelectAllCheckbox
611610
label="Select All"
612-
checked={selectedIds.length === rows.length}
613-
onCheckedChange={onSelectAll}
611+
onCheckedChange={onSelect}
612+
rows={rows}
613+
selectedIds={selectedIds}
614614
/>
615615
)}
616616
</MobileActions>
@@ -624,11 +624,12 @@ const Table = forwardRef<HTMLTableElement, TableProps>(
624624
<Thead
625625
headers={headers}
626626
isSelectable={isSelectable}
627-
onSelectAll={onSelectAll}
627+
onSelectAll={onSelect}
628628
actionsList={actionsList}
629629
onSort={onSort}
630-
hasRows={hasRows}
631630
size={size}
631+
rows={rows}
632+
selectedIds={selectedIds}
632633
/>
633634
)}
634635
<Tbody>
@@ -677,6 +678,82 @@ const Table = forwardRef<HTMLTableElement, TableProps>(
677678
}
678679
);
679680

681+
interface SelectAllCheckboxProps extends Omit<CheckboxProps, "onCheckedChange"> {
682+
onCheckedChange?: (selectedValues: Array<SelectReturnValue>) => void;
683+
selectedIds: (number | string)[];
684+
rows: TableRowType[];
685+
}
686+
687+
const SelectAllCheckbox: FC<SelectAllCheckboxProps> = ({
688+
rows,
689+
selectedIds,
690+
onCheckedChange,
691+
...checkboxProps
692+
}) => {
693+
const selectedIdSet = useMemo(() => new Set(selectedIds), [selectedIds]);
694+
695+
const { checked, disabled } = useMemo(() => {
696+
let areAllChecked = true;
697+
let maybeIndeterminate: CheckedState = false;
698+
let disabled = true;
699+
700+
for (const row of rows) {
701+
if (row.isDisabled || row.isDeleted) {
702+
continue;
703+
} else {
704+
disabled = false;
705+
}
706+
707+
if (selectedIdSet.has(row.id)) {
708+
maybeIndeterminate = "indeterminate";
709+
} else {
710+
areAllChecked = false;
711+
}
712+
}
713+
714+
return {
715+
checked: disabled ? false : areAllChecked || maybeIndeterminate,
716+
disabled,
717+
};
718+
}, [rows, selectedIdSet]);
719+
720+
const handleCheckedChange = (checked: boolean) => {
721+
if (typeof onCheckedChange !== "function") {
722+
return;
723+
}
724+
725+
// disabled items should not change their selected state because of user interaction
726+
727+
const newSelectedRows = rows.reduce((acc: SelectReturnValue[], row, index) => {
728+
const isDisabled = row.isDisabled || row.isDeleted;
729+
730+
const shouldBeSelected = checked
731+
? !isDisabled || selectedIdSet.has(row.id)
732+
: isDisabled && selectedIdSet.has(row.id);
733+
734+
if (shouldBeSelected) {
735+
acc.push({
736+
item: row,
737+
index,
738+
});
739+
}
740+
741+
return acc;
742+
}, []);
743+
744+
onCheckedChange(newSelectedRows);
745+
};
746+
747+
return (
748+
<Checkbox
749+
checked={checked}
750+
disabled={disabled}
751+
onCheckedChange={handleCheckedChange}
752+
{...checkboxProps}
753+
/>
754+
);
755+
};
756+
680757
const StyledTable = styled.table`
681758
width: 100%;
682759
border-collapse: collapse;

0 commit comments

Comments
 (0)