From 11a2f8bda72048eefef9a8794a23edde3429ea04 Mon Sep 17 00:00:00 2001 From: Federico Ercoles Date: Thu, 14 Sep 2023 16:22:29 +0200 Subject: [PATCH] Add support for header groups --- .../src/Table/Table.css.ts | 14 +- .../bento-design-system/src/Table/Table.tsx | 167 ++++++++++++------ .../bento-design-system/src/Table/types.ts | 8 + 3 files changed, 132 insertions(+), 57 deletions(-) diff --git a/packages/bento-design-system/src/Table/Table.css.ts b/packages/bento-design-system/src/Table/Table.css.ts index 73eb34bed..c8027e72c 100644 --- a/packages/bento-design-system/src/Table/Table.css.ts +++ b/packages/bento-design-system/src/Table/Table.css.ts @@ -25,10 +25,16 @@ export const sortIconContainer = style({ filter: "opacity(80%)", }); -export const stickyColumnHeader = bentoSprinkles({ - position: "sticky", - top: 0, -}); +export const stickyTopHeight = createVar(); + +export const stickyColumnHeader = style([ + { + top: stickyTopHeight, + }, + bentoSprinkles({ + position: "sticky", + }), +]); export const rowContainer = style({ // NOTE(gabro): this allows us to use the entire row as a parent selector, diff --git a/packages/bento-design-system/src/Table/Table.tsx b/packages/bento-design-system/src/Table/Table.tsx index fbfc5e623..c367ffb96 100644 --- a/packages/bento-design-system/src/Table/Table.tsx +++ b/packages/bento-design-system/src/Table/Table.tsx @@ -9,6 +9,7 @@ import { useGroupBy, Cell, SortingRule, + HeaderGroup, } from "react-table"; import { IconProps } from "../Icons/IconProps"; import { useDefaultMessages } from "../util/useDefaultMessages"; @@ -38,21 +39,27 @@ import { selectedRowBackgroundColor, sortIconContainer, stickyColumnHeader, + stickyTopHeight, table, } from "./Table.css"; -import { Column as ColumnType, GridWidth, Row as RowType } from "./types"; +import { + Column as SimpleColumnType, + GroupedColumn as GroupedColumnType, + GridWidth, + Row as RowType, +} from "./types"; import { useLayoutEffect, useMemo, useState, CSSProperties, useEffect } from "react"; import { IconQuestionSolid, IconInfo } from "../Icons"; import { match, __ } from "ts-pattern"; import { useBentoConfig } from "../BentoConfigContext"; import { assignInlineVars } from "@vanilla-extract/dynamic"; -type SortFn>> = ( +type SortFn>> = ( a: Row>, b: Row> ) => number; -type SortingProps>> = +type SortingProps>> = | { /** * `customSorting` can be used to customize the sorting logic of the table. It supports cross-columns comparison. @@ -74,8 +81,11 @@ type SortingProps>> = onSort?: never; }; -type Props>> = { - columns: C; +type Props< + C extends ReadonlyArray>, + D extends ReadonlyArray> +> = { + columns: C | D; data: ReadonlyArray>; groupBy?: C[number]["accessor"]; noResultsTitle?: LocalizedString; @@ -106,7 +116,10 @@ type Props>> = { * * ``` */ -export function Table>>({ +export function Table< + C extends ReadonlyArray>, + D extends ReadonlyArray> +>({ columns, data, groupBy, @@ -119,7 +132,7 @@ export function Table>>({ stickyHeaders, height, onRowPress, -}: Props) { +}: Props) { const config = useBentoConfig().table; const customOrderByFn = useMemo( () => @@ -146,6 +159,11 @@ export function Table>>({ [customSorting] ); + const flatColumns = useMemo( + () => columns.flatMap((c) => ("columns" in c ? c.columns : [c])), + [columns] + ); + const { getTableProps, headerGroups, @@ -179,15 +197,19 @@ export function Table>>({ // Determine the ids of the sticky columns to the left const stickyLeftColumnsIds = useMemo( () => - columns - .filter((c) => c.sticky === "left") - .map((c) => headerGroups[0].headers.find((h) => h.id === c.accessor)?.id) - .filter((id): id is string => id !== undefined), - [columns, headerGroups] + headerGroups[0].headers + .filter((h) => h.sticky) + .flatMap((h) => h.columns || [h]) + .map((h) => h.id), + [headerGroups] + ); + const stickyLeftColumnGroupsIds = useMemo( + () => headerGroups[0].headers.filter((h) => h.sticky).map((h) => h.id), + [headerGroups] ); // Determine the id of the last left sticky column (used to draw a visual separator in the UI) - const lastStickyColumnIndex = columns + const lastStickyColumnIndex = flatColumns .map((c) => c.accessor) .indexOf(stickyLeftColumnsIds[stickyLeftColumnsIds.length - 1]); @@ -196,6 +218,9 @@ export function Table>>({ {} as Record ); + // Keep a state for the height of the first row of headers, which will be updated by the useLayoutEffect below + const [stickyHeaderHeight, setStickyHeaderHeight] = useState(0); + /** Get the width of each sticky column (using the header width as reference) and use it to set the `left` of each sticky column. * Each sticky column must have as `left` the total width of the previous sticky columns. */ @@ -206,35 +231,55 @@ export function Table>>({ const columnWidths = stickyLeftColumnsIds.map( (id) => document.getElementById(`header-cell-${id}`)!.clientWidth ); + const columnGroupWidths = stickyLeftColumnGroupsIds.map( + (id) => document.getElementById(`header-cell-${id}`)!.clientWidth + ); + const columnGroupHeight = Math.max( + ...headerGroups[0].headers.map((h) => + h.columns ? document.getElementById(`header-cell-${h.id}`)!.clientHeight : 0 + ) + ); - const columnStyles = stickyLeftColumnsIds.reduce((styles, id, index) => { - if (index > 0) { - const totalLeftWidth = columnWidths - .filter((_w, i) => i < index) - .reduce((acc, w) => acc + w, 0); - return { - ...styles, - [id]: { - left: totalLeftWidth, - zIndex: zIndexes.leftStickyCell, - position: "sticky", - } as CSSProperties, - }; - } else { - return { - ...styles, - [id]: { - left: 0, - zIndex: zIndexes.leftStickyCell, - position: "sticky", - } as CSSProperties, - }; - } - }, {} as Record); + const styleReducer = + (widths: number[]) => + (styles: Record, id: string, index: number) => { + if (index > 0) { + const totalLeftWidth = widths + .filter((_w, i) => i < index) + .reduce((acc, w) => acc + w, 0); + return { + ...styles, + [id]: { + left: totalLeftWidth, + zIndex: zIndexes.leftStickyCell, + position: "sticky", + } as CSSProperties, + }; + } else { + return { + ...styles, + [id]: { + left: 0, + zIndex: zIndexes.leftStickyCell, + position: "sticky", + } as CSSProperties, + }; + } + }; + + const columnStyles = stickyLeftColumnsIds.reduce( + styleReducer(columnWidths), + {} as Record + ); + const columnGroupStyles = stickyLeftColumnGroupsIds.reduce( + styleReducer(columnGroupWidths), + {} as Record + ); - setStickyLeftColumnStyle(columnStyles); + setStickyLeftColumnStyle({ ...columnStyles, ...columnGroupStyles }); + setStickyHeaderHeight(columnGroupHeight); } - }, [data.length, stickyLeftColumnsIds]); + }, [data.length, headerGroups, stickyLeftColumnsIds, stickyLeftColumnGroupsIds]); if (data.length === 0) { return ( @@ -265,7 +310,7 @@ export function Table>>({ .exhaustive(); } - function tableHeight(height: Props["height"]): string | undefined { + function tableHeight(height: Props["height"]): string | undefined { return match(height) .with({ custom: __.string }, ({ custom: width }) => width) .with({ custom: __.number }, ({ custom: width }) => `${width}px`) @@ -273,7 +318,7 @@ export function Table>>({ .exhaustive(); } - const gridTemplateColumns = columns + const gridTemplateColumns = flatColumns .filter(({ accessor }) => accessor !== groupBy) .map(({ gridWidth = "fit-content" }) => gridWidthStyle(gridWidth)) .join(" "); @@ -290,7 +335,7 @@ export function Table>>({ lastLeftSticky={index === lastStickyColumnIndex} style={stickyLeftColumnStyle[cell.column.id]} first={index === 0} - last={(index + 1) % columns.length === 0} + last={(index + 1) % flatColumns.length === 0} interactiveRow={interactiveRow} > {cell.render("Cell")} @@ -307,16 +352,28 @@ export function Table>>({ style={{ ...getTableProps().style, gridTemplateColumns, height: tableHeight(height) }} > {headerGroups.map((headerGroup) => - headerGroup.headers.map((column, index) => ( + headerGroup.headers.map((header, index) => ( )) )} @@ -347,7 +404,7 @@ export function Table>>({ ); } -function RowContainer>>({ +function RowContainer>>({ row, children, onPress, @@ -379,7 +436,7 @@ function ColumnHeader>({ first, last, }: { - column: ColumnInstance; + column: ColumnInstance | HeaderGroup; style: CSSProperties; lastLeftSticky: boolean; stickyHeaders?: boolean; @@ -432,7 +489,11 @@ function ColumnHeader>({ return ( = { Cell: (props: CellProps) => ReturnType; } & Omit, "accessor" | "Cell" | "width">; +export type GroupedColumn = { + Header: string; + columns: Array>; + align?: "left" | "right" | "center"; + sticky?: "left"; + hint?: LocalizedString | { onPress: () => void }; +}; + type ColumnValueByAccessor = C extends Column ? V : never;