From 6bc8c9bcb984bfcfd32b879976a8a9f6f9d6360d Mon Sep 17 00:00:00 2001 From: Zhafran Date: Wed, 17 Jun 2026 16:39:11 +0700 Subject: [PATCH 1/4] fix: AppTable remounting children on table state updates (#6323) App wrappers (AppTable, AppCell, AppHeader, AppFooter) were memoized on the unstable `table` reference, causing them to be recreated every render. React then remounted their entire subtrees on every state change, destroying controlled input focus. Keep wrappers stable (created once) and read the current table from a ref updated each render. Fixes children remounting while preserving latest table state access. --- packages/react-table/src/createTableHook.tsx | 42 +++++++++++++------- 1 file changed, 28 insertions(+), 14 deletions(-) diff --git a/packages/react-table/src/createTableHook.tsx b/packages/react-table/src/createTableHook.tsx index 7f976a2959..5bd8a55be1 100644 --- a/packages/react-table/src/createTableHook.tsx +++ b/packages/react-table/src/createTableHook.tsx @@ -1,6 +1,6 @@ 'use client' /* eslint-disable @eslint-react/no-context-provider */ -import React, { createContext, useContext, useMemo } from 'react' +import React, { createContext, useContext, useMemo, useRef } from 'react' import { createColumnHelper as coreCreateColumnHelper } from '@tanstack/table-core' import { useTable } from './useTable' import { FlexRender } from './FlexRender' @@ -823,6 +823,16 @@ export function createTableHook< selector, ) + // `useTable` returns a fresh `table` reference on every render (required for + // the React Compiler). The App wrapper components below must NOT depend on + // that reference, or they would be recreated each render and React would + // remount their entire subtree on every state update (e.g. a controlled + // input in a toolbar would lose focus on each keystroke). Instead we keep + // the components stable (created once) and read the current table from a + // ref that we refresh on every render. + const tableRef = useRef(table) + tableRef.current = table + // AppTable - Root wrapper that provides table context with optional Subscribe const AppTable = useMemo(() => { function AppTableImpl(props: AppTablePropsWithoutSelector): ReactNode @@ -835,15 +845,16 @@ export function createTableHook< | AppTablePropsWithSelector, ): ReactNode { const { children, selector: appTableSelector } = props + const currentTable = tableRef.current return ( - + {appTableSelector ? ( - + {(state: TAppTableSelected) => (children as (state: TAppTableSelected) => ReactNode)(state) } - + ) : ( children )} @@ -851,7 +862,7 @@ export function createTableHook< ) } return AppTableImpl as AppTableComponent - }, [table]) + }, []) // AppCell - Wraps cell with context, pre-bound cellComponents, and optional Subscribe const AppCell = useMemo(() => { @@ -895,6 +906,7 @@ export function createTableHook< >, ): ReactNode { const { cell, children, selector: appCellSelector } = props as any + const currentTable = tableRef.current const extendedCell = Object.assign(cell, { FlexRender: CellFlexRender, ...cellComponents, @@ -903,7 +915,7 @@ export function createTableHook< return ( {appCellSelector ? ( - + {(state: TAppCellSelected) => ( children as ( @@ -913,7 +925,7 @@ export function createTableHook< ) => ReactNode )(extendedCell, state) } - + ) : ( ( children as ( @@ -926,7 +938,7 @@ export function createTableHook< ) } return AppCellImpl as AppCellComponent - }, [table]) + }, []) // AppHeader - Wraps header with context, pre-bound headerComponents, and optional Subscribe const AppHeader = useMemo(() => { @@ -970,6 +982,7 @@ export function createTableHook< >, ): ReactNode { const { header, children, selector: appHeaderSelector } = props as any + const currentTable = tableRef.current const extendedHeader = Object.assign(header, { FlexRender: HeaderFlexRender, ...headerComponents, @@ -978,7 +991,7 @@ export function createTableHook< return ( {appHeaderSelector ? ( - + {(state: TAppHeaderSelected) => ( children as ( @@ -988,7 +1001,7 @@ export function createTableHook< ) => ReactNode )(extendedHeader, state) } - + ) : ( ( children as ( @@ -1005,7 +1018,7 @@ export function createTableHook< TData, THeaderComponents > - }, [table]) + }, []) // AppFooter - Same as AppHeader (footers use Header type) const AppFooter = useMemo(() => { @@ -1049,6 +1062,7 @@ export function createTableHook< >, ): ReactNode { const { header, children, selector: appFooterSelector } = props as any + const currentTable = tableRef.current const extendedHeader = Object.assign(header, { FlexRender: FooterFlexRender, ...headerComponents, @@ -1057,7 +1071,7 @@ export function createTableHook< return ( {appFooterSelector ? ( - + {(state: TAppFooterSelected) => ( children as ( @@ -1067,7 +1081,7 @@ export function createTableHook< ) => ReactNode )(extendedHeader, state) } - + ) : ( ( children as ( @@ -1084,7 +1098,7 @@ export function createTableHook< TData, THeaderComponents > - }, [table]) + }, []) // Combine everything into the extended table API const extendedTable = useMemo(() => { From 214695c4179f41b67c399e6aa112b01d39cf7818 Mon Sep 17 00:00:00 2001 From: Kevin Van Cott Date: Wed, 17 Jun 2026 07:40:53 -0500 Subject: [PATCH 2/4] also apply fix to preact --- packages/preact-table/src/createTableHook.tsx | 42 ++++++++++++------- 1 file changed, 28 insertions(+), 14 deletions(-) diff --git a/packages/preact-table/src/createTableHook.tsx b/packages/preact-table/src/createTableHook.tsx index ee8fadbd0c..9b3e57d057 100644 --- a/packages/preact-table/src/createTableHook.tsx +++ b/packages/preact-table/src/createTableHook.tsx @@ -1,6 +1,6 @@ 'use client' import { createContext } from 'preact' -import { useContext, useMemo } from 'preact/hooks' +import { useContext, useMemo, useRef } from 'preact/hooks' import { createColumnHelper as coreCreateColumnHelper } from '@tanstack/table-core' import { useTable } from './useTable' import { FlexRender } from './FlexRender' @@ -819,6 +819,16 @@ export function createTableHook< selector, ) + // `useTable` returns a fresh `table` reference on every render (required for + // the React Compiler). The App wrapper components below must NOT depend on + // that reference, or they would be recreated each render and Preact would + // remount their entire subtree on every state update (e.g. a controlled + // input in a toolbar would lose focus on each keystroke). Instead we keep + // the components stable (created once) and read the current table from a + // ref that we refresh on every render. + const tableRef = useRef(table) + tableRef.current = table + // AppTable - Root wrapper that provides table context with optional Subscribe const AppTable = useMemo(() => { function AppTableImpl( @@ -833,17 +843,18 @@ export function createTableHook< | AppTablePropsWithSelector, ): ComponentChildren { const { children, selector: appTableSelector } = props as any + const currentTable = tableRef.current return ( - + {appTableSelector ? ( - + {(state: TAppTableSelected) => (children as (state: TAppTableSelected) => ComponentChildren)( state, ) } - + ) : ( children )} @@ -851,7 +862,7 @@ export function createTableHook< ) } return AppTableImpl as AppTableComponent - }, [table]) + }, []) // AppCell - Wraps cell with context, pre-bound cellComponents, and optional Subscribe const AppCell = useMemo(() => { @@ -895,6 +906,7 @@ export function createTableHook< >, ): ComponentChildren { const { cell, children, selector: appCellSelector } = props as any + const currentTable = tableRef.current const extendedCell = Object.assign(cell, { FlexRender: CellFlexRender, ...cellComponents, @@ -903,7 +915,7 @@ export function createTableHook< return ( {appCellSelector ? ( - + {(state: TAppCellSelected) => ( children as ( @@ -915,7 +927,7 @@ export function createTableHook< ) => ComponentChildren )(extendedCell, state) } - + ) : ( ( children as ( @@ -928,7 +940,7 @@ export function createTableHook< ) } return AppCellImpl as AppCellComponent - }, [table]) + }, []) // AppHeader - Wraps header with context, pre-bound headerComponents, and optional Subscribe const AppHeader = useMemo(() => { @@ -972,6 +984,7 @@ export function createTableHook< >, ): ComponentChildren { const { header, children, selector: appHeaderSelector } = props as any + const currentTable = tableRef.current const extendedHeader = Object.assign(header, { FlexRender: HeaderFlexRender, ...headerComponents, @@ -980,7 +993,7 @@ export function createTableHook< return ( {appHeaderSelector ? ( - + {(state: TAppHeaderSelected) => ( children as ( @@ -990,7 +1003,7 @@ export function createTableHook< ) => ComponentChildren )(extendedHeader, state) } - + ) : ( ( children as ( @@ -1007,7 +1020,7 @@ export function createTableHook< TData, THeaderComponents > - }, [table]) + }, []) // AppFooter - Same as AppHeader (footers use Header type) const AppFooter = useMemo(() => { @@ -1051,6 +1064,7 @@ export function createTableHook< >, ): ComponentChildren { const { header, children, selector: appFooterSelector } = props as any + const currentTable = tableRef.current const extendedHeader = Object.assign(header, { FlexRender: FooterFlexRender, ...headerComponents, @@ -1059,7 +1073,7 @@ export function createTableHook< return ( {appFooterSelector ? ( - + {(state: TAppFooterSelected) => ( children as ( @@ -1069,7 +1083,7 @@ export function createTableHook< ) => ComponentChildren )(extendedHeader, state) } - + ) : ( ( children as ( @@ -1086,7 +1100,7 @@ export function createTableHook< TData, THeaderComponents > - }, [table]) + }, []) // Combine everything into the extended table API const extendedTable = useMemo(() => { From 9b6daf1e6127ae79c60d1103ffc543ba6ab8dc9f Mon Sep 17 00:00:00 2001 From: Kevin Van Cott Date: Wed, 17 Jun 2026 08:48:20 -0500 Subject: [PATCH 3/4] modify composable table examples to make this easier to test --- .../src/components/cell-components.tsx | 31 +- .../src/components/indeterminate-checkbox.tsx | 39 ++ .../composable-tables/src/hooks/table.ts | 4 + .../preact/composable-tables/src/index.css | 15 + .../preact/composable-tables/src/main.tsx | 456 ++++++++++-------- .../src/components/cell-components.tsx | 31 +- .../src/components/indeterminate-checkbox.tsx | 22 + .../composable-tables/src/hooks/table.ts | 4 + .../react/composable-tables/src/index.css | 15 + examples/react/composable-tables/src/main.tsx | 453 +++++++++-------- 10 files changed, 660 insertions(+), 410 deletions(-) create mode 100644 examples/preact/composable-tables/src/components/indeterminate-checkbox.tsx create mode 100644 examples/react/composable-tables/src/components/indeterminate-checkbox.tsx diff --git a/examples/preact/composable-tables/src/components/cell-components.tsx b/examples/preact/composable-tables/src/components/cell-components.tsx index 29c0c22ee9..33a3fe52b4 100644 --- a/examples/preact/composable-tables/src/components/cell-components.tsx +++ b/examples/preact/composable-tables/src/components/cell-components.tsx @@ -4,7 +4,36 @@ * These components can be used via the pre-bound cellComponents * in AppCell children, e.g., */ -import { useCellContext } from '../hooks/table' +import { Subscribe } from '@tanstack/preact-table' +import { useCellContext, useTableContext } from '../hooks/table' +import { IndeterminateCheckbox } from './indeterminate-checkbox' + +/** + * Row-selection checkbox cell - toggles selection for the current row. + * + * The `Subscribe` boundary keeps the checkbox in sync with the row-selection + * state. It reads `row.getIsSelected()` (a table API call, not a reactive prop + * or hook), so subscribing to the selection state ensures it re-renders when + * selection changes without depending on a parent re-render. + */ +export function SelectCell() { + const cell = useCellContext() + const table = useTableContext() + const row = cell.row + + return ( + + {() => ( + + )} + + ) +} /** * Generic text cell renderer diff --git a/examples/preact/composable-tables/src/components/indeterminate-checkbox.tsx b/examples/preact/composable-tables/src/components/indeterminate-checkbox.tsx new file mode 100644 index 0000000000..5f0fa43f57 --- /dev/null +++ b/examples/preact/composable-tables/src/components/indeterminate-checkbox.tsx @@ -0,0 +1,39 @@ +/** + * Indeterminate checkbox used by the row-selection column + * (both the select-all header and the per-row select cell). + */ +import { useEffect, useRef } from 'preact/hooks' + +export function IndeterminateCheckbox({ + indeterminate, + className = '', + checked, + onChange, + disabled, + ...rest +}: { + indeterminate?: boolean + checked?: boolean + disabled?: boolean + onChange?: (event: Event) => void +} & Record) { + const ref = useRef(null!) + + useEffect(() => { + if (typeof indeterminate === 'boolean') { + ref.current.indeterminate = !checked && indeterminate + } + }, [ref, indeterminate, checked]) + + return ( + + ) +} diff --git a/examples/preact/composable-tables/src/hooks/table.ts b/examples/preact/composable-tables/src/hooks/table.ts index 49cc16891d..7d5a6a906f 100644 --- a/examples/preact/composable-tables/src/hooks/table.ts +++ b/examples/preact/composable-tables/src/hooks/table.ts @@ -13,6 +13,7 @@ import { createTableHook, filterFns, rowPaginationFeature, + rowSelectionFeature, rowSortingFeature, sortFns, tableFeatures, @@ -32,6 +33,7 @@ import { PriceCell, ProgressCell, RowActionsCell, + SelectCell, StatusCell, TextCell, } from '../components/cell-components' @@ -64,6 +66,7 @@ export const { features: tableFeatures({ columnFilteringFeature, rowPaginationFeature, + rowSelectionFeature, rowSortingFeature, sortedRowModel: createSortedRowModel(), filteredRowModel: createFilteredRowModel(), @@ -84,6 +87,7 @@ export const { // Register cell-level components (accessible via cell.ComponentName in AppCell) cellComponents: { + SelectCell, TextCell, NumberCell, StatusCell, diff --git a/examples/preact/composable-tables/src/index.css b/examples/preact/composable-tables/src/index.css index 4d8fef0142..521b9c190c 100644 --- a/examples/preact/composable-tables/src/index.css +++ b/examples/preact/composable-tables/src/index.css @@ -43,6 +43,21 @@ tfoot th { border-spacing: 0; } +/* Fixed-height, scrollable region around the table. With a page size larger + than ~12 rows the body overflows and scrolls; this is what makes any + remounting of the App wrappers visible (the scroll position would reset). */ +.table-scroll { + max-height: 400px; + overflow: auto; + border: 1px solid lightgray; +} + +.table-scroll thead th { + position: sticky; + top: 0; + z-index: 1; +} + .pagination { display: flex; align-items: center; diff --git a/examples/preact/composable-tables/src/main.tsx b/examples/preact/composable-tables/src/main.tsx index 5792f91e3c..77f28d33ae 100644 --- a/examples/preact/composable-tables/src/main.tsx +++ b/examples/preact/composable-tables/src/main.tsx @@ -5,7 +5,9 @@ import { tableDevtoolsPlugin, useTanStackTableDevtools, } from '@tanstack/preact-table-devtools' +import { Subscribe } from '@tanstack/preact-table' import { createAppColumnHelper, useAppTable } from './hooks/table' +import { IndeterminateCheckbox } from './components/indeterminate-checkbox' import { makeData, makeProductData } from './makeData' import type { Person, Product } from './makeData' import './index.css' @@ -34,6 +36,23 @@ function UsersTable() { () => // NOTE: You must use `createAppColumnHelper` instead of `createColumnHelper` when using pre-bound components like personColumnHelper.columns([ + personColumnHelper.display({ + id: 'select', + header: ({ table }) => ( + // Subscribe keeps the select-all checkbox in sync with selection state (see SelectCell) + + {() => ( + + )} + + ), + // Cell uses the pre-bound SelectCell component via AppCell + cell: ({ cell }) => , + }), personColumnHelper.accessor('firstName', { header: 'First Name', footer: (props) => props.column.id, @@ -80,6 +99,7 @@ function UsersTable() { columns, data, debugTable: true, + enableRowSelection: true, // more table options }, (state) => state, // default selector @@ -106,112 +126,118 @@ function UsersTable() { onStressTest={stressTest} /> - {/* Table element */} - - - {table.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers.map((h) => ( - - {(header) => ( - - )} - - ))} - - ))} - - - {table.getRowModel().rows.map((row) => ( - - {row.getAllCells().map((c) => ( - - {(cell) => ( - - )} - - ))} - - ))} - - - {table.getFooterGroups().map((footerGroup) => ( - - {footerGroup.headers.map((f) => ( - - {(footer) => { - const columnId = footer.column.id - const hasFilter = columnFilters.some( - (cf) => cf.id === columnId, - ) - - return ( - + ))} + + + {table.getFooterGroups().map((footerGroup) => ( + + {footerGroup.headers.map((f) => ( + + {(footer) => { + const columnId = footer.column.id + const hasFilter = columnFilters.some( + (cf) => cf.id === columnId, + ) + + return ( + + ) + }} + + ))} + + ))} + +
- {header.isPlaceholder ? null : ( - <> - - - - {/* Show sort order number when multiple columns sorted */} - {sorting.length > 1 && - sorting.findIndex( - (s) => s.id === header.column.id, - ) > -1 && ( - - {sorting.findIndex( - (s) => s.id === header.column.id, - ) + 1} - - )} - - )} -
- {/* Cell components are pre-bound via AppCell */} - -
- {footer.isPlaceholder ? null : ( + {/* Scroll container with a fixed max-height so larger page sizes + scroll. If the App wrappers remount on state updates, the scroll + position jumps back to the top on every keystroke/selection. */} +
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((h) => ( + + {(header) => ( + + )} + + ))} + + ))} + + + {table.getRowModel().rows.map((row) => ( + + {row.getAllCells().map((c) => ( + + {(cell) => ( + - ) - }} - - ))} - - ))} - -
+ {header.isPlaceholder ? null : ( <> - {/* Use FooterSum for numeric columns, FooterColumnId for others */} - {columnId === 'age' || - columnId === 'visits' || - columnId === 'progress' ? ( - <> - - {hasFilter && ( - - {' '} - (filtered) - - )} - - ) : columnId === 'actions' ? null : ( - <> - - {hasFilter && ( - - {' '} - ✓ - - )} - - )} + + + + {/* Show sort order number when multiple columns sorted */} + {sorting.length > 1 && + sorting.findIndex( + (s) => s.id === header.column.id, + ) > -1 && ( + + {sorting.findIndex( + (s) => s.id === header.column.id, + ) + 1} + + )} )} +
+ {/* Cell components are pre-bound via AppCell */} +
+ )} + + ))} +
+ {footer.isPlaceholder ? null : ( + <> + {/* Use FooterSum for numeric columns, FooterColumnId for others */} + {columnId === 'age' || + columnId === 'visits' || + columnId === 'progress' ? ( + <> + + {hasFilter && ( + + {' '} + (filtered) + + )} + + ) : columnId === 'actions' ? null : ( + <> + + {hasFilter && ( + + {' '} + ✓ + + )} + + )} + + )} +
+ {/* Pagination using pre-bound component */} @@ -242,6 +268,23 @@ function ProductsTable() { const columns = useMemo( () => productColumnHelper.columns([ + productColumnHelper.display({ + id: 'select', + header: ({ table }) => ( + // Subscribe keeps the select-all checkbox in sync with selection state (see SelectCell) + + {() => ( + + )} + + ), + // Cell uses the pre-bound SelectCell component via AppCell + cell: ({ cell }) => , + }), productColumnHelper.accessor('name', { header: 'Product Name', footer: (props) => props.column.id, @@ -279,6 +322,7 @@ function ProductsTable() { columns, data, getRowId: (row) => row.id, + enableRowSelection: true, }, (state) => state, // default selector ) @@ -302,111 +346,117 @@ function ProductsTable() { onStressTest={stressTest} /> - {/* Table element */} - - - {table.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers.map((h) => ( - - {(header) => ( - - )} - - ))} - - ))} - - - {table.getRowModel().rows.map((row) => ( - - {row.getAllCells().map((c) => ( - - {(cell) => ( - - )} - - ))} - - ))} - - - {table.getFooterGroups().map((footerGroup) => ( - - {footerGroup.headers.map((f) => ( - - {(footer) => { - const columnId = footer.column.id - const hasFilter = columnFilters.some( - (cf) => cf.id === columnId, - ) - - return ( - + ))} + + + {table.getFooterGroups().map((footerGroup) => ( + + {footerGroup.headers.map((f) => ( + + {(footer) => { + const columnId = footer.column.id + const hasFilter = columnFilters.some( + (cf) => cf.id === columnId, + ) + + return ( + + ) + }} + + ))} + + ))} + +
- {header.isPlaceholder ? null : ( - <> - - - - {sorting.length > 1 && - sorting.findIndex( - (s) => s.id === header.column.id, - ) > -1 && ( - - {sorting.findIndex( - (s) => s.id === header.column.id, - ) + 1} - - )} - - )} -
- {/* Cell components are pre-bound via AppCell */} - -
- {footer.isPlaceholder ? null : ( + {/* Scroll container with a fixed max-height so larger page sizes + scroll. If the App wrappers remount on state updates, the scroll + position jumps back to the top on every keystroke/selection. */} +
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((h) => ( + + {(header) => ( + + )} + + ))} + + ))} + + + {table.getRowModel().rows.map((row) => ( + + {row.getAllCells().map((c) => ( + + {(cell) => ( + - ) - }} - - ))} - - ))} - -
+ {header.isPlaceholder ? null : ( <> - {/* Use FooterSum for numeric columns, FooterColumnId for others */} - {columnId === 'price' || - columnId === 'stock' || - columnId === 'rating' ? ( - <> - - {hasFilter && ( - - {' '} - (filtered) - - )} - - ) : ( - <> - - {hasFilter && ( - - {' '} - ✓ - - )} - - )} + + + + {sorting.length > 1 && + sorting.findIndex( + (s) => s.id === header.column.id, + ) > -1 && ( + + {sorting.findIndex( + (s) => s.id === header.column.id, + ) + 1} + + )} )} +
+ {/* Cell components are pre-bound via AppCell */} +
+ )} + + ))} +
+ {footer.isPlaceholder ? null : ( + <> + {/* Use FooterSum for numeric columns, FooterColumnId for others */} + {columnId === 'price' || + columnId === 'stock' || + columnId === 'rating' ? ( + <> + + {hasFilter && ( + + {' '} + (filtered) + + )} + + ) : ( + <> + + {hasFilter && ( + + {' '} + ✓ + + )} + + )} + + )} +
+ {/* Pagination using the same pre-bound component */} diff --git a/examples/react/composable-tables/src/components/cell-components.tsx b/examples/react/composable-tables/src/components/cell-components.tsx index 29c0c22ee9..fb84eeb668 100644 --- a/examples/react/composable-tables/src/components/cell-components.tsx +++ b/examples/react/composable-tables/src/components/cell-components.tsx @@ -4,7 +4,36 @@ * These components can be used via the pre-bound cellComponents * in AppCell children, e.g., */ -import { useCellContext } from '../hooks/table' +import { Subscribe } from '@tanstack/react-table' +import { useCellContext, useTableContext } from '../hooks/table' +import { IndeterminateCheckbox } from './indeterminate-checkbox' + +/** + * Row-selection checkbox cell - toggles selection for the current row. + * + * The `Subscribe` boundary is required to work around React Compiler + * memoization: the checkbox reads `row.getIsSelected()` (a table API call, not + * a prop or hook the compiler can track), so without an explicit subscription + * to the row-selection state it would never re-render when selection changes. + */ +export function SelectCell() { + const cell = useCellContext() + const table = useTableContext() + const row = cell.row + + return ( + + {() => ( + + )} + + ) +} /** * Generic text cell renderer diff --git a/examples/react/composable-tables/src/components/indeterminate-checkbox.tsx b/examples/react/composable-tables/src/components/indeterminate-checkbox.tsx new file mode 100644 index 0000000000..b3cd414cc7 --- /dev/null +++ b/examples/react/composable-tables/src/components/indeterminate-checkbox.tsx @@ -0,0 +1,22 @@ +/** + * Indeterminate checkbox used by the row-selection column + * (both the select-all header and the per-row select cell). + */ +import { useEffect, useRef } from 'react' +import type { HTMLProps } from 'react' + +export function IndeterminateCheckbox({ + indeterminate, + className = '', + ...rest +}: { indeterminate?: boolean } & HTMLProps) { + const ref = useRef(null!) + + useEffect(() => { + if (typeof indeterminate === 'boolean') { + ref.current.indeterminate = !rest.checked && indeterminate + } + }, [ref, indeterminate, rest.checked]) + + return +} diff --git a/examples/react/composable-tables/src/hooks/table.ts b/examples/react/composable-tables/src/hooks/table.ts index e5c218c8f6..028ed4348a 100644 --- a/examples/react/composable-tables/src/hooks/table.ts +++ b/examples/react/composable-tables/src/hooks/table.ts @@ -13,6 +13,7 @@ import { createTableHook, filterFns, rowPaginationFeature, + rowSelectionFeature, rowSortingFeature, sortFns, tableFeatures, @@ -32,6 +33,7 @@ import { PriceCell, ProgressCell, RowActionsCell, + SelectCell, StatusCell, TextCell, } from '../components/cell-components' @@ -64,6 +66,7 @@ export const { features: tableFeatures({ columnFilteringFeature, rowPaginationFeature, + rowSelectionFeature, rowSortingFeature, sortedRowModel: createSortedRowModel(), filteredRowModel: createFilteredRowModel(), @@ -84,6 +87,7 @@ export const { // Register cell-level components (accessible via cell.ComponentName in AppCell) cellComponents: { + SelectCell, TextCell, NumberCell, StatusCell, diff --git a/examples/react/composable-tables/src/index.css b/examples/react/composable-tables/src/index.css index 4d8fef0142..521b9c190c 100644 --- a/examples/react/composable-tables/src/index.css +++ b/examples/react/composable-tables/src/index.css @@ -43,6 +43,21 @@ tfoot th { border-spacing: 0; } +/* Fixed-height, scrollable region around the table. With a page size larger + than ~12 rows the body overflows and scrolls; this is what makes any + remounting of the App wrappers visible (the scroll position would reset). */ +.table-scroll { + max-height: 400px; + overflow: auto; + border: 1px solid lightgray; +} + +.table-scroll thead th { + position: sticky; + top: 0; + z-index: 1; +} + .pagination { display: flex; align-items: center; diff --git a/examples/react/composable-tables/src/main.tsx b/examples/react/composable-tables/src/main.tsx index 3afef5e1a6..20c03c9342 100644 --- a/examples/react/composable-tables/src/main.tsx +++ b/examples/react/composable-tables/src/main.tsx @@ -6,9 +6,12 @@ import { tableDevtoolsPlugin, useTanStackTableDevtools, } from '@tanstack/react-table-devtools' +import { Subscribe } from '@tanstack/react-table' import { createAppColumnHelper, useAppTable } from './hooks/table' +import { IndeterminateCheckbox } from './components/indeterminate-checkbox' import { makeData, makeProductData } from './makeData' import type { Person, Product } from './makeData' +import './index.css' // Import cell components directly - they use useCellContext internally // Create column helpers with TFeatures already bound - only need TData! @@ -34,6 +37,23 @@ function UsersTable() { () => // NOTE: You must use `createAppColumnHelper` instead of `createColumnHelper` when using pre-bound components like personColumnHelper.columns([ + personColumnHelper.display({ + id: 'select', + header: ({ table }) => ( + // Subscribe works around React Compiler memoization (see SelectCell) + + {() => ( + + )} + + ), + // Cell uses the pre-bound SelectCell component via AppCell + cell: ({ cell }) => , + }), personColumnHelper.accessor('firstName', { header: 'First Name', footer: (props) => props.column.id, @@ -80,6 +100,7 @@ function UsersTable() { columns, data, debugTable: true, + enableRowSelection: true, // more table options }, (state) => state, // default selector @@ -105,113 +126,115 @@ function UsersTable() { onRefresh={refreshData} onStressTest={stressTest} /> - - {/* Table element */} - - - {table.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers.map((h) => ( - - {(header) => ( - - )} - - ))} - - ))} - - - {table.getRowModel().rows.map((row) => ( - - {row.getAllCells().map((c) => ( - - {(cell) => ( - - )} - - ))} - - ))} - - - {table.getFooterGroups().map((footerGroup) => ( - - {footerGroup.headers.map((f) => ( - - {(footer) => { - const columnId = footer.column.id - const hasFilter = columnFilters.some( - (cf) => cf.id === columnId, - ) - - return ( - + ))} + + + {table.getFooterGroups().map((footerGroup) => ( + + {footerGroup.headers.map((f) => ( + + {(footer) => { + const columnId = footer.column.id + const hasFilter = columnFilters.some( + (cf) => cf.id === columnId, + ) + + return ( + + ) + }} + + ))} + + ))} + +
- {header.isPlaceholder ? null : ( - <> - - - - {/* Show sort order number when multiple columns sorted */} - {sorting.length > 1 && - sorting.findIndex( - (s) => s.id === header.column.id, - ) > -1 && ( - - {sorting.findIndex( - (s) => s.id === header.column.id, - ) + 1} - - )} - - )} -
- {/* Cell components are pre-bound via AppCell */} - -
- {footer.isPlaceholder ? null : ( +
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((h) => ( + + {(header) => ( + + )} + + ))} + + ))} + + + {table.getRowModel().rows.map((row) => ( + + {row.getAllCells().map((c) => ( + + {(cell) => ( + - ) - }} - - ))} - - ))} - -
+ {header.isPlaceholder ? null : ( <> - {/* Use FooterSum for numeric columns, FooterColumnId for others */} - {columnId === 'age' || - columnId === 'visits' || - columnId === 'progress' ? ( - <> - - {hasFilter && ( - - {' '} - (filtered) - - )} - - ) : columnId === 'actions' ? null : ( - <> - - {hasFilter && ( - - {' '} - ✓ - - )} - - )} + + + + {/* Show sort order number when multiple columns sorted */} + {sorting.length > 1 && + sorting.findIndex( + (s) => s.id === header.column.id, + ) > -1 && ( + + {sorting.findIndex( + (s) => s.id === header.column.id, + ) + 1} + + )} )} +
+ {/* Cell components are pre-bound via AppCell */} +
+ )} + + ))} +
+ {footer.isPlaceholder ? null : ( + <> + {/* Use FooterSum for numeric columns, FooterColumnId for others */} + {columnId === 'age' || + columnId === 'visits' || + columnId === 'progress' ? ( + <> + + {hasFilter && ( + + {' '} + (filtered) + + )} + + ) : columnId === 'actions' ? null : ( + <> + + {hasFilter && ( + + {' '} + ✓ + + )} + + )} + + )} +
+ {/* Pagination using pre-bound component */} @@ -242,6 +265,23 @@ function ProductsTable() { const columns = useMemo( () => productColumnHelper.columns([ + productColumnHelper.display({ + id: 'select', + header: ({ table }) => ( + // Subscribe works around React Compiler memoization (see SelectCell) + + {() => ( + + )} + + ), + // Cell uses the pre-bound SelectCell component via AppCell + cell: ({ cell }) => , + }), productColumnHelper.accessor('name', { header: 'Product Name', footer: (props) => props.column.id, @@ -279,6 +319,7 @@ function ProductsTable() { columns, data, getRowId: (row) => row.id, + enableRowSelection: true, }, (state) => state, // default selector ) @@ -301,112 +342,114 @@ function ProductsTable() { onRefresh={refreshData} onStressTest={stressTest} /> - - {/* Table element */} - - - {table.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers.map((h) => ( - - {(header) => ( - - )} - - ))} - - ))} - - - {table.getRowModel().rows.map((row) => ( - - {row.getAllCells().map((c) => ( - - {(cell) => ( - - )} - - ))} - - ))} - - - {table.getFooterGroups().map((footerGroup) => ( - - {footerGroup.headers.map((f) => ( - - {(footer) => { - const columnId = footer.column.id - const hasFilter = columnFilters.some( - (cf) => cf.id === columnId, - ) - - return ( - + ))} + + + {table.getFooterGroups().map((footerGroup) => ( + + {footerGroup.headers.map((f) => ( + + {(footer) => { + const columnId = footer.column.id + const hasFilter = columnFilters.some( + (cf) => cf.id === columnId, + ) + + return ( + + ) + }} + + ))} + + ))} + +
- {header.isPlaceholder ? null : ( - <> - - - - {sorting.length > 1 && - sorting.findIndex( - (s) => s.id === header.column.id, - ) > -1 && ( - - {sorting.findIndex( - (s) => s.id === header.column.id, - ) + 1} - - )} - - )} -
- {/* Cell components are pre-bound via AppCell */} - -
- {footer.isPlaceholder ? null : ( +
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((h) => ( + + {(header) => ( + + )} + + ))} + + ))} + + + {table.getRowModel().rows.map((row) => ( + + {row.getAllCells().map((c) => ( + + {(cell) => ( + - ) - }} - - ))} - - ))} - -
+ {header.isPlaceholder ? null : ( <> - {/* Use FooterSum for numeric columns, FooterColumnId for others */} - {columnId === 'price' || - columnId === 'stock' || - columnId === 'rating' ? ( - <> - - {hasFilter && ( - - {' '} - (filtered) - - )} - - ) : ( - <> - - {hasFilter && ( - - {' '} - ✓ - - )} - - )} + + + + {sorting.length > 1 && + sorting.findIndex( + (s) => s.id === header.column.id, + ) > -1 && ( + + {sorting.findIndex( + (s) => s.id === header.column.id, + ) + 1} + + )} )} +
+ {/* Cell components are pre-bound via AppCell */} +
+ )} + + ))} +
+ {footer.isPlaceholder ? null : ( + <> + {/* Use FooterSum for numeric columns, FooterColumnId for others */} + {columnId === 'price' || + columnId === 'stock' || + columnId === 'rating' ? ( + <> + + {hasFilter && ( + + {' '} + (filtered) + + )} + + ) : ( + <> + + {hasFilter && ( + + {' '} + ✓ + + )} + + )} + + )} +
+ {/* Pagination using the same pre-bound component */} From b02f7939407b24cec268bd78b65df651ddde8128 Mon Sep 17 00:00:00 2001 From: Kevin Van Cott Date: Wed, 17 Jun 2026 09:13:17 -0500 Subject: [PATCH 4/4] update other composable table examples --- .../src/app/components/cell-components.ts | 23 +- .../src/app/components/header-components.ts | 22 +- .../products-table/products-table.html | 110 ++-- .../products-table/products-table.ts | 7 + .../components/users-table/users-table.html | 110 ++-- .../app/components/users-table/users-table.ts | 6 + .../composable-tables/src/app/table.ts | 6 + .../angular/composable-tables/src/styles.css | 12 + .../src/components/products-table.ts | 215 ++++---- .../src/components/users-table.ts | 214 ++++---- .../lit/composable-tables/src/hooks/table.ts | 2 + examples/lit/composable-tables/src/index.css | 14 + .../src/components/cell-components.tsx | 28 +- .../preact/composable-tables/src/main.tsx | 31 +- examples/solid/composable-tables/src/App.tsx | 491 ++++++++++-------- .../src/components/cell-components.tsx | 21 + .../src/components/indeterminate-checkbox.tsx | 36 ++ .../composable-tables/src/hooks/table.ts | 4 + .../solid/composable-tables/src/index.css | 14 + .../src/components/ProductsTable.svelte | 8 + .../src/components/SelectCell.svelte | 31 ++ .../src/components/SelectHeader.svelte | 31 ++ .../src/components/UsersTable.svelte | 8 + .../composable-tables/src/hooks/table.ts | 6 + .../svelte/composable-tables/src/index.css | 12 + .../src/components/IndeterminateCheckbox.vue | 28 + .../src/components/ProductsTable.vue | 142 ++--- .../src/components/UsersTable.vue | 163 +++--- .../src/components/cell-components.ts | 22 + .../src/components/header-components.ts | 17 + .../vue/composable-tables/src/hooks/table.ts | 6 + examples/vue/composable-tables/src/index.css | 12 + 32 files changed, 1147 insertions(+), 705 deletions(-) create mode 100644 examples/solid/composable-tables/src/components/indeterminate-checkbox.tsx create mode 100644 examples/svelte/composable-tables/src/components/SelectCell.svelte create mode 100644 examples/svelte/composable-tables/src/components/SelectHeader.svelte create mode 100644 examples/vue/composable-tables/src/components/IndeterminateCheckbox.vue diff --git a/examples/angular/composable-tables/src/app/components/cell-components.ts b/examples/angular/composable-tables/src/app/components/cell-components.ts index 3cca4ec1d2..f8a4622e39 100644 --- a/examples/angular/composable-tables/src/app/components/cell-components.ts +++ b/examples/angular/composable-tables/src/app/components/cell-components.ts @@ -1,4 +1,4 @@ -import { Component, computed } from '@angular/core' +import { ChangeDetectionStrategy, Component, computed } from '@angular/core' import { injectFlexRenderContext } from '@tanstack/angular-table' import { CurrencyPipe } from '@angular/common' import { injectTableCellContext } from '../table' @@ -20,6 +20,27 @@ export class TextCell { injectFlexRenderContext>() } +@Component({ + selector: 'table-select-cell', + host: { + class: 'selection-cell', + }, + template: ` + + `, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class SelectCell { + readonly cell = injectTableCellContext() + readonly row = computed(() => this.cell().row) +} + @Component({ selector: 'span', host: { diff --git a/examples/angular/composable-tables/src/app/components/header-components.ts b/examples/angular/composable-tables/src/app/components/header-components.ts index 7afeaacb8e..65e195dd0b 100644 --- a/examples/angular/composable-tables/src/app/components/header-components.ts +++ b/examples/angular/composable-tables/src/app/components/header-components.ts @@ -9,7 +9,7 @@ // ) // } -import { Component, computed } from '@angular/core' +import { ChangeDetectionStrategy, Component, computed } from '@angular/core' import { flexRenderComponent } from '@tanstack/angular-table' import { FormsModule } from '@angular/forms' import { injectTableHeaderContext } from '../table' @@ -24,6 +24,26 @@ export function SortIndicator(): string | null { return `${sorted === 'asc' ? '🔼' : '🔽'}` } +@Component({ + selector: 'table-select-header', + host: { + class: 'selection-cell', + }, + template: ` + + `, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class SelectHeader { + readonly header = injectTableHeaderContext() + readonly table = computed(() => this.header().getContext().table) +} + export function ColumnFilter(): FlexRenderComponent | null { const header = injectTableHeaderContext() if (!header().column.getCanFilter()) return null diff --git a/examples/angular/composable-tables/src/app/components/products-table/products-table.html b/examples/angular/composable-tables/src/app/components/products-table/products-table.html index ad0643dbea..f4eb639602 100644 --- a/examples/angular/composable-tables/src/app/components/products-table/products-table.html +++ b/examples/angular/composable-tables/src/app/components/products-table/products-table.html @@ -6,65 +6,67 @@ " /> - - - @for (headerGroup of table.getHeaderGroups(); track headerGroup.id) { - - @for (_header of headerGroup.headers; track _header.id) { - @let header = table.appHeader(_header); - @if (!header.isPlaceholder) { - + @for (footerGroup of table.getFooterGroups(); track footerGroup.id) { + + @for (footer of footerGroup.headers; track footer.id) { + + } + + } + +
- - {{ header }} - +
+ + + @for (headerGroup of table.getHeaderGroups(); track headerGroup.id) { + + @for (_header of headerGroup.headers; track _header.id) { + @let header = table.appHeader(_header); + @if (!header.isPlaceholder) { + + } + } + + } + + + @for (row of table.getRowModel().rows; track row.id) { + + @for (cell of row.getAllCells(); track cell.id) { + } - } - - } - - - @for (row of table.getRowModel().rows; track row.id) { - - @for (cell of row.getAllCells(); track cell.id) { - - } - - } - + + } + - - @for (footerGroup of table.getFooterGroups(); track footerGroup.id) { - - @for (footer of footerGroup.headers; track footer.id) { - - } - - } - -
+ + {{ header }} + - -
-
+ +
+
- -
+ +
+
+
+ + {{ cell }} - +
- - {{ cell }} - -
- @if (!footer.isPlaceholder) { - - {{ footer }} - - } -
+
+ @if (!footer.isPlaceholder) { + + {{ footer }} + + } +
+ diff --git a/examples/angular/composable-tables/src/app/components/products-table/products-table.ts b/examples/angular/composable-tables/src/app/components/products-table/products-table.ts index c8bad25b6d..303580cdd0 100644 --- a/examples/angular/composable-tables/src/app/components/products-table/products-table.ts +++ b/examples/angular/composable-tables/src/app/components/products-table/products-table.ts @@ -5,6 +5,7 @@ import { TanStackTable, TanStackTableCell, TanStackTableHeader, + flexRenderComponent, } from '@tanstack/angular-table' import { injectTanStackTableDevtools } from '@tanstack/angular-table-devtools' import { makeProductData } from '../../makeData' @@ -34,6 +35,11 @@ export class ProductsTable { readonly data = signal(makeProductData(1_000)) readonly columns = productColumnHelper.columns([ + productColumnHelper.display({ + id: 'select', + header: ({ header }) => flexRenderComponent(header.SelectHeader), + cell: ({ cell }) => flexRenderComponent(cell.SelectCell), + }), productColumnHelper.accessor('name', { header: 'Product Name', footer: (props) => props.column.id, @@ -66,6 +72,7 @@ export class ProductsTable { columns: this.columns, data: this.data(), getRowId: (row) => row.id, + enableRowSelection: true, // more table options })) diff --git a/examples/angular/composable-tables/src/app/components/users-table/users-table.html b/examples/angular/composable-tables/src/app/components/users-table/users-table.html index 3744b5b0f7..f08f9499c2 100644 --- a/examples/angular/composable-tables/src/app/components/users-table/users-table.html +++ b/examples/angular/composable-tables/src/app/components/users-table/users-table.html @@ -6,65 +6,67 @@ " /> - - - @for (headerGroup of table.getHeaderGroups(); track headerGroup.id) { - - @for (_header of headerGroup.headers; track _header.id) { - @let header = table.appHeader(_header); - @if (!header.isPlaceholder) { - + @for (footerGroup of table.getFooterGroups(); track footerGroup.id) { + + @for (footer of footerGroup.headers; track footer.id) { + + } + + } + +
- - {{ header }} - +
+ + + @for (headerGroup of table.getHeaderGroups(); track headerGroup.id) { + + @for (_header of headerGroup.headers; track _header.id) { + @let header = table.appHeader(_header); + @if (!header.isPlaceholder) { + + } + } + + } + + + @for (row of table.getRowModel().rows; track row.id) { + + @for (cell of row.getAllCells(); track cell.id) { + } - } - - } - - - @for (row of table.getRowModel().rows; track row.id) { - - @for (cell of row.getAllCells(); track cell.id) { - - } - - } - + + } + - - @for (footerGroup of table.getFooterGroups(); track footerGroup.id) { - - @for (footer of footerGroup.headers; track footer.id) { - - } - - } - -
+ + {{ header }} + - -
-
+ +
+
- -
+ +
+
+
+ + {{ cell }} - +
- - {{ cell }} - -
- @if (!footer.isPlaceholder) { - - {{ footer }} - - } -
+
+ @if (!footer.isPlaceholder) { + + {{ footer }} + + } +
+ diff --git a/examples/angular/composable-tables/src/app/components/users-table/users-table.ts b/examples/angular/composable-tables/src/app/components/users-table/users-table.ts index 7441694757..ac07756c0d 100644 --- a/examples/angular/composable-tables/src/app/components/users-table/users-table.ts +++ b/examples/angular/composable-tables/src/app/components/users-table/users-table.ts @@ -35,6 +35,11 @@ export class UsersTable { readonly data = signal(makeData(1_000)) readonly columns = personColumnHelper.columns([ + personColumnHelper.display({ + id: 'select', + header: ({ header }) => flexRenderComponent(header.SelectHeader), + cell: ({ cell }) => flexRenderComponent(cell.SelectCell), + }), personColumnHelper.accessor('firstName', { header: 'First Name', footer: ({ header }) => flexRenderComponent(header.FooterColumnId), @@ -77,6 +82,7 @@ export class UsersTable { columns: this.columns, data: this.data(), debugTable: true, + enableRowSelection: true, // more table options })) diff --git a/examples/angular/composable-tables/src/app/table.ts b/examples/angular/composable-tables/src/app/table.ts index cff38db8bc..5d6a897920 100644 --- a/examples/angular/composable-tables/src/app/table.ts +++ b/examples/angular/composable-tables/src/app/table.ts @@ -13,6 +13,7 @@ import { createTableHook, filterFns, rowPaginationFeature, + rowSelectionFeature, rowSortingFeature, sortFns, tableFeatures, @@ -31,6 +32,7 @@ import { PriceCell, ProgressCell, RowActionsCell, + SelectCell, StatusCell, TextCell, } from './components/cell-components' @@ -39,6 +41,7 @@ import { ColumnFilter, FooterColumnId, FooterSum, + SelectHeader, SortIndicator, } from './components/header-components' @@ -66,6 +69,7 @@ export const { features: tableFeatures({ columnFilteringFeature, rowPaginationFeature, + rowSelectionFeature, rowSortingFeature, sortedRowModel: createSortedRowModel(), filteredRowModel: createFilteredRowModel(), @@ -86,6 +90,7 @@ export const { // Register cell-level components (accessible via cell.ComponentName in AppCell) cellComponents: { + SelectCell, TextCell, NumberCell, ProgressCell, @@ -97,6 +102,7 @@ export const { // Register header/footer-level components (accessible via header.ComponentName in AppHeader/AppFooter) headerComponents: { + SelectHeader, SortIndicator, ColumnFilter, FooterColumnId, diff --git a/examples/angular/composable-tables/src/styles.css b/examples/angular/composable-tables/src/styles.css index 4d8fef0142..b171dde3f0 100644 --- a/examples/angular/composable-tables/src/styles.css +++ b/examples/angular/composable-tables/src/styles.css @@ -35,6 +35,18 @@ tfoot th { font-weight: normal; } +.table-scroll { + max-height: 400px; + overflow: auto; + border: 1px solid lightgray; +} + +.table-scroll thead th { + position: sticky; + top: 0; + z-index: 1; +} + .table-container { padding: 16px; max-width: 1200px; diff --git a/examples/lit/composable-tables/src/components/products-table.ts b/examples/lit/composable-tables/src/components/products-table.ts index 96d75df3c4..1c40109b92 100644 --- a/examples/lit/composable-tables/src/components/products-table.ts +++ b/examples/lit/composable-tables/src/components/products-table.ts @@ -11,6 +11,26 @@ const productColumnHelper = createAppColumnHelper() // Define columns — different structure than Users table, same reusable components const columns = productColumnHelper.columns([ + productColumnHelper.display({ + id: 'select', + header: ({ table }) => html` + + `, + cell: ({ row }) => html` + + `, + }), productColumnHelper.accessor('name', { header: 'Product Name', footer: (props) => props.column.id, @@ -55,11 +75,13 @@ export class ProductsTable extends LitElement { get data() { return host.data }, + enableRowSelection: true, }, (state) => ({ pagination: state.pagination, sorting: state.sorting, columnFilters: state.columnFilters, + rowSelection: state.rowSelection, }), ) })() @@ -88,104 +110,109 @@ export class ProductsTable extends LitElement { > - - - ${table.getHeaderGroups().map( - (headerGroup) => html` - - ${headerGroup.headers.map((h) => - table.AppHeader( - h, - (header) => html` - - `, - ), - )} - - `, - )} - - - ${table.getRowModel().rows.map( - (row) => html` - - ${row - .getAllCells() - .map((c) => - table.AppCell( - c, - (cell) => html` `, - ), - )} - - `, - )} - - - ${table.getFooterGroups().map( - (footerGroup) => html` - - ${footerGroup.headers.map((f) => - table.AppFooter(f, (footer) => { - const columnId = footer.column.id - const hasFilter = columnFilters.some( - (cf) => cf.id === columnId, - ) - return html` - + `, + )} + + + ${table.getRowModel().rows.map( + (row) => html` + + ${row + .getAllCells() + .map((c) => + table.AppCell( + c, + (cell) => html` `, + ), + )} + + `, + )} + + + ${table.getFooterGroups().map( + (footerGroup) => html` + + ${footerGroup.headers.map((f) => + table.AppFooter(f, (footer) => { + const columnId = footer.column.id + const hasFilter = columnFilters.some( + (cf) => cf.id === columnId, + ) + return html` + + ` + }), + )} + + `, + )} + +
- ${header.isPlaceholder - ? nothing - : html` - ${header.FlexRender()} ${header.SortIndicator()} - ${header.ColumnFilter()} - ${sorting.length > 1 && - sorting.findIndex( - (s) => s.id === header.column.id, - ) > -1 - ? html`${sorting.findIndex( - (s) => s.id === header.column.id, - ) + 1}` - : nothing} - `} -
${cell.FlexRender()}
- ${footer.isPlaceholder - ? nothing - : columnId === 'price' || - columnId === 'stock' || - columnId === 'rating' - ? html` - ${footer.FooterSum()} - ${hasFilter - ? html` - (filtered)` - : nothing} - ` +
+ + + ${table.getHeaderGroups().map( + (headerGroup) => html` + + ${headerGroup.headers.map((h) => + table.AppHeader( + h, + (header) => html` + - `, - )} - -
+ ${header.isPlaceholder + ? nothing : html` - ${footer.FooterColumnId()} - ${hasFilter - ? html` - ✓ 1 && + sorting.findIndex( + (s) => s.id === header.column.id, + ) > -1 + ? html`${sorting.findIndex( + (s) => s.id === header.column.id, + ) + 1}` : nothing} `} - - ` - }), - )} -
+ + `, + ), + )} +
${cell.FlexRender()}
+ ${footer.isPlaceholder + ? nothing + : columnId === 'price' || + columnId === 'stock' || + columnId === 'rating' + ? html` + ${footer.FooterSum()} + ${hasFilter + ? html` + (filtered)` + : nothing} + ` + : columnId === 'select' + ? nothing + : html` + ${footer.FooterColumnId()} + ${hasFilter + ? html` + ✓` + : nothing} + `} +
+ diff --git a/examples/lit/composable-tables/src/components/users-table.ts b/examples/lit/composable-tables/src/components/users-table.ts index 737ba0f5a2..7c43bf5112 100644 --- a/examples/lit/composable-tables/src/components/users-table.ts +++ b/examples/lit/composable-tables/src/components/users-table.ts @@ -13,6 +13,26 @@ const personColumnHelper = createAppColumnHelper() // NOTE: Use createAppColumnHelper (not createColumnHelper) to get pre-bound // component types on cell/header objects (e.g., cell.TextCell, header.SortIndicator). const columns = personColumnHelper.columns([ + personColumnHelper.display({ + id: 'select', + header: ({ table }) => html` + + `, + cell: ({ row }) => html` + + `, + }), personColumnHelper.accessor('firstName', { header: 'First Name', footer: (props) => props.column.id, @@ -68,12 +88,14 @@ export class UsersTable extends LitElement { get data() { return host.data }, + enableRowSelection: true, debugTable: true, }, (state) => ({ pagination: state.pagination, sorting: state.sorting, columnFilters: state.columnFilters, + rowSelection: state.rowSelection, }), ) })() @@ -102,106 +124,110 @@ export class UsersTable extends LitElement { > - - - ${table.getHeaderGroups().map( - (headerGroup) => html` - - ${headerGroup.headers.map((h) => - table.AppHeader( - h, - (header) => html` - - `, - ), - )} - - `, - )} - - - ${table.getRowModel().rows.map( - (row) => html` - - ${row - .getAllCells() - .map((c) => - table.AppCell( - c, - (cell) => html` `, - ), - )} - - `, - )} - - - ${table.getFooterGroups().map( - (footerGroup) => html` - - ${footerGroup.headers.map((f) => - table.AppFooter(f, (footer) => { - const columnId = footer.column.id - const hasFilter = columnFilters.some( - (cf) => cf.id === columnId, - ) - return html` - + ` + }), + )} + + `, + )} + +
- ${header.isPlaceholder - ? nothing - : html` - ${header.FlexRender()} ${header.SortIndicator()} - ${header.ColumnFilter()} - ${sorting.length > 1 && - sorting.findIndex( - (s) => s.id === header.column.id, - ) > -1 - ? html`${sorting.findIndex( - (s) => s.id === header.column.id, - ) + 1}` - : nothing} - `} -
${cell.FlexRender()}
- ${footer.isPlaceholder - ? nothing - : columnId === 'age' || - columnId === 'visits' || - columnId === 'progress' - ? html` - ${footer.FooterSum()} - ${hasFilter - ? html` - (filtered) + + + ${table.getHeaderGroups().map( + (headerGroup) => html` + + ${headerGroup.headers.map((h) => + table.AppHeader( + h, + (header) => html` + + `, + ), + )} + + `, + )} + + + ${table.getRowModel().rows.map( + (row) => html` + + ${row + .getAllCells() + .map((c) => + table.AppCell( + c, + (cell) => html` `, + ), + )} + + `, + )} + + + ${table.getFooterGroups().map( + (footerGroup) => html` + + ${footerGroup.headers.map((f) => + table.AppFooter(f, (footer) => { + const columnId = footer.column.id + const hasFilter = columnFilters.some( + (cf) => cf.id === columnId, + ) + return html` + - ` - }), - )} - - `, - )} - -
+ ${header.isPlaceholder + ? nothing + : html` + ${header.FlexRender()} + ${header.SortIndicator()} + ${header.ColumnFilter()} + ${sorting.length > 1 && + sorting.findIndex( + (s) => s.id === header.column.id, + ) > -1 + ? html`${sorting.findIndex( + (s) => s.id === header.column.id, + ) + 1}` : nothing} - ` - : columnId === 'actions' - ? nothing - : html` - ${footer.FooterColumnId()} + `} +
${cell.FlexRender()}
+ ${footer.isPlaceholder + ? nothing + : columnId === 'age' || + columnId === 'visits' || + columnId === 'progress' + ? html` + ${footer.FooterSum()} ${hasFilter ? html` - ✓` : nothing} - `} -
+ ` + : columnId === 'actions' || + columnId === 'select' + ? nothing + : html` + ${footer.FooterColumnId()} + ${hasFilter + ? html` + ✓` + : nothing} + `} +
+ diff --git a/examples/lit/composable-tables/src/hooks/table.ts b/examples/lit/composable-tables/src/hooks/table.ts index 68327899b6..58c0e5f4f6 100644 --- a/examples/lit/composable-tables/src/hooks/table.ts +++ b/examples/lit/composable-tables/src/hooks/table.ts @@ -6,6 +6,7 @@ import { createTableHook, filterFns, rowPaginationFeature, + rowSelectionFeature, rowSortingFeature, sortFns, tableFeatures, @@ -33,6 +34,7 @@ import { export const features = tableFeatures({ columnFilteringFeature, rowPaginationFeature, + rowSelectionFeature, rowSortingFeature, sortedRowModel: createSortedRowModel(), filteredRowModel: createFilteredRowModel(), diff --git a/examples/lit/composable-tables/src/index.css b/examples/lit/composable-tables/src/index.css index 4d8fef0142..d03f22d5b7 100644 --- a/examples/lit/composable-tables/src/index.css +++ b/examples/lit/composable-tables/src/index.css @@ -43,6 +43,20 @@ tfoot th { border-spacing: 0; } +/* Fixed-height, scrollable region around the table. With a page size larger + than ~12 rows the body overflows and scrolls. */ +.table-scroll { + max-height: 400px; + overflow: auto; + border: 1px solid lightgray; +} + +.table-scroll thead th { + position: sticky; + top: 0; + z-index: 1; +} + .pagination { display: flex; align-items: center; diff --git a/examples/preact/composable-tables/src/components/cell-components.tsx b/examples/preact/composable-tables/src/components/cell-components.tsx index 33a3fe52b4..ebd1d03de0 100644 --- a/examples/preact/composable-tables/src/components/cell-components.tsx +++ b/examples/preact/composable-tables/src/components/cell-components.tsx @@ -4,34 +4,28 @@ * These components can be used via the pre-bound cellComponents * in AppCell children, e.g., */ -import { Subscribe } from '@tanstack/preact-table' -import { useCellContext, useTableContext } from '../hooks/table' +import { useCellContext } from '../hooks/table' import { IndeterminateCheckbox } from './indeterminate-checkbox' /** * Row-selection checkbox cell - toggles selection for the current row. * - * The `Subscribe` boundary keeps the checkbox in sync with the row-selection - * state. It reads `row.getIsSelected()` (a table API call, not a reactive prop - * or hook), so subscribing to the selection state ensures it re-renders when - * selection changes without depending on a parent re-render. + * Unlike React (which needs a `Subscribe` boundary to work around React + * Compiler memoization), Preact re-renders this cell when selection state + * changes via the parent table's subscription, so reading `row.getIsSelected()` + * directly is enough. */ export function SelectCell() { const cell = useCellContext() - const table = useTableContext() const row = cell.row return ( - - {() => ( - - )} - + ) } diff --git a/examples/preact/composable-tables/src/main.tsx b/examples/preact/composable-tables/src/main.tsx index 77f28d33ae..2ebac34597 100644 --- a/examples/preact/composable-tables/src/main.tsx +++ b/examples/preact/composable-tables/src/main.tsx @@ -5,7 +5,6 @@ import { tableDevtoolsPlugin, useTanStackTableDevtools, } from '@tanstack/preact-table-devtools' -import { Subscribe } from '@tanstack/preact-table' import { createAppColumnHelper, useAppTable } from './hooks/table' import { IndeterminateCheckbox } from './components/indeterminate-checkbox' import { makeData, makeProductData } from './makeData' @@ -39,16 +38,11 @@ function UsersTable() { personColumnHelper.display({ id: 'select', header: ({ table }) => ( - // Subscribe keeps the select-all checkbox in sync with selection state (see SelectCell) - - {() => ( - - )} - + ), // Cell uses the pre-bound SelectCell component via AppCell cell: ({ cell }) => , @@ -271,16 +265,11 @@ function ProductsTable() { productColumnHelper.display({ id: 'select', header: ({ table }) => ( - // Subscribe keeps the select-all checkbox in sync with selection state (see SelectCell) - - {() => ( - - )} - + ), // Cell uses the pre-bound SelectCell component via AppCell cell: ({ cell }) => , diff --git a/examples/solid/composable-tables/src/App.tsx b/examples/solid/composable-tables/src/App.tsx index c7c83e0d03..3a84f00366 100644 --- a/examples/solid/composable-tables/src/App.tsx +++ b/examples/solid/composable-tables/src/App.tsx @@ -1,6 +1,7 @@ import { For, createSignal } from 'solid-js' import { useTanStackTableDevtools } from '@tanstack/solid-table-devtools' import { createAppColumnHelper, createAppTable } from './hooks/table' +import { IndeterminateCheckbox } from './components/indeterminate-checkbox' import { makeData, makeProductData } from './makeData' import type { Person, Product } from './makeData' // Import cell components directly - they use useCellContext internally @@ -26,6 +27,19 @@ function UsersTable() { const columns = // NOTE: You must use `createAppColumnHelper` instead of `createColumnHelper` when using pre-bound components like personColumnHelper.columns([ + personColumnHelper.display({ + id: 'select', + header: ({ table }) => ( + // Solid tracks these calls natively, so no Subscribe is needed. + + ), + // Cell uses the pre-bound SelectCell component via AppCell + cell: ({ cell }) => , + }), personColumnHelper.accessor('firstName', { header: 'First Name', footer: (props) => props.column.id, @@ -71,6 +85,7 @@ function UsersTable() { return data() }, debugTable: true, + enableRowSelection: true, // more table options }) @@ -99,124 +114,128 @@ function UsersTable() { /> {/* Table element */} - - - - {(headerGroup) => ( - - - {(h) => ( - - {(header) => ( - - )} - - )} - - - )} - - - - - {(row) => ( - - - {(c) => ( - - {(cell) => ( - - )} - - )} - - - )} - - - - - {(footerGroup) => ( - - - {(f) => ( - - {(footer) => { - const columnId = footer.column.id - const hasFilter = () => - columnFilters().some((cf) => cf.id === columnId) - - return ( - + )} + + + + + {(footerGroup) => ( + + + {(f) => ( + + {(footer) => { + const columnId = footer.column.id + const hasFilter = () => + columnFilters().some( + (cf) => cf.id === columnId, + ) + + return ( + + ) + }} + + )} + + + )} + + +
- {header.isPlaceholder ? null : ( - <> - - - - {/* Show sort order number when multiple columns sorted */} - {sorting().length > 1 && - sorting().findIndex( - (s) => s.id === header.column.id, - ) > -1 && ( - - {sorting().findIndex( - (s) => s.id === header.column.id, - ) + 1} - - )} - - )} -
- {/* Cell components are pre-bound via AppCell */} - -
- {footer.isPlaceholder ? null : ( +
+ + + + {(headerGroup) => ( + + + {(h) => ( + + {(header) => ( + + )} + + )} + + + )} + + + + + {(row) => ( + + + {(c) => ( + + {(cell) => ( + - ) - }} - - )} - - - )} - - -
+ {header.isPlaceholder ? null : ( <> - {/* Use FooterSum for numeric columns, FooterColumnId for others */} - {columnId === 'age' || - columnId === 'visits' || - columnId === 'progress' ? ( - <> - - {hasFilter() && ( - - {' '} - (filtered) - - )} - - ) : columnId === 'actions' ? null : ( - <> - - {hasFilter() && ( - - {' '} - ✓ - - )} - - )} + + + + {/* Show sort order number when multiple columns sorted */} + {sorting().length > 1 && + sorting().findIndex( + (s) => s.id === header.column.id, + ) > -1 && ( + + {sorting().findIndex( + (s) => s.id === header.column.id, + ) + 1} + + )} )} +
+ {/* Cell components are pre-bound via AppCell */} +
+ )} + + )} + +
+ {footer.isPlaceholder ? null : ( + <> + {/* Use FooterSum for numeric columns, FooterColumnId for others */} + {columnId === 'age' || + columnId === 'visits' || + columnId === 'progress' ? ( + <> + + {hasFilter() && ( + + {' '} + (filtered) + + )} + + ) : columnId === 'actions' ? null : ( + <> + + {hasFilter() && ( + + {' '} + ✓ + + )} + + )} + + )} +
+ {/* Pagination using pre-bound component */} @@ -245,6 +264,19 @@ function ProductsTable() { // Define columns using the column helper - different structure than Users table const columns = productColumnHelper.columns([ + productColumnHelper.display({ + id: 'select', + header: ({ table }) => ( + // Solid tracks these calls natively, so no Subscribe is needed. + + ), + // Cell uses the pre-bound SelectCell component via AppCell + cell: ({ cell }) => , + }), productColumnHelper.accessor('name', { header: 'Product Name', footer: (props) => props.column.id, @@ -281,6 +313,7 @@ function ProductsTable() { return data() }, getRowId: (row) => row.id, + enableRowSelection: true, }) useTanStackTableDevtools(table) @@ -306,123 +339,127 @@ function ProductsTable() { /> {/* Table element */} - - - - {(headerGroup) => ( - - - {(h) => ( - - {(header) => ( - - )} - - )} - - - )} - - - - - {(row) => ( - - - {(c) => ( - - {(cell) => ( - - )} - - )} - - - )} - - - - - {(footerGroup) => ( - - - {(f) => ( - - {(footer) => { - const columnId = footer.column.id - const hasFilter = () => - columnFilters().some((cf) => cf.id === columnId) - - return ( - + )} + + + + + {(footerGroup) => ( + + + {(f) => ( + + {(footer) => { + const columnId = footer.column.id + const hasFilter = () => + columnFilters().some( + (cf) => cf.id === columnId, + ) + + return ( + + ) + }} + + )} + + + )} + + +
- {header.isPlaceholder ? null : ( - <> - - - - {sorting().length > 1 && - sorting().findIndex( - (s) => s.id === header.column.id, - ) > -1 && ( - - {sorting().findIndex( - (s) => s.id === header.column.id, - ) + 1} - - )} - - )} -
- {/* Cell components are pre-bound via AppCell */} - -
- {footer.isPlaceholder ? null : ( +
+ + + + {(headerGroup) => ( + + + {(h) => ( + + {(header) => ( + + )} + + )} + + + )} + + + + + {(row) => ( + + + {(c) => ( + + {(cell) => ( + - ) - }} - - )} - - - )} - - -
+ {header.isPlaceholder ? null : ( <> - {/* Use FooterSum for numeric columns, FooterColumnId for others */} - {columnId === 'price' || - columnId === 'stock' || - columnId === 'rating' ? ( - <> - - {hasFilter() && ( - - {' '} - (filtered) - - )} - - ) : ( - <> - - {hasFilter() && ( - - {' '} - ✓ - - )} - - )} + + + + {sorting().length > 1 && + sorting().findIndex( + (s) => s.id === header.column.id, + ) > -1 && ( + + {sorting().findIndex( + (s) => s.id === header.column.id, + ) + 1} + + )} )} +
+ {/* Cell components are pre-bound via AppCell */} +
+ )} + + )} + +
+ {footer.isPlaceholder ? null : ( + <> + {/* Use FooterSum for numeric columns, FooterColumnId for others */} + {columnId === 'price' || + columnId === 'stock' || + columnId === 'rating' ? ( + <> + + {hasFilter() && ( + + {' '} + (filtered) + + )} + + ) : ( + <> + + {hasFilter() && ( + + {' '} + ✓ + + )} + + )} + + )} +
+ {/* Pagination using the same pre-bound component */} diff --git a/examples/solid/composable-tables/src/components/cell-components.tsx b/examples/solid/composable-tables/src/components/cell-components.tsx index af78520679..1cb6b35c20 100644 --- a/examples/solid/composable-tables/src/components/cell-components.tsx +++ b/examples/solid/composable-tables/src/components/cell-components.tsx @@ -5,6 +5,27 @@ * in AppCell children, e.g., */ import { useCellContext } from '../hooks/table' +import { IndeterminateCheckbox } from './indeterminate-checkbox' + +/** + * Row-selection checkbox cell - toggles selection for the current row. + * + * Solid tracks the `row.getIsSelected()` etc. calls natively, so passing them + * as accessors keeps the checkbox reactive without any Subscribe boundary. + */ +export function SelectCell() { + const cell = useCellContext() + const row = cell.row + + return ( + + ) +} /** * Generic text cell renderer diff --git a/examples/solid/composable-tables/src/components/indeterminate-checkbox.tsx b/examples/solid/composable-tables/src/components/indeterminate-checkbox.tsx new file mode 100644 index 0000000000..ed760d1f7d --- /dev/null +++ b/examples/solid/composable-tables/src/components/indeterminate-checkbox.tsx @@ -0,0 +1,36 @@ +/** + * Indeterminate checkbox used by the row-selection column + * (both the select-all header and the per-row select cell). + * + * Solid handles reactivity natively, so `checked`/`indeterminate` are read + * from props (kept reactive by the callers) and the indeterminate DOM property + * is synced via a ref in createEffect. + */ +import { createEffect } from 'solid-js' + +export function IndeterminateCheckbox(props: { + indeterminate?: boolean + class?: string + checked?: boolean + disabled?: boolean + onChange?: (event: Event) => void +}) { + let ref: HTMLInputElement | undefined + + createEffect(() => { + if (typeof props.indeterminate === 'boolean' && ref) { + ref.indeterminate = !props.checked && props.indeterminate + } + }) + + return ( + + ) +} diff --git a/examples/solid/composable-tables/src/hooks/table.ts b/examples/solid/composable-tables/src/hooks/table.ts index d8379b071c..377aed21e8 100644 --- a/examples/solid/composable-tables/src/hooks/table.ts +++ b/examples/solid/composable-tables/src/hooks/table.ts @@ -13,6 +13,7 @@ import { createTableHook, filterFns, rowPaginationFeature, + rowSelectionFeature, rowSortingFeature, sortFns, tableFeatures, @@ -32,6 +33,7 @@ import { PriceCell, ProgressCell, RowActionsCell, + SelectCell, StatusCell, TextCell, } from '../components/cell-components' @@ -64,6 +66,7 @@ export const { features: tableFeatures({ columnFilteringFeature, rowPaginationFeature, + rowSelectionFeature, rowSortingFeature, sortedRowModel: createSortedRowModel(), filteredRowModel: createFilteredRowModel(), @@ -84,6 +87,7 @@ export const { // Register cell-level components (accessible via cell.ComponentName in AppCell) cellComponents: { + SelectCell, TextCell, NumberCell, StatusCell, diff --git a/examples/solid/composable-tables/src/index.css b/examples/solid/composable-tables/src/index.css index 4d8fef0142..8dd0d1ca90 100644 --- a/examples/solid/composable-tables/src/index.css +++ b/examples/solid/composable-tables/src/index.css @@ -603,3 +603,17 @@ tfoot th { font-size: 1.875rem; font-weight: 700; } + +/* Fixed-height scroll container for the table body. When the page size exceeds + ~12 rows the body overflows and scrolls, with sticky headers. */ +.table-scroll { + max-height: 400px; + overflow: auto; + border: 1px solid lightgray; +} + +.table-scroll thead th { + position: sticky; + top: 0; + z-index: 1; +} diff --git a/examples/svelte/composable-tables/src/components/ProductsTable.svelte b/examples/svelte/composable-tables/src/components/ProductsTable.svelte index 596db98a56..1aa036e8c6 100644 --- a/examples/svelte/composable-tables/src/components/ProductsTable.svelte +++ b/examples/svelte/composable-tables/src/components/ProductsTable.svelte @@ -24,6 +24,11 @@ // Define columns using the column helper - different structure than Users table const columns = productColumnHelper.columns([ + productColumnHelper.display({ + id: 'select', + header: ({ header }) => renderComponent(header.SelectHeader), + cell: ({ cell }) => renderComponent(cell.SelectCell), + }), productColumnHelper.accessor('name', { header: 'Product Name', footer: (props) => props.column.id, @@ -59,6 +64,7 @@ return data }, getRowId: (row) => row.id, + enableRowSelection: true, }) // Reactive derived values from table state @@ -84,6 +90,7 @@ /> +
{#each table.getHeaderGroups() as headerGroup (headerGroup.id) @@ -166,6 +173,7 @@ {/each}
+
diff --git a/examples/svelte/composable-tables/src/components/SelectCell.svelte b/examples/svelte/composable-tables/src/components/SelectCell.svelte new file mode 100644 index 0000000000..6b144f1a07 --- /dev/null +++ b/examples/svelte/composable-tables/src/components/SelectCell.svelte @@ -0,0 +1,31 @@ + + + + diff --git a/examples/svelte/composable-tables/src/components/SelectHeader.svelte b/examples/svelte/composable-tables/src/components/SelectHeader.svelte new file mode 100644 index 0000000000..c19be36e72 --- /dev/null +++ b/examples/svelte/composable-tables/src/components/SelectHeader.svelte @@ -0,0 +1,31 @@ + + + + + + e.stopPropagation()} +/> diff --git a/examples/svelte/composable-tables/src/components/UsersTable.svelte b/examples/svelte/composable-tables/src/components/UsersTable.svelte index a09cab1763..95fe06bc76 100644 --- a/examples/svelte/composable-tables/src/components/UsersTable.svelte +++ b/examples/svelte/composable-tables/src/components/UsersTable.svelte @@ -25,6 +25,11 @@ // NOTE: You must use `createAppColumnHelper` instead of `createColumnHelper` // when using pre-bound components like cell.TextCell const columns = personColumnHelper.columns([ + personColumnHelper.display({ + id: 'select', + header: ({ header }) => renderComponent(header.SelectHeader), + cell: ({ cell }) => renderComponent(cell.SelectCell), + }), personColumnHelper.accessor('firstName', { header: 'First Name', footer: (props) => props.column.id, @@ -68,6 +73,7 @@ get data() { return data }, + enableRowSelection: true, debugTable: true, }) @@ -96,6 +102,7 @@ /> +
{#each table.getHeaderGroups() as headerGroup (headerGroup.id) @@ -180,6 +187,7 @@ {/each}
+
diff --git a/examples/svelte/composable-tables/src/hooks/table.ts b/examples/svelte/composable-tables/src/hooks/table.ts index 9694a6b1cf..fcff4aca29 100644 --- a/examples/svelte/composable-tables/src/hooks/table.ts +++ b/examples/svelte/composable-tables/src/hooks/table.ts @@ -13,6 +13,7 @@ import { createTableHook, filterFns, rowPaginationFeature, + rowSelectionFeature, rowSortingFeature, sortFns, tableFeatures, @@ -29,6 +30,7 @@ import NumberCell from '../components/NumberCell.svelte' import PriceCell from '../components/PriceCell.svelte' import ProgressCell from '../components/ProgressCell.svelte' import RowActionsCell from '../components/RowActionsCell.svelte' +import SelectCell from '../components/SelectCell.svelte' import StatusCell from '../components/StatusCell.svelte' import TextCell from '../components/TextCell.svelte' @@ -36,6 +38,7 @@ import TextCell from '../components/TextCell.svelte' import ColumnFilter from '../components/ColumnFilter.svelte' import FooterColumnId from '../components/FooterColumnId.svelte' import FooterSum from '../components/FooterSum.svelte' +import SelectHeader from '../components/SelectHeader.svelte' import SortIndicator from '../components/SortIndicator.svelte' /** @@ -58,6 +61,7 @@ export const { features: tableFeatures({ columnFilteringFeature, rowPaginationFeature, + rowSelectionFeature, rowSortingFeature, sortedRowModel: createSortedRowModel(), filteredRowModel: createFilteredRowModel(), @@ -78,6 +82,7 @@ export const { // Register cell-level components (accessible via cell.ComponentName in AppCell) cellComponents: { + SelectCell, TextCell, NumberCell, StatusCell, @@ -89,6 +94,7 @@ export const { // Register header/footer-level components (accessible via header.ComponentName in AppHeader/AppFooter) headerComponents: { + SelectHeader, SortIndicator, ColumnFilter, FooterColumnId, diff --git a/examples/svelte/composable-tables/src/index.css b/examples/svelte/composable-tables/src/index.css index 4d8fef0142..09ce02888c 100644 --- a/examples/svelte/composable-tables/src/index.css +++ b/examples/svelte/composable-tables/src/index.css @@ -43,6 +43,18 @@ tfoot th { border-spacing: 0; } +.table-scroll { + max-height: 400px; + overflow: auto; + border: 1px solid lightgray; +} + +.table-scroll thead th { + position: sticky; + top: 0; + z-index: 1; +} + .pagination { display: flex; align-items: center; diff --git a/examples/vue/composable-tables/src/components/IndeterminateCheckbox.vue b/examples/vue/composable-tables/src/components/IndeterminateCheckbox.vue new file mode 100644 index 0000000000..7ea24e9f0d --- /dev/null +++ b/examples/vue/composable-tables/src/components/IndeterminateCheckbox.vue @@ -0,0 +1,28 @@ + + + diff --git a/examples/vue/composable-tables/src/components/ProductsTable.vue b/examples/vue/composable-tables/src/components/ProductsTable.vue index 35d811cbd2..58eaa61cad 100644 --- a/examples/vue/composable-tables/src/components/ProductsTable.vue +++ b/examples/vue/composable-tables/src/components/ProductsTable.vue @@ -10,6 +10,11 @@ const columnHelper = createAppColumnHelper() const data = ref(makeProductData(1_000)) const columns = columnHelper.columns([ + columnHelper.display({ + id: 'select', + header: ({ header }) => header.SelectAllHeader, + cell: ({ cell }) => cell.SelectCell, + }), columnHelper.accessor('name', { header: 'Product', footer: (props) => props.column.id, @@ -48,6 +53,7 @@ function stressTest() { const table = useAppTable({ key: 'products-table', // needed for devtools debugTable: true, + enableRowSelection: true, columns, data, initialState: { @@ -71,75 +77,79 @@ useTanStackTableDevtools(table) :onStressTest="stressTest" /> - - - - +
+ + - - - - - - - - - - - - - - + + + + + + + + + + + + + + - - - - -
- -
- -
+ +
- -
+ + + + + + + + + diff --git a/examples/vue/composable-tables/src/components/UsersTable.vue b/examples/vue/composable-tables/src/components/UsersTable.vue index 9295bed4dd..1ed3ec5acb 100644 --- a/examples/vue/composable-tables/src/components/UsersTable.vue +++ b/examples/vue/composable-tables/src/components/UsersTable.vue @@ -10,6 +10,11 @@ const columnHelper = createAppColumnHelper() const data = ref(makeData(1_000)) const columns = columnHelper.columns([ + columnHelper.display({ + id: 'select', + header: ({ header }) => header.SelectAllHeader, + cell: ({ cell }) => cell.SelectCell, + }), columnHelper.accessor('firstName', { header: 'First Name', footer: (props) => props.column.id, @@ -58,6 +63,7 @@ function stressTest() { const table = useAppTable({ key: 'users-table', // needed for devtools debugTable: true, + enableRowSelection: true, columns, data, initialState: { @@ -89,85 +95,90 @@ function tableSelector(state: ReturnType) { :onStressTest="stressTest" /> - - - - +
+ + - - - - - - - - - - - - - - + + + + + + + + + + + + + + - - - - -
- -
- -
+ +
- -
+ + + + + + + + + diff --git a/examples/vue/composable-tables/src/components/cell-components.ts b/examples/vue/composable-tables/src/components/cell-components.ts index bacd92252e..b39e149cbb 100644 --- a/examples/vue/composable-tables/src/components/cell-components.ts +++ b/examples/vue/composable-tables/src/components/cell-components.ts @@ -1,5 +1,27 @@ import { defineComponent, h } from 'vue' import { useCellContext } from '../hooks/table' +import IndeterminateCheckbox from './IndeterminateCheckbox.vue' + +// Row-selection checkbox cell - toggles selection for the current row. +// Vue tracks the reactive table state natively, so no Subscribe boundary is +// needed for the checkbox to update when the row-selection state changes. +export const SelectCell = defineComponent({ + name: 'SelectCell', + setup() { + const cell = useCellContext() + return () => { + const row = cell.row + return h('div', { class: 'column-toggle-row' }, [ + h(IndeterminateCheckbox, { + checked: row.getIsSelected(), + disabled: !row.getCanSelect(), + indeterminate: row.getIsSomeSelected(), + onChange: row.getToggleSelectedHandler(), + }), + ]) + } + }, +}) export const TextCell = defineComponent({ name: 'TextCell', diff --git a/examples/vue/composable-tables/src/components/header-components.ts b/examples/vue/composable-tables/src/components/header-components.ts index 5cb64d8667..af6d826783 100644 --- a/examples/vue/composable-tables/src/components/header-components.ts +++ b/examples/vue/composable-tables/src/components/header-components.ts @@ -1,5 +1,22 @@ import { computed, defineComponent, h } from 'vue' import { useHeaderContext } from '../hooks/table' +import IndeterminateCheckbox from './IndeterminateCheckbox.vue' + +// Select-all checkbox used in the header of the row-selection column. +export const SelectAllHeader = defineComponent({ + name: 'SelectAllHeader', + setup() { + const header = useHeaderContext() + return () => { + const table = header.getContext().table + return h(IndeterminateCheckbox, { + checked: table.getIsAllRowsSelected(), + indeterminate: table.getIsSomeRowsSelected(), + onChange: table.getToggleAllRowsSelectedHandler(), + }) + } + }, +}) export const SortIndicator = defineComponent({ name: 'SortIndicator', diff --git a/examples/vue/composable-tables/src/hooks/table.ts b/examples/vue/composable-tables/src/hooks/table.ts index 3fd438e13d..a94114613e 100644 --- a/examples/vue/composable-tables/src/hooks/table.ts +++ b/examples/vue/composable-tables/src/hooks/table.ts @@ -7,6 +7,7 @@ import { filterFns, globalFilteringFeature, rowPaginationFeature, + rowSelectionFeature, rowSortingFeature, sortFns, tableFeatures, @@ -17,6 +18,7 @@ import { PriceCell, ProgressCell, RowActionsCell, + SelectCell, StatusCell, TextCell, } from '../components/cell-components' @@ -24,6 +26,7 @@ import { ColumnFilter, FooterColumnId, FooterSum, + SelectAllHeader, SortIndicator, } from '../components/header-components' import { @@ -47,6 +50,7 @@ const features = tableFeatures({ columnFilteringFeature, globalFilteringFeature, rowPaginationFeature, + rowSelectionFeature, rowSortingFeature, filteredRowModel: createFilteredRowModel(), paginatedRowModel: createPaginatedRowModel(), @@ -71,12 +75,14 @@ const _hook = createTableHook({ RowActionsCell, PriceCell, CategoryCell, + SelectCell, }, headerComponents: { SortIndicator, ColumnFilter, FooterColumnId, FooterSum, + SelectAllHeader, }, }) diff --git a/examples/vue/composable-tables/src/index.css b/examples/vue/composable-tables/src/index.css index 4d8fef0142..b171dde3f0 100644 --- a/examples/vue/composable-tables/src/index.css +++ b/examples/vue/composable-tables/src/index.css @@ -35,6 +35,18 @@ tfoot th { font-weight: normal; } +.table-scroll { + max-height: 400px; + overflow: auto; + border: 1px solid lightgray; +} + +.table-scroll thead th { + position: sticky; + top: 0; + z-index: 1; +} + .table-container { padding: 16px; max-width: 1200px;