Skip to content

Commit ebc9414

Browse files
authored
feat: Add row selection functionality to MultiLevelTable (#18)
* feat: Add row selection functionality to MultiLevelTable * fix: Review comment fix
1 parent 455d56e commit ebc9414

File tree

8 files changed

+190
-26
lines changed

8 files changed

+190
-26
lines changed

src/App.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -389,11 +389,16 @@ const StatusCell: React.FC<{ value: string; theme: ThemeProps }> = ({
389389
const App: React.FC = () => {
390390
const [isDarkMode, setIsDarkMode] = useState(false);
391391
const theme = isDarkMode ? darkTheme : lightTheme;
392+
const [selectedRows, setSelectedRows] = useState<Set<string | number>>(new Set());
392393

393394
const toggleTheme = () => {
394395
setIsDarkMode((prev) => !prev);
395396
};
396397

398+
const handleSelectionChange = (newSelectedRows: Set<string | number>) => {
399+
setSelectedRows(newSelectedRows);
400+
};
401+
397402
const columns: Column[] = [
398403
{
399404
key: "name",
@@ -448,8 +453,15 @@ const App: React.FC = () => {
448453
columns={columns}
449454
theme={theme}
450455
sortable={true}
456+
selectable={true}
457+
onSelectionChange={handleSelectionChange}
451458
/>
452459
</div>
460+
{selectedRows.size > 0 && (
461+
<div className="selection-info" >
462+
Selected rows: {Array.from(selectedRows).join(', ')}
463+
</div>
464+
)}
453465
</main>
454466
</div>
455467
);

src/components/MultiLevelTable.tsx

Lines changed: 56 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import type { ThemeProps } from "../types/theme";
2020
import type {
2121
Column,
2222
DataItem,
23+
SelectionState,
2324
TableInstanceWithHooks,
2425
TableStateWithPagination,
2526
} from "../types/types";
@@ -43,6 +44,8 @@ export interface MultiLevelTableProps {
4344
ascendingIcon?: React.ReactNode;
4445
descendingIcon?: React.ReactNode;
4546
expandIcon?: React.ReactNode;
47+
selectable?: boolean;
48+
onSelectionChange?: (selectedRows: Set<string | number>) => void;
4649
}
4750

4851
/**
@@ -61,9 +64,48 @@ export const MultiLevelTable: React.FC<MultiLevelTableProps> = ({
6164
ascendingIcon,
6265
descendingIcon,
6366
expandIcon,
67+
selectable = false,
68+
onSelectionChange,
6469
}) => {
6570
const mergedTheme = mergeThemeProps(defaultTheme, theme);
6671
const [filterInput, setFilterInput] = useState("");
72+
const [selectionState, setSelectionState] = useState<SelectionState>({
73+
selectedRows: new Set(),
74+
isAllSelected: false,
75+
});
76+
77+
// Get all parent row IDs (level 0)
78+
const parentRowIds = useMemo(() => data.map(item => item.id), [data]);
79+
80+
const handleSelectAll = () => {
81+
const newIsAllSelected = !selectionState.isAllSelected;
82+
const newSelectedRows = new Set<string | number>();
83+
84+
if (newIsAllSelected) parentRowIds.forEach(id => newSelectedRows.add(id));
85+
86+
setSelectionState({
87+
selectedRows: newSelectedRows,
88+
isAllSelected: newIsAllSelected,
89+
});
90+
91+
onSelectionChange?.(newSelectedRows);
92+
};
93+
94+
const handleRowSelect = (rowId: string | number) => {
95+
const newSelectedRows = new Set(selectionState.selectedRows);
96+
97+
if (newSelectedRows.has(rowId)) newSelectedRows.delete(rowId);
98+
else newSelectedRows.add(rowId);
99+
100+
const newIsAllSelected = newSelectedRows.size === parentRowIds.length;
101+
102+
setSelectionState({
103+
selectedRows: newSelectedRows,
104+
isAllSelected: newIsAllSelected,
105+
});
106+
107+
onSelectionChange?.(newSelectedRows);
108+
};
67109

68110
/**
69111
* Prepares columns configuration for react-table
@@ -84,7 +126,7 @@ export const MultiLevelTable: React.FC<MultiLevelTableProps> = ({
84126
value: string | number;
85127
}) => {
86128
const item = row.original;
87-
129+
88130
return (
89131
<div>{col.render ? col.render(value, item) : value?.toString()}</div>
90132
);
@@ -146,7 +188,7 @@ export const MultiLevelTable: React.FC<MultiLevelTableProps> = ({
146188
) as TableInstanceWithHooks<DataItem>;
147189

148190
const rowsMap = useMemo(() => {
149-
const map = new Map<number, DataItem[]>();
191+
const map = new Map<string | number, DataItem[]>();
150192

151193
const processItem = (item: DataItem) => {
152194
if (item.children) {
@@ -160,9 +202,9 @@ export const MultiLevelTable: React.FC<MultiLevelTableProps> = ({
160202
return map;
161203
}, [data]);
162204

163-
const [expandedRows, setExpandedRows] = useState<Set<number>>(new Set());
205+
const [expandedRows, setExpandedRows] = useState<Set<string | number>>(new Set());
164206

165-
const toggleRow = (rowId: number) => {
207+
const toggleRow = (rowId: string | number) => {
166208
setExpandedRows((prev) => {
167209
const newSet = new Set(prev);
168210

@@ -175,9 +217,8 @@ export const MultiLevelTable: React.FC<MultiLevelTableProps> = ({
175217
});
176218
};
177219

178-
const renderNestedRows = (parentId: number, level = 1) => {
220+
const renderNestedRows = (parentId: string | number, level = 1) => {
179221
if (!expandedRows.has(parentId)) return null;
180-
181222
const children = rowsMap.get(parentId) || [];
182223

183224
return children.map((child) => {
@@ -194,6 +235,8 @@ export const MultiLevelTable: React.FC<MultiLevelTableProps> = ({
194235
level={level}
195236
theme={mergedTheme}
196237
expandIcon={expandIcon}
238+
selectable={false}
239+
isRowSelected={false}
197240
/>
198241
{renderNestedRows(child.id, level + 1)}
199242
</React.Fragment>
@@ -251,8 +294,12 @@ export const MultiLevelTable: React.FC<MultiLevelTableProps> = ({
251294
hasChildren={hasChildren}
252295
isExpanded={expandedRows.has(parentId)}
253296
onToggle={() => hasChildren && toggleRow(parentId)}
297+
level={0}
254298
theme={mergedTheme}
255299
expandIcon={expandIcon}
300+
selectable={true}
301+
isRowSelected={selectionState.selectedRows.has(row.original.id)}
302+
onRowSelect={handleRowSelect}
256303
/>
257304
{renderNestedRows(parentId)}
258305
</React.Fragment>
@@ -276,6 +323,9 @@ export const MultiLevelTable: React.FC<MultiLevelTableProps> = ({
276323
sortable={sortable}
277324
ascendingIcon={ascendingIcon}
278325
descendingIcon={descendingIcon}
326+
selectable={selectable}
327+
isAllSelected={selectionState.isAllSelected}
328+
onSelectAll={handleSelectAll}
279329
/>
280330
{renderTableBody()}
281331
</table>

src/components/TableCell.tsx

Lines changed: 41 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@ import '../styles/TableCell.css';
1919
* @property {number} [paddingLeft=0] - Left padding for nested cells
2020
* @property {ThemeProps} theme - Theme properties
2121
* @property {React.ReactNode} [expandIcon] - Custom expand icon
22+
* @property {boolean} [selectable=false] - Whether the cell is selectable
23+
* @property {boolean} [isRowSelected=false] - Whether the row is selected
24+
* @property {(rowId: number) => void} [onRowSelect] - Function to select a row
25+
* @property {number} [rowId] - ID of the row
2226
*/
2327
interface TableCellProps {
2428
cell: Cell<DataItem>;
@@ -28,6 +32,10 @@ interface TableCellProps {
2832
paddingLeft?: number;
2933
theme: ThemeProps;
3034
expandIcon?: React.ReactNode;
35+
selectable?: boolean;
36+
isRowSelected?: boolean;
37+
onRowSelect?: (rowId: number) => void;
38+
rowId?: number;
3139
}
3240

3341
/**
@@ -44,15 +52,25 @@ export const TableCell: React.FC<TableCellProps> = ({
4452
paddingLeft = 0,
4553
theme,
4654
expandIcon,
55+
selectable = false,
56+
isRowSelected = false,
57+
onRowSelect,
58+
rowId,
4759
}) => {
4860
const { key, ...cellProps } = cell.getCellProps();
61+
const isSelectionColumn = cell.column.id === 'selection';
4962

5063
const handleExpandClick = (e: React.MouseEvent) => {
5164
e.stopPropagation();
5265
e.preventDefault();
5366
onToggle();
5467
};
5568

69+
const onSelect = () => {
70+
if (rowId && onRowSelect)
71+
onRowSelect(rowId);
72+
};
73+
5674
return (
5775
<td
5876
key={key}
@@ -65,15 +83,29 @@ export const TableCell: React.FC<TableCellProps> = ({
6583
}}
6684
>
6785
<div className="table-cell-content">
68-
{hasChildren ? (
69-
<button
70-
onClick={handleExpandClick}
71-
className="expand-button"
72-
>
73-
{expandIcon || <ExpandIcon isExpanded={isExpanded} theme={theme} />}
74-
</button>
75-
) : <div className="expand-button" />}
76-
{cell.render('Cell')}
86+
{selectable && (
87+
<input
88+
type="checkbox"
89+
checked={isRowSelected}
90+
onChange={onSelect}
91+
className="row-checkbox"
92+
/>
93+
)}
94+
{isSelectionColumn ? (
95+
cell.render('Cell')
96+
) : (
97+
<>
98+
{hasChildren ? (
99+
<button
100+
onClick={handleExpandClick}
101+
className="expand-button"
102+
>
103+
{expandIcon || <ExpandIcon isExpanded={isExpanded} theme={theme} />}
104+
</button>
105+
) : <div className="expand-button" />}
106+
{cell.render('Cell')}
107+
</>
108+
)}
77109
</div>
78110
</td>
79111
);

src/components/TableHeader.tsx

Lines changed: 33 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,19 @@ import "../styles/TableHeader.css";
1515
* @property {boolean} [sortable=false] - Whether the table is sortable
1616
* @property {React.ReactNode} [ascendingIcon] - Custom icon for ascending sort
1717
* @property {React.ReactNode} [descendingIcon] - Custom icon for descending sort
18+
* @property {boolean} [selectable=false] - Whether the table is selectable
19+
* @property {boolean} [isAllSelected=false] - Whether all rows are selected
20+
* @property {() => void} [onSelectAll] - Function to select all rows
1821
*/
1922
interface TableHeaderProps {
2023
headerGroups: HeaderGroup<DataItem>[];
2124
theme: ThemeProps;
2225
sortable?: boolean;
2326
ascendingIcon?: React.ReactNode;
2427
descendingIcon?: React.ReactNode;
28+
selectable?: boolean;
29+
isAllSelected?: boolean;
30+
onSelectAll?: () => void;
2531
}
2632

2733
type ColumnWithSorting = {
@@ -57,6 +63,9 @@ export const TableHeader: React.FC<TableHeaderProps> = ({
5763
sortable = false,
5864
ascendingIcon,
5965
descendingIcon,
66+
selectable = false,
67+
isAllSelected = false,
68+
onSelectAll,
6069
}) => {
6170
return (
6271
<thead>
@@ -67,30 +76,44 @@ export const TableHeader: React.FC<TableHeaderProps> = ({
6776
return (
6877
<tr key={headerGroupKey} {...headerGroupProps}>
6978
{(headerGroup.headers as unknown as ColumnWithSorting[]).map(
70-
(column) => {
79+
(column, index) => {
7180
const isColumnSortable = sortable && !column.disableSortBy;
72-
const { key: columnKey, ...columnProps } = isColumnSortable
81+
const { key: columnKey } = isColumnSortable
7382
? column.getHeaderProps(column.getSortByToggleProps())
7483
: column.getHeaderProps();
7584

85+
const sortProps = isColumnSortable ? column.getSortByToggleProps() : {};
86+
7687
return (
7788
<th
7889
key={columnKey}
79-
{...columnProps}
8090
style={{
8191
backgroundColor: theme.table?.header?.background,
8292
color: theme.table?.header?.textColor,
8393
borderColor: theme.table?.cell?.borderColor,
8494
}}
8595
>
8696
<div className="table-header-cell">
87-
<span>{column.title || column.id}</span>
88-
<span className="sort-icon">
89-
{column.isSorted
90-
? column.isSortedDesc
91-
? descendingIcon || "↓"
92-
: ascendingIcon || "↑"
93-
: " "}
97+
{index === 0 && selectable && (
98+
<input
99+
type="checkbox"
100+
checked={isAllSelected}
101+
onChange={onSelectAll}
102+
style={{ marginRight: 8, cursor: 'pointer' }}
103+
/>
104+
)}
105+
<span
106+
style={{ display: 'inline-flex', alignItems: 'center', cursor: isColumnSortable ? 'pointer' : 'default', userSelect: 'none' }}
107+
onClick={isColumnSortable ? (e => { e.stopPropagation(); (sortProps.onClick as any)?.(e); }) : undefined}
108+
>
109+
{column.title || column.id}
110+
<span className="sort-icon" style={{ marginLeft: 4 }}>
111+
{column.isSorted
112+
? column.isSortedDesc
113+
? descendingIcon || "↓"
114+
: ascendingIcon || "↑"
115+
: " "}
116+
</span>
94117
</span>
95118
{column.Filter && (
96119
<div className="filter-container">

src/components/TableRow.tsx

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@ import "../styles/TableRow.css";
2020
* @property {number} [level=0] - Nesting level of the row
2121
* @property {ThemeProps} theme - Theme properties
2222
* @property {React.ReactNode} [expandIcon] - Custom expand icon
23+
* @property {boolean} [selectable=false] - Whether the row is selectable
24+
* @property {boolean} [isRowSelected=false] - Whether the row is selected
25+
* @property {(rowId: number) => void} [onRowSelect] - Function to select a row
2326
*/
2427
interface TableRowProps {
2528
row: Row<DataItem> | DataItem;
@@ -30,6 +33,9 @@ interface TableRowProps {
3033
level?: number;
3134
theme: ThemeProps;
3235
expandIcon?: React.ReactNode;
36+
selectable?: boolean;
37+
isRowSelected?: boolean;
38+
onRowSelect?: (rowId: number) => void;
3339
}
3440

3541
/**
@@ -47,6 +53,9 @@ export const TableRow: React.FC<TableRowProps> = ({
4753
level = 0,
4854
theme,
4955
expandIcon,
56+
selectable = false,
57+
isRowSelected = false,
58+
onRowSelect,
5059
}) => {
5160
const getRowClassName = useMemo(() => {
5261
const classes = ["table-row"];
@@ -92,6 +101,14 @@ export const TableRow: React.FC<TableRowProps> = ({
92101
}}
93102
>
94103
<div className="table-cell-content">
104+
{index === 0 && selectable && (
105+
<input
106+
type="checkbox"
107+
checked={isRowSelected}
108+
onChange={() => onRowSelect?.(dataItem.id)}
109+
style={{ marginRight: 8, cursor: 'pointer' }}
110+
/>
111+
)}
95112
{hasChildren && index === 0 ? (
96113
<button onClick={handleExpandClick} className="expand-button">
97114
{expandIcon || (
@@ -133,6 +150,10 @@ export const TableRow: React.FC<TableRowProps> = ({
133150
paddingLeft={level > 0 ? 32 + level * 16 : 0}
134151
theme={theme}
135152
expandIcon={expandIcon}
153+
selectable={selectable && index === 0}
154+
isRowSelected={isRowSelected}
155+
onRowSelect={onRowSelect}
156+
rowId={tableRow.original.id}
136157
/>
137158
))}
138159
</tr>

0 commit comments

Comments
 (0)