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>;