diff --git a/src/lib/components/Table/Table.mdx b/src/lib/components/Table/Table.mdx index c860fb2..cdf8547 100644 --- a/src/lib/components/Table/Table.mdx +++ b/src/lib/components/Table/Table.mdx @@ -169,3 +169,58 @@ will be called for each row. This function should return a string which is the c type. + +## ColSpan + +**colSpan** merges adjacent cells horizontally within a row. It can be provided as either a static number or a function. + +#### Static colSpan + +A static number merges the column with the given number of following columns everywhere — header, filter row, and body +cells all collapse together. The absorbed columns' header labels and filter inputs are hidden, since they no longer have +a visible cell to render into. + +```tsx +const columns = [ + { title: "Personal Info", dataKey: "fullName", colSpan: 2 }, + { title: "Age", dataKey: "age" }, + { title: "City", dataKey: "city" }, +]; +``` + +If an absorbed column has a **footer**, its computed value (sum, avg, etc.) is still surfaced inside the spanning footer +cell, so aggregated data is never silently lost. + +#### Function colSpan + +When **colSpan** is a function, it receives the row's data object and returns the span value for that row. This form +applies **only to body cells** — the header and filter row always show all column labels, since they have no row data +to evaluate the function with. This makes function colSpan ideal for row-dependent merging where some rows should be +collapsed and others should not. + + + +## RowSpan + +**rowSpan** merges a cell downward across consecutive rows. Like colSpan, it can be provided as a static number or a +function that receives the row's data object. + +A common pattern is to span grouped rows together: when the first row of a group returns the group size as the span +value, subsequent rows in the group return 1 (or omit the prop), and the first row's cell visually covers all of them. + +```tsx +const columns = [ + { + title: "Name", + dataKey: "name", + rowSpan: (row) => row.surname === "Surname 1" || row.surname === "Surname 3" ? 2 : 1, + }, + { title: "Surname", dataKey: "surname" }, + { title: "Age", dataKey: "age" }, +]; +``` + +The table automatically suppresses the covered cells so the DOM stays valid. RowSpan is clamped to the number of +remaining rows on the current page, so spans never bleed across pagination boundaries. + + diff --git a/src/lib/components/Table/Table.module.scss b/src/lib/components/Table/Table.module.scss index 86ac022..1f05c66 100644 --- a/src/lib/components/Table/Table.module.scss +++ b/src/lib/components/Table/Table.module.scss @@ -133,6 +133,9 @@ $row-color-variants: "primary", "secondary", "light", "success", "danger", "warn .bordered { &.cellBorders { + table:has(thead [colspan], thead [rowspan], tbody [colspan], tbody [rowspan]) { + border-collapse: collapse; + } td, th { border-color: var(--theme-color-border-light-default); @@ -203,7 +206,7 @@ $row-color-variants: "primary", "secondary", "light", "success", "danger", "warn } &.rowBorders { - table { + table:has(thead [colspan], thead [rowspan], tbody [colspan], tbody [rowspan]) { border-collapse: collapse; } tr { diff --git a/src/lib/components/Table/Table.stories.tsx b/src/lib/components/Table/Table.stories.tsx index 28c9b94..91da23c 100644 --- a/src/lib/components/Table/Table.stories.tsx +++ b/src/lib/components/Table/Table.stories.tsx @@ -413,11 +413,7 @@ export const Filtering: Story = { { title: "Full Name", dataKey: "name", filter: true }, { title: "Age", dataKey: "age", filter: true }, ]; - return ( -
- - - ); + return
; }, }; @@ -448,9 +444,7 @@ export const Selection: Story = { { title: "Age", dataKey: "age", sorting: {} }, ]; return ( -
-
alert(JSON.stringify(selection))} /> - +
alert(JSON.stringify(selection))} /> ); }, }; @@ -526,11 +520,7 @@ export const RowNumbers: Story = { { title: "Name", dataKey: "name", sorting: {} }, { title: "Age", dataKey: "age", sorting: {} }, ]; - return ( -
-
- - ); + return
; }, }; @@ -561,13 +551,64 @@ export const RowColoring: Story = { { title: "Age", dataKey: "age" }, ]; return ( -
-
(rowData.age > 80 ? "danger" : rowData.age < 30 ? "success" : undefined)} - /> - +
(rowData.age > 80 ? "danger" : rowData.age < 30 ? "success" : undefined)} + /> ); }, }; + +export const Colspan: Story = { + render: () => { + type RowData = { fullName: string; age: number; city: string; merged: boolean }; + const data: RowData[] = [ + { fullName: "John Doe", age: 28, city: "New York", merged: true }, + { fullName: "Jane Smith", age: 34, city: "Los Angeles", merged: true }, + { fullName: "Alice Johnson", age: 29, city: "Chicago", merged: false }, + { fullName: "Bob Williams", age: 42, city: "Houston", merged: false }, + ]; + const columns = [ + { + title: "Personal Info", + dataKey: "fullName", + colSpan: (row: unknown) => ((row as RowData).merged ? 2 : 1), + }, + { + title: "Age", + dataKey: "age", + }, + { + title: "City", + dataKey: "city", + }, + ]; + return
; + }, +}; + +export const Rowspan: Story = { + render: () => { + type RowData = { name: string; surname: string; age: number }; + const data = [ + { name: "Name 1", surname: "Surname 1", age: 25 }, + { name: "Name 1", surname: "Surname 2", age: 30 }, + { name: "Name 2", surname: "Surname 3", age: 22 }, + { name: "Name 2", surname: "Surname 4", age: 28 }, + ]; + const columns = [ + { + title: "Name", + dataKey: "name", + rowSpan: (row: object) => { + const rowData = row as RowData; + return rowData.surname === "Surname 1" || rowData.surname === "Surname 3" ? 2 : 1; + }, + }, + { title: "Surname", dataKey: "surname" }, + { title: "Age", dataKey: "age" }, + ]; + return
; + }, +}; diff --git a/src/lib/components/Table/Table.test.tsx b/src/lib/components/Table/Table.test.tsx index af01f01..38b430c 100644 --- a/src/lib/components/Table/Table.test.tsx +++ b/src/lib/components/Table/Table.test.tsx @@ -18,9 +18,13 @@ describe("Table", () => { const getSelectAllCheckbox = () => getCheckboxes()[0].firstElementChild as HTMLInputElement; const getPaginationBar = () => queryByTestId("pagination") as HTMLDivElement; const getSortButton = () => container.getElementsByClassName("sortButton")[0]; - const getFirstRow = () => getTableBody().firstElementChild as HTMLTableRowElement; const getTableBody = () => getByTestId("TableBody") as HTMLDivElement; + const getFirstRow = () => getTableBody().firstElementChild as HTMLTableRowElement; const getCountText = () => getByTestId("FooterForNumbers").firstElementChild as HTMLDivElement; + const getHeaderCells = () => within(getByTestId("TableHead").firstElementChild as HTMLTableRowElement).getAllByRole("columnheader"); + const getFilterCells = () => (getByTestId("TableHead").lastElementChild as HTMLTableRowElement).querySelectorAll("th"); + const getBodyCells = () => within(getFirstRow()).getAllByRole("cell"); + const getFooterCells = () => within(getByTestId("TableFooter").firstElementChild as HTMLTableRowElement).getAllByRole("columnheader"); return { ...result, @@ -33,6 +37,10 @@ describe("Table", () => { getCountText, getTableBody, getTableContainer, + getHeaderCells, + getFilterCells, + getBodyCells, + getFooterCells, }; }; @@ -685,4 +693,296 @@ describe("Table", () => { rerender(
); expect(getTableContainer()).not.toHaveClass("fluid"); }); + + it("should render static colSpan across header, filter, body and footer cells", () => { + const { queryByText, getHeaderCells, getFilterCells, getBodyCells, getFooterCells } = renderExt( +
, + ); + + const headerCells = getHeaderCells(); + expect(headerCells).toHaveLength(2); + expect(headerCells[0]).toHaveAttribute("colspan", "2"); + expect(headerCells[0]).toHaveTextContent("Name"); + expect(headerCells[1]).toHaveTextContent("Amount"); + expect(queryByText("Skipped")).not.toBeInTheDocument(); + + const filterCells = getFilterCells(); + expect(filterCells).toHaveLength(2); + expect(filterCells[0]).toHaveAttribute("colspan", "2"); + + const bodyCells = getBodyCells(); + expect(bodyCells).toHaveLength(2); + expect(bodyCells[0]).toHaveAttribute("colspan", "2"); + expect(bodyCells[0]).toHaveTextContent("John"); + + const footerCells = getFooterCells(); + expect(footerCells).toHaveLength(2); + expect(footerCells[0]).toHaveAttribute("colspan", "2"); + expect(footerCells[0]).toHaveTextContent("99"); + expect(footerCells[1]).toHaveTextContent("10"); + }); + + it("should render static rowSpan attribute in cells and skip covered rows", () => { + const { getFirstRow, getTableBody } = renderExt( +
, + ); + + const firstRowCells = within(getFirstRow()).getAllByRole("cell"); + expect(firstRowCells[0]).toHaveAttribute("rowspan", "2"); + + const secondRowCells = within(getTableBody().children[1] as HTMLTableRowElement).getAllByRole("cell"); + expect(secondRowCells).toHaveLength(1); + expect(secondRowCells[0]).toHaveTextContent("25"); + }); + + it("should not render cells covered by previous colspan", () => { + const { getBodyCells } = renderExt( +
, + ); + + const cells = getBodyCells(); + expect(cells).toHaveLength(2); + expect(cells[0]).toHaveTextContent("John"); + expect(cells[1]).toHaveTextContent("Active"); + }); + + it("should render dynamic colSpan in body cells only and recalculate it when data changes", () => { + const getNameColSpan = (rowData: object) => ("name" in rowData && rowData.name === "John" ? 2 : 1); + const { getHeaderCells, getBodyCells, rerender } = renderExt( +
, + ); + + expect(getHeaderCells()).toHaveLength(2); + expect(getHeaderCells()[0]).not.toHaveAttribute("colspan"); + + let cells = getBodyCells(); + expect(cells.length).toBe(1); + expect(cells[0]).toHaveAttribute("colspan", "2"); + + rerender( +
, + ); + cells = getBodyCells(); + expect(cells.length).toBe(2); + expect(cells[0]).not.toHaveAttribute("colspan"); + }); + + it("should clamp spans to the available columns and rows", () => { + const { getTableBody, getHeaderCells, getBodyCells } = renderExt( +
, + ); + + const headerCells = getHeaderCells(); + expect(headerCells).toHaveLength(1); + expect(headerCells[0]).toHaveAttribute("colspan", "2"); + + const firstRowCells = getBodyCells(); + expect(firstRowCells).toHaveLength(1); + expect(firstRowCells[0]).toHaveAttribute("colspan", "2"); + expect(firstRowCells[0]).toHaveAttribute("rowspan", "2"); + + expect(within(getTableBody().children[1] as HTMLTableRowElement).queryAllByRole("cell")).toHaveLength(0); + }); + + it("should handle invalid span values", () => { + const { getBodyCells } = renderExt( +
, + ); + + const cells = getBodyCells(); + expect(cells).toHaveLength(2); + expect(cells[0]).not.toHaveAttribute("colspan"); + expect(cells[0]).not.toHaveAttribute("rowspan"); + }); + + it("should correctly handle rowSpan with filtered data", async () => { + const { container, getTableBody } = renderExt( +
, + ); + + const filterInputs = container.querySelectorAll('[data-testid="inputItem"]'); + const groupColumnFilterInput = filterInputs[filterInputs.length - 1].querySelector("input") as HTMLInputElement; + + await userEvent.type(groupColumnFilterInput, "A"); + + const rows = Array.from(getTableBody().children) as HTMLTableRowElement[]; + expect(rows).toHaveLength(2); + + const firstRowCells = within(rows[0]).getAllByRole("cell"); + expect(firstRowCells[0]).toHaveTextContent("A"); + expect(firstRowCells[0]).toHaveAttribute("rowspan", "2"); + + const secondRowCells = within(rows[1]).getAllByRole("cell"); + expect(secondRowCells).toHaveLength(2); + expect(secondRowCells[0]).toHaveTextContent("Charlie"); + }); + + it("should render dynamic rowSpan in body cells based on row data", () => { + const { getFirstRow, getTableBody } = renderExt( +
("spanned" in row && row.spanned ? 2 : 1), + }, + { title: "Age", dataKey: "age" }, + ]} + data={[ + { name: "Alice", age: 28, spanned: true }, + { name: "Alice", age: 30, spanned: false }, + { name: "Bob", age: 25, spanned: false }, + ]} + />, + ); + + const firstRowCells = within(getFirstRow()).getAllByRole("cell"); + expect(firstRowCells[0]).toHaveAttribute("rowspan", "2"); + expect(firstRowCells[0]).toHaveTextContent("Alice"); + + const secondRowCells = within(getTableBody().children[1] as HTMLTableRowElement).getAllByRole("cell"); + expect(secondRowCells).toHaveLength(1); + expect(secondRowCells[0]).toHaveTextContent("30"); + + const thirdRowCells = within(getTableBody().children[2] as HTMLTableRowElement).getAllByRole("cell"); + expect(thirdRowCells).toHaveLength(2); + expect(thirdRowCells[0]).toHaveTextContent("Bob"); + }); + + it("should recalculate dynamic rowSpan after data is sorted", async () => { + const { getTableBody, getSortButton } = renderExt( +
("spanned" in row && row.spanned ? 2 : 1), + }, + { title: "Age", dataKey: "age", sorting: {} }, + ]} + data={[ + { name: "Alice", age: 28, spanned: true }, + { name: "Bob", age: 25 }, + { name: "Charlie", age: 30 }, + ]} + />, + ); + + // Before sort: Alice is row 0, her rowspan=2 absorbs row 1's Name cell + let rows = Array.from(getTableBody().children) as HTMLTableRowElement[]; + expect(within(rows[0]).getAllByRole("cell")[0]).toHaveAttribute("rowspan", "2"); + expect(within(rows[1]).getAllByRole("cell")).toHaveLength(1); + + // Sort ascending by age → Bob(25), Alice(28), Charlie(30) + await userEvent.click(getSortButton()); + + rows = Array.from(getTableBody().children) as HTMLTableRowElement[]; + expect(within(rows[0]).getAllByRole("cell")[0]).not.toHaveAttribute("rowspan"); + expect(within(rows[0]).getAllByRole("cell")[0]).toHaveTextContent("Bob"); + expect(within(rows[1]).getAllByRole("cell")[0]).toHaveAttribute("rowspan", "2"); + expect(within(rows[1]).getAllByRole("cell")[0]).toHaveTextContent("Alice"); + expect(within(rows[2]).getAllByRole("cell")).toHaveLength(1); + }); + + it("should add a spacer cell in the footer row to align with the selectable checkbox column", () => { + const { getFooterCells } = renderExt( +
, + ); + + const footerCells = getFooterCells(); + expect(footerCells).toHaveLength(2); + expect(footerCells[0]).toHaveAttribute("colspan", "2"); + expect(footerCells[1]).toHaveTextContent("100"); + }); + + it("should add a spacer cell in the footer row to align with the showFixedRowNumbers column", () => { + const { getFooterCells } = renderExt( +
, + ); + + const footerCells = getFooterCells(); + expect(footerCells).toHaveLength(2); + expect(footerCells[0]).toHaveAttribute("colspan", "2"); + expect(footerCells[1]).toHaveTextContent("100"); + }); }); diff --git a/src/lib/components/Table/TableContext.tsx b/src/lib/components/Table/TableContext.tsx index 0f152d1..f5b5125 100644 --- a/src/lib/components/Table/TableContext.tsx +++ b/src/lib/components/Table/TableContext.tsx @@ -2,9 +2,8 @@ import { createContext, PropsWithChildren, useCallback, useEffect, useMemo, useState } from "react"; import { getNextItemInArray, getTextFromNode, getValueByChainedKey } from "../../../utils/utils"; -import { sortByType } from "@/components/Table/sorting"; +import { sortByType, SORT_DIRECTIONS, getSpannedCellsMap } from "@/components/Table/helper"; import { ColumState, RowDetail, TableContextDefaultValues, TableContextProps, TableContextType } from "@/components/Table/types"; -import { SORT_DIRECTIONS } from "@/components/Table/constants"; export const TableContext = createContext(TableContextDefaultValues); @@ -80,6 +79,8 @@ export const TableProvider = (props: PropsWithChildren) => { [currentPage, usableRows, pagination], ); + const spannedCellsMap = useMemo(() => getSpannedCellsMap(columns, visibleRows), [columns, visibleRows]); + const refillOriginalRows = useCallback(() => { const mappedData = dataRaw?.map(mapDataToMotifTableRow); setOriginalRows(mappedData); @@ -149,6 +150,7 @@ export const TableProvider = (props: PropsWithChildren) => { updateSortState, visibleRows, columns, + spannedCellsMap, columnStates, showFixedRowNumbers, setCurrentPage, @@ -170,6 +172,7 @@ export const TableProvider = (props: PropsWithChildren) => { updateSortState, visibleRows, columns, + spannedCellsMap, columnStates, showFixedRowNumbers, currentPage, diff --git a/src/lib/components/Table/components/Body/DataCell.tsx b/src/lib/components/Table/components/Body/DataCell.tsx index 0a69c3a..2b27663 100644 --- a/src/lib/components/Table/components/Body/DataCell.tsx +++ b/src/lib/components/Table/components/Body/DataCell.tsx @@ -1,16 +1,18 @@ -import { Column } from "@/components/Table/types"; +import { getSpanProps } from "@/components/Table/helper"; +import { Column, ResolvedCellSpan } from "@/components/Table/types"; import { getValueByChainedKey } from "../../../../../utils/utils"; type Props = { column: Column; rowData: object; + span: ResolvedCellSpan; }; const DataCell = (props: Props) => { - const { column, rowData } = props; + const { column, rowData, span } = props; const data = getValueByChainedKey(rowData, column.dataKey); - return ; + return ; }; export default DataCell; diff --git a/src/lib/components/Table/components/Body/DataRow.tsx b/src/lib/components/Table/components/Body/DataRow.tsx index 0d698bb..724d4fc 100644 --- a/src/lib/components/Table/components/Body/DataRow.tsx +++ b/src/lib/components/Table/components/Body/DataRow.tsx @@ -1,7 +1,6 @@ import styles from "../../Table.module.scss"; import Checkbox from "@/components/Checkbox"; import DataCell from "@/components/Table/components/Body/DataCell"; - import { useContext } from "react"; import { TableContext } from "@/components/Table/TableContext"; import { RowDetail } from "@/components/Table/types"; @@ -10,11 +9,12 @@ import { sanitizeModuleClasses } from "../../../../../utils/cssUtils"; type Props = { rowNumberStatic: number; row: RowDetail; + rowIndex: number; }; const DataRow = (props: Props) => { - const { rowNumberStatic, row } = props; - const { columns, showFixedRowNumbers, selectable, selectHandler, rowColorCallback } = useContext(TableContext); + const { rowNumberStatic, row, rowIndex } = props; + const { columns, showFixedRowNumbers, selectable, selectHandler, rowColorCallback, spannedCellsMap } = useContext(TableContext); const className = sanitizeModuleClasses(styles, row.isSelected && "selected", rowColorCallback?.(row.data)); @@ -26,9 +26,10 @@ const DataRow = (props: Props) => { )} {showFixedRowNumbers ? : null} - {columns.map((column, cIndex) => ( - - ))} + {columns.map((column, cIndex) => { + const span = spannedCellsMap.get(`${rowIndex}-${cIndex}`); + return span && ; + })} ); }; diff --git a/src/lib/components/Table/components/Body/TableBody.tsx b/src/lib/components/Table/components/Body/TableBody.tsx index 970494b..6e839b4 100644 --- a/src/lib/components/Table/components/Body/TableBody.tsx +++ b/src/lib/components/Table/components/Body/TableBody.tsx @@ -31,7 +31,7 @@ const TableBody = memo((props: Props) => { ) : ( - visibleRows.map((row, index) => ) + visibleRows.map((row, index) => ) )} ); diff --git a/src/lib/components/Table/components/Foot/ColumnFootArea.tsx b/src/lib/components/Table/components/Foot/ColumnFootArea.tsx index 1cb3e7f..22278b0 100644 --- a/src/lib/components/Table/components/Foot/ColumnFootArea.tsx +++ b/src/lib/components/Table/components/Foot/ColumnFootArea.tsx @@ -1,6 +1,7 @@ import { ReactNode, useContext, useMemo } from "react"; import { TableContext } from "@/components/Table/TableContext"; import { Column, RowBackground } from "@/components/Table/types"; +import { getRenderableHeaderColumns, getSpanProps } from "@/components/Table/helper"; import { getValueByChainedKey } from "../../../../../utils/utils"; import styles from "../../Table.module.scss"; @@ -20,33 +21,17 @@ const ColumnFootArea = ({ background, customFooter }: Props) => { [columns, showFixedRowNumbers, selectable], ); - const colspanInfoList = useMemo( - () => - columnsConsideringExtraCols?.reduceRight( - (acc, column) => { - if (column.footer) { - return [{ colSpan: 0, title: column.footer.title }, ...acc]; - } else { - if (!acc.length) return [{ colSpan: 1 }]; - - const [lastItem, ...rest] = acc; - return [{ colSpan: lastItem.colSpan + 1, title: lastItem.title }, { colSpan: 0 }, ...rest]; - } - }, - [] as { colSpan: number; title?: string }[], - ), - [columnsConsideringExtraCols], - ); - const getFooterValue = ({ title, dataKey, footer }: Column) => { const { type } = footer || {}; switch (type) { case "avg": - return originalRows + return originalRows?.length ? (originalRows.reduce((acc, row) => acc + getValueByChainedKey(row.data, dataKey), 0) / originalRows.length).toFixed(2) : undefined; case "sum": - return originalRows?.reduce((acc, row) => acc + getValueByChainedKey(row.data, dataKey), 0); + return originalRows?.length + ? originalRows.reduce((acc, row) => acc + getValueByChainedKey(row.data, dataKey), 0) + : undefined; case "title": return title; default: @@ -54,22 +39,60 @@ const ColumnFootArea = ({ background, customFooter }: Props) => { } }; - const footerCells = columnsConsideringExtraCols?.map((column, index) => { - if (!colspanInfoList?.[index] || (!column.footer && !colspanInfoList[index].colSpan)) { - return null; - } + const buildFooterCells = (renderableColumns: ReturnType, allColumns: Column[]) => { + const { cells, pending } = renderableColumns.reduce<{ cells: ReactNode[]; pending: number }>( + (acc, { column, index, colSpan }) => { + if (column.footer) { + const value = getFooterValue(column); + return { + cells: [ + ...acc.cells, + ...(acc.pending + ? [ + , + ] + : []), + , + ], + pending: 0, + }; + } + + const spanCount = colSpan ?? 1; + const absorbedFooterCol = spanCount > 1 ? allColumns.slice(index + 1, index + spanCount).find(c => c.footer) : undefined; + + if (absorbedFooterCol) { + const value = getFooterValue(absorbedFooterCol); + return { + cells: [ + ...acc.cells, + ...(acc.pending ? [, + ], + pending: 0, + }; + } - const { colSpan, title } = colspanInfoList[index]; - return ( - + return { ...acc, pending: acc.pending + spanCount }; + }, + { cells: [], pending: 0 }, ); - }); + return pending ? [...cells, - {footerCells && {footerCells}} + {footerCells && {footerCells.filter(Boolean)}} {customFooter && ( {selectable && ); diff --git a/src/lib/components/Table/components/Head/HeaderCell.tsx b/src/lib/components/Table/components/Head/HeaderCell.tsx index 58281e1..fa76b65 100644 --- a/src/lib/components/Table/components/Head/HeaderCell.tsx +++ b/src/lib/components/Table/components/Head/HeaderCell.tsx @@ -1,5 +1,6 @@ import styles from "../../Table.module.scss"; import { MotifIconButton } from "@/components/Motif/Icon"; +import { getSpanProps } from "@/components/Table/helper"; import { Column } from "@/components/Table/types"; import { useContext } from "react"; import { TableContext } from "../../TableContext"; @@ -7,17 +8,18 @@ import { TableContext } from "../../TableContext"; type Props = { index: number; column: Column; + colSpan?: number; }; const HeaderCell = (props: Props) => { - const { index, column } = props; + const { index, column, colSpan } = props; const { updateSortState, columnStates } = useContext(TableContext); const lastSortDirection = columnStates[index]?.lastSortDirection; const iconName = lastSortDirection === "asc" ? "keyboard_arrow_up" : lastSortDirection === "desc" ? "keyboard_arrow_down" : "expand_all"; return ( - {selectable && } {showFixedRowNumbers && } - {columns.map((column, index) => ( - + {getRenderableHeaderColumns(columns).map(({ column, index, colSpan }) => ( + ))} {filterableColumns && } diff --git a/src/lib/components/Table/constants.ts b/src/lib/components/Table/constants.ts deleted file mode 100644 index e2c9660..0000000 --- a/src/lib/components/Table/constants.ts +++ /dev/null @@ -1 +0,0 @@ -export const SORT_DIRECTIONS: ("asc" | "desc" | undefined)[] = ["asc", "desc", undefined] as const; diff --git a/src/lib/components/Table/helper.ts b/src/lib/components/Table/helper.ts new file mode 100644 index 0000000..5d7e365 --- /dev/null +++ b/src/lib/components/Table/helper.ts @@ -0,0 +1,115 @@ +import { Column, RowDetail, ResolvedCellSpan, RenderableColumn, SpannedCellKey, SpannedCellsMap } from "@/components/Table/types"; + +// Constants +export const SORT_DIRECTIONS: ("asc" | "desc" | undefined)[] = ["asc", "desc", undefined] as const; + +// Cell Span Utils +const clampSpanValue = (span: number | undefined, maxSpan?: number): number => { + if (!span || !Number.isFinite(span)) return 1; + const clamped = Math.max(Math.floor(span), 1); + return maxSpan != null ? Math.min(clamped, Math.max(Math.floor(maxSpan), 1)) : clamped; +}; + +export const resolveCellSpan = ( + column: Column, + rowData: object, + limits?: { + maxColSpan?: number; + maxRowSpan?: number; + }, +): ResolvedCellSpan => ({ + colSpan: clampSpanValue(typeof column.colSpan === "function" ? column.colSpan(rowData) : column.colSpan, limits?.maxColSpan), + rowSpan: clampSpanValue(typeof column.rowSpan === "function" ? column.rowSpan(rowData) : column.rowSpan, limits?.maxRowSpan), +}); + +export const getColSpan = (column: Column, maxColSpan?: number) => { + if (typeof column.colSpan !== "number") return undefined; + + const colSpan = clampSpanValue(column.colSpan, maxColSpan); + return colSpan > 1 ? colSpan : undefined; +}; + +export const getSpanProps = (colSpan?: number, rowSpan?: number) => ({ + ...(colSpan && colSpan > 1 ? { colSpan } : {}), + ...(rowSpan && rowSpan > 1 ? { rowSpan } : {}), +}); + +export const getRenderableHeaderColumns = (columns: Column[]) => { + const renderableColumns: RenderableColumn[] = []; + + for (let index = 0; index < columns.length; index++) { + if (renderableColumns.length) { + const previousColumn = renderableColumns[renderableColumns.length - 1]; + if (previousColumn.index + (previousColumn.colSpan ?? 1) > index) continue; + } + + renderableColumns.push({ + column: columns[index], + index, + colSpan: getColSpan(columns[index], columns.length - index), + }); + } + + return renderableColumns; +}; + +export const getSpannedCellsMap = (columns: Column[], rows?: RowDetail[]): SpannedCellsMap => { + const map = new Map(); + if (!rows) return map; + + rows.forEach((row, rowIndex) => { + const remainingRowsCount = rows.length - rowIndex; + columns.forEach((column, colIndex) => { + const cellKey: SpannedCellKey = `${rowIndex}-${colIndex}`; + if (map.has(cellKey)) return; + + const span = resolveCellSpan(column, row.data, { + maxColSpan: columns.length - colIndex, + maxRowSpan: remainingRowsCount, + }); + const { colSpan, rowSpan } = span; + map.set(cellKey, span); + + for (let c = 1; c < colSpan; c++) { + map.set(`${rowIndex}-${colIndex + c}`, undefined); + } + + for (let r = 1; r < rowSpan; r++) { + for (let c = 0; c < colSpan; c++) { + map.set(`${rowIndex + r}-${colIndex + c}`, undefined); + } + } + }); + }); + + return map; +}; + +// Sorting Utils +export const sortByType = (a: unknown, b: unknown, type: string) => { + switch (type) { + case "string": + return sortStrings(a as string, b as string); + case "number": + return sortNumbers(a as number, b as number); + case "boolean": + return sortBoolean(a as boolean, b as boolean); + case "object": + return sortObjects(a, b); + default: + return 0; + } +}; + +const sortStrings = (s1: string, s2: string) => s1.localeCompare(s2); + +const sortNumbers = (n1: number, n2: number) => n1 - n2; + +const sortBoolean = (b1: boolean, b2: boolean) => (b1 === b2 ? 0 : b1 ? 1 : -1); + +const sortObjects = (a: unknown, b: unknown) => { + if (!a || !b) { + return a === b ? 0 : !a ? -1 : 1; + } + return 0; +}; diff --git a/src/lib/components/Table/sorting.ts b/src/lib/components/Table/sorting.ts deleted file mode 100644 index f92c5c2..0000000 --- a/src/lib/components/Table/sorting.ts +++ /dev/null @@ -1,27 +0,0 @@ -export const sortByType = (a: unknown, b: unknown, type: string) => { - switch (type) { - case "string": - return sortStrings(a as string, b as string); - case "number": - return sortNumbers(a as number, b as number); - case "boolean": - return sortBoolean(a as boolean, b as boolean); - case "object": - return sortObjects(a, b); - default: - return 0; - } -}; - -const sortStrings = (s1: string, s2: string) => s1.localeCompare(s2); - -const sortNumbers = (n1: number, n2: number) => n1 - n2; - -const sortBoolean = (b1: boolean, b2: boolean) => (b1 === b2 ? 0 : b1 ? 1 : -1); - -const sortObjects = (a: unknown, b: unknown) => { - if (!a || !b) { - return a === b ? 0 : !a ? -1 : 1; - } - return 0; -}; diff --git a/src/lib/components/Table/types.ts b/src/lib/components/Table/types.ts index c74536a..bc2d7a2 100644 --- a/src/lib/components/Table/types.ts +++ b/src/lib/components/Table/types.ts @@ -1,5 +1,19 @@ import { Dispatch, ReactNode, SetStateAction } from "react"; +export type ResolvedCellSpan = { + colSpan: number; + rowSpan: number; +}; + +export type SpannedCellKey = `${number}-${number}`; +export type SpannedCellsMap = Map; + +export type RenderableColumn = { + column: Column; + index: number; + colSpan?: number; +}; + export type TableProps = { columns: Column[]; data?: T[]; @@ -38,6 +52,8 @@ export type Column = { footer?: Footer; width?: string; filter?: boolean; + colSpan?: number | ((rowData: object) => number); + rowSpan?: number | ((rowData: object) => number); }; export type Sorting = { @@ -82,6 +98,7 @@ export type TableContextType = { visibleRows?: RowDetail[]; totalRecords: number; columns: Column[]; + spannedCellsMap: SpannedCellsMap; updateSortState: (columnIndex: number) => void; columnStates: ColumState[]; showFixedRowNumbers?: boolean; @@ -118,6 +135,7 @@ export const TableContextDefaultValues: TableContextType = { columnStates: [], currentPage: 1, numberOfVisibleColumns: 0, + spannedCellsMap: new Map(), }; //
{column.render?.(data) ?? (data as string)}{column.render?.(data) ?? (data as string)}{rowNumberStatic}
+ {column.footer.title} + + {column.footer.render?.(value) ?? value} + ] : []), + + {absorbedFooterCol.footer!.render?.(value) ?? value} + - {column.footer ? (column.footer.render?.(getFooterValue(column)) ?? getFooterValue(column)) : colSpan > 0 && title ? title : ""} - ] : cells; + }; + + const footerCells = columnsConsideringExtraCols + ? buildFooterCells(getRenderableHeaderColumns(columnsConsideringExtraCols), columnsConsideringExtraCols) + : undefined; return (
diff --git a/src/lib/components/Table/components/Head/FilterCell.tsx b/src/lib/components/Table/components/Head/FilterCell.tsx index 3ec4d09..fa151dd 100644 --- a/src/lib/components/Table/components/Head/FilterCell.tsx +++ b/src/lib/components/Table/components/Head/FilterCell.tsx @@ -1,17 +1,19 @@ import InputText from "@/components/Motif/InputText/InputText"; +import { getSpanProps } from "@/components/Table/helper"; import { TableContext } from "@/components/Table/TableContext"; import { useContext } from "react"; import styles from "../../Table.module.scss"; type Props = { index: number; + colSpan?: number; }; -const FilterCell = ({ index }: Props) => { +const FilterCell = ({ index, colSpan }: Props) => { const { updateFilterState } = useContext(TableContext); return ( - +
updateFilterState((val as string).toLowerCase(), index)} />
diff --git a/src/lib/components/Table/components/Head/FilterColumnsRow.tsx b/src/lib/components/Table/components/Head/FilterColumnsRow.tsx index 6562434..1139f37 100644 --- a/src/lib/components/Table/components/Head/FilterColumnsRow.tsx +++ b/src/lib/components/Table/components/Head/FilterColumnsRow.tsx @@ -2,6 +2,7 @@ import styles from "../../Table.module.scss"; import FilterCell from "@/components/Table/components/Head/FilterCell"; import { useContext } from "react"; import { TableContext } from "@/components/Table/TableContext"; +import { getRenderableHeaderColumns, getSpanProps } from "@/components/Table/helper"; const FilterColumnsRow = () => { const { columns, showFixedRowNumbers, selectable } = useContext(TableContext); @@ -10,9 +11,9 @@ const FilterColumnsRow = () => {
} {showFixedRowNumbers && } - {columns.map((column, index) => { + {getRenderableHeaderColumns(columns).map(({ column, index, colSpan }) => { const key = "0-filter" + index; - return column.filter ? : ; + return column.filter ? : ; })}
+
{column.title} {column.sorting && ( diff --git a/src/lib/components/Table/components/Head/TableHead.tsx b/src/lib/components/Table/components/Head/TableHead.tsx index 22d42e3..8576aed 100644 --- a/src/lib/components/Table/components/Head/TableHead.tsx +++ b/src/lib/components/Table/components/Head/TableHead.tsx @@ -6,6 +6,7 @@ import styles from "../../Table.module.scss"; import HeaderRow from "@/components/Table/components/Head/HeaderRow"; import FilterColumnsRow from "@/components/Table/components/Head/FilterColumnsRow"; import RowSelectionCell from "@/components/Table/components/Head/RowSelectionCell"; +import { getRenderableHeaderColumns } from "@/components/Table/helper"; type Props = { background: RowBackground; @@ -21,8 +22,8 @@ const TableHead = ({ background, header }: Props) => {
#