From f3f12e78a1f236df9d4c31eca9f7d40c1553f3c5 Mon Sep 17 00:00:00 2001 From: Shawn Zhu Date: Wed, 22 Mar 2023 14:31:00 -0400 Subject: [PATCH] feat(PHC-4380): add sticky column feature #331 --- .../TableModule/ReactTableModule.stories.tsx | 122 ++++++++++++++++++ .../TableModule/ReactTableModule.tsx | 101 ++++++++++++++- src/components/TableModule/TableModuleRow.tsx | 47 +++++-- src/components/TableModule/utils.ts | 2 +- 4 files changed, 257 insertions(+), 15 deletions(-) diff --git a/src/components/TableModule/ReactTableModule.stories.tsx b/src/components/TableModule/ReactTableModule.stories.tsx index 27ff3181..37526ab2 100644 --- a/src/components/TableModule/ReactTableModule.stories.tsx +++ b/src/components/TableModule/ReactTableModule.stories.tsx @@ -158,6 +158,128 @@ Default.args = { config, }; +export const Sticky = Template.bind({}); +Sticky.args = { + data: [...data, ...data, ...data, ...data], + config: [ + { + header: { + label: 'Description', + }, + cell: { + content: (dataValue: any) => { + return dataValue.description; + }, + }, + isSticky: true, + }, + { + header: { + label: 'Calories', + }, + cell: { + content: (dataValue: any) => { + return dataValue.calories; + }, + }, + isSticky: true, + }, + { + header: { + label: 'Fat', + }, + cell: { + content: (dataValue: any) => { + return dataValue.fat; + }, + }, + }, + { + header: { + label: 'Carbs', + }, + cell: { + content: (dataValue: any) => { + return dataValue.carbs; + }, + }, + }, + { + header: { + label: 'Category', + }, + cell: { + content: (dataValue: any) => { + return dataValue.category; + }, + }, + }, + { + header: { + label: 'CategoryA', + }, + cell: { + content: (dataValue: any) => { + return dataValue.category; + }, + }, + isSticky: true, + }, + { + header: { + label: 'CategoryB', + }, + cell: { + content: (dataValue: any) => { + return dataValue.category; + }, + }, + }, + { + header: { + label: 'CategoryC', + }, + cell: { + content: (dataValue: any) => { + return dataValue.category; + }, + }, + }, + { + header: { + label: 'CategoryD', + }, + cell: { + content: (dataValue: any) => { + return dataValue.category; + }, + }, + }, + { + header: { + label: 'CategoryE', + }, + cell: { + content: (dataValue: any) => { + return dataValue.category; + }, + }, + }, + ] as Array, +}; +Sticky.parameters = { + docs: { + description: { + story: `Columns can be made "sticky" or so they don't travel off-screen when scrolling the + table horizontally. This helps keep track of what row one is looking at in tables with more + columns than can be visible at one time in the document. \n \n Any number of columns can be made + sticky. They don't have to be consecutive, and can start and end at any column. However, + most common use-cases will likely just involve the first one or two consecutive columns + being sticky.`, + }, + }, +}; + export const Sort: ComponentStory = (args) => { const [sort, setSort] = React.useState({ key: null, direction: null }); diff --git a/src/components/TableModule/ReactTableModule.tsx b/src/components/TableModule/ReactTableModule.tsx index 65bb12e5..4c4eaaef 100644 --- a/src/components/TableModule/ReactTableModule.tsx +++ b/src/components/TableModule/ReactTableModule.tsx @@ -9,7 +9,6 @@ import { ColumnDef, OnChangeFn, TableState, - ColumnSort, Updater, SortingState, } from '@tanstack/react-table'; @@ -66,15 +65,90 @@ export const ReactTableModule = React.memo( const [sorting, setSorting] = React.useState([]); - if (state && !state.sorting) { - state.sorting = sorting; - } + const [stickyCols, setStickyCols] = React.useState>([]); + const [stickyCellsLeft, setStickyCellsLeft] = React.useState< + Array + >([]); // use legacy when using config of TableModule if (config) { columns = config.map(mapTableConfigToColumnDef); } + React.useEffect(() => { + if (columns && columns.length > 0) { + setStickyCols( + columns + .map((c, index) => { + if (c.meta?.isSticky) { + return index; + } + }) + .filter((index) => { + if (index !== undefined) { + return true; + } + }) + ); + } + }, []); + + // add isStickyLast class + React.useEffect(() => { + if (stickyCols.length === 0) { + return; + } + const allStickyCells = Array.from( + document.querySelectorAll('.sticky-cell-hook') + ); + allStickyCells.forEach((cell, index) => { + if ((index + 1) % stickyCols.length === 0) { + cell?.classList.add(classes.isStickyLast); + } + }); + }, []); + + const setStickyCellLeftValues = React.useCallback(() => { + let sum = 0; + const stickyCellsLeft: Array = []; + // only need to grab column width from one row, since all rows should be the same in each column + if (forwardedRef != null && typeof forwardedRef !== 'function') { + const row = forwardedRef?.current?.childNodes[1].childNodes[0]; + row?.childNodes.forEach((node: any, index: number) => { + if (stickyCols.includes(index)) { + stickyCellsLeft.push(sum); + sum += node?.clientWidth; + } + }); + setStickyCellsLeft(stickyCellsLeft); + } else { + console.warn( + "Table's forwardRef is null, please set it if you want the sticky cell's left value to be set correctly" + ); + } + }, [forwardedRef, stickyCols]); + + React.useLayoutEffect(() => { + if (stickyCols.length === 0) { + return; + } + setStickyCellLeftValues(); + }, [setStickyCellLeftValues, stickyCols]); + + React.useEffect(() => { + if (stickyCols.length === 0) { + return; + } + window.addEventListener('resize', setStickyCellLeftValues); + return () => { + window.removeEventListener('resize', setStickyCellLeftValues); + }; + }, [setStickyCellLeftValues, stickyCols]); + + if (state && !state.sorting) { + state.sorting = sorting; + } + // custom handler to connect onSort() of header config const sortingChangeHandler = (updater: Updater): void => { if (!enableSorting) { @@ -110,7 +184,7 @@ export const ReactTableModule = React.memo( const table = useReactTable({ data, - columns, + columns: columns!, enableRowSelection, enableSorting, onRowSelectionChange, @@ -143,6 +217,12 @@ export const ReactTableModule = React.memo( key={header.id} header={{ label: header.id }} coreHeader={header} + isSticky={stickyCols.indexOf(i) >= 0} + left={ + stickyCols.indexOf(i) >= 0 + ? stickyCellsLeft[stickyCols.indexOf(i)] + : undefined + } onClick={header.column.getToggleSortingHandler()} sortDirection={null} headingsCount={headers.length} @@ -157,6 +237,12 @@ export const ReactTableModule = React.memo( headingsCount={headers.length} isSorting={false} sortDirection={null} + isSticky={stickyCols.indexOf(headers.length + 1) >= 0} + left={ + stickyCols.indexOf(headers.length + 1) >= 0 + ? stickyCellsLeft[stickyCols.indexOf(headers.length + 1)] + : undefined + } /> )} @@ -192,9 +278,12 @@ export const ReactTableModule = React.memo( maxCellWidth={maxCellWidth} row={row} headingsLength={headers?.length} - cells={row.getVisibleCells()} + cells={[]} + coreCells={row.getAllCells()} rowActions={rowActions} rowClickLabel={rowClickLabel} + stickyCols={stickyCols} + stickyCellsLeft={stickyCellsLeft} /> ))} {isLoading && ( diff --git a/src/components/TableModule/TableModuleRow.tsx b/src/components/TableModule/TableModuleRow.tsx index 76747abf..4b5f402e 100644 --- a/src/components/TableModule/TableModuleRow.tsx +++ b/src/components/TableModule/TableModuleRow.tsx @@ -23,7 +23,8 @@ export interface TableModuleRowProps maxCellWidth?: 1 | 2; row: any; headingsLength: number; - cells: Array | Cell[]; + cells: Array; + coreCells?: Cell[]; rowActions?: TableModuleProps['rowActions']; rowClickLabel?: TableModuleProps['rowClickLabel']; stickyCols?: Array; @@ -38,6 +39,7 @@ const TableModuleRow: React.FC = React.memo( maxCellWidth, headingsLength, cells, + coreCells, rowActions, rowClickLabel, stickyCols = [], @@ -79,11 +81,7 @@ const TableModuleRow: React.FC = React.memo( const rowContents = React.useMemo( () => - cells?.map((cell, colIndex) => { - // table-core API - const cellContent = cell.column - ? flexRender(cell.column.columnDef.cell, cell.getContext()) - : cellContentAccessor(cell)(row); + cells?.map((cell: TableCell, colIndex) => { return ( = React.memo( : undefined } > - {cellContent} + {cellContentAccessor(cell)(row)} ); }), [cells, headingsLength, maxCellWidth, row, stickyCols, stickyCellsLeft] ); + // table-core API + const coreRowContents = React.useMemo( + () => + coreCells?.map((cell: Cell, colIndex) => { + return ( + 1 && colIndex === headingsLength - 1 + } + cell={{}} + isSticky={stickyCols.indexOf(colIndex) >= 0} + left={ + stickyCols.indexOf(colIndex) >= 0 + ? stickyCellsLeft[stickyCols.indexOf(colIndex)] + : undefined + } + > + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ); + }), + [ + coreCells, + headingsLength, + maxCellWidth, + row, + stickyCols, + stickyCellsLeft, + ] + ); + const maybeRowActions = React.useMemo(() => rowActions?.(row), [ row, rowActions, @@ -123,7 +154,7 @@ const TableModuleRow: React.FC = React.memo( tabIndex={0} {...getTestProps(testIds.bodyRow)} > - {rowContents} + {coreCells ? coreRowContents : rowContents} {(onRowClick || rowActions) && ( => { // TODO support accessor columnFn const columnDef: ColumnDef = { - id: config.header.label || config.header.content(config.header), + id: config.header.label || config.header.content?.(config.header), accessorFn: cellContentAccessor(config.cell), };