From 1bb21b4a70ca72e536be162560d949b46883110a Mon Sep 17 00:00:00 2001 From: Daniel Kostro Date: Wed, 11 Dec 2024 18:22:43 +0100 Subject: [PATCH 1/7] feat(Table): add scrollToRowRef prop to the Table component Closes: https://github.com/zakodium-oss/react-science/issues/815 --- src/components/table/table_body.tsx | 1 + src/components/table/table_root.tsx | 64 ++++++++++++- src/components/table/table_utils.ts | 9 ++ stories/components/table.stories.tsx | 135 +++++++++++++++++++++++++++ 4 files changed, 206 insertions(+), 3 deletions(-) diff --git a/src/components/table/table_body.tsx b/src/components/table/table_body.tsx index bb9e5790..c3709231 100644 --- a/src/components/table/table_body.tsx +++ b/src/components/table/table_body.tsx @@ -107,5 +107,6 @@ function getTrRenderProps( children: row .getVisibleCells() .map((cell) => ), + 'data-row-id': row.id, }; } diff --git a/src/components/table/table_root.tsx b/src/components/table/table_root.tsx index 104d1f97..5cec6662 100644 --- a/src/components/table/table_root.tsx +++ b/src/components/table/table_root.tsx @@ -8,14 +8,25 @@ import { getSortedRowModel, useReactTable, } from '@tanstack/react-table'; +import type { ScrollToOptions as VirtualScrollToOptions } from '@tanstack/react-virtual'; import { useVirtualizer } from '@tanstack/react-virtual'; -import type { ReactNode, RefObject, TableHTMLAttributes } from 'react'; -import { useRef } from 'react'; +import type { + MutableRefObject, + ReactNode, + RefObject, + TableHTMLAttributes, +} from 'react'; +import { useEffect, useRef } from 'react'; import { TableBody } from './table_body.js'; import { TableHeader } from './table_header.js'; import type { HeaderCellRenderer } from './table_header_cell.js'; -import type { TableColumnDef, TableRowTrRenderer } from './table_utils.js'; +import type { + ScrollToRow, + TableColumnDef, + TableRowTrRenderer, + VirtualScrollToRow, +} from './table_utils.js'; import { useTableColumns } from './use_table_columns.js'; const CustomHTMLTable = styled(HTMLTable, { @@ -110,16 +121,28 @@ interface TableBaseProps { * Override the columns' header cell rendering. */ renderHeaderCell?: HeaderCellRenderer; + + /** + * A ref which will be set with a callback to scroll to a row in the + * table body specified by its index in the data array. + */ + scrollToRowRef?: MutableRefObject< + VirtualScrollToRow | ScrollToOptions | undefined + >; + + getRowId?: TableOptions['getRowId']; } interface RegularTableProps extends TableBaseProps { virtualizeRows?: false | undefined; + scrollToRowRef?: MutableRefObject; } interface VirtualizedTableProps extends TableBaseProps { virtualizeRows: true; + scrollToRowRef?: MutableRefObject; /** * For virtualization of the table rows, provide an estimate of the height of each row. * @param index The index of the row in the data array. @@ -150,9 +173,12 @@ export function Table(props: TableProps) { renderHeaderCell, virtualizeRows, + getRowId, + scrollToRowRef, } = props; const scrollElementRef = useRef(null); + const tableRef = useRef(null); const columnDefs = useTableColumns(columns); const table = useReactTable({ ...reactTable, @@ -160,6 +186,7 @@ export function Table(props: TableProps) { columns: columnDefs, getCoreRowModel: getCoreRowModel(), getSortedRowModel: getSortedRowModel(), + getRowId, }); const tanstackVirtualizer = useVirtualizer({ @@ -171,6 +198,36 @@ export function Table(props: TableProps) { overscan: 5, }); + useEffect(() => { + if (scrollToRowRef) { + if (virtualizeRows) { + scrollToRowRef.current = ( + id: string, + options?: VirtualScrollToOptions, + ) => { + const sortedRows = table.getRowModel().rows; // Get sorted rows + const rowIndex = sortedRows.findIndex((row) => row.id === id); // Find the index of the row by ID + if (rowIndex === -1) { + // We don't expect this situation, but it's not critical enough to throw an error. + // eslint-disable-next-line no-console + console.warn( + `Could not scroll to row with ID ${id}, the row does not exist`, + ); + } + tanstackVirtualizer.scrollToIndex(rowIndex, options); + }; + } else { + scrollToRowRef.current = (id: string, options?: ScrollToOptions) => { + const element = tableRef.current?.querySelector( + `tr[data-row-id="${id}"]`, + ); + + element?.scrollIntoView(options); + }; + } + } + }, [scrollToRowRef, tanstackVirtualizer, virtualizeRows, tableRef, table]); + // Make the table component compatible with styled components libraries. let finalClassName: string | undefined; if (tableProps?.className && className) { @@ -184,6 +241,7 @@ export function Table(props: TableProps) { return ( = ColumnDef< @@ -14,9 +15,17 @@ export function createTableColumnHelper() { export interface TableRowTrProps { className: string; children: ReactNode; + // eslint-disable-next-line @typescript-eslint/naming-convention + 'data-row-id': string; } export type TableRowTrRenderer = ( trProps: TableRowTrProps, row: Row, ) => ReactNode; + +export type VirtualScrollToRow = ( + id: string, + options?: ScrollToOptions, +) => void; +export type ScrollToRow = (id: string, options?: ScrollIntoViewOptions) => void; diff --git a/stories/components/table.stories.tsx b/stories/components/table.stories.tsx index 6ce424b1..f5815547 100644 --- a/stories/components/table.stories.tsx +++ b/stories/components/table.stories.tsx @@ -1,7 +1,14 @@ +import { Button } from '@blueprintjs/core'; import styled from '@emotion/styled'; +import type { Meta } from '@storybook/react'; import type { ComponentType } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { IdcodeSvgRenderer } from 'react-ocl'; +import type { + ScrollToRow, + VirtualScrollToRow, +} from '../../src/components/index.js'; import { createTableColumnHelper, Table, @@ -187,3 +194,131 @@ export function StyledTable(props: ControlProps) { /> ); } + +export const ScrollToVirtualRow = { + args: { + scrollBehavior: 'smooth', + scrollAlign: 'center', + }, + argTypes: { + scrollBehavior: { + control: { + type: 'select', + }, + options: ['auto', 'smooth'], + }, + scrollAlign: { + control: { + type: 'select', + }, + options: ['auto', 'center', 'start', 'end'], + }, + }, + render: (props) => { + const { rowIndex, buttons } = useScrollButtons(); + const scrollToRef = useRef(); + useEffect(() => { + if (scrollToRef.current) { + scrollToRef.current(String(rowIndex), { + align: props.scrollAlign, + behavior: props.scrollBehavior, + }); + } + }, [rowIndex, props.scrollAlign, props.scrollBehavior]); + + return ( +
+ {buttons} +
+ 172} + scrollToRowRef={scrollToRef} + /> + + + ); + }, +} satisfies Meta; + +export const ScrollRowIntoView = { + args: { + scrollBehavior: 'smooth', + scrollBlock: 'center', + }, + argTypes: { + scrollBehavior: { + control: { + type: 'select', + }, + options: ['auto', 'smooth', 'instant'], + }, + scrollBlock: { + control: { + type: 'select', + }, + options: ['nearest', 'center', 'start', 'end'], + }, + virtualizeRows: { + table: { + disable: true, + }, + }, + }, + render: (props) => { + const { rowIndex, buttons } = useScrollButtons(); + const scrollToRef = useRef(); + useEffect(() => { + if (scrollToRef.current) { + scrollToRef.current(String(rowIndex), { + behavior: props.scrollBehavior, + block: props.scrollBlock, + }); + } + }, [rowIndex, props.scrollBehavior, props.scrollBlock]); + + return ( +
+ {buttons} +
+
+ + + ); + }, +} satisfies Meta; + +function useScrollButtons() { + const [rowIndex, setRowIndex] = useState(0); + + const buttons = ( +
+ + + +
Use controls to change scroll behavior and alignment
+
+ ); + return { rowIndex, buttons }; +} From 86d06b3d7c4a22a5ea7e877e36d33e02c4741e4c Mon Sep 17 00:00:00 2001 From: Daniel Kostro Date: Thu, 12 Dec 2024 09:37:10 +0100 Subject: [PATCH 2/7] refactor: displace scroll-to logic in a hook --- src/components/table/table_root.tsx | 38 ++--------------- src/components/table/use_table_scroll.ts | 53 ++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 34 deletions(-) create mode 100644 src/components/table/use_table_scroll.ts diff --git a/src/components/table/table_root.tsx b/src/components/table/table_root.tsx index 5cec6662..bb762e4a 100644 --- a/src/components/table/table_root.tsx +++ b/src/components/table/table_root.tsx @@ -8,7 +8,6 @@ import { getSortedRowModel, useReactTable, } from '@tanstack/react-table'; -import type { ScrollToOptions as VirtualScrollToOptions } from '@tanstack/react-virtual'; import { useVirtualizer } from '@tanstack/react-virtual'; import type { MutableRefObject, @@ -16,7 +15,7 @@ import type { RefObject, TableHTMLAttributes, } from 'react'; -import { useEffect, useRef } from 'react'; +import { useRef } from 'react'; import { TableBody } from './table_body.js'; import { TableHeader } from './table_header.js'; @@ -28,6 +27,7 @@ import type { VirtualScrollToRow, } from './table_utils.js'; import { useTableColumns } from './use_table_columns.js'; +import { useTableScroll } from './use_table_scroll.js'; const CustomHTMLTable = styled(HTMLTable, { shouldForwardProp: (prop) => prop !== 'striped' && prop !== 'stickyHeader', @@ -124,7 +124,7 @@ interface TableBaseProps { /** * A ref which will be set with a callback to scroll to a row in the - * table body specified by its index in the data array. + * table body specified by the row's ID. */ scrollToRowRef?: MutableRefObject< VirtualScrollToRow | ScrollToOptions | undefined @@ -174,11 +174,9 @@ export function Table(props: TableProps) { virtualizeRows, getRowId, - scrollToRowRef, } = props; const scrollElementRef = useRef(null); - const tableRef = useRef(null); const columnDefs = useTableColumns(columns); const table = useReactTable({ ...reactTable, @@ -198,35 +196,7 @@ export function Table(props: TableProps) { overscan: 5, }); - useEffect(() => { - if (scrollToRowRef) { - if (virtualizeRows) { - scrollToRowRef.current = ( - id: string, - options?: VirtualScrollToOptions, - ) => { - const sortedRows = table.getRowModel().rows; // Get sorted rows - const rowIndex = sortedRows.findIndex((row) => row.id === id); // Find the index of the row by ID - if (rowIndex === -1) { - // We don't expect this situation, but it's not critical enough to throw an error. - // eslint-disable-next-line no-console - console.warn( - `Could not scroll to row with ID ${id}, the row does not exist`, - ); - } - tanstackVirtualizer.scrollToIndex(rowIndex, options); - }; - } else { - scrollToRowRef.current = (id: string, options?: ScrollToOptions) => { - const element = tableRef.current?.querySelector( - `tr[data-row-id="${id}"]`, - ); - - element?.scrollIntoView(options); - }; - } - } - }, [scrollToRowRef, tanstackVirtualizer, virtualizeRows, tableRef, table]); + const tableRef = useTableScroll(props, table, tanstackVirtualizer); // Make the table component compatible with styled components libraries. let finalClassName: string | undefined; diff --git a/src/components/table/use_table_scroll.ts b/src/components/table/use_table_scroll.ts new file mode 100644 index 00000000..261fe4e0 --- /dev/null +++ b/src/components/table/use_table_scroll.ts @@ -0,0 +1,53 @@ +import type { RowData, Table } from '@tanstack/react-table'; +import type { + ScrollToOptions as VirtualScrollToOptions, + Virtualizer, +} from '@tanstack/react-virtual'; +import { useEffect, useRef } from 'react'; + +import type { TableProps } from './table_root.js'; + +export function useTableScroll( + tableProps: TableProps, + table: Table, + virtualizer: Virtualizer, +) { + const tableRef = useRef(null); + const { scrollToRowRef, virtualizeRows } = tableProps; + useEffect(() => { + if (scrollToRowRef) { + if (virtualizeRows) { + scrollToRowRef.current = ( + id: string, + options?: VirtualScrollToOptions, + ) => { + const sortedRows = table.getRowModel().rows; // Get sorted rows + const rowIndex = sortedRows.findIndex((row) => row.id === id); // Find the index of the row by ID + if (rowIndex === -1) { + // We don't expect this situation, but it's not critical enough to throw an error. + // eslint-disable-next-line no-console + console.warn( + `Could not scroll to row with ID ${id}, the row does not exist`, + ); + } + virtualizer.scrollToIndex(rowIndex, options); + }; + } else { + scrollToRowRef.current = (id: string, options?: ScrollToOptions) => { + const element = tableRef.current?.querySelector( + `tr[data-row-id="${id}"]`, + ); + if (!element) { + // eslint-disable-next-line no-console + console.warn( + `Could not scroll to row with ID ${id}, the row does not exist`, + ); + } + element?.scrollIntoView(options); + }; + } + } + }, [scrollToRowRef, virtualizer, virtualizeRows, tableRef, table]); + + return tableRef; +} From 0da2b1e2f2425b4d6a2223acfe283f3ca85b6736 Mon Sep 17 00:00:00 2001 From: Daniel Kostro Date: Thu, 12 Dec 2024 09:55:33 +0100 Subject: [PATCH 3/7] docs: improve stories --- src/components/table/table_root.tsx | 2 +- stories/components/table.stories.tsx | 51 ++++++++++++++++++---------- 2 files changed, 34 insertions(+), 19 deletions(-) diff --git a/src/components/table/table_root.tsx b/src/components/table/table_root.tsx index bb762e4a..97053011 100644 --- a/src/components/table/table_root.tsx +++ b/src/components/table/table_root.tsx @@ -124,7 +124,7 @@ interface TableBaseProps { /** * A ref which will be set with a callback to scroll to a row in the - * table body specified by the row's ID. + * table, specified by the row's ID. */ scrollToRowRef?: MutableRefObject< VirtualScrollToRow | ScrollToOptions | undefined diff --git a/stories/components/table.stories.tsx b/stories/components/table.stories.tsx index f5815547..e1091375 100644 --- a/stories/components/table.stories.tsx +++ b/stories/components/table.stories.tsx @@ -1,4 +1,4 @@ -import { Button } from '@blueprintjs/core'; +import { Button, Callout } from '@blueprintjs/core'; import styled from '@emotion/styled'; import type { Meta } from '@storybook/react'; import type { ComponentType } from 'react'; @@ -197,8 +197,9 @@ export function StyledTable(props: ControlProps) { export const ScrollToVirtualRow = { args: { - scrollBehavior: 'smooth', - scrollAlign: 'center', + scrollBehavior: 'auto', + scrollAlign: 'start', + stickyHeader: true, }, argTypes: { scrollBehavior: { @@ -249,8 +250,9 @@ export const ScrollToVirtualRow = { export const ScrollRowIntoView = { args: { - scrollBehavior: 'smooth', - scrollBlock: 'center', + scrollBehavior: 'instant', + scrollBlock: 'start', + stickyHeader: true, }, argTypes: { scrollBehavior: { @@ -284,7 +286,13 @@ export const ScrollRowIntoView = { }, [rowIndex, props.scrollBehavior, props.scrollBlock]); return ( -
+
{buttons}
- - - -
Use controls to change scroll behavior and alignment
- + +
+
Last: {table[rowIndex].name}
+
Use controls to change scroll behavior and alignment
+
+ + + +
+
+
); return { rowIndex, buttons }; } From 4b97c2b8deb86a5c089527936fc41abb26a58d65 Mon Sep 17 00:00:00 2001 From: Daniel Kostro Date: Thu, 12 Dec 2024 09:56:34 +0100 Subject: [PATCH 4/7] docs: no sticky header for non-virtualized scroll to story --- stories/components/table.stories.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/stories/components/table.stories.tsx b/stories/components/table.stories.tsx index e1091375..277986b1 100644 --- a/stories/components/table.stories.tsx +++ b/stories/components/table.stories.tsx @@ -252,7 +252,7 @@ export const ScrollRowIntoView = { args: { scrollBehavior: 'instant', scrollBlock: 'start', - stickyHeader: true, + stickyHeader: false, }, argTypes: { scrollBehavior: { From 2618afee7c9b450f2fa3cb5f4f284574284c0dee Mon Sep 17 00:00:00 2001 From: Daniel Kostro Date: Fri, 13 Dec 2024 09:46:25 +0100 Subject: [PATCH 5/7] docs: remove useEffect from example, and use striped rows in stories --- stories/components/table.stories.tsx | 63 ++++++++++++++++------------ 1 file changed, 37 insertions(+), 26 deletions(-) diff --git a/stories/components/table.stories.tsx b/stories/components/table.stories.tsx index 277986b1..86c8ed51 100644 --- a/stories/components/table.stories.tsx +++ b/stories/components/table.stories.tsx @@ -2,7 +2,7 @@ import { Button, Callout } from '@blueprintjs/core'; import styled from '@emotion/styled'; import type { Meta } from '@storybook/react'; import type { ComponentType } from 'react'; -import { useEffect, useRef, useState } from 'react'; +import { useRef, useState } from 'react'; import { IdcodeSvgRenderer } from 'react-ocl'; import type { @@ -200,6 +200,7 @@ export const ScrollToVirtualRow = { scrollBehavior: 'auto', scrollAlign: 'start', stickyHeader: true, + striped: true, }, argTypes: { scrollBehavior: { @@ -216,16 +217,13 @@ export const ScrollToVirtualRow = { }, }, render: (props) => { - const { rowIndex, buttons } = useScrollButtons(); + const buttons = useScrollButtons((index) => { + scrollToRef.current?.(String(index), { + align: props.scrollAlign, + behavior: props.scrollBehavior, + }); + }); const scrollToRef = useRef(); - useEffect(() => { - if (scrollToRef.current) { - scrollToRef.current(String(rowIndex), { - align: props.scrollAlign, - behavior: props.scrollBehavior, - }); - } - }, [rowIndex, props.scrollAlign, props.scrollBehavior]); return (
@@ -253,6 +251,7 @@ export const ScrollRowIntoView = { scrollBehavior: 'instant', scrollBlock: 'start', stickyHeader: false, + striped: true, }, argTypes: { scrollBehavior: { @@ -274,16 +273,13 @@ export const ScrollRowIntoView = { }, }, render: (props) => { - const { rowIndex, buttons } = useScrollButtons(); const scrollToRef = useRef(); - useEffect(() => { - if (scrollToRef.current) { - scrollToRef.current(String(rowIndex), { - behavior: props.scrollBehavior, - block: props.scrollBlock, - }); - } - }, [rowIndex, props.scrollBehavior, props.scrollBlock]); + const buttons = useScrollButtons((index) => + scrollToRef.current?.(String(index), { + behavior: props.scrollBehavior, + block: props.scrollBlock, + }), + ); return (
void) { const [rowIndex, setRowIndex] = useState(0); const buttons = ( @@ -321,19 +317,34 @@ function useScrollButtons() {
Use controls to change scroll behavior and alignment
- - +
); - return { rowIndex, buttons }; + return buttons; } From ae99aa4687feceddbc30b3076a8a0cd6d1688ec6 Mon Sep 17 00:00:00 2001 From: Daniel Kostro Date: Fri, 13 Dec 2024 09:48:55 +0100 Subject: [PATCH 6/7] chore: rename variable --- stories/components/table.stories.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/stories/components/table.stories.tsx b/stories/components/table.stories.tsx index 86c8ed51..9870ed36 100644 --- a/stories/components/table.stories.tsx +++ b/stories/components/table.stories.tsx @@ -307,7 +307,7 @@ export const ScrollRowIntoView = { }, } satisfies Meta; -function useScrollButtons(cb: (index: number) => void) { +function useScrollButtons(onIndexChanged: (index: number) => void) { const [rowIndex, setRowIndex] = useState(0); const buttons = ( @@ -320,7 +320,7 @@ function useScrollButtons(cb: (index: number) => void) { onClick={() => { const index = Math.floor(Math.random() * table.length); setRowIndex(index); - cb(index); + onIndexChanged(index); }} > Scroll to random row @@ -328,7 +328,7 @@ function useScrollButtons(cb: (index: number) => void) {