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..bf0e45453 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,32 @@ 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>> = ( - a: Row>, - b: Row> -) => number; - -type SortingProps>> = +type SortFn< + C extends + | ReadonlyArray> + | ReadonlyArray> +> = (a: Row>, b: Row>) => number; + +type SortingProps< + C extends + | ReadonlyArray> + | ReadonlyArray> +> = | { /** * `customSorting` can be used to customize the sorting logic of the table. It supports cross-columns comparison. @@ -74,10 +86,18 @@ type SortingProps>> = onSort?: never; }; -type Props>> = { +type Props< + C extends + | ReadonlyArray> + | ReadonlyArray> +> = { columns: C; data: ReadonlyArray>; - groupBy?: C[number]["accessor"]; + groupBy?: C extends ReadonlyArray> + ? C[number]["accessor"] + : C extends ReadonlyArray> + ? C[number]["columns"][number]["accessor"] + : never; noResultsTitle?: LocalizedString; noResultsDescription?: LocalizedString; noResultsFeedbackSize?: FeedbackProps["size"]; @@ -106,7 +126,11 @@ type Props>> = { * * ``` */ -export function Table>>({ +export function Table< + C extends + | ReadonlyArray> + | ReadonlyArray> +>({ columns, data, groupBy, @@ -146,6 +170,11 @@ export function Table>>({ [customSorting] ); + const flatColumns = useMemo( + () => columns.flatMap((c) => ("columns" in c ? c.columns : [c])), + [columns] + ); + const { getTableProps, headerGroups, @@ -179,15 +208,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 +229,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 +242,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 ( @@ -273,7 +329,7 @@ export function Table>>({ .exhaustive(); } - const gridTemplateColumns = columns + const gridTemplateColumns = flatColumns .filter(({ accessor }) => accessor !== groupBy) .map(({ gridWidth = "fit-content" }) => gridWidthStyle(gridWidth)) .join(" "); @@ -290,7 +346,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 +363,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 +415,11 @@ export function Table>>({ ); } -function RowContainer>>({ +function RowContainer< + C extends + | ReadonlyArray> + | ReadonlyArray> +>({ row, children, onPress, @@ -379,7 +451,7 @@ function ColumnHeader>({ first, last, }: { - column: ColumnInstance; + column: ColumnInstance | HeaderGroup; style: CSSProperties; lastLeftSticky: boolean; stickyHeaders?: boolean; @@ -432,7 +504,11 @@ function ColumnHeader>({ return ( = { accessor: A; headerLabel?: LocalizedString; missingValue?: LocalizedString; - width?: Column_<{}>["gridWidth"]; -} & Omit, "accessor" | "Header" | "Cell" | "sortType" | "width" | "gridWidth">; + width?: Column_["gridWidth"]; +} & Omit, "accessor" | "Header" | "Cell" | "sortType" | "width" | "gridWidth">; export function custom>({ headerLabel, @@ -47,7 +47,7 @@ export function custom>({ const column = { ...options, gridWidth: width, - Cell: (props) => { + Cell: (props: CellProps) => { const { defaultMessages } = useDefaultMessages(); const config = useBentoConfig().table; if (props.value == null) { diff --git a/packages/bento-design-system/src/Table/types.ts b/packages/bento-design-system/src/Table/types.ts index cb2f46f4d..ce6409c8e 100644 --- a/packages/bento-design-system/src/Table/types.ts +++ b/packages/bento-design-system/src/Table/types.ts @@ -29,12 +29,33 @@ export type Column = { 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; -export type Row>> = { - [k in Columns[number]["accessor"]]?: ColumnValueByAccessor; -}; +export type Row< + Columns extends + | ReadonlyArray> + | ReadonlyArray> +> = Columns[number] extends GroupedColumn + ? { + [k in Columns[number]["columns"][number]["accessor"]]?: ColumnValueByAccessor< + Columns[number]["columns"][number], + k + >; + } + : Columns[number] extends Column + ? { + [k in Columns[number]["accessor"]]?: ColumnValueByAccessor; + } + : never; export type GridWidth = "fit-content" | "fill-available" | { custom: string | number }; diff --git a/packages/bento-design-system/src/useComponentsShowcase.tsx b/packages/bento-design-system/src/useComponentsShowcase.tsx index 1dd64edd7..c574af335 100644 --- a/packages/bento-design-system/src/useComponentsShowcase.tsx +++ b/packages/bento-design-system/src/useComponentsShowcase.tsx @@ -556,7 +556,7 @@ export function useComponentsShowcase({ action }: { action: (s: string) => () => experiments: 0, status: { label: "pending", color: "yellow" }, }, - ], + ] as any, }, ], ], diff --git a/packages/bento-design-system/stories/Components/Table.stories.tsx b/packages/bento-design-system/stories/Components/Table.stories.tsx index 692a94ed0..5eafc02ec 100644 --- a/packages/bento-design-system/stories/Components/Table.stories.tsx +++ b/packages/bento-design-system/stories/Components/Table.stories.tsx @@ -78,6 +78,84 @@ const exampleColumns = [ }), ] as const; +const exampleGroupedColumns = [ + { + Header: "Group 1", + sticky: "left" as const, + hint: "This is a hint", + columns: [ + tableColumn.button({ + headerLabel: "Button", + accessor: "button", + sticky: "left", + disableSortBy: true, + align: "center", + }), + tableColumn.text({ + headerLabel: "Name", + accessor: "name", + }), + tableColumn.text({ + headerLabel: "Extended address", + accessor: "address", + width: { custom: 200 }, + }), + tableColumn.textWithIcon({ + headerLabel: "Country", + accessor: "country", + iconPosition: "right", + hint: "This is a hint", + }), + ], + }, + { + Header: "Group 2", + columns: [ + tableColumn.number({ + headerLabel: "Applications", + accessor: "applications", + valueFormatter: (value) => Intl.NumberFormat("en").format(value), + align: "right", + hint: { onPress: action("hint") }, + }), + tableColumn.numberWithIcon({ + headerLabel: "Value", + accessor: "value", + valueFormatter: (value) => Intl.NumberFormat("en").format(value), + align: "right", + }), + tableColumn.label({ + headerLabel: "Type", + accessor: "type", + }), + tableColumn.link({ + headerLabel: "Website", + accessor: "website", + }), + ], + }, + { + Header: "Group 3", + columns: [ + tableColumn.icon({ + headerLabel: "Alerts", + accessor: "alerts", + }), + tableColumn.chip({ + headerLabel: "Status", + accessor: "status", + align: "center", + }), + tableColumn.iconButton({ + headerLabel: "Actions", + accessor: "deleteAction", + align: "center", + disableSortBy: true, + }), + ], + }, +]; + const customizedColumns = [ tableColumn.button({ headerLabel: "Button", @@ -290,7 +368,7 @@ const meta = { data: exampleData, }, parameters: { actions: { argTypesRegex: "" } }, -} satisfies Meta>; +} satisfies Meta>; export default meta; @@ -473,3 +551,11 @@ export const InteractiveRow = { onRowPress: action("onRowPress"), }, } satisfies Story; + +export const GroupedHeaders = { + args: { + columns: exampleGroupedColumns, + stickyHeaders: true, + height: { custom: 320 }, + }, +} satisfies Story; diff --git a/packages/bento-design-system/test/Table.tsx b/packages/bento-design-system/test/Table.tsx index f06fbeb94..35d2da88c 100644 --- a/packages/bento-design-system/test/Table.tsx +++ b/packages/bento-design-system/test/Table.tsx @@ -52,3 +52,29 @@ const columns = [ // passing columns inline groupBy="error" />; + +
;