import * as React from "react"; import { cx } from "@emotion/css"; import * as style from "./style"; import { Draggable, Sorter } from "./Util"; import { ResetButton } from "../button"; import { useIntersect } from "../utilities/useIntersect"; export type Column = { /** * An id given to that column. Needed for persistence. */ id: B; /** * Renderer for the cells of that column. */ render: (a: A) => React.ReactNode; /** * Render a header cell */ header?: React.ReactNode; /** * A function to sort the entries of the table. * It first needs to handle a direction and then has to compare two elements. * e.g. (dir: "asc" | "desc", dirAsNum: 1 | -1) => (a: number, b: number ) => dirAsNum * (a - b) */ sorter?: Sorter; /** * The initial width of that column. Will be interpolated to a grid-template: * https://developer.mozilla.org/en-US/docs/Web/CSS/grid-template-columns * Fine: * - `"20px"` * - `"10%"` * - `"1fr"` * - `"mixmax(100px, 1fr)"` * - ... * Note that each row calculates its own grid, so using e.g max-content might not be a good idea. */ initialWidth?: string; /** * Text alignment for cells in the column */ textAlign?: React.CSSProperties["textAlign"]; /** * The contentNoWrap prop sets the `whitespace` to `nowrap` which prevents content from wrapping within the cell. The default is set to true to preserve truncation backwards compatibility. */ contentNoWrap?: boolean; }; // we internally use a different type than we expose in the API. type InternalColumn = Column & { width: string }; type TableProps = { /** * Table columns */ columns: Array>; /** * The ID and direction to initially sort by. */ initialSorter?: { by: C; order?: "asc" | "desc" }; /** * A callback invoked whenever the internal state of the table is changed. * Have a look at the State type to see what's in there. */ onStateChange?: (a: State) => void; /** * Whether the first column shall be sticky. Defaults to true. */ stickyFirstCol?: boolean; /** * A function that provides a stable and unique ID for entries in the table. */ toId: (a: A) => string; }; // DON'T put stuff here that you don't want persisted. type State = { sortBy: string | null; order: "asc" | "desc"; columns: Array<{ id: string; width: string | null; }>; }; const rowClassName = ( columns: Array>, stickyFirstCol?: boolean ) => cx(style.row(columns.map(c => c.width)), { [style.rowWithStickyColumn]: stickyFirstCol }); type HeaderCellProps = { column: Column; id?: string; state: State; textAlign?: "left" | "right" | "center"; update: (a: Partial) => void; showScrollShadow?: boolean; }; const ariaSortStringMap: { asc: "ascending"; desc: "descending" } = { asc: "ascending", desc: "descending" }; function HeaderCell({ column, id, update, state, showScrollShadow }: HeaderCellProps) { const generatedId = `headerCell-${React.useId()}`; const headerCellId = id || generatedId; // -- WIDTH -- const cell = React.useRef(null); const [width, setWidth] = React.useState(0); const onRelativeXChange = (change: number) => { const newWidth = Math.max(width + change, 50); state.columns.find(c => c.id === id)!.width = `${newWidth}px`; update({ columns: state.columns.slice(0) }); }; React.useEffect(() => setWidth(cell.current?.clientWidth ?? 0)); // -- SORTING -- const order = state.sortBy === headerCellId ? state.order : null; const onClick = column.sorter && (() => update({ sortBy: headerCellId, order: order === "asc" ? "desc" : "asc" })); const header = column.sorter ? ( {column.header} {Boolean(column.sorter) && ( )} ) : ( column.header ); return (
{header}
); } type HeaderRowProps = { columns: Array>; stickyFirstCol?: boolean; state: State; update: (a: Partial) => void; showScrollShadow: boolean; }; function HeaderRow({ columns, state, stickyFirstCol = true, update, showScrollShadow }: HeaderRowProps) { const toHeaderCell = ({ id }) => ( c.id === id)!} update={update} state={state} showScrollShadow={showScrollShadow} /> ); const className = cx(rowClassName(columns, stickyFirstCol), style.headerRow); return (
{columns.map(toHeaderCell)}
); } const getWidth = (col: Column, state: State["columns"]) => state.find(s => s.id === col.id)?.width || col.initialWidth || "1fr"; type DivProps = React.HTMLAttributes; export function Table({ data, columns, initialSorter, onStateChange, stickyFirstCol = true, toId, children, ...divProps }: { data: readonly Entry[] } & DivProps & TableProps) { const [state, setState] = React.useState({ columns: columns.map(c => ({ id: c.id, width: c.initialWidth || null })), order: initialSorter?.order ?? "asc", sortBy: initialSorter?.by ?? null }); const tableRef = React.useRef(null); const [ref, entry] = useIntersect({ root: tableRef.current }); const update = (x: Partial) => { setState({ ...state, ...x }); onStateChange?.({ ...state, ...x }); }; const col = columns.find(c => c.id === state.sortBy); const sort = col?.sorter?.(state.order, state.order === "asc" ? 1 : -1) ?? (() => 0); const sortedData = [...data].sort(sort); const internalColumns: Array> = columns.map(c => { return { ...c, width: getWidth(c, state.columns) }; }); const toRow = (el: Entry) => ( ); return (
{sortedData.map(toRow)}
); } type RowProps = { columns: Array>; el: Data; stickyFirstCol: boolean; toId: TableProps["toId"]; showScrollShadow: boolean; }; const Row = React.memo(function Row
({ columns, el, stickyFirstCol, toId, showScrollShadow = false }: RowProps) { const rowId = toId(el); const className = cx(rowClassName(columns, stickyFirstCol), style.contentRow); return (
{columns.map( ({ id: colID, render, textAlign, sorter, contentNoWrap = true }) => (
{render(el)}
) )}
); });