From a79a28ec0ed2ed8953ecc4ffce372fec3fa3b70b Mon Sep 17 00:00:00 2001 From: Yossi Saadi Date: Thu, 27 Jun 2024 14:49:38 +0300 Subject: [PATCH] feat(Table): add sticky column capabilities (#2172) --- .../core/src/components/Table/Table/Table.tsx | 5 +- .../Table/Table/TableRoot.module.scss | 7 ++ .../src/components/Table/Table/TableRoot.tsx | 32 +++++ .../__stories__/Table.stories.helpers.tsx | 8 +- .../Table/Table/__tests__/Table.test.tsx | 24 +++- .../Table/TableCell/TableCell.module.scss | 9 +- .../components/Table/TableCell/TableCell.tsx | 5 +- .../Table/TableHeader/TableHeader.module.scss | 16 ++- .../Table/TableHeader/TableHeader.tsx | 10 +- .../TableHeaderCell.module.scss | 7 ++ .../Table/TableHeaderCell/TableHeaderCell.tsx | 10 +- .../Table/TableRow/TableRow.module.scss | 12 +- .../components/Table/TableRow/TableRow.tsx | 9 +- .../TableVirtualizedBody.tsx | 110 +++++++++--------- .../context/TableContext/TableContext.tsx | 58 ++++++++- .../TableContext/TableContext.types.ts | 14 ++- packages/core/src/components/Table/index.ts | 1 - 17 files changed, 243 insertions(+), 94 deletions(-) create mode 100644 packages/core/src/components/Table/Table/TableRoot.module.scss create mode 100644 packages/core/src/components/Table/Table/TableRoot.tsx diff --git a/packages/core/src/components/Table/Table/Table.tsx b/packages/core/src/components/Table/Table/Table.tsx index 557697ab74..77505ed64b 100644 --- a/packages/core/src/components/Table/Table/Table.tsx +++ b/packages/core/src/components/Table/Table/Table.tsx @@ -9,6 +9,7 @@ import { ComponentDefaultTestId } from "../../../tests/constants"; import { RowHeights, RowSizes } from "./TableConsts"; import styles from "./Table.module.scss"; import { TableProvider } from "../context/TableContext/TableContext"; +import TableRoot from "./TableRoot"; export type TableLoadingStateType = "long-text" | "medium-text" | "circle" | "rectangle"; @@ -76,9 +77,9 @@ const Table: VibeComponent & { return ( -
+ {children} -
+
); } diff --git a/packages/core/src/components/Table/Table/TableRoot.module.scss b/packages/core/src/components/Table/Table/TableRoot.module.scss new file mode 100644 index 0000000000..bd84f4d689 --- /dev/null +++ b/packages/core/src/components/Table/Table/TableRoot.module.scss @@ -0,0 +1,7 @@ +.virtualized { + overflow: hidden; +} + +.hasScroll { + --sticky-cell-box-shadow: 3px 0 4px rgba(0, 0, 0, 0.1); +} diff --git a/packages/core/src/components/Table/Table/TableRoot.tsx b/packages/core/src/components/Table/Table/TableRoot.tsx new file mode 100644 index 0000000000..497e08ab58 --- /dev/null +++ b/packages/core/src/components/Table/Table/TableRoot.tsx @@ -0,0 +1,32 @@ +import React, { forwardRef } from "react"; +import { ITableProps } from "./Table"; +import cx from "classnames"; +import { useTable } from "../context/TableContext/TableContext"; +import styles from "./TableRoot.module.scss"; + +type TableRootProps = Pick; + +const TableRoot = forwardRef( + ( + { id, className, "data-testid": dataTestId, style, children }: TableRootProps, + ref: React.ForwardedRef + ) => { + const { isVirtualized, scrollLeft, onTableRootScroll } = useTable(); + + return ( +
0 })} + data-testid={dataTestId} + role="table" + style={style} + onScroll={onTableRootScroll} + > + {children} +
+ ); + } +); + +export default TableRoot; diff --git a/packages/core/src/components/Table/Table/__stories__/Table.stories.helpers.tsx b/packages/core/src/components/Table/Table/__stories__/Table.stories.helpers.tsx index 0b816ea137..4a07a57bb5 100644 --- a/packages/core/src/components/Table/Table/__stories__/Table.stories.helpers.tsx +++ b/packages/core/src/components/Table/Table/__stories__/Table.stories.helpers.tsx @@ -2,6 +2,8 @@ import React from "react"; import Avatar from "../../../Avatar/Avatar"; import { Calendar, Doc, Status } from "../../../Icon/Icons"; import { LabelColor } from "../../../Label/LabelConstants"; +import { TableVirtualizedRow } from "../../TableVirtualizedBody/TableVirtualizedBody"; +import { ITableColumn } from "../Table"; export const doAndDontIconsRuleColumns = [ { @@ -305,13 +307,13 @@ export const scrollTableColumns = [ } ]; -export const virtualizedScrollTableData = [...new Array(5000)].map((_, index) => ({ - id: index, +export const virtualizedScrollTableData: TableVirtualizedRow[] = [...new Array(5000)].map((_, index) => ({ + id: index.toString(), num: index, text: `This is line number ${index}` })); -export const virtualizedScrollTableColumns = [ +export const virtualizedScrollTableColumns: ITableColumn[] = [ { id: "num", title: "#", diff --git a/packages/core/src/components/Table/Table/__tests__/Table.test.tsx b/packages/core/src/components/Table/Table/__tests__/Table.test.tsx index 841c944c4c..5b0f6081cf 100644 --- a/packages/core/src/components/Table/Table/__tests__/Table.test.tsx +++ b/packages/core/src/components/Table/Table/__tests__/Table.test.tsx @@ -9,14 +9,26 @@ import TableHeader from "../../TableHeader/TableHeader"; import TableCellSkeleton from "../../TableCellSkeleton/TableCellSkeleton"; import * as TableContextModule from "../../context/TableContext/TableContext"; import { RowSizes } from "../TableConsts"; +import { ITableContext } from "../../context/TableContext/TableContext.types"; function mockUseTable() { - jest.spyOn(TableContextModule, "useTable").mockImplementation(() => ({ - columns: [], - emptyState:
, - errorState:
, - size: RowSizes.MEDIUM - })); + jest.spyOn(TableContextModule, "useTable").mockImplementation( + () => + ({ + columns: [], + emptyState:
, + errorState:
, + size: RowSizes.MEDIUM, + scrollLeft: 0, + onTableRootScroll: jest.fn(), + headRef: { current: null }, + onHeadScroll: jest.fn(), + virtualizedListRef: { current: null }, + onVirtualizedListScroll: jest.fn(), + isVirtualized: false, + markTableAsVirtualized: jest.fn() + } satisfies ITableContext) + ); } jest.mock("../../context/TableContext/TableContext", () => ({ diff --git a/packages/core/src/components/Table/TableCell/TableCell.module.scss b/packages/core/src/components/Table/TableCell/TableCell.module.scss index acd0a3a053..39710a5bfd 100644 --- a/packages/core/src/components/Table/TableCell/TableCell.module.scss +++ b/packages/core/src/components/Table/TableCell/TableCell.module.scss @@ -3,4 +3,11 @@ overflow: hidden; display: flex; align-items: center; -} \ No newline at end of file + + &.sticky { + z-index: 1; + position: sticky; + left: 0; + box-shadow: var(--sticky-cell-box-shadow); + } +} diff --git a/packages/core/src/components/Table/TableCell/TableCell.tsx b/packages/core/src/components/Table/TableCell/TableCell.tsx index 05a8fd9b67..db8ed608ea 100644 --- a/packages/core/src/components/Table/TableCell/TableCell.tsx +++ b/packages/core/src/components/Table/TableCell/TableCell.tsx @@ -8,10 +8,11 @@ import { ComponentDefaultTestId } from "../../../tests/constants"; export interface ITableCellProps extends VibeComponentProps { children?: React.ReactNode; + sticky?: boolean; } const TableCell: VibeComponent = forwardRef( - ({ id, className, "data-testid": dataTestId, children }, ref) => { + ({ sticky, id, className, "data-testid": dataTestId, children }, ref) => { const isSingleChild = React.Children.count(children) === 1; const typeOfFirstChild = typeof React.Children.toArray(children)[0]; const isFirstChildString = typeOfFirstChild === "string" || typeOfFirstChild === "number"; @@ -20,7 +21,7 @@ const TableCell: VibeComponent = forwardRef(
diff --git a/packages/core/src/components/Table/TableHeader/TableHeader.module.scss b/packages/core/src/components/Table/TableHeader/TableHeader.module.scss index d41a6d27e1..c25d2e78de 100644 --- a/packages/core/src/components/Table/TableHeader/TableHeader.module.scss +++ b/packages/core/src/components/Table/TableHeader/TableHeader.module.scss @@ -3,10 +3,22 @@ grid-template-columns: var(--table-grid-template-columns); position: sticky; top: 0; - z-index: 1; + z-index: 2; background-color: inherit; + min-width: 100%; + width: fit-content; - > * { + > [role="columnheader"] { background-color: inherit; } + + &.virtualized { + overflow: auto; + width: auto; + scrollbar-width: none; + + &::-webkit-scrollbar { + display: none; + } + } } diff --git a/packages/core/src/components/Table/TableHeader/TableHeader.tsx b/packages/core/src/components/Table/TableHeader/TableHeader.tsx index 00c92f85c0..61198d765d 100644 --- a/packages/core/src/components/Table/TableHeader/TableHeader.tsx +++ b/packages/core/src/components/Table/TableHeader/TableHeader.tsx @@ -5,6 +5,8 @@ import { ITableHeaderCellProps } from "../TableHeaderCell/TableHeaderCell"; import cx from "classnames"; import { getTestId } from "../../../tests/test-ids-utils"; import { ComponentDefaultTestId } from "../../../tests/constants"; +import { useTable } from "../context/TableContext/TableContext"; +import useMergeRef from "../../../hooks/useMergeRef"; export interface ITableHeaderProps extends VibeComponentProps { children?: React.ReactElement | React.ReactElement[]; @@ -12,13 +14,17 @@ export interface ITableHeaderProps extends VibeComponentProps { const TableHeader: VibeComponent = forwardRef( ({ id, className, "data-testid": dataTestId, children }, ref) => { + const { headRef, onHeadScroll, isVirtualized } = useTable(); + const mergedRef = useMergeRef(headRef, ref); + return (
{children}
diff --git a/packages/core/src/components/Table/TableHeaderCell/TableHeaderCell.module.scss b/packages/core/src/components/Table/TableHeaderCell/TableHeaderCell.module.scss index 96c2cb7ec5..82498a88e0 100644 --- a/packages/core/src/components/Table/TableHeaderCell/TableHeaderCell.module.scss +++ b/packages/core/src/components/Table/TableHeaderCell/TableHeaderCell.module.scss @@ -13,6 +13,13 @@ background-color: inherit; @include focus-style(); + &.sticky { + z-index: 1; + position: sticky; + left: 0; + box-shadow: var(--sticky-cell-box-shadow); + } + &:hover, &.sortActive { background-color: var(--primary-background-hover-color); diff --git a/packages/core/src/components/Table/TableHeaderCell/TableHeaderCell.tsx b/packages/core/src/components/Table/TableHeaderCell/TableHeaderCell.tsx index 1a5030f218..9c087b7df3 100644 --- a/packages/core/src/components/Table/TableHeaderCell/TableHeaderCell.tsx +++ b/packages/core/src/components/Table/TableHeaderCell/TableHeaderCell.tsx @@ -21,6 +21,7 @@ export interface ITableHeaderCellProps extends VibeComponentProps { sortState?: "asc" | "desc" | "none"; onSortClicked?: (direction: "asc" | "desc" | "none") => void; sortButtonAriaLabel?: string; + sticky?: boolean; } const TableHeaderCell: VibeComponent = forwardRef( @@ -34,7 +35,8 @@ const TableHeaderCell: VibeComponent = fo infoContent, icon, sortState = "none", - sortButtonAriaLabel = "Sort" + sortButtonAriaLabel = "Sort", + sticky }, ref ) => { @@ -47,7 +49,11 @@ const TableHeaderCell: VibeComponent = fo
setIsHovered(true)} diff --git a/packages/core/src/components/Table/TableRow/TableRow.module.scss b/packages/core/src/components/Table/TableRow/TableRow.module.scss index a82dfc5d9a..aef814de08 100644 --- a/packages/core/src/components/Table/TableRow/TableRow.module.scss +++ b/packages/core/src/components/Table/TableRow/TableRow.module.scss @@ -4,17 +4,23 @@ height: var(--table-row-size); display: grid; grid-template-columns: var(--table-grid-template-columns); + min-width: 100%; + width: fit-content; - &[aria-selected="true"] > * { + &[aria-selected="true"] > [role="cell"] { background-color: var(--primary-selected-color); } + > [role="cell"] { + background-color: var(--primary-background-color); + } + &:hover { - > * { + > [role="cell"] { background-color: var(--primary-background-hover-color); } - &[aria-selected="true"] > * { + &[aria-selected="true"] > [role="cell"] { background-color: var(--primary-selected-hover-color); } } diff --git a/packages/core/src/components/Table/TableRow/TableRow.tsx b/packages/core/src/components/Table/TableRow/TableRow.tsx index 440b3463a3..07401a596a 100644 --- a/packages/core/src/components/Table/TableRow/TableRow.tsx +++ b/packages/core/src/components/Table/TableRow/TableRow.tsx @@ -1,4 +1,4 @@ -import React, { forwardRef, useEffect, useRef } from "react"; +import React, { forwardRef, useRef } from "react"; import { VibeComponent, VibeComponentProps } from "../../../types"; import { ITableCellProps } from "../TableCell/TableCell"; import useMergeRef from "../../../hooks/useMergeRef"; @@ -6,7 +6,6 @@ import { getTestId } from "../../../tests/test-ids-utils"; import { ComponentDefaultTestId } from "../../../tests/constants"; import cx from "classnames"; import styles from "./TableRow.module.scss"; -import { useTable } from "../context/TableContext/TableContext"; export interface ITableRowProps extends VibeComponentProps { /** @@ -19,15 +18,9 @@ export interface ITableRowProps extends VibeComponentProps { const TableRow: VibeComponent = forwardRef( ({ highlighted, children, style, id, className, "data-testid": dataTestId }, ref) => { - const { rowWidth, setRowWidth } = useTable(); const componentRef = useRef(null); const mergedRef = useMergeRef(componentRef, ref); - useEffect(() => { - if (rowWidth > 0 || !componentRef.current?.scrollWidth) return; - setRowWidth(componentRef.current.scrollWidth); - }, [rowWidth, setRowWidth]); - return (
& { id: string }>; export type TableVirtualizedRow = TableVirtualizedRows[number]; @@ -19,66 +19,72 @@ export interface ITableVirtualizedBodyProps extends VibeComponentProps { onScroll?: (horizontalScrollDirection: ScrollDirection, scrollTop: number, scrollUpdateWasRequested: boolean) => void; } -const TableVirtualizedBody: FC = ({ - items, - rowRenderer, - onScroll, - id, - className, - "data-testid": dataTestId -}) => { - const { size, rowWidth } = useTable(); +const TableVirtualizedBody = forwardRef( + ( + { items, rowRenderer, onScroll, id, className, "data-testid": dataTestId }: ITableVirtualizedBodyProps, + ref: React.ForwardedRef + ) => { + const { size, virtualizedListRef, onVirtualizedListScroll, markTableAsVirtualized } = useTable(); - const itemRenderer = useCallback>>( - ({ index, style }) => { - const currentItem = items[index]; - const element = rowRenderer(currentItem); - return React.cloneElement(element, { style, key: index }); - }, - [items, rowRenderer] - ); + useEffect(() => { + markTableAsVirtualized(); + }, [markTableAsVirtualized]); - const handleOnScroll = useCallback( - ({ - scrollDirection, - scrollOffset, - scrollUpdateWasRequested - }: { - scrollDirection: ScrollDirection; - scrollOffset: number; - scrollUpdateWasRequested: boolean; - }) => { - onScroll?.(scrollDirection, scrollOffset, scrollUpdateWasRequested); - }, - [onScroll] - ); + const itemRenderer = useCallback>>( + ({ index, style: { width: _width, ...style } }) => { + const currentItem = items[index]; + const element = rowRenderer(currentItem); + return React.cloneElement(element, { + style: { ...style, ...element.props?.style }, + key: index + }); + }, + [items, rowRenderer] + ); - return ( - - {items?.length && ( - - {({ height }: { height: number }) => { - return ( + const handleOnScroll = useCallback( + ({ + scrollDirection, + scrollOffset, + scrollUpdateWasRequested + }: { + scrollDirection: ScrollDirection; + scrollOffset: number; + scrollUpdateWasRequested: boolean; + }) => { + onScroll?.(scrollDirection, scrollOffset, scrollUpdateWasRequested); + }, + [onScroll] + ); + + return ( + + {items?.length && ( + + {({ height, width }: AutoSizerSize) => ( { + virtualizedListRef.current = element; + }} > {itemRenderer} - ); - }} - - )} - - ); -}; + )} + + )} + + ); + } +); export default TableVirtualizedBody; diff --git a/packages/core/src/components/Table/context/TableContext/TableContext.tsx b/packages/core/src/components/Table/context/TableContext/TableContext.tsx index 1efec322a3..5da476e7a0 100644 --- a/packages/core/src/components/Table/context/TableContext/TableContext.tsx +++ b/packages/core/src/components/Table/context/TableContext/TableContext.tsx @@ -1,17 +1,63 @@ -import React, { createContext, useContext, useMemo, useState } from "react"; +import React, { createContext, UIEventHandler, useCallback, useContext, useMemo, useRef, useState } from "react"; import { ITableContext, ITableProviderProps } from "./TableContext.types"; const TableContext = createContext(undefined); export const TableProvider = ({ value, children }: ITableProviderProps) => { - const [rowWidth, setRowWidth] = useState(0); - const contextValue = useMemo( + const [isVirtualized, setIsVirtualized] = useState(false); + const markTableAsVirtualized = useCallback(() => { + setIsVirtualized(true); + }, []); + + const [scrollLeft, setScrollLeft] = useState(0); + const headRef = useRef(null); + const virtualizedListRef = useRef(null); + + const onTableRootScroll = useCallback>( + e => { + const newLeft = (e.target as HTMLDivElement).scrollLeft; + if (newLeft !== scrollLeft) { + setScrollLeft(newLeft); + } + }, + [scrollLeft] + ); + + const onHeadScroll: UIEventHandler = useCallback( + e => { + const newLeft = (e.target as HTMLDivElement).scrollLeft; + if (virtualizedListRef.current && newLeft !== scrollLeft) { + virtualizedListRef.current.scrollLeft = newLeft; + setScrollLeft(newLeft); + } + }, + [scrollLeft] + ); + + const onVirtualizedListScroll = useCallback>( + e => { + const newLeft = (e.target as HTMLDivElement).scrollLeft; + if (headRef.current && newLeft !== scrollLeft) { + headRef.current.scrollLeft = newLeft; + setScrollLeft(newLeft); + } + }, + [scrollLeft] + ); + + const contextValue = useMemo( () => ({ ...value, - rowWidth, - setRowWidth + headRef, + scrollLeft, + onTableRootScroll, + onHeadScroll, + virtualizedListRef, + onVirtualizedListScroll, + isVirtualized, + markTableAsVirtualized }), - [value, rowWidth] + [value, scrollLeft, onTableRootScroll, onHeadScroll, onVirtualizedListScroll, isVirtualized, markTableAsVirtualized] ); return {children}; }; diff --git a/packages/core/src/components/Table/context/TableContext/TableContext.types.ts b/packages/core/src/components/Table/context/TableContext/TableContext.types.ts index 69eea339b7..19c3168412 100644 --- a/packages/core/src/components/Table/context/TableContext/TableContext.types.ts +++ b/packages/core/src/components/Table/context/TableContext/TableContext.types.ts @@ -1,5 +1,5 @@ import { ITableProps } from "../../Table/Table"; -import React from "react"; +import React, { UIEventHandler } from "react"; export interface ITableContext { columns: ITableProps["columns"]; @@ -7,11 +7,17 @@ export interface ITableContext { emptyState: ITableProps["emptyState"]; errorState: ITableProps["errorState"]; size: ITableProps["size"]; - rowWidth?: number; - setRowWidth?: (width: number) => void; + scrollLeft: number; + onTableRootScroll: UIEventHandler; + headRef: React.MutableRefObject; + onHeadScroll: UIEventHandler; + virtualizedListRef: React.MutableRefObject; + onVirtualizedListScroll: UIEventHandler; + isVirtualized: boolean; + markTableAsVirtualized: () => void; } export type ITableProviderProps = { - value: ITableContext; + value: Pick; children: React.ReactNode; }; diff --git a/packages/core/src/components/Table/index.ts b/packages/core/src/components/Table/index.ts index 033ddd05c0..1a768d9858 100644 --- a/packages/core/src/components/Table/index.ts +++ b/packages/core/src/components/Table/index.ts @@ -8,7 +8,6 @@ export { default as TableBody, ITableBodyProps as TableBodyProps } from "./Table export { default as TableVirtualizedBody, ITableVirtualizedBodyProps as TableVirtualizedBodyProps, - TableVirtualizedRows, TableVirtualizedRow } from "./TableVirtualizedBody/TableVirtualizedBody"; export { default as TableRow, ITableRowProps as TableRowProps } from "./TableRow/TableRow";