Skip to content

feat: Add row selection functionality to MultiLevelTable #18

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
May 13, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -389,11 +389,16 @@ const StatusCell: React.FC<{ value: string; theme: ThemeProps }> = ({
const App: React.FC = () => {
const [isDarkMode, setIsDarkMode] = useState(false);
const theme = isDarkMode ? darkTheme : lightTheme;
const [selectedRows, setSelectedRows] = useState<Set<string | number>>(new Set());

const toggleTheme = () => {
setIsDarkMode((prev) => !prev);
};

const handleSelectionChange = (newSelectedRows: Set<string | number>) => {
setSelectedRows(newSelectedRows);
};

const columns: Column[] = [
{
key: "name",
Expand Down Expand Up @@ -448,8 +453,15 @@ const App: React.FC = () => {
columns={columns}
theme={theme}
sortable={true}
selectable={true}
onSelectionChange={handleSelectionChange}
/>
</div>
{selectedRows.size > 0 && (
<div className="selection-info" >
Selected rows: {Array.from(selectedRows).join(', ')}
</div>
)}
</main>
</div>
);
Expand Down
62 changes: 56 additions & 6 deletions src/components/MultiLevelTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import type { ThemeProps } from "../types/theme";
import type {
Column,
DataItem,
SelectionState,
TableInstanceWithHooks,
TableStateWithPagination,
} from "../types/types";
Expand All @@ -43,6 +44,8 @@ interface MultiLevelTableProps {
ascendingIcon?: React.ReactNode;
descendingIcon?: React.ReactNode;
expandIcon?: React.ReactNode;
selectable?: boolean;
onSelectionChange?: (selectedRows: Set<string | number>) => void;
}

/**
Expand All @@ -61,9 +64,48 @@ export const MultiLevelTable: React.FC<MultiLevelTableProps> = ({
ascendingIcon,
descendingIcon,
expandIcon,
selectable = false,
onSelectionChange,
}) => {
const mergedTheme = mergeThemeProps(defaultTheme, theme);
const [filterInput, setFilterInput] = useState("");
const [selectionState, setSelectionState] = useState<SelectionState>({
selectedRows: new Set(),
isAllSelected: false,
});

// Get all parent row IDs (level 0)
const parentRowIds = useMemo(() => data.map(item => item.id), [data]);

const handleSelectAll = () => {
const newIsAllSelected = !selectionState.isAllSelected;
const newSelectedRows = new Set<string | number>();

if (newIsAllSelected) parentRowIds.forEach(id => newSelectedRows.add(id));

setSelectionState({
selectedRows: newSelectedRows,
isAllSelected: newIsAllSelected,
});

onSelectionChange?.(newSelectedRows);
};

const handleRowSelect = (rowId: string | number) => {
const newSelectedRows = new Set(selectionState.selectedRows);

if (newSelectedRows.has(rowId)) newSelectedRows.delete(rowId);
else newSelectedRows.add(rowId);

const newIsAllSelected = newSelectedRows.size === parentRowIds.length;

setSelectionState({
selectedRows: newSelectedRows,
isAllSelected: newIsAllSelected,
});

onSelectionChange?.(newSelectedRows);
};

/**
* Prepares columns configuration for react-table
Expand All @@ -84,7 +126,7 @@ export const MultiLevelTable: React.FC<MultiLevelTableProps> = ({
value: string | number;
}) => {
const item = row.original;

return (
<div>{col.render ? col.render(value, item) : value?.toString()}</div>
);
Expand Down Expand Up @@ -146,7 +188,7 @@ export const MultiLevelTable: React.FC<MultiLevelTableProps> = ({
) as TableInstanceWithHooks<DataItem>;

const rowsMap = useMemo(() => {
const map = new Map<number, DataItem[]>();
const map = new Map<string | number, DataItem[]>();

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

const [expandedRows, setExpandedRows] = useState<Set<number>>(new Set());
const [expandedRows, setExpandedRows] = useState<Set<string | number>>(new Set());

const toggleRow = (rowId: number) => {
const toggleRow = (rowId: string | number) => {
setExpandedRows((prev) => {
const newSet = new Set(prev);

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

const renderNestedRows = (parentId: number, level = 1) => {
const renderNestedRows = (parentId: string | number, level = 1) => {
if (!expandedRows.has(parentId)) return null;

const children = rowsMap.get(parentId) || [];

return children.map((child) => {
Expand All @@ -194,6 +235,8 @@ export const MultiLevelTable: React.FC<MultiLevelTableProps> = ({
level={level}
theme={mergedTheme}
expandIcon={expandIcon}
selectable={false}
isRowSelected={false}
/>
{renderNestedRows(child.id, level + 1)}
</React.Fragment>
Expand Down Expand Up @@ -251,8 +294,12 @@ export const MultiLevelTable: React.FC<MultiLevelTableProps> = ({
hasChildren={hasChildren}
isExpanded={expandedRows.has(parentId)}
onToggle={() => hasChildren && toggleRow(parentId)}
level={0}
theme={mergedTheme}
expandIcon={expandIcon}
selectable={true}
isRowSelected={selectionState.selectedRows.has(row.original.id)}
onRowSelect={handleRowSelect}
/>
{renderNestedRows(parentId)}
</React.Fragment>
Expand All @@ -276,6 +323,9 @@ export const MultiLevelTable: React.FC<MultiLevelTableProps> = ({
sortable={sortable}
ascendingIcon={ascendingIcon}
descendingIcon={descendingIcon}
selectable={selectable}
isAllSelected={selectionState.isAllSelected}
onSelectAll={handleSelectAll}
/>
{renderTableBody()}
</table>
Expand Down
50 changes: 41 additions & 9 deletions src/components/TableCell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ import '../styles/TableCell.css';
* @property {number} [paddingLeft=0] - Left padding for nested cells
* @property {ThemeProps} theme - Theme properties
* @property {React.ReactNode} [expandIcon] - Custom expand icon
* @property {boolean} [selectable=false] - Whether the cell is selectable
* @property {boolean} [isRowSelected=false] - Whether the row is selected
* @property {(rowId: number) => void} [onRowSelect] - Function to select a row
* @property {number} [rowId] - ID of the row
*/
interface TableCellProps {
cell: Cell<DataItem>;
Expand All @@ -28,6 +32,10 @@ interface TableCellProps {
paddingLeft?: number;
theme: ThemeProps;
expandIcon?: React.ReactNode;
selectable?: boolean;
isRowSelected?: boolean;
onRowSelect?: (rowId: number) => void;
rowId?: number;
}

/**
Expand All @@ -44,15 +52,25 @@ export const TableCell: React.FC<TableCellProps> = ({
paddingLeft = 0,
theme,
expandIcon,
selectable = false,
isRowSelected = false,
onRowSelect,
rowId,
}) => {
const { key, ...cellProps } = cell.getCellProps();
const isSelectionColumn = cell.column.id === 'selection';

const handleExpandClick = (e: React.MouseEvent) => {
e.stopPropagation();
e.preventDefault();
onToggle();
};

const onSelect = () => {
if (rowId && onRowSelect)
onRowSelect(rowId);
};

return (
<td
key={key}
Expand All @@ -65,15 +83,29 @@ export const TableCell: React.FC<TableCellProps> = ({
}}
>
<div className="table-cell-content">
{hasChildren ? (
<button
onClick={handleExpandClick}
className="expand-button"
>
{expandIcon || <ExpandIcon isExpanded={isExpanded} theme={theme} />}
</button>
) : <div className="expand-button" />}
{cell.render('Cell')}
{selectable && (
<input
type="checkbox"
checked={isRowSelected}
onChange={onSelect}
className="row-checkbox"
/>
)}
{isSelectionColumn ? (
cell.render('Cell')
) : (
<>
{hasChildren ? (
<button
onClick={handleExpandClick}
className="expand-button"
>
{expandIcon || <ExpandIcon isExpanded={isExpanded} theme={theme} />}
</button>
) : <div className="expand-button" />}
{cell.render('Cell')}
</>
)}
</div>
</td>
);
Expand Down
43 changes: 33 additions & 10 deletions src/components/TableHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,19 @@ import "../styles/TableHeader.css";
* @property {boolean} [sortable=false] - Whether the table is sortable
* @property {React.ReactNode} [ascendingIcon] - Custom icon for ascending sort
* @property {React.ReactNode} [descendingIcon] - Custom icon for descending sort
* @property {boolean} [selectable=false] - Whether the table is selectable
* @property {boolean} [isAllSelected=false] - Whether all rows are selected
* @property {() => void} [onSelectAll] - Function to select all rows
*/
interface TableHeaderProps {
headerGroups: HeaderGroup<DataItem>[];
theme: ThemeProps;
sortable?: boolean;
ascendingIcon?: React.ReactNode;
descendingIcon?: React.ReactNode;
selectable?: boolean;
isAllSelected?: boolean;
onSelectAll?: () => void;
}

type ColumnWithSorting = {
Expand Down Expand Up @@ -57,6 +63,9 @@ export const TableHeader: React.FC<TableHeaderProps> = ({
sortable = false,
ascendingIcon,
descendingIcon,
selectable = false,
isAllSelected = false,
onSelectAll,
}) => {
return (
<thead>
Expand All @@ -67,30 +76,44 @@ export const TableHeader: React.FC<TableHeaderProps> = ({
return (
<tr key={headerGroupKey} {...headerGroupProps}>
{(headerGroup.headers as unknown as ColumnWithSorting[]).map(
(column) => {
(column, index) => {
const isColumnSortable = sortable && !column.disableSortBy;
const { key: columnKey, ...columnProps } = isColumnSortable
const { key: columnKey } = isColumnSortable
? column.getHeaderProps(column.getSortByToggleProps())
: column.getHeaderProps();

const sortProps = isColumnSortable ? column.getSortByToggleProps() : {};

return (
<th
key={columnKey}
{...columnProps}
style={{
backgroundColor: theme.table?.header?.background,
color: theme.table?.header?.textColor,
borderColor: theme.table?.cell?.borderColor,
}}
>
<div className="table-header-cell">
<span>{column.title || column.id}</span>
<span className="sort-icon">
{column.isSorted
? column.isSortedDesc
? descendingIcon || "↓"
: ascendingIcon || "↑"
: " "}
{index === 0 && selectable && (
<input
type="checkbox"
checked={isAllSelected}
onChange={onSelectAll}
style={{ marginRight: 8, cursor: 'pointer' }}
/>
)}
<span
style={{ display: 'inline-flex', alignItems: 'center', cursor: isColumnSortable ? 'pointer' : 'default', userSelect: 'none' }}
onClick={isColumnSortable ? (e => { e.stopPropagation(); (sortProps.onClick as any)?.(e); }) : undefined}
>
{column.title || column.id}
<span className="sort-icon" style={{ marginLeft: 4 }}>
{column.isSorted
? column.isSortedDesc
? descendingIcon || "↓"
: ascendingIcon || "↑"
: " "}
</span>
</span>
{column.Filter && (
<div className="filter-container">
Expand Down
21 changes: 21 additions & 0 deletions src/components/TableRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ import "../styles/TableRow.css";
* @property {number} [level=0] - Nesting level of the row
* @property {ThemeProps} theme - Theme properties
* @property {React.ReactNode} [expandIcon] - Custom expand icon
* @property {boolean} [selectable=false] - Whether the row is selectable
* @property {boolean} [isRowSelected=false] - Whether the row is selected
* @property {(rowId: number) => void} [onRowSelect] - Function to select a row
*/
interface TableRowProps {
row: Row<DataItem> | DataItem;
Expand All @@ -30,6 +33,9 @@ interface TableRowProps {
level?: number;
theme: ThemeProps;
expandIcon?: React.ReactNode;
selectable?: boolean;
isRowSelected?: boolean;
onRowSelect?: (rowId: number) => void;
}

/**
Expand All @@ -47,6 +53,9 @@ export const TableRow: React.FC<TableRowProps> = ({
level = 0,
theme,
expandIcon,
selectable = false,
isRowSelected = false,
onRowSelect,
}) => {
const getRowClassName = useMemo(() => {
const classes = ["table-row"];
Expand Down Expand Up @@ -92,6 +101,14 @@ export const TableRow: React.FC<TableRowProps> = ({
}}
>
<div className="table-cell-content">
{index === 0 && selectable && (
<input
type="checkbox"
checked={isRowSelected}
onChange={() => onRowSelect?.(dataItem.id)}
style={{ marginRight: 8, cursor: 'pointer' }}
/>
)}
{hasChildren && index === 0 ? (
<button onClick={handleExpandClick} className="expand-button">
{expandIcon || (
Expand Down Expand Up @@ -133,6 +150,10 @@ export const TableRow: React.FC<TableRowProps> = ({
paddingLeft={level > 0 ? 32 + level * 16 : 0}
theme={theme}
expandIcon={expandIcon}
selectable={selectable && index === 0}
isRowSelected={isRowSelected}
onRowSelect={onRowSelect}
rowId={tableRow.original.id}
/>
))}
</tr>
Expand Down
Loading