diff --git a/packages/components/src/components/Table/Table.test.tsx b/packages/components/src/components/Table/Table.test.tsx
index 52a204fc..e67fd09b 100644
--- a/packages/components/src/components/Table/Table.test.tsx
+++ b/packages/components/src/components/Table/Table.test.tsx
@@ -1,4 +1,4 @@
-import { createRef } from 'react';
+import { createRef, useMemo, useState } from 'react';
import { render, screen } from '@testing-library/react';
import { userEvent } from '@testing-library/user-event';
@@ -91,6 +91,252 @@ describe('Table', () => {
expect(firstElement).toHaveStyle({ padding: '20px' });
});
+ it('should invalidate dynamic rows when Table.Body props change', () => {
+ const rows = [{ id: 1, name: 'home' }];
+ const renderRow = vi.fn();
+
+ const ConditionalTable = ({
+ isHighlighted,
+ }: {
+ isHighlighted: boolean;
+ }) => (
+
+
+ Name
+
+
+ {(item) => {
+ renderRow();
+
+ return (
+
+ {item.name}
+
+ );
+ }}
+
+
+ );
+
+ const { rerender } = render();
+
+ expect(screen.getByTestId('row')).not.toHaveClass('highlighted');
+ expect(renderRow).toHaveBeenCalledTimes(1);
+
+ rerender();
+
+ expect(renderRow).toHaveBeenCalledTimes(2);
+
+ rerender();
+
+ expect(screen.getByTestId('row')).toHaveClass('highlighted');
+ expect(renderRow).toHaveBeenCalledTimes(3);
+
+ rerender();
+
+ expect(screen.getByTestId('row')).not.toHaveClass('highlighted');
+ expect(renderRow).toHaveBeenCalledTimes(4);
+ });
+
+ it('should invalidate dynamic columns when Table.Header props change', () => {
+ const columns = [{ key: 'name', name: 'Name' }];
+ const rows = [{ id: 1, name: 'home' }];
+
+ const ConditionalTable = ({
+ isHighlighted,
+ }: {
+ isHighlighted: boolean;
+ }) => (
+
+
+ {(column) => (
+
+ {column.name}
+
+ )}
+
+
+ {(item) => (
+
+ {item.name}
+
+ )}
+
+
+ );
+
+ const { rerender } = render();
+ const column = screen.getByRole('columnheader');
+
+ expect(column).not.toHaveClass('highlighted');
+
+ rerender();
+
+ expect(column).toHaveClass('highlighted');
+
+ rerender();
+
+ expect(column).not.toHaveClass('highlighted');
+ });
+
+ it('should preserve selection when rebuilding the collection', async () => {
+ const rows = [{ id: 1, name: 'home' }];
+
+ const SelectableTable = ({ isHighlighted }: { isHighlighted: boolean }) => (
+
+
+ Name
+
+
+ {(item) => (
+
+ {item.name}
+
+ )}
+
+
+ );
+
+ const { rerender } = render();
+ const row = screen.getByTestId('selectable-row');
+
+ await userEvent.click(row);
+
+ expect(row).toHaveAttribute('data-selected', 'true');
+
+ rerender();
+
+ expect(screen.getByTestId('selectable-row')).toHaveAttribute(
+ 'data-selected',
+ 'true'
+ );
+
+ expect(screen.getByTestId('selectable-row')).toHaveClass('highlighted');
+ });
+
+ it('should preserve sorting when rebuilding the collection', async () => {
+ type Row = { id: number; name: string };
+
+ const rows: Row[] = [
+ { id: 1, name: 'Beta' },
+ { id: 2, name: 'Alpha' },
+ ];
+
+ const SortableTable = ({ isHighlighted }: { isHighlighted: boolean }) => {
+ const [sortDescriptor, setSortDescriptor] =
+ useState['sortDescriptor']>();
+
+ const sortedRows = useMemo(() => {
+ if (!sortDescriptor) return rows;
+
+ const direction = sortDescriptor.direction === 'ascending' ? 1 : -1;
+
+ return [...rows].sort(
+ (a, b) => a.name.localeCompare(b.name) * direction
+ );
+ }, [sortDescriptor]);
+
+ return (
+ setSortDescriptor(descriptor)}
+ >
+
+
+ Name
+
+
+
+ {(item) => (
+
+ {item.name}
+
+ )}
+
+
+ );
+ };
+
+ const getRowNames = () =>
+ screen
+ .getAllByRole('row')
+ .slice(1)
+ .map((row) => row.textContent);
+
+ const { rerender } = render();
+
+ await userEvent.click(screen.getByRole('columnheader', { name: 'Name' }));
+
+ expect(getRowNames()).toEqual(['Alpha', 'Beta']);
+
+ rerender();
+
+ expect(getRowNames()).toEqual(['Alpha', 'Beta']);
+ expect(screen.getAllByRole('row')[1]).toHaveClass('highlighted');
+
+ await userEvent.click(screen.getByRole('columnheader', { name: 'Name' }));
+
+ expect(getRowNames()).toEqual(['Beta', 'Alpha']);
+ });
+
+ it('should preserve resized column width when rebuilding the collection', async () => {
+ const columns = [{ key: 'name', name: 'Name' }];
+ const rows = [{ id: 1, name: 'Alpha' }];
+
+ const ResizableTable = ({ isHighlighted }: { isHighlighted: boolean }) => (
+
+
+ {(column) => (
+
+ {column.name}
+
+ )}
+
+
+ {(item) => (
+
+ {item.name}
+
+ )}
+
+
+ );
+
+ const { rerender } = render();
+ const initialWidth = screen.getByRole('columnheader').style.inlineSize;
+ const resizer = screen.getByRole('slider');
+
+ resizer.focus();
+ await userEvent.keyboard('{Enter}{ArrowRight}{Enter}');
+
+ const resizedWidth = screen.getByRole('columnheader').style.inlineSize;
+
+ expect(resizedWidth).not.toBe(initialWidth);
+
+ rerender();
+
+ expect(screen.getByRole('columnheader').style.inlineSize).toBe(
+ resizedWidth
+ );
+
+ expect(screen.getByRole('columnheader')).toHaveClass('highlighted');
+ });
+
it('should accept a ref', () => {
const ref = createRef();
diff --git a/packages/components/src/components/Table/Table.tsx b/packages/components/src/components/Table/Table.tsx
index dcd6d4de..3889b274 100644
--- a/packages/components/src/components/Table/Table.tsx
+++ b/packages/components/src/components/Table/Table.tsx
@@ -1,7 +1,7 @@
'use client';
import type { ComponentRef, Ref } from 'react';
-import { forwardRef, useCallback } from 'react';
+import { forwardRef, useCallback, useMemo } from 'react';
import { once } from '@koobiq/logger';
import { clsx, useDOMRef, mergeProps } from '@koobiq/react-core';
@@ -9,6 +9,8 @@ import type { Node } from '@koobiq/react-core';
import {
useTable,
useTableState,
+ TableCollection,
+ LegacyCollectionBuilder,
useTableColumnResizeState,
} from '@koobiq/react-primitives';
import type {
@@ -45,6 +47,40 @@ type ResizableTableProps = TableProps & {
tableRef?: Ref;
};
+function useLegacyTableCollection(
+ props: TableProps,
+ showSelectionCheckboxes: boolean
+) {
+ const { children, selectionMode = 'none' } = props;
+
+ const context = useMemo(
+ () => ({
+ showSelectionCheckboxes,
+ showDragButtons: false,
+ selectionMode,
+ columns: [] as Node[],
+ }),
+ [children, selectionMode, showSelectionCheckboxes]
+ );
+
+ const builder = useMemo(() => new LegacyCollectionBuilder(), [context]);
+
+ return useMemo(() => {
+ // The legacy builder's public types only describe generic Item/Section
+ // collections, while Table uses compatible Row/Cell collection elements.
+ const nodes = builder.build(
+ { children } as Parameters[0],
+ context
+ );
+
+ return new TableCollection(
+ nodes as ConstructorParameters>[0],
+ null,
+ context
+ );
+ }, [builder, children, context]);
+}
+
function TableBase(props: TableBaseProps) {
const {
divider = 'none',
@@ -175,10 +211,15 @@ function TableRender(
) {
const { selectionMode, selectionBehavior, isResizable } = props;
+ const showSelectionCheckboxes =
+ selectionMode === 'multiple' && selectionBehavior !== 'replace';
+
+ const collection = useLegacyTableCollection(props, showSelectionCheckboxes);
+
const state = useTableState({
...props,
- showSelectionCheckboxes:
- selectionMode === 'multiple' && selectionBehavior !== 'replace',
+ collection,
+ showSelectionCheckboxes,
});
return isResizable ? (
diff --git a/packages/primitives/src/index.ts b/packages/primitives/src/index.ts
index 387d6cef..d9e51333 100644
--- a/packages/primitives/src/index.ts
+++ b/packages/primitives/src/index.ts
@@ -6,7 +6,10 @@ export {
type AriaToggleButtonGroupItemProps,
} from '@react-aria/button';
-export { Item } from '@react-stately/collections';
+export {
+ Item,
+ CollectionBuilder as LegacyCollectionBuilder,
+} from '@react-stately/collections';
export * from '@react-stately/data';
diff --git a/tools/public_api_guard/react-primitives.api.md b/tools/public_api_guard/react-primitives.api.md
index 7e7cbc19..cb65ac70 100644
--- a/tools/public_api_guard/react-primitives.api.md
+++ b/tools/public_api_guard/react-primitives.api.md
@@ -92,6 +92,7 @@ import type { Key as Key_2 } from '@koobiq/react-core';
import { KeyboardEventHandler } from 'react';
import { LabelableProps } from '@koobiq/react-core';
import { LabelHTMLAttributes } from 'react';
+import { CollectionBuilder as LegacyCollectionBuilder } from '@react-stately/collections';
import type { ListProps } from '@react-stately/list';
import type { ListState } from '@react-stately/list';
import type { MenuTriggerState } from '@react-stately/menu';
@@ -412,6 +413,8 @@ export const LabelContext: Context>;
// @public (undocumented)
export type LabelProps = ComponentPropsWithRef>;
+export { LegacyCollectionBuilder }
+
// @public
export const Link: PolyForwardComponent<"a", LinkBaseProps, ElementType>;