Compare commits

...
Author SHA1 Message Date
Ken 82656da435 📝 Added comments 2026-04-27 09:57:48 +02:00
Ken ab9932ed8f 📝 Added comments 2026-04-27 09:55:47 +02:00
Ken 180ca25861 ⚡ Add cached parser for optimization 2026-04-24 13:38:51 +02:00
Ken cbac535fcb 📝 Add docs 2026-04-24 13:10:18 +02:00
Ken 296049b8c7 [DataTable] Optimize recursive parsing 2026-04-24 13:00:21 +02:00
Ken 073d811b3f [DataTable] Now supports branch selection 2026-04-24 12:45:15 +02:00
Ken da1374e833 [DataTable] Updated selection 2026-04-24 12:22:41 +02:00
12 changed files with 678 additions and 110 deletions

No files matched your search

@@ -817,6 +817,7 @@ export const NestedRows: Story = {
selectionMode={args.selectionMode}
withKeyboardNav
getSubRows={(row) => row.children}
onSelectionChange={console.info}
/>
),
play: async ({ canvasElement }) => {
@@ -14,45 +14,77 @@ interface ItemDetail<T> {
children: readonly T[];
}
type CollectTableRowEntriesReturn<T> = {
itemDetails: Map<T, ItemDetail<T>>;
/**
* Direct child ids for each row, used to traverse nested selection groups
* without storing every descendant list on each ancestor.
*/
childRowIdsById: Map<TableRowEntryId, TableRowEntryId[]>;
};
function collectTableRowEntries<T>({
items,
getRowId,
getSubRows,
isSubRowExpandable,
}: CollectTableRowEntriesArgs<T>): Map<T, ItemDetail<T>> {
}: CollectTableRowEntriesArgs<T>): CollectTableRowEntriesReturn<T> {
const itemDetailsMap = new Map<T, ItemDetail<T>>();
const childRowIdsById = new Map<TableRowEntryId, TableRowEntryId[]>();
const traverseRows = (
rows: T[],
const traverseRow = (
rowData: T,
rowIndex: number,
level: number,
parent: T | null,
parentId?: TableRowEntryId,
) => {
for (let rowIndex = 0; rowIndex < rows.length; rowIndex++) {
const rowData = rows[rowIndex];
const rowId =
getRowId?.(rowData, rowIndex) ??
(parentId == null ? rowIndex : `${parentId}-${rowIndex}`);
const isRowExpandable = isSubRowExpandable?.(rowData) ?? true;
const children = (isRowExpandable ? getSubRows?.(rowData) : []) ?? [];
): TableRowEntryId => {
const rowId =
getRowId?.(rowData, rowIndex) ??
(parentId == null ? rowIndex : `${parentId}-${rowIndex}`);
const isRowExpandable = isSubRowExpandable?.(rowData) ?? true;
const children = (isRowExpandable ? getSubRows?.(rowData) : []) ?? [];
itemDetailsMap.set(rowData, {
id: rowId,
level,
parent,
children,
});
itemDetailsMap.set(rowData, {
id: rowId,
level,
parent,
children,
});
if (children.length > 0) {
traverseRows(children, level + 1, rowData, rowId);
}
const childRowIds: TableRowEntryId[] = [];
for (let childIndex = 0; childIndex < children.length; childIndex++) {
const childRow = children[childIndex];
const childRowId = traverseRow(
childRow,
childIndex,
level + 1,
rowData,
rowId,
);
childRowIds.push(childRowId);
}
childRowIdsById.set(rowId, childRowIds);
return rowId;
};
traverseRows(items, 0, null);
for (let rowIndex = 0; rowIndex < items.length; rowIndex++) {
traverseRow(items[rowIndex], rowIndex, 0, null);
}
return itemDetailsMap;
return {
itemDetails: itemDetailsMap,
childRowIdsById,
};
}
export { collectTableRowEntries };
export type { CollectTableRowEntriesArgs, TableRowEntryId, ItemDetail };
export type {
CollectTableRowEntriesArgs,
CollectTableRowEntriesReturn,
TableRowEntryId,
ItemDetail,
};
@@ -0,0 +1,66 @@
import { describe, expect, test } from "vitest";
import { SelectionSubtreeHelper } from "./SelectionSubtreeHelper";
const childRowIdsById = new Map<string | number, (string | number)[]>([
["a", ["a1", "a2"]],
["a1", []],
["a2", ["a2a"]],
["a2a", []],
["b", []],
]);
describe("SelectionSubtreeHelper", () => {
test("returns selectable subtree keys without duplicates", () => {
const helper = new SelectionSubtreeHelper({
childRowIdsById,
disabledKeysSet: new Set(["a2"]),
selectedKeysSet: new Set(),
});
expect(helper.getSelectableKeys(["a", "a1"])).toEqual(["a", "a1", "a2a"]);
});
test("returns cached subtree selection stats", () => {
const helper = new SelectionSubtreeHelper({
childRowIdsById,
disabledKeysSet: new Set(["a2"]),
selectedKeysSet: new Set(["a", "a1", "a2a"]),
});
const firstStats = helper.getSelectionStats("a");
const secondStats = helper.getSelectionStats("a");
expect(firstStats).toEqual({ selectableCount: 3, selectedCount: 3 });
expect(secondStats).toBe(firstStats);
expect(helper.isFullySelected("a")).toBe(true);
});
test("handles deep trees iteratively", () => {
const depth = 12000;
const deepChildRowIdsById = new Map<string | number, (string | number)[]>();
const selectedKeysSet = new Set<string | number>();
for (let index = 0; index < depth; index++) {
const key = `node-${index}`;
const childKey = `node-${index + 1}`;
deepChildRowIdsById.set(key, index === depth - 1 ? [] : [childKey]);
if (index % 2 === 0) {
selectedKeysSet.add(key);
}
}
const helper = new SelectionSubtreeHelper({
childRowIdsById: deepChildRowIdsById,
disabledKeysSet: new Set(),
selectedKeysSet,
});
expect(helper.getSelectableKeys(["node-0"])).toHaveLength(depth);
expect(helper.getSelectionStats("node-0")).toEqual({
selectableCount: depth,
selectedCount: Math.ceil(depth / 2),
});
});
});
@@ -0,0 +1,162 @@
type SelectionKey = string | number;
type SelectionStats = {
selectableCount: number;
selectedCount: number;
};
type SelectionSubtreeHelperArgs = {
childRowIdsById?: Map<SelectionKey, SelectionKey[]>;
disabledKeysSet: Set<SelectionKey>;
selectedKeysSet: Set<SelectionKey>;
};
/**
* Helper class for managing selection state in a tree structure.
* - It provides methods to get selectable keys in a subtree
* - Compute selection statistics for a subtree
* - Determine if a subtree is fully selected.
*
* Results of selection statistics are cached to optimize performance for repeated calls on the same subtree.
*/
class SelectionSubtreeHelper {
private childRowIdsById: Map<SelectionKey, SelectionKey[]>;
private disabledKeysSet: Set<SelectionKey>;
private selectedKeysSet: Set<SelectionKey>;
private selectionStatsCache = new Map<SelectionKey, SelectionStats>();
constructor({
childRowIdsById,
disabledKeysSet,
selectedKeysSet,
}: SelectionSubtreeHelperArgs) {
this.childRowIdsById = childRowIdsById ?? new Map();
this.disabledKeysSet = disabledKeysSet;
this.selectedKeysSet = selectedKeysSet;
}
getSelectableKeys(rootIds: SelectionKey[]): SelectionKey[] {
const visitedKeys = new Set<SelectionKey>();
const selectableKeys: SelectionKey[] = [];
const stack = [...rootIds].reverse();
while (stack.length > 0) {
const key = stack.pop();
if (key == null || visitedKeys.has(key)) {
continue;
}
visitedKeys.add(key);
if (!this.disabledKeysSet.has(key)) {
selectableKeys.push(key);
}
const childRowIds = this.childRowIdsById.get(key) ?? [];
for (
let childIndex = childRowIds.length - 1;
childIndex >= 0;
childIndex--
) {
stack.push(childRowIds[childIndex]);
}
}
return selectableKeys;
}
/**
* Returns the number of selectable and selected rows in the subtree of the given root ID.
* Results are cached after the first computation to optimize repeated calls for the same root ID.
*
* The selectable count excludes disabled rows, and the selected count excludes disabled rows that are selected.
* The method is implemented iteratively to handle deep trees without hitting call stack limits.
*
* How it works:
* - Manually add root ID to stack to get processing going. Note that the ready-flag is `false`.
* - Pop stack until empty. For each entry:
* - - If entry is already cached, skip it.
* - - If entry is not ready, push it back as ready and push all its children as not ready.
* - - If entry is ready, compute its stats based on its own state and the stats of its children, then cache the result.
* - Since we add all the children to the stack after pushing element with ready: true, while "popping" the stack we will always encounter the children before their parent is ready, ensuring that the stats for all children are computed and cached before computing the stats for their parent.
* - Finally, return the cached stats for the root ID.
*/
getSelectionStats(rootId: SelectionKey): SelectionStats {
const cachedStats = this.selectionStatsCache.get(rootId);
if (cachedStats) {
return cachedStats;
}
/* Compute subtree totals iteratively so deep trees do not depend on call stack depth. */
const stack: { key: SelectionKey; ready: boolean }[] = [
{ key: rootId, ready: false },
];
while (stack.length > 0) {
const entry = stack.pop();
if (!entry) {
continue;
}
if (this.selectionStatsCache.has(entry.key)) {
continue;
}
if (entry.ready) {
let selectableCount = this.disabledKeysSet.has(entry.key) ? 0 : 1;
let selectedCount =
!this.disabledKeysSet.has(entry.key) &&
this.selectedKeysSet.has(entry.key)
? 1
: 0;
for (const childKey of this.childRowIdsById.get(entry.key) ?? []) {
const childStats = this.selectionStatsCache.get(childKey);
if (!childStats) {
continue;
}
selectableCount += childStats.selectableCount;
selectedCount += childStats.selectedCount;
}
this.selectionStatsCache.set(entry.key, {
selectableCount,
selectedCount,
});
continue;
}
stack.push({ key: entry.key, ready: true });
for (const childKey of this.childRowIdsById.get(entry.key) ?? []) {
if (!this.selectionStatsCache.has(childKey)) {
stack.push({ key: childKey, ready: false });
}
}
}
return (
this.selectionStatsCache.get(rootId) ?? {
selectableCount: 0,
selectedCount: 0,
}
);
}
isFullySelected(rootId: SelectionKey): boolean {
const stats = this.getSelectionStats(rootId);
return (
stats.selectableCount > 0 && stats.selectedCount === stats.selectableCount
);
}
}
export { SelectionSubtreeHelper };
export type { SelectionKey, SelectionStats, SelectionSubtreeHelperArgs };
@@ -1,11 +1,13 @@
import type { CheckboxInputProps } from "../../../../form/checkbox/checkbox-input/CheckboxInput";
import { SelectionSubtreeHelper } from "./SelectionSubtreeHelper";
type GetMultipleSelectPropsArgs = {
selectedKeysSet: Set<string | number>;
selectedKeys: (string | number)[];
setSelectedKeys: (keys: (string | number)[]) => void;
disabledKeysSet: Set<string | number>;
allRowKeys: (string | number)[];
visibleRowIds: (string | number)[];
childRowIdsById?: Map<string | number, (string | number)[]>;
};
function getMultipleSelectProps({
@@ -13,34 +15,56 @@ function getMultipleSelectProps({
selectedKeys,
setSelectedKeys,
disabledKeysSet,
allRowKeys,
visibleRowIds,
childRowIdsById,
}: GetMultipleSelectPropsArgs) {
const allRowKeysSet = new Set(allRowKeys);
const selectableKeys = allRowKeys.filter((k) => !disabledKeysSet.has(k));
const subtreeHelper = new SelectionSubtreeHelper({
childRowIdsById,
disabledKeysSet,
selectedKeysSet,
});
const selectedSelectableCount = selectableKeys.filter((k) =>
// Header selection traverses the visible roots and skips already visited
// descendants, so expanded trees stay linear in the number of rows.
const headerSelectableKeys = subtreeHelper.getSelectableKeys(visibleRowIds);
const headerSelectableKeysSet = new Set(headerSelectableKeys);
const selectedSelectableCount = headerSelectableKeys.filter((k) =>
selectedKeysSet.has(k),
).length;
const allSelectableSelected =
selectableKeys.length > 0 &&
selectedSelectableCount === selectableKeys.length;
headerSelectableKeys.length > 0 &&
selectedSelectableCount === headerSelectableKeys.length;
const indeterminate =
selectedSelectableCount > 0 &&
selectedSelectableCount < selectableKeys.length;
selectedSelectableCount < headerSelectableKeys.length;
const selectedKeysNotInView = selectedKeys.filter(
(k) => !allRowKeysSet.has(k),
(k) => !headerSelectableKeysSet.has(k),
);
const disabledSelected = selectedKeys.filter((k) => disabledKeysSet.has(k));
const preservedKeys = [...selectedKeysNotInView, ...disabledSelected];
const preservedKeys = [
...new Set([...selectedKeysNotInView, ...disabledSelected]),
];
const isGroupFullySelected = (key: string | number) => {
const groupStats = subtreeHelper.getSelectionStats(key);
return (
groupStats.selectableCount > 0 &&
groupStats.selectedCount === groupStats.selectableCount
);
};
const handleToggleAll = () => {
if (allSelectableSelected) {
setSelectedKeys(preservedKeys);
} else {
setSelectedKeys([...new Set([...preservedKeys, ...selectableKeys])]);
setSelectedKeys([
...new Set([...preservedKeys, ...headerSelectableKeys]),
]);
}
};
@@ -48,10 +72,16 @@ function getMultipleSelectProps({
if (disabledKeysSet.has(key)) {
return;
}
if (selectedKeysSet.has(key)) {
setSelectedKeys(selectedKeys.filter((k) => k !== key));
const groupKeys = subtreeHelper.getSelectableKeys([key]);
if (isGroupFullySelected(key)) {
const groupKeysSet = new Set(groupKeys);
setSelectedKeys(
selectedKeys.filter((selectedKey) => !groupKeysSet.has(selectedKey)),
);
} else {
setSelectedKeys([...selectedKeys, key]);
setSelectedKeys([...new Set([...selectedKeys, ...groupKeys])]);
}
};
@@ -60,13 +90,20 @@ function getMultipleSelectProps({
onChange: handleToggleAll,
checked: allSelectableSelected,
indeterminate,
disabled: selectableKeys.length === 0,
}),
getRowCheckboxProps: (key: string | number): CheckboxInputProps => ({
onChange: () => handleToggleRow(key),
checked: selectedKeysSet.has(key),
disabled: disabledKeysSet.has(key),
disabled: headerSelectableKeys.length === 0,
}),
getRowCheckboxProps: (key: string | number): CheckboxInputProps => {
const groupStats = subtreeHelper.getSelectionStats(key);
return {
onChange: () => handleToggleRow(key),
checked: isGroupFullySelected(key),
indeterminate:
groupStats.selectedCount > 0 &&
groupStats.selectedCount < groupStats.selectableCount,
disabled: disabledKeysSet.has(key),
};
},
toggleSelection: handleToggleRow,
};
}
@@ -10,6 +10,7 @@ type SelectionProps = {
* When set to "single", only one row can be selected at a time (renders radio buttons).
*
* When set to "multiple", multiple rows can be selected (renders checkboxes).
* Nested rows use cascading selection, so selecting a parent toggles its descendants too.
*
* @default "none"
*/
@@ -86,6 +86,20 @@ describe("useTableItems", () => {
expect(getVisibleIds(result.current.items)).toEqual(["a", "a1", "a2", "b"]);
});
test("collects direct child row ids even when nested rows are collapsed", () => {
const { result } = renderHook(() =>
useTableItems({
items: nestedRows,
getRowId: (row) => row.id,
getSubRows,
}),
);
expect(result.current.childRowIdsById.get("a")).toEqual(["a1", "a2"]);
expect(result.current.childRowIdsById.get("a2")).toEqual(["a2a"]);
expect(result.current.childRowIdsById.get("b")).toEqual(["b1"]);
});
test("uses the same fallback root id to reveal child rows when getRowId is omitted", () => {
const { result } = renderHook(() =>
useTableItems({
@@ -17,7 +17,13 @@ const items: Item[] = [
{ id: "c", name: "Charlie" },
];
const allRowKeys = items.map((item) => item.id);
const visibleRowIds = items.map((item) => item.id);
const childRowIdsById = new Map<string | number, (string | number)[]>([
["a", ["a1", "a2"]],
["a1", []],
["a2", ["a2a"]],
["a2a", []],
]);
function asSingle(result: {
current: UseTableSelectionReturn;
@@ -37,7 +43,7 @@ describe("useTableSelection", () => {
const { result } = renderHook(() =>
useTableSelection({
selectionMode: "none",
allRowKeys,
visibleRowIds,
}),
);
@@ -51,7 +57,7 @@ describe("useTableSelection", () => {
const { result } = renderHook(() =>
useTableSelection({
selectionMode: "single",
allRowKeys,
visibleRowIds,
}),
);
@@ -64,7 +70,7 @@ describe("useTableSelection", () => {
const { result } = renderHook(() =>
useTableSelection({
selectionMode: "single",
allRowKeys,
visibleRowIds,
onSelectionChange: onChange,
}),
);
@@ -83,7 +89,7 @@ describe("useTableSelection", () => {
const { result } = renderHook(() =>
useTableSelection({
selectionMode: "single",
allRowKeys,
visibleRowIds,
defaultSelectedKeys: ["a"],
}),
);
@@ -101,7 +107,7 @@ describe("useTableSelection", () => {
const { result } = renderHook(() =>
useTableSelection({
selectionMode: "single",
allRowKeys,
visibleRowIds,
defaultSelectedKeys: ["a"],
}),
);
@@ -119,7 +125,7 @@ describe("useTableSelection", () => {
const { result } = renderHook(() =>
useTableSelection({
selectionMode: "single",
allRowKeys,
visibleRowIds,
disabledSelectionKeys: ["b"],
}),
);
@@ -133,7 +139,7 @@ describe("useTableSelection", () => {
({ selectedKeys }) =>
useTableSelection({
selectionMode: "single",
allRowKeys,
visibleRowIds,
selectedKeys,
}),
{ initialProps: { selectedKeys: ["a"] as (string | number)[] } },
@@ -151,7 +157,7 @@ describe("useTableSelection", () => {
const { result } = renderHook(() =>
useTableSelection({
selectionMode: "multiple",
allRowKeys,
visibleRowIds,
}),
);
@@ -164,7 +170,7 @@ describe("useTableSelection", () => {
const { result } = renderHook(() =>
useTableSelection({
selectionMode: "multiple",
allRowKeys,
visibleRowIds,
}),
);
@@ -189,7 +195,7 @@ describe("useTableSelection", () => {
const { result } = renderHook(() =>
useTableSelection({
selectionMode: "multiple",
allRowKeys,
visibleRowIds,
defaultSelectedKeys: ["a", "b"],
}),
);
@@ -207,7 +213,7 @@ describe("useTableSelection", () => {
const { result } = renderHook(() =>
useTableSelection({
selectionMode: "multiple",
allRowKeys,
visibleRowIds,
}),
);
@@ -220,11 +226,29 @@ describe("useTableSelection", () => {
expect(asMultiple(result).selectedKeys).toEqual(["a", "b", "c"]);
});
test("select all via thead includes hidden descendants for visible parents", () => {
const { result } = renderHook(() =>
useTableSelection({
selectionMode: "multiple",
visibleRowIds: ["a"],
childRowIdsById,
}),
);
act(() => {
asMultiple(result)
.getTheadCheckboxProps()
.onChange?.({} as React.ChangeEvent<HTMLInputElement>);
});
expect(asMultiple(result).selectedKeys).toEqual(["a", "a1", "a2", "a2a"]);
});
test("deselect all when all are selected", () => {
const { result } = renderHook(() =>
useTableSelection({
selectionMode: "multiple",
allRowKeys,
visibleRowIds,
defaultSelectedKeys: ["a", "b", "c"],
}),
);
@@ -238,11 +262,30 @@ describe("useTableSelection", () => {
expect(asMultiple(result).selectedKeys).toEqual([]);
});
test("deselect all clears hidden descendants for visible parents but preserves unrelated keys", () => {
const { result } = renderHook(() =>
useTableSelection({
selectionMode: "multiple",
visibleRowIds: ["a"],
childRowIdsById,
defaultSelectedKeys: ["a", "a1", "a2", "a2a", "external"],
}),
);
act(() => {
asMultiple(result)
.getTheadCheckboxProps()
.onChange?.({} as React.ChangeEvent<HTMLInputElement>);
});
expect(asMultiple(result).selectedKeys).toEqual(["external"]);
});
test("select all skips disabled keys", () => {
const { result } = renderHook(() =>
useTableSelection({
selectionMode: "multiple",
allRowKeys,
visibleRowIds,
disabledSelectionKeys: ["b"],
}),
);
@@ -260,7 +303,7 @@ describe("useTableSelection", () => {
const { result } = renderHook(() =>
useTableSelection({
selectionMode: "multiple",
allRowKeys,
visibleRowIds,
defaultSelectedKeys: ["a", "b", "c"],
disabledSelectionKeys: ["b"],
}),
@@ -279,7 +322,7 @@ describe("useTableSelection", () => {
const { result } = renderHook(() =>
useTableSelection({
selectionMode: "multiple",
allRowKeys,
visibleRowIds,
defaultSelectedKeys: ["a"],
}),
);
@@ -293,7 +336,7 @@ describe("useTableSelection", () => {
const { result } = renderHook(() =>
useTableSelection({
selectionMode: "multiple",
allRowKeys,
visibleRowIds,
defaultSelectedKeys: ["a", "b", "c"],
}),
);
@@ -307,7 +350,7 @@ describe("useTableSelection", () => {
const { result } = renderHook(() =>
useTableSelection({
selectionMode: "multiple",
allRowKeys,
visibleRowIds,
defaultSelectedKeys: ["a", "c"],
disabledSelectionKeys: ["b"],
}),
@@ -322,7 +365,7 @@ describe("useTableSelection", () => {
const { result } = renderHook(() =>
useTableSelection({
selectionMode: "multiple",
allRowKeys,
visibleRowIds,
defaultSelectedKeys: ["a", "b", "c"],
}),
);
@@ -340,7 +383,7 @@ describe("useTableSelection", () => {
const { result } = renderHook(() =>
useTableSelection({
selectionMode: "multiple",
allRowKeys,
visibleRowIds,
disabledSelectionKeys: ["b"],
}),
);
@@ -353,12 +396,80 @@ describe("useTableSelection", () => {
const { result } = renderHook(() =>
useTableSelection({
selectionMode: "multiple",
allRowKeys,
visibleRowIds,
disabledSelectionKeys: ["a", "b", "c"],
}),
);
expect(asMultiple(result).getTheadCheckboxProps().disabled).toBe(true);
});
test("parent rows show indeterminate when visible descendants are partially selected", () => {
const { result } = renderHook(() =>
useTableSelection({
selectionMode: "multiple",
visibleRowIds: ["a", "a1", "a2"],
childRowIdsById,
defaultSelectedKeys: ["a1"],
}),
);
const parentProps = asMultiple(result).getRowCheckboxProps("a");
expect(parentProps.checked).toBe(false);
expect(parentProps.indeterminate).toBe(true);
});
test("toggling a parent row selects and deselects its descendants", () => {
const { result } = renderHook(() =>
useTableSelection({
selectionMode: "multiple",
visibleRowIds: ["a", "a1", "a2"],
childRowIdsById,
}),
);
act(() => {
asMultiple(result)
.getRowCheckboxProps("a")
.onChange?.({} as React.ChangeEvent<HTMLInputElement>);
});
expect(asMultiple(result).selectedKeys).toEqual(["a", "a1", "a2", "a2a"]);
act(() => {
asMultiple(result)
.getRowCheckboxProps("a")
.onChange?.({} as React.ChangeEvent<HTMLInputElement>);
});
expect(asMultiple(result).selectedKeys).toEqual([]);
});
test("toggling a collapsed parent selects and deselects hidden descendants", () => {
const { result } = renderHook(() =>
useTableSelection({
selectionMode: "multiple",
visibleRowIds: ["a"],
childRowIdsById,
}),
);
act(() => {
asMultiple(result)
.getRowCheckboxProps("a")
.onChange?.({} as React.ChangeEvent<HTMLInputElement>);
});
expect(asMultiple(result).selectedKeys).toEqual(["a", "a1", "a2", "a2a"]);
act(() => {
asMultiple(result)
.getRowCheckboxProps("a")
.onChange?.({} as React.ChangeEvent<HTMLInputElement>);
});
expect(asMultiple(result).selectedKeys).toEqual([]);
});
});
});
@@ -3,6 +3,7 @@ import { createStrictContext } from "../../../utils/helpers";
import { useControllableState } from "../../../utils/hooks";
import {
type ItemDetail,
type TableRowEntryId,
collectTableRowEntries,
} from "../helpers/collectTableRowEntries";
@@ -32,6 +33,10 @@ type UseTableItemsArgs<T> = {
type useTableItemsReturn<T> = {
items: T[];
itemDetails: Map<T, ItemDetail<T>>;
/** Row ids for the rows currently rendered in the table body. */
visibleRowIds: TableRowEntryId[];
/** Direct child ids for each row, used to traverse selection groups lazily. */
childRowIdsById: Map<TableRowEntryId, TableRowEntryId[]>;
onExpandedSubRowIdsChange: (id: string | number) => void;
isSubRowExpanded: (id: string | number) => boolean;
};
@@ -59,38 +64,52 @@ function useTableItems<T>(args: UseTableItemsArgs<T>): useTableItemsReturn<T> {
[nestedSubRowsExpandedIds],
);
const { itemDetails, visibleItems } = useMemo(() => {
const rowEntriesMap = collectTableRowEntries({
items,
getRowId,
getSubRows,
isSubRowExpandable,
});
const { itemDetails, visibleItems, visibleRowIds, childRowIdsById } =
useMemo(() => {
const { itemDetails: rowEntriesMap, childRowIdsById: _childRowIdsById } =
collectTableRowEntries({
items,
getRowId,
getSubRows,
isSubRowExpandable,
});
const localVisibleItems: T[] = [];
const addVisibleRows = (rowData: T) => {
localVisibleItems.push(rowData);
const localVisibleItems: T[] = [];
const localVisibleRowIds: TableRowEntryId[] = [];
const details = rowEntriesMap.get(rowData);
const addVisibleRows = (rowData: T): TableRowEntryId[] => {
const details = rowEntriesMap.get(rowData);
if (!details || !expandedIdsSet.has(details.id)) {
return;
if (!details) {
return [];
}
localVisibleItems.push(rowData);
localVisibleRowIds.push(details.id);
const visibleDescendantRowIds: TableRowEntryId[] = [];
if (expandedIdsSet.has(details.id)) {
for (const childRow of details.children) {
const childVisibleRowIds = addVisibleRows(childRow);
visibleDescendantRowIds.push(...childVisibleRowIds);
}
}
return [details.id, ...visibleDescendantRowIds];
};
for (const rowData of items) {
addVisibleRows(rowData);
}
for (const childRow of details.children) {
addVisibleRows(childRow);
}
};
for (const rowData of items) {
addVisibleRows(rowData);
}
return {
visibleItems: localVisibleItems,
itemDetails: rowEntriesMap,
};
}, [getSubRows, items, getRowId, isSubRowExpandable, expandedIdsSet]);
return {
visibleItems: localVisibleItems,
visibleRowIds: localVisibleRowIds,
childRowIdsById: _childRowIdsById,
itemDetails: rowEntriesMap,
};
}, [getSubRows, items, getRowId, isSubRowExpandable, expandedIdsSet]);
const handleExpandedSubRowIdChange = useCallback(
(id: string | number) => {
@@ -106,6 +125,8 @@ function useTableItems<T>(args: UseTableItemsArgs<T>): useTableItemsReturn<T> {
return {
items: visibleItems,
itemDetails,
visibleRowIds,
childRowIdsById,
onExpandedSubRowIdsChange: handleExpandedSubRowIdChange,
isSubRowExpanded: (id: string | number) => expandedIdsSet.has(id),
};
@@ -113,7 +134,9 @@ function useTableItems<T>(args: UseTableItemsArgs<T>): useTableItemsReturn<T> {
const { Provider: TableItemsProvider, useContext: useTableItemsContext } =
/* TODO: Can we type this better? */
createStrictContext<useTableItemsReturn<any>>({
createStrictContext<
Omit<useTableItemsReturn<any>, "visibleRowIds" | "childRowIdsById">
>({
name: "TableItemsContext",
errorMessage:
"useTableItemsContext must be used within a TableItemsProvider",
@@ -10,8 +10,10 @@ import type {
} from "../helpers/selection/selection.types";
type UseTableSelectionArgs = SelectionProps & {
/* This is needed for multiple selection to know which keys to select when "select all" is used */
allRowKeys: (string | number)[];
/* Visible rows manage the header checkbox state and render selection cells. */
visibleRowIds: (string | number)[];
/* Direct child ids let selection walk nested rows lazily. */
childRowIdsById?: Map<string | number, (string | number)[]>;
};
type UseTableSelectionReturn = {
@@ -25,7 +27,8 @@ function useTableSelection({
selectedKeys: selectedKeysProp,
onSelectionChange,
disabledSelectionKeys = [],
allRowKeys,
visibleRowIds = [],
childRowIdsById,
}: UseTableSelectionArgs): UseTableSelectionReturn {
const radioGroupName = useId();
@@ -72,7 +75,7 @@ function useTableSelection({
name: radioGroupName,
}),
},
renderSelection: allRowKeys.length !== 0,
renderSelection: visibleRowIds.length !== 0,
};
}
@@ -85,10 +88,11 @@ function useTableSelection({
selectedKeys,
setSelectedKeys,
disabledKeysSet,
allRowKeys,
visibleRowIds,
childRowIdsById,
}),
},
renderSelection: allRowKeys.length !== 0,
renderSelection: visibleRowIds.length !== 0,
};
}
@@ -20,6 +20,20 @@ const data: TestRow[] = [
},
];
const deepNestedData: TestRow[] = [
{
id: "root",
name: "Root",
subRows: [
{
id: "child",
name: "Child",
subRows: [{ id: "grandchild", name: "Grandchild" }],
},
],
},
];
const fallbackIdData: TestRow[] = [
{
id: "unused-root-1",
@@ -94,6 +108,118 @@ describe("DataTableAuto", () => {
expect(rowCheckboxes.every((checkbox) => checkbox.checked)).toBe(true);
});
test("parent row selection follows visible nested rows", () => {
render(
<DataTableAuto
columnDefinitions={columns}
data={data}
getRowId={(row) => row.id}
getSubRows={(row) => row.subRows ?? []}
selectionMode="multiple"
/>,
);
fireEvent.click(screen.getByRole("button", { name: "Vis under-rader" }));
const getCheckboxes = () =>
screen.getAllByRole("checkbox") as HTMLInputElement[];
fireEvent.click(getCheckboxes()[2]);
expect(getCheckboxes()[1].checked).toBe(false);
expect(getCheckboxes()[1].indeterminate).toBe(true);
fireEvent.click(getCheckboxes()[1]);
expect(getCheckboxes()[1].checked).toBe(true);
expect(getCheckboxes()[2].checked).toBe(true);
fireEvent.click(getCheckboxes()[1]);
expect(getCheckboxes()[1].checked).toBe(false);
expect(getCheckboxes()[2].checked).toBe(false);
});
test("collapsed parent selection includes hidden descendants and can clear them again", () => {
render(
<DataTableAuto
columnDefinitions={columns}
data={deepNestedData}
getRowId={(row) => row.id}
getSubRows={(row) => row.subRows ?? []}
selectionMode="multiple"
/>,
);
const getCheckboxes = () =>
screen.getAllByRole("checkbox") as HTMLInputElement[];
fireEvent.click(getCheckboxes()[1]);
fireEvent.click(screen.getByRole("button", { name: "Vis under-rader" }));
expect(getCheckboxes()[1].checked).toBe(true);
expect(getCheckboxes()[2].checked).toBe(true);
fireEvent.click(screen.getByRole("button", { name: "Vis under-rader" }));
expect(getCheckboxes()[3].checked).toBe(true);
fireEvent.click(
screen.getAllByRole("button", { name: "Skjul under-rader" })[0],
);
fireEvent.click(getCheckboxes()[1]);
fireEvent.click(screen.getByRole("button", { name: "Vis under-rader" }));
expect(getCheckboxes()[1].checked).toBe(false);
expect(getCheckboxes()[2].checked).toBe(false);
expect(getCheckboxes()[3].checked).toBe(false);
});
test("select-all includes hidden descendants for collapsed parents and clears them again", () => {
render(
<DataTableAuto
columnDefinitions={columns}
data={deepNestedData}
getRowId={(row) => row.id}
getSubRows={(row) => row.subRows ?? []}
selectionMode="multiple"
/>,
);
const getCheckboxes = () =>
screen.getAllByRole("checkbox") as HTMLInputElement[];
fireEvent.click(
screen.getByRole("checkbox", { name: "Velg alle synlige rader" }),
);
fireEvent.click(screen.getByRole("button", { name: "Vis under-rader" }));
expect(getCheckboxes()[1].checked).toBe(true);
expect(getCheckboxes()[2].checked).toBe(true);
fireEvent.click(screen.getByRole("button", { name: "Vis under-rader" }));
expect(getCheckboxes()[3].checked).toBe(true);
fireEvent.click(
screen.getByRole("checkbox", { name: "Fjern alle synlige valgte rader" }),
);
expect(getCheckboxes()[0].checked).toBe(false);
fireEvent.click(
screen.getAllByRole("button", { name: "Skjul under-rader" })[0],
);
fireEvent.click(screen.getByRole("button", { name: "Vis under-rader" }));
expect(getCheckboxes()[1].checked).toBe(false);
expect(getCheckboxes()[2].checked).toBe(false);
expect(getCheckboxes()[3].checked).toBe(false);
});
test("does not render expansion controls in the manual table variant", () => {
render(
<DataTable>
@@ -1,5 +1,5 @@
/** biome-ignore-all lint/correctness/useHookAtTopLevel: False positive because of the way forwardRef() is added */
import React, { forwardRef, useMemo } from "react";
import React, { forwardRef } from "react";
import { ChevronDownIcon, ChevronRightIcon } from "@navikt/aksel-icons";
import { Button } from "../../../button";
import { Skeleton } from "../../../skeleton";
@@ -279,23 +279,14 @@ function DataTableAutoInner<T>(
onExpandedSubRowIdsChange,
});
const allRowKeys = useMemo(() => {
const rowKeys: (string | number)[] = [];
for (const details of tableItems.itemDetails.values()) {
rowKeys.push(details.id);
}
return rowKeys;
}, [tableItems.itemDetails]);
const tableSelectionState = useTableSelection({
selectionMode: selectionModeProp,
selectedKeys,
defaultSelectedKeys,
onSelectionChange,
disabledSelectionKeys,
allRowKeys,
visibleRowIds: tableItems.visibleRowIds,
childRowIdsById: tableItems.childRowIdsById,
});
const { columns, stickySelection } = useColumnOptions<T>(columnDefinitions, {