Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow grouped table headers #769

Merged
merged 6 commits into from
Sep 21, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 10 additions & 4 deletions packages/bento-design-system/src/Table/Table.css.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
186 changes: 131 additions & 55 deletions packages/bento-design-system/src/Table/Table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
useGroupBy,
Cell,
SortingRule,
HeaderGroup,
} from "react-table";
import { IconProps } from "../Icons/IconProps";
import { useDefaultMessages } from "../util/useDefaultMessages";
Expand Down Expand Up @@ -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<C extends ReadonlyArray<ColumnType<string, {}, any>>> = (
a: Row<RowType<C>>,
b: Row<RowType<C>>
) => number;

type SortingProps<C extends ReadonlyArray<ColumnType<string, {}, any>>> =
type SortFn<
C extends
| ReadonlyArray<SimpleColumnType<string, any, any>>
| ReadonlyArray<GroupedColumnType<string, any, any>>
> = (a: Row<RowType<C>>, b: Row<RowType<C>>) => number;

type SortingProps<
C extends
| ReadonlyArray<SimpleColumnType<string, any, any>>
| ReadonlyArray<GroupedColumnType<string, any, any>>
> =
| {
/**
* `customSorting` can be used to customize the sorting logic of the table. It supports cross-columns comparison.
Expand All @@ -74,10 +86,18 @@ type SortingProps<C extends ReadonlyArray<ColumnType<string, {}, any>>> =
onSort?: never;
};

type Props<C extends ReadonlyArray<ColumnType<string, {}, any>>> = {
type Props<
C extends
| ReadonlyArray<SimpleColumnType<string, any, any>>
| ReadonlyArray<GroupedColumnType<string, any, any>>
> = {
columns: C;
data: ReadonlyArray<RowType<C>>;
groupBy?: C[number]["accessor"];
groupBy?: C extends ReadonlyArray<SimpleColumnType<string, any, any>>
? C[number]["accessor"]
: C extends ReadonlyArray<GroupedColumnType<string, any, any>>
? C[number]["columns"][number]["accessor"]
: never;
noResultsTitle?: LocalizedString;
noResultsDescription?: LocalizedString;
noResultsFeedbackSize?: FeedbackProps["size"];
Expand Down Expand Up @@ -106,7 +126,11 @@ type Props<C extends ReadonlyArray<ColumnType<string, {}, any>>> = {
* <Table columns={[tableColumn(...), tableColumn(...)]} data={data} />
* ```
*/
export function Table<C extends ReadonlyArray<ColumnType<string, {}, any>>>({
export function Table<
C extends
| ReadonlyArray<SimpleColumnType<string, any, any>>
| ReadonlyArray<GroupedColumnType<string, any, any>>
>({
columns,
data,
groupBy,
Expand Down Expand Up @@ -146,6 +170,11 @@ export function Table<C extends ReadonlyArray<ColumnType<string, {}, any>>>({
[customSorting]
);

const flatColumns = useMemo(
() => columns.flatMap((c) => ("columns" in c ? c.columns : [c])),
[columns]
);

const {
getTableProps,
headerGroups,
Expand Down Expand Up @@ -179,15 +208,19 @@ export function Table<C extends ReadonlyArray<ColumnType<string, {}, any>>>({
// 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]);

Expand All @@ -196,6 +229,9 @@ export function Table<C extends ReadonlyArray<ColumnType<string, {}, any>>>({
{} as Record<string, CSSProperties>
);

// 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.
*/
Expand All @@ -206,35 +242,55 @@ export function Table<C extends ReadonlyArray<ColumnType<string, {}, any>>>({
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<string, CSSProperties>);
const styleReducer =
(widths: number[]) =>
(styles: Record<string, CSSProperties>, 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<string, CSSProperties>
);
const columnGroupStyles = stickyLeftColumnGroupsIds.reduce(
styleReducer(columnGroupWidths),
{} as Record<string, CSSProperties>
);

setStickyLeftColumnStyle(columnStyles);
setStickyLeftColumnStyle({ ...columnStyles, ...columnGroupStyles });
setStickyHeaderHeight(columnGroupHeight);
}
}, [data.length, stickyLeftColumnsIds]);
}, [data.length, headerGroups, stickyLeftColumnsIds, stickyLeftColumnGroupsIds]);

if (data.length === 0) {
return (
Expand Down Expand Up @@ -273,7 +329,7 @@ export function Table<C extends ReadonlyArray<ColumnType<string, {}, any>>>({
.exhaustive();
}

const gridTemplateColumns = columns
const gridTemplateColumns = flatColumns
.filter(({ accessor }) => accessor !== groupBy)
.map(({ gridWidth = "fit-content" }) => gridWidthStyle(gridWidth))
.join(" ");
Expand All @@ -290,7 +346,7 @@ export function Table<C extends ReadonlyArray<ColumnType<string, {}, any>>>({
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")}
Expand All @@ -307,16 +363,28 @@ export function Table<C extends ReadonlyArray<ColumnType<string, {}, any>>>({
style={{ ...getTableProps().style, gridTemplateColumns, height: tableHeight(height) }}
>
{headerGroups.map((headerGroup) =>
headerGroup.headers.map((column, index) => (
headerGroup.headers.map((header, index) => (
<ColumnHeader
column={column}
key={column.id}
style={stickyLeftColumnStyle[column.id]}
lastLeftSticky={index === lastStickyColumnIndex}
column={header}
key={header.id}
style={{
...stickyLeftColumnStyle[header.id],
...assignInlineVars({
[stickyTopHeight]: header.columns ? "0" : `${stickyHeaderHeight}px`,
}),
}}
lastLeftSticky={
header.columns
? header.id === stickyLeftColumnGroupsIds.at(-1)
: index === lastStickyColumnIndex
}
stickyHeaders={stickyHeaders}
sticky={stickyLeftColumnsIds.includes(column.id)}
sticky={
stickyLeftColumnsIds.includes(header.id) ||
stickyLeftColumnGroupsIds.includes(header.id)
}
first={index === 0}
last={index + 1 === columns.length}
last={index + 1 === flatColumns.length}
/>
))
)}
Expand Down Expand Up @@ -347,7 +415,11 @@ export function Table<C extends ReadonlyArray<ColumnType<string, {}, any>>>({
);
}

function RowContainer<C extends ReadonlyArray<ColumnType<string, {}, any>>>({
function RowContainer<
C extends
| ReadonlyArray<SimpleColumnType<string, any, any>>
| ReadonlyArray<GroupedColumnType<string, any, any>>
>({
row,
children,
onPress,
Expand Down Expand Up @@ -379,7 +451,7 @@ function ColumnHeader<D extends Record<string, unknown>>({
first,
last,
}: {
column: ColumnInstance<D>;
column: ColumnInstance<D> | HeaderGroup<D>;
style: CSSProperties;
lastLeftSticky: boolean;
stickyHeaders?: boolean;
Expand Down Expand Up @@ -432,7 +504,11 @@ function ColumnHeader<D extends Record<string, unknown>>({
return (
<Box
className={[lastLeftSticky && lastLeftStickyColumn, stickyHeaders && stickyColumnHeader]}
style={{ ...style, zIndex: sticky ? zIndexes.leftStickyHeader : zIndexes.header }}
style={{
...style,
gridColumnEnd: column.columns ? `span ${column.columns.length}` : undefined,
zIndex: sticky ? zIndexes.leftStickyHeader : zIndexes.header,
}}
>
<Box
className={columnHeader}
Expand Down Expand Up @@ -557,7 +633,7 @@ export type {
Row as TableRow,
} from "react-table";

export type { Column as ColumnType, Row as RowType } from "./types";
export type { Column as SimpleColumnType, Row as RowType } from "./types";

export type { ColumnOptionsBase } from "./tableColumn";
export type { Props as TableProps };
Expand Down
6 changes: 3 additions & 3 deletions packages/bento-design-system/src/Table/tableColumn.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,8 @@ export type ColumnOptionsBase<A> = {
accessor: A;
headerLabel?: LocalizedString;
missingValue?: LocalizedString;
width?: Column_<{}>["gridWidth"];
} & Omit<Column_<{}>, "accessor" | "Header" | "Cell" | "sortType" | "width" | "gridWidth">;
width?: Column_<any>["gridWidth"];
} & Omit<Column_<any>, "accessor" | "Header" | "Cell" | "sortType" | "width" | "gridWidth">;

export function custom<A extends string, V, D extends Record<string, unknown>>({
headerLabel,
Expand All @@ -47,7 +47,7 @@ export function custom<A extends string, V, D extends Record<string, unknown>>({
const column = {
...options,
gridWidth: width,
Cell: (props) => {
Cell: (props: CellProps<D, V>) => {
const { defaultMessages } = useDefaultMessages();
const config = useBentoConfig().table;
if (props.value == null) {
Expand Down
27 changes: 24 additions & 3 deletions packages/bento-design-system/src/Table/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,12 +29,33 @@ export type Column<A extends string, D extends object, V> = {
Cell: (props: CellProps<D, V>) => ReturnType<FunctionComponent>;
} & Omit<Column_<D>, "accessor" | "Cell" | "width">;

export type GroupedColumn<A extends string, D extends object, V> = {
Header: string;
columns: Array<Column<A, D, V>>;
align?: "left" | "right" | "center";
sticky?: "left";
hint?: LocalizedString | { onPress: () => void };
};

type ColumnValueByAccessor<C, K extends string> = C extends Column<K, infer _D, infer V>
? V
: never;

export type Row<Columns extends ReadonlyArray<Column<string, {}, any>>> = {
[k in Columns[number]["accessor"]]?: ColumnValueByAccessor<Columns[number], k>;
};
export type Row<
Columns extends
| ReadonlyArray<Column<string, any, any>>
| ReadonlyArray<GroupedColumn<string, any, any>>
> = Columns[number] extends GroupedColumn<string, any, any>
? {
[k in Columns[number]["columns"][number]["accessor"]]?: ColumnValueByAccessor<
Columns[number]["columns"][number],
k
>;
}
: Columns[number] extends Column<string, any, any>
? {
[k in Columns[number]["accessor"]]?: ColumnValueByAccessor<Columns[number], k>;
}
: never;

export type GridWidth = "fit-content" | "fill-available" | { custom: string | number };
2 changes: 1 addition & 1 deletion packages/bento-design-system/src/useComponentsShowcase.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -556,7 +556,7 @@ export function useComponentsShowcase({ action }: { action: (s: string) => () =>
experiments: 0,
status: { label: "pending", color: "yellow" },
},
],
] as any,
},
],
],
Expand Down
Loading