From 442c354c153fe09f05fc74b69ab64a0f9a7b2c3d Mon Sep 17 00:00:00 2001 From: ZehranurC Date: Thu, 4 Jun 2026 09:15:00 +0300 Subject: [PATCH 01/11] feat(Table): add colspan and rowspan support --- src/lib/components/Table/Table.module.scss | 3 + src/lib/components/Table/Table.stories.tsx | 120 ++++++++++++++++++ src/lib/components/Table/cellSpan.ts | 96 ++++++++++++++ .../Table/components/Body/DataCell.tsx | 6 +- .../Table/components/Body/DataRow.tsx | 12 +- .../Table/components/Body/TableBody.tsx | 11 +- .../Table/components/Foot/ColumnFootArea.tsx | 33 +++-- .../Table/components/Head/FilterCell.tsx | 6 +- .../components/Head/FilterColumnsRow.tsx | 13 +- .../Table/components/Head/HeaderCell.tsx | 6 +- .../Table/components/Head/TableHead.tsx | 5 +- src/lib/components/Table/types.ts | 2 + 12 files changed, 279 insertions(+), 34 deletions(-) create mode 100644 src/lib/components/Table/cellSpan.ts diff --git a/src/lib/components/Table/Table.module.scss b/src/lib/components/Table/Table.module.scss index 8783524..b5b6b14 100644 --- a/src/lib/components/Table/Table.module.scss +++ b/src/lib/components/Table/Table.module.scss @@ -132,6 +132,9 @@ $row-color-variants: "primary", "secondary", "light", "success", "danger", "warn .bordered { &.cellBorders { + table { + border-collapse: collapse; + } td, th { border-color: var(--theme-color-border-light-default); diff --git a/src/lib/components/Table/Table.stories.tsx b/src/lib/components/Table/Table.stories.tsx index 28c9b94..c99e4f0 100644 --- a/src/lib/components/Table/Table.stories.tsx +++ b/src/lib/components/Table/Table.stories.tsx @@ -571,3 +571,123 @@ export const RowColoring: Story = { ); }, }; + +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", + // İsim bazlı gruplama: İlk ve üçüncü satırda 2 satır kapla diyoruz. + // Bir sonraki satırlar TableBody tarafından otomatik olarak atlanır. + 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 ( +
+
+ + ); + }, +}; + +export const FilteredRowSpanAndColSpan: Story = { + render: () => { + type RowData = { + department: string; + employee: string; + role: string; + status: "Active" | "Passive"; + firstInGroup: boolean; + summary?: boolean; + }; + + const allData: RowData[] = [ + { department: "Engineering", employee: "John", role: "Frontend", status: "Active", firstInGroup: true }, + { department: "Engineering", employee: "Jane", role: "Backend", status: "Active", firstInGroup: false }, + { department: "Engineering", employee: "Bob", role: "DevOps", status: "Passive", firstInGroup: false }, + { department: "Sales", employee: "Alice", role: "Lead", status: "Active", firstInGroup: true }, + { department: "Sales", employee: "Charlie", role: "Representative", status: "Passive", firstInGroup: false }, + { department: "Active Team Total", employee: "3 employees", role: "", status: "Active", firstInGroup: true, summary: true }, + ]; + + const data = allData.filter(row => row.status === "Active"); + const groupSizes = data.reduce>((acc, row) => { + if (!row.summary) acc[row.department] = (acc[row.department] ?? 0) + 1; + return acc; + }, {}); + + return ( +
+
{ + const rowData = row as RowData; + return rowData.firstInGroup && !rowData.summary ? groupSizes[rowData.department] : 1; + }, + }, + { + title: "Employee", + dataKey: "employee", + filter: true, + colSpan: (row: object) => ((row as RowData).summary ? 2 : 1), + }, + { title: "Role", dataKey: "role", filter: true }, + { title: "Status", dataKey: "status", filter: true }, + ]} + /> + + ); + }, +}; diff --git a/src/lib/components/Table/cellSpan.ts b/src/lib/components/Table/cellSpan.ts new file mode 100644 index 0000000..d94bb99 --- /dev/null +++ b/src/lib/components/Table/cellSpan.ts @@ -0,0 +1,96 @@ +import { Column, RowDetail } from "@/components/Table/types"; + +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; +}; + +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 ? 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 => { + columns.forEach((column, colIndex) => { + const cellKey: SpannedCellKey = `${row.motifIndex}-${colIndex}`; + if (map.has(cellKey)) return; + + const remainingRowsCount = rows.filter(r => r.motifIndex >= row.motifIndex).length; + 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(`${row.motifIndex}-${colIndex + c}`, undefined); + } + + for (let r = 1; r < rowSpan; r++) { + for (let c = 0; c < colSpan; c++) { + map.set(`${row.motifIndex + r}-${colIndex + c}`, undefined); + } + } + }); + }); + + return map; +}; diff --git a/src/lib/components/Table/components/Body/DataCell.tsx b/src/lib/components/Table/components/Body/DataCell.tsx index 0a69c3a..572e79c 100644 --- a/src/lib/components/Table/components/Body/DataCell.tsx +++ b/src/lib/components/Table/components/Body/DataCell.tsx @@ -1,16 +1,18 @@ +import { ResolvedCellSpan, getSpanProps } from "@/components/Table/cellSpan"; import { Column } 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..cff9cde 100644 --- a/src/lib/components/Table/components/Body/DataRow.tsx +++ b/src/lib/components/Table/components/Body/DataRow.tsx @@ -1,19 +1,20 @@ 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"; import { sanitizeModuleClasses } from "../../../../../utils/cssUtils"; +import { SpannedCellsMap } from "@/components/Table/cellSpan"; type Props = { rowNumberStatic: number; row: RowDetail; + spannedCellsMap: SpannedCellsMap; }; const DataRow = (props: Props) => { - const { rowNumberStatic, row } = props; + const { rowNumberStatic, row, spannedCellsMap } = props; const { columns, showFixedRowNumbers, selectable, selectHandler, rowColorCallback } = useContext(TableContext); const className = sanitizeModuleClasses(styles, row.isSelected && "selected", rowColorCallback?.(row.data)); @@ -26,9 +27,10 @@ const DataRow = (props: Props) => { )} {showFixedRowNumbers ? : null} - {columns.map((column, cIndex) => ( - - ))} + {columns.map((column, cIndex) => { + const span = spannedCellsMap.get(`${row.motifIndex}-${cIndex}`); + return span && ; + })} ); }; diff --git a/src/lib/components/Table/components/Body/TableBody.tsx b/src/lib/components/Table/components/Body/TableBody.tsx index a89a1e8..fbca72d 100644 --- a/src/lib/components/Table/components/Body/TableBody.tsx +++ b/src/lib/components/Table/components/Body/TableBody.tsx @@ -1,9 +1,10 @@ -import { memo, ReactNode, useContext } from "react"; +import { memo, ReactNode, useContext, useMemo } from "react"; import { TableContext } from "@/components/Table/TableContext"; import styles from "../../Table.module.scss"; import Skeleton from "@/components/Skeleton"; import DataRow from "@/components/Table/components/Body/DataRow"; import { sanitizeModuleClasses } from "../../../../../utils/cssUtils"; +import { getSpannedCellsMap } from "@/components/Table/cellSpan"; type Props = { loading?: boolean; @@ -13,7 +14,9 @@ type Props = { const TableBody = memo((props: Props) => { const { loading, emptyMessage, striped } = props; - const { visibleRows, numberOfVisibleColumns } = useContext(TableContext); + const { columns, visibleRows, numberOfVisibleColumns } = useContext(TableContext); + + const spannedCellsMap = useMemo(() => getSpannedCellsMap(columns, visibleRows), [columns, visibleRows]); const className = sanitizeModuleClasses(styles, striped && "striped"); return ( @@ -31,7 +34,9 @@ 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..f758af4 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 { getColSpan, getRenderableHeaderColumns } from "@/components/Table/cellSpan"; import { getValueByChainedKey } from "../../../../../utils/utils"; import styles from "../../Table.module.scss"; @@ -23,14 +24,15 @@ const ColumnFootArea = ({ background, customFooter }: Props) => { const colspanInfoList = useMemo( () => columnsConsideringExtraCols?.reduceRight( - (acc, column) => { + (acc, column, index, arr) => { + const columnSpan = getColSpan(column, arr.length - index) ?? 1; if (column.footer) { return [{ colSpan: 0, title: column.footer.title }, ...acc]; } else { - if (!acc.length) return [{ colSpan: 1 }]; + if (!acc.length) return [{ colSpan: columnSpan }]; const [lastItem, ...rest] = acc; - return [{ colSpan: lastItem.colSpan + 1, title: lastItem.title }, { colSpan: 0 }, ...rest]; + return [{ colSpan: lastItem.colSpan + columnSpan, title: lastItem.title }, { colSpan: 0 }, ...rest]; } }, [] as { colSpan: number; title?: string }[], @@ -54,18 +56,21 @@ const ColumnFootArea = ({ background, customFooter }: Props) => { } }; - const footerCells = columnsConsideringExtraCols?.map((column, index) => { - if (!colspanInfoList?.[index] || (!column.footer && !colspanInfoList[index].colSpan)) { - return null; - } + const footerCells = columnsConsideringExtraCols + ? getRenderableHeaderColumns(columnsConsideringExtraCols).map(({ column, index }) => { + const info = colspanInfoList?.[index]; + if (!info || (!column.footer && !info.colSpan)) { + return null; + } - const { colSpan, title } = colspanInfoList[index]; - return ( - - ); - }); + const { colSpan, title } = info; + return ( + + ); + }) + : undefined; return ( diff --git a/src/lib/components/Table/components/Head/FilterCell.tsx b/src/lib/components/Table/components/Head/FilterCell.tsx index 3ec4d09..dc0f1bf 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/cellSpan"; 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 ( - - {selectable && ); diff --git a/src/lib/components/Table/components/Head/HeaderCell.tsx b/src/lib/components/Table/components/Head/HeaderCell.tsx index 58281e1..b736b9a 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/cellSpan"; 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/types.ts b/src/lib/components/Table/types.ts index c74536a..532c8d4 100644 --- a/src/lib/components/Table/types.ts +++ b/src/lib/components/Table/types.ts @@ -38,6 +38,8 @@ export type Column = { footer?: Footer; width?: string; filter?: boolean; + colSpan?: number | ((rowData: object) => number); + rowSpan?: number | ((rowData: object) => number); }; export type Sorting = { From 2fa7198d0d822b0c025875f645609837d8e542f0 Mon Sep 17 00:00:00 2001 From: ZehranurC Date: Thu, 4 Jun 2026 09:29:51 +0300 Subject: [PATCH 02/11] fix: stories fixed --- src/lib/components/Table/Table.stories.tsx | 59 ---------------------- 1 file changed, 59 deletions(-) diff --git a/src/lib/components/Table/Table.stories.tsx b/src/lib/components/Table/Table.stories.tsx index c99e4f0..cdb77b3 100644 --- a/src/lib/components/Table/Table.stories.tsx +++ b/src/lib/components/Table/Table.stories.tsx @@ -617,8 +617,6 @@ export const RowSpan: Story = { { title: "Name", dataKey: "name", - // İsim bazlı gruplama: İlk ve üçüncü satırda 2 satır kapla diyoruz. - // Bir sonraki satırlar TableBody tarafından otomatik olarak atlanır. rowSpan: (row: object) => { const rowData = row as RowData; return rowData.surname === "Surname 1" || rowData.surname === "Surname 3" ? 2 : 1; @@ -634,60 +632,3 @@ export const RowSpan: Story = { ); }, }; - -export const FilteredRowSpanAndColSpan: Story = { - render: () => { - type RowData = { - department: string; - employee: string; - role: string; - status: "Active" | "Passive"; - firstInGroup: boolean; - summary?: boolean; - }; - - const allData: RowData[] = [ - { department: "Engineering", employee: "John", role: "Frontend", status: "Active", firstInGroup: true }, - { department: "Engineering", employee: "Jane", role: "Backend", status: "Active", firstInGroup: false }, - { department: "Engineering", employee: "Bob", role: "DevOps", status: "Passive", firstInGroup: false }, - { department: "Sales", employee: "Alice", role: "Lead", status: "Active", firstInGroup: true }, - { department: "Sales", employee: "Charlie", role: "Representative", status: "Passive", firstInGroup: false }, - { department: "Active Team Total", employee: "3 employees", role: "", status: "Active", firstInGroup: true, summary: true }, - ]; - - const data = allData.filter(row => row.status === "Active"); - const groupSizes = data.reduce>((acc, row) => { - if (!row.summary) acc[row.department] = (acc[row.department] ?? 0) + 1; - return acc; - }, {}); - - return ( -
-
{column.render?.(data) ?? (data as string)}{column.render?.(data) ?? (data as string)}{rowNumberStatic}
- {column.footer ? (column.footer.render?.(getFooterValue(column)) ?? getFooterValue(column)) : colSpan > 0 && title ? title : ""} - + {column.footer ? (column.footer.render?.(getFooterValue(column)) ?? getFooterValue(column)) : colSpan > 0 && title ? title : ""} +
+
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..42ba33a 100644 --- a/src/lib/components/Table/components/Head/FilterColumnsRow.tsx +++ b/src/lib/components/Table/components/Head/FilterColumnsRow.tsx @@ -2,17 +2,22 @@ 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/cellSpan"; const FilterColumnsRow = () => { const { columns, showFixedRowNumbers, selectable } = useContext(TableContext); return (
} - {showFixedRowNumbers && } - {columns.map((column, index) => { + {selectable && } + {showFixedRowNumbers && } + {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..f9129b9 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/cellSpan"; type Props = { background: RowBackground; @@ -21,8 +22,8 @@ const TableHead = ({ background, header }: Props) => {
#
{ - const rowData = row as RowData; - return rowData.firstInGroup && !rowData.summary ? groupSizes[rowData.department] : 1; - }, - }, - { - title: "Employee", - dataKey: "employee", - filter: true, - colSpan: (row: object) => ((row as RowData).summary ? 2 : 1), - }, - { title: "Role", dataKey: "role", filter: true }, - { title: "Status", dataKey: "status", filter: true }, - ]} - /> - - ); - }, -}; From dce109660036e5fda5570a16ae959fd56b3dc969 Mon Sep 17 00:00:00 2001 From: ZehranurC Date: Thu, 4 Jun 2026 10:26:41 +0300 Subject: [PATCH 03/11] test(Table): Add colspan rowspan tests --- src/lib/components/Table/Table.test.tsx | 163 +++++++++++++++++++++++- 1 file changed, 162 insertions(+), 1 deletion(-) diff --git a/src/lib/components/Table/Table.test.tsx b/src/lib/components/Table/Table.test.tsx index af01f01..353b5a2 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,157 @@ 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(""); + 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"); + }); }); From fe02b8e1156a8576be9f638fba428427b3137e67 Mon Sep 17 00:00:00 2001 From: ZehranurC Date: Fri, 12 Jun 2026 16:38:01 +0300 Subject: [PATCH 04/11] fix: review fixes --- src/lib/components/Table/Table.test.tsx | 36 +++++++++++++++ src/lib/components/Table/TableContext.tsx | 7 ++- .../Table/components/Body/DataCell.tsx | 2 +- .../Table/components/Body/DataRow.tsx | 11 +++-- .../Table/components/Body/TableBody.tsx | 11 ++--- .../Table/components/Foot/ColumnFootArea.tsx | 45 ++++++------------- .../Table/components/Head/FilterCell.tsx | 2 +- .../components/Head/FilterColumnsRow.tsx | 2 +- .../Table/components/Head/HeaderCell.tsx | 2 +- .../Table/components/Head/TableHead.tsx | 2 +- src/lib/components/Table/constants.ts | 1 - .../Table/{cellSpan.ts => helper.ts} | 44 +++++++++++++++--- src/lib/components/Table/sorting.ts | 27 ----------- src/lib/components/Table/types.ts | 3 ++ 14 files changed, 109 insertions(+), 86 deletions(-) delete mode 100644 src/lib/components/Table/constants.ts rename src/lib/components/Table/{cellSpan.ts => helper.ts} (69%) delete mode 100644 src/lib/components/Table/sorting.ts diff --git a/src/lib/components/Table/Table.test.tsx b/src/lib/components/Table/Table.test.tsx index 353b5a2..9009fdc 100644 --- a/src/lib/components/Table/Table.test.tsx +++ b/src/lib/components/Table/Table.test.tsx @@ -846,4 +846,40 @@ describe("Table", () => { 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"); + }); }); 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 572e79c..8b70833 100644 --- a/src/lib/components/Table/components/Body/DataCell.tsx +++ b/src/lib/components/Table/components/Body/DataCell.tsx @@ -1,4 +1,4 @@ -import { ResolvedCellSpan, getSpanProps } from "@/components/Table/cellSpan"; +import { ResolvedCellSpan, getSpanProps } from "@/components/Table/helper"; import { Column } from "@/components/Table/types"; import { getValueByChainedKey } from "../../../../../utils/utils"; diff --git a/src/lib/components/Table/components/Body/DataRow.tsx b/src/lib/components/Table/components/Body/DataRow.tsx index cff9cde..dd7bbb1 100644 --- a/src/lib/components/Table/components/Body/DataRow.tsx +++ b/src/lib/components/Table/components/Body/DataRow.tsx @@ -5,17 +5,16 @@ import { useContext } from "react"; import { TableContext } from "@/components/Table/TableContext"; import { RowDetail } from "@/components/Table/types"; import { sanitizeModuleClasses } from "../../../../../utils/cssUtils"; -import { SpannedCellsMap } from "@/components/Table/cellSpan"; type Props = { rowNumberStatic: number; row: RowDetail; - spannedCellsMap: SpannedCellsMap; + rowIndex: number; }; const DataRow = (props: Props) => { - const { rowNumberStatic, row, spannedCellsMap } = 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)); @@ -28,8 +27,8 @@ const DataRow = (props: Props) => { )} {showFixedRowNumbers ? : null} {columns.map((column, cIndex) => { - const span = spannedCellsMap.get(`${row.motifIndex}-${cIndex}`); - return span && ; + 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 fbca72d..a08a3e4 100644 --- a/src/lib/components/Table/components/Body/TableBody.tsx +++ b/src/lib/components/Table/components/Body/TableBody.tsx @@ -1,10 +1,9 @@ -import { memo, ReactNode, useContext, useMemo } from "react"; +import { memo, ReactNode, useContext } from "react"; import { TableContext } from "@/components/Table/TableContext"; import styles from "../../Table.module.scss"; import Skeleton from "@/components/Skeleton"; import DataRow from "@/components/Table/components/Body/DataRow"; import { sanitizeModuleClasses } from "../../../../../utils/cssUtils"; -import { getSpannedCellsMap } from "@/components/Table/cellSpan"; type Props = { loading?: boolean; @@ -14,9 +13,7 @@ type Props = { const TableBody = memo((props: Props) => { const { loading, emptyMessage, striped } = props; - const { columns, visibleRows, numberOfVisibleColumns } = useContext(TableContext); - - const spannedCellsMap = useMemo(() => getSpannedCellsMap(columns, visibleRows), [columns, visibleRows]); + const { visibleRows, numberOfVisibleColumns } = useContext(TableContext); const className = sanitizeModuleClasses(styles, striped && "striped"); return ( @@ -34,9 +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 f758af4..601d90c 100644 --- a/src/lib/components/Table/components/Foot/ColumnFootArea.tsx +++ b/src/lib/components/Table/components/Foot/ColumnFootArea.tsx @@ -1,7 +1,7 @@ import { ReactNode, useContext, useMemo } from "react"; import { TableContext } from "@/components/Table/TableContext"; import { Column, RowBackground } from "@/components/Table/types"; -import { getColSpan, getRenderableHeaderColumns } from "@/components/Table/cellSpan"; +import { getRenderableHeaderColumns } from "@/components/Table/helper"; import { getValueByChainedKey } from "../../../../../utils/utils"; import styles from "../../Table.module.scss"; @@ -11,7 +11,7 @@ type Props = { }; const ColumnFootArea = ({ background, customFooter }: Props) => { - const { originalRows, columns, showFixedRowNumbers, selectable, numberOfVisibleColumns } = useContext(TableContext); + const { usableRows, columns, showFixedRowNumbers, selectable, numberOfVisibleColumns } = useContext(TableContext); const columnsConsideringExtraCols = useMemo( () => @@ -21,34 +21,15 @@ const ColumnFootArea = ({ background, customFooter }: Props) => { [columns, showFixedRowNumbers, selectable], ); - const colspanInfoList = useMemo( - () => - columnsConsideringExtraCols?.reduceRight( - (acc, column, index, arr) => { - const columnSpan = getColSpan(column, arr.length - index) ?? 1; - if (column.footer) { - return [{ colSpan: 0, title: column.footer.title }, ...acc]; - } else { - if (!acc.length) return [{ colSpan: columnSpan }]; - - const [lastItem, ...rest] = acc; - return [{ colSpan: lastItem.colSpan + columnSpan, 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 - ? (originalRows.reduce((acc, row) => acc + getValueByChainedKey(row.data, dataKey), 0) / originalRows.length).toFixed(2) + return usableRows + ? (usableRows.reduce((acc, row) => acc + getValueByChainedKey(row.data, dataKey), 0) / usableRows.length).toFixed(2) : undefined; case "sum": - return originalRows?.reduce((acc, row) => acc + getValueByChainedKey(row.data, dataKey), 0); + return usableRows?.reduce((acc, row) => acc + getValueByChainedKey(row.data, dataKey), 0); case "title": return title; default: @@ -57,16 +38,16 @@ const ColumnFootArea = ({ background, customFooter }: Props) => { }; const footerCells = columnsConsideringExtraCols - ? getRenderableHeaderColumns(columnsConsideringExtraCols).map(({ column, index }) => { - const info = colspanInfoList?.[index]; - if (!info || (!column.footer && !info.colSpan)) { - return null; - } + ? getRenderableHeaderColumns(columnsConsideringExtraCols).map(({ column, colSpan: headerColSpan }) => { + const actualColSpan = headerColSpan ?? 1; + const footerValue = getFooterValue(column); + const footerContent = column.footer ? (column.footer.render?.(footerValue) ?? footerValue) : ""; + const footerTitle = column.footer?.title; - const { colSpan, title } = info; return ( - ); }) diff --git a/src/lib/components/Table/components/Head/FilterCell.tsx b/src/lib/components/Table/components/Head/FilterCell.tsx index dc0f1bf..fa151dd 100644 --- a/src/lib/components/Table/components/Head/FilterCell.tsx +++ b/src/lib/components/Table/components/Head/FilterCell.tsx @@ -1,5 +1,5 @@ import InputText from "@/components/Motif/InputText/InputText"; -import { getSpanProps } from "@/components/Table/cellSpan"; +import { getSpanProps } from "@/components/Table/helper"; import { TableContext } from "@/components/Table/TableContext"; import { useContext } from "react"; import styles from "../../Table.module.scss"; diff --git a/src/lib/components/Table/components/Head/FilterColumnsRow.tsx b/src/lib/components/Table/components/Head/FilterColumnsRow.tsx index 42ba33a..5506c30 100644 --- a/src/lib/components/Table/components/Head/FilterColumnsRow.tsx +++ b/src/lib/components/Table/components/Head/FilterColumnsRow.tsx @@ -2,7 +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/cellSpan"; +import { getRenderableHeaderColumns, getSpanProps } from "@/components/Table/helper"; const FilterColumnsRow = () => { const { columns, showFixedRowNumbers, selectable } = useContext(TableContext); diff --git a/src/lib/components/Table/components/Head/HeaderCell.tsx b/src/lib/components/Table/components/Head/HeaderCell.tsx index b736b9a..fa76b65 100644 --- a/src/lib/components/Table/components/Head/HeaderCell.tsx +++ b/src/lib/components/Table/components/Head/HeaderCell.tsx @@ -1,6 +1,6 @@ import styles from "../../Table.module.scss"; import { MotifIconButton } from "@/components/Motif/Icon"; -import { getSpanProps } from "@/components/Table/cellSpan"; +import { getSpanProps } from "@/components/Table/helper"; import { Column } from "@/components/Table/types"; import { useContext } from "react"; import { TableContext } from "../../TableContext"; diff --git a/src/lib/components/Table/components/Head/TableHead.tsx b/src/lib/components/Table/components/Head/TableHead.tsx index f9129b9..8576aed 100644 --- a/src/lib/components/Table/components/Head/TableHead.tsx +++ b/src/lib/components/Table/components/Head/TableHead.tsx @@ -6,7 +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/cellSpan"; +import { getRenderableHeaderColumns } from "@/components/Table/helper"; type Props = { background: RowBackground; 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/cellSpan.ts b/src/lib/components/Table/helper.ts similarity index 69% rename from src/lib/components/Table/cellSpan.ts rename to src/lib/components/Table/helper.ts index d94bb99..2787379 100644 --- a/src/lib/components/Table/cellSpan.ts +++ b/src/lib/components/Table/helper.ts @@ -1,5 +1,9 @@ import { Column, RowDetail } from "@/components/Table/types"; +// Constants +export const SORT_DIRECTIONS: ("asc" | "desc" | undefined)[] = ["asc", "desc", undefined] as const; + +// Cell Span Types export type ResolvedCellSpan = { colSpan: number; rowSpan: number; @@ -14,6 +18,7 @@ export type RenderableColumn = { colSpan?: number; }; +// 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); @@ -67,12 +72,12 @@ export const getSpannedCellsMap = (columns: Column[], rows?: RowDetail[]): Spann const map = new Map(); if (!rows) return map; - rows.forEach(row => { + rows.forEach((row, rowIndex) => { + const remainingRowsCount = rows.length - rowIndex; columns.forEach((column, colIndex) => { - const cellKey: SpannedCellKey = `${row.motifIndex}-${colIndex}`; + const cellKey: SpannedCellKey = `${rowIndex}-${colIndex}`; if (map.has(cellKey)) return; - const remainingRowsCount = rows.filter(r => r.motifIndex >= row.motifIndex).length; const span = resolveCellSpan(column, row.data, { maxColSpan: columns.length - colIndex, maxRowSpan: remainingRowsCount, @@ -81,12 +86,12 @@ export const getSpannedCellsMap = (columns: Column[], rows?: RowDetail[]): Spann map.set(cellKey, span); for (let c = 1; c < colSpan; c++) { - map.set(`${row.motifIndex}-${colIndex + c}`, undefined); + map.set(`${rowIndex}-${colIndex + c}`, undefined); } for (let r = 1; r < rowSpan; r++) { for (let c = 0; c < colSpan; c++) { - map.set(`${row.motifIndex + r}-${colIndex + c}`, undefined); + map.set(`${rowIndex + r}-${colIndex + c}`, undefined); } } }); @@ -94,3 +99,32 @@ export const getSpannedCellsMap = (columns: Column[], rows?: RowDetail[]): Spann 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 532c8d4..d368bba 100644 --- a/src/lib/components/Table/types.ts +++ b/src/lib/components/Table/types.ts @@ -1,4 +1,5 @@ import { Dispatch, ReactNode, SetStateAction } from "react"; +import { SpannedCellsMap } from "@/components/Table/helper"; export type TableProps = { columns: Column[]; @@ -84,6 +85,7 @@ export type TableContextType = { visibleRows?: RowDetail[]; totalRecords: number; columns: Column[]; + spannedCellsMap: SpannedCellsMap; updateSortState: (columnIndex: number) => void; columnStates: ColumState[]; showFixedRowNumbers?: boolean; @@ -120,6 +122,7 @@ export const TableContextDefaultValues: TableContextType = { columnStates: [], currentPage: 1, numberOfVisibleColumns: 0, + spannedCellsMap: new Map(), }; // From f6a6580db9231f9c3d911ed160fc8c59e443d641 Mon Sep 17 00:00:00 2001 From: ZehranurC Date: Fri, 12 Jun 2026 16:40:28 +0300 Subject: [PATCH 05/11] fix: review fixes --- .../Table/components/Head/FilterColumnsRow.tsx | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/src/lib/components/Table/components/Head/FilterColumnsRow.tsx b/src/lib/components/Table/components/Head/FilterColumnsRow.tsx index 5506c30..1139f37 100644 --- a/src/lib/components/Table/components/Head/FilterColumnsRow.tsx +++ b/src/lib/components/Table/components/Head/FilterColumnsRow.tsx @@ -9,15 +9,11 @@ const FilterColumnsRow = () => { return ( - {selectable && ); From e2fffb6a4e8be3ba79a648d4ed49949aebdb6d9f Mon Sep 17 00:00:00 2001 From: ZehranurC Date: Wed, 17 Jun 2026 14:09:36 +0300 Subject: [PATCH 06/11] fix: review fixes --- .../Table/components/Body/DataCell.tsx | 4 ++-- .../Table/components/Foot/ColumnFootArea.tsx | 12 ++++++------ src/lib/components/Table/helper.ts | 17 +---------------- src/lib/components/Table/types.ts | 15 ++++++++++++++- 4 files changed, 23 insertions(+), 25 deletions(-) diff --git a/src/lib/components/Table/components/Body/DataCell.tsx b/src/lib/components/Table/components/Body/DataCell.tsx index 8b70833..2b27663 100644 --- a/src/lib/components/Table/components/Body/DataCell.tsx +++ b/src/lib/components/Table/components/Body/DataCell.tsx @@ -1,5 +1,5 @@ -import { ResolvedCellSpan, getSpanProps } from "@/components/Table/helper"; -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 = { diff --git a/src/lib/components/Table/components/Foot/ColumnFootArea.tsx b/src/lib/components/Table/components/Foot/ColumnFootArea.tsx index 601d90c..07fdda0 100644 --- a/src/lib/components/Table/components/Foot/ColumnFootArea.tsx +++ b/src/lib/components/Table/components/Foot/ColumnFootArea.tsx @@ -11,7 +11,7 @@ type Props = { }; const ColumnFootArea = ({ background, customFooter }: Props) => { - const { usableRows, columns, showFixedRowNumbers, selectable, numberOfVisibleColumns } = useContext(TableContext); + const { originalRows, columns, showFixedRowNumbers, selectable, numberOfVisibleColumns } = useContext(TableContext); const columnsConsideringExtraCols = useMemo( () => @@ -25,11 +25,11 @@ const ColumnFootArea = ({ background, customFooter }: Props) => { const { type } = footer || {}; switch (type) { case "avg": - return usableRows - ? (usableRows.reduce((acc, row) => acc + getValueByChainedKey(row.data, dataKey), 0) / usableRows.length).toFixed(2) + return originalRows + ? (originalRows.reduce((acc, row) => acc + getValueByChainedKey(row.data, dataKey), 0) / originalRows.length).toFixed(2) : undefined; case "sum": - return usableRows?.reduce((acc, row) => acc + getValueByChainedKey(row.data, dataKey), 0); + return originalRows?.reduce((acc, row) => acc + getValueByChainedKey(row.data, dataKey), 0); case "title": return title; default: @@ -38,14 +38,14 @@ const ColumnFootArea = ({ background, customFooter }: Props) => { }; const footerCells = columnsConsideringExtraCols - ? getRenderableHeaderColumns(columnsConsideringExtraCols).map(({ column, colSpan: headerColSpan }) => { + ? getRenderableHeaderColumns(columnsConsideringExtraCols).map(({ column, index, colSpan: headerColSpan }) => { const actualColSpan = headerColSpan ?? 1; const footerValue = getFooterValue(column); const footerContent = column.footer ? (column.footer.render?.(footerValue) ?? footerValue) : ""; const footerTitle = column.footer?.title; return ( - diff --git a/src/lib/components/Table/helper.ts b/src/lib/components/Table/helper.ts index 2787379..4ca92aa 100644 --- a/src/lib/components/Table/helper.ts +++ b/src/lib/components/Table/helper.ts @@ -1,23 +1,8 @@ -import { Column, RowDetail } from "@/components/Table/types"; +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 Types -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; -}; - // Cell Span Utils const clampSpanValue = (span: number | undefined, maxSpan?: number): number => { if (!span || !Number.isFinite(span)) return 1; diff --git a/src/lib/components/Table/types.ts b/src/lib/components/Table/types.ts index d368bba..bc2d7a2 100644 --- a/src/lib/components/Table/types.ts +++ b/src/lib/components/Table/types.ts @@ -1,5 +1,18 @@ import { Dispatch, ReactNode, SetStateAction } from "react"; -import { SpannedCellsMap } from "@/components/Table/helper"; + +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[]; From 6774ee080f6b4bd2e2f1ec71e6bd6394dd839115 Mon Sep 17 00:00:00 2001 From: ZehranurC Date: Fri, 19 Jun 2026 12:44:17 +0300 Subject: [PATCH 07/11] fix: review fixes --- src/lib/components/Table/components/Foot/ColumnFootArea.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/lib/components/Table/components/Foot/ColumnFootArea.tsx b/src/lib/components/Table/components/Foot/ColumnFootArea.tsx index 07fdda0..7a3ecd1 100644 --- a/src/lib/components/Table/components/Foot/ColumnFootArea.tsx +++ b/src/lib/components/Table/components/Foot/ColumnFootArea.tsx @@ -1,7 +1,7 @@ import { ReactNode, useContext, useMemo } from "react"; import { TableContext } from "@/components/Table/TableContext"; import { Column, RowBackground } from "@/components/Table/types"; -import { getRenderableHeaderColumns } from "@/components/Table/helper"; +import { getRenderableHeaderColumns, getSpanProps } from "@/components/Table/helper"; import { getValueByChainedKey } from "../../../../../utils/utils"; import styles from "../../Table.module.scss"; @@ -25,7 +25,7 @@ const ColumnFootArea = ({ background, customFooter }: Props) => { 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": @@ -45,7 +45,7 @@ const ColumnFootArea = ({ background, customFooter }: Props) => { const footerTitle = column.footer?.title; return ( - From 89a8374c5bab2981090915e670d861a22df15a48 Mon Sep 17 00:00:00 2001 From: ZehranurC Date: Mon, 22 Jun 2026 12:38:27 +0300 Subject: [PATCH 08/11] fix: fixed chromatic issue --- .../Table/components/Foot/ColumnFootArea.tsx | 43 ++++++++++++++----- 1 file changed, 32 insertions(+), 11 deletions(-) diff --git a/src/lib/components/Table/components/Foot/ColumnFootArea.tsx b/src/lib/components/Table/components/Foot/ColumnFootArea.tsx index 7a3ecd1..41a2304 100644 --- a/src/lib/components/Table/components/Foot/ColumnFootArea.tsx +++ b/src/lib/components/Table/components/Foot/ColumnFootArea.tsx @@ -1,7 +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 { getColSpan, getRenderableHeaderColumns, getSpanProps } from "@/components/Table/helper"; import { getValueByChainedKey } from "../../../../../utils/utils"; import styles from "../../Table.module.scss"; @@ -21,6 +21,25 @@ const ColumnFootArea = ({ background, customFooter }: Props) => { [columns, showFixedRowNumbers, selectable], ); + const colspanInfoList = useMemo( + () => + columnsConsideringExtraCols?.reduceRight( + (acc, column, index, arr) => { + const columnSpan = getColSpan(column, arr.length - index) ?? 1; + if (column.footer) { + return [{ colSpan: 0, title: column.footer.title }, ...acc]; + } else { + if (!acc.length) return [{ colSpan: columnSpan }]; + + const [lastItem, ...rest] = acc; + return [{ colSpan: lastItem.colSpan + columnSpan, title: lastItem.title }, { colSpan: 0 }, ...rest]; + } + }, + [] as { colSpan: number; title?: string }[], + ), + [columnsConsideringExtraCols], + ); + const getFooterValue = ({ title, dataKey, footer }: Column) => { const { type } = footer || {}; switch (type) { @@ -29,7 +48,9 @@ const ColumnFootArea = ({ background, customFooter }: Props) => { ? (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: @@ -38,16 +59,16 @@ const ColumnFootArea = ({ background, customFooter }: Props) => { }; const footerCells = columnsConsideringExtraCols - ? getRenderableHeaderColumns(columnsConsideringExtraCols).map(({ column, index, colSpan: headerColSpan }) => { - const actualColSpan = headerColSpan ?? 1; - const footerValue = getFooterValue(column); - const footerContent = column.footer ? (column.footer.render?.(footerValue) ?? footerValue) : ""; - const footerTitle = column.footer?.title; + ? getRenderableHeaderColumns(columnsConsideringExtraCols).map(({ column, index }) => { + const info = colspanInfoList?.[index]; + if (!info || (!column.footer && !info.colSpan)) { + return null; + } + const { colSpan, title } = info; return ( - ); }) @@ -55,7 +76,7 @@ const ColumnFootArea = ({ background, customFooter }: Props) => { return ( - {footerCells && {footerCells}} + {footerCells && {footerCells.filter(Boolean)}} {customFooter && (
{rowNumberStatic}
- {column.footer ? (column.footer.render?.(getFooterValue(column)) ?? getFooterValue(column)) : colSpan > 0 && title ? title : ""} + 1 && { colSpan: actualColSpan })}> + {footerTitle &&
{footerTitle}
} + {footerContent}
} - {showFixedRowNumbers && } + {selectable && } + {showFixedRowNumbers && } {getRenderableHeaderColumns(columns).map(({ column, index, colSpan }) => { const key = "0-filter" + index; - return column.filter ? ( - - ) : ( - - ); + return column.filter ? : ; })}
1 && { colSpan: actualColSpan })}> + 1 && { colSpan: actualColSpan })}> {footerTitle &&
{footerTitle}
} {footerContent}
1 && { colSpan: actualColSpan })}> + {footerTitle &&
{footerTitle}
} {footerContent}
- {footerTitle &&
{footerTitle}
} - {footerContent} +
+ {column.footer ? (column.footer.render?.(getFooterValue(column)) ?? getFooterValue(column)) : colSpan > 0 && title ? title : ""}
From bd3e6a96fd12fcf8c173381a6fdd8813b2dc068b Mon Sep 17 00:00:00 2001 From: ZehranurC Date: Mon, 22 Jun 2026 15:13:17 +0300 Subject: [PATCH 09/11] fix: chromatic fixes --- src/lib/components/Table/Table.module.scss | 4 ++-- src/lib/components/Table/components/TableContainer.tsx | 6 +++++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/lib/components/Table/Table.module.scss b/src/lib/components/Table/Table.module.scss index 1ab6ab4..4478887 100644 --- a/src/lib/components/Table/Table.module.scss +++ b/src/lib/components/Table/Table.module.scss @@ -133,7 +133,7 @@ $row-color-variants: "primary", "secondary", "light", "success", "danger", "warn .bordered { &.cellBorders { - table { + table.hasCellSpan { border-collapse: collapse; } td, @@ -206,7 +206,7 @@ $row-color-variants: "primary", "secondary", "light", "success", "danger", "warn } &.rowBorders { - table { + table.hasCellSpan { border-collapse: collapse; } tr { diff --git a/src/lib/components/Table/components/TableContainer.tsx b/src/lib/components/Table/components/TableContainer.tsx index 3546166..6a532c2 100644 --- a/src/lib/components/Table/components/TableContainer.tsx +++ b/src/lib/components/Table/components/TableContainer.tsx @@ -7,6 +7,8 @@ import FooterForNumbers from "@/components/Table/components/Foot/FooterForNumber import { PropsWithRef } from "../../../types"; import { sanitizeModuleRootClasses } from "../../../../utils/cssUtils"; import TableTitleSection from "./TableTitleSection"; +import { useContext } from "react"; +import { TableContext } from "@/components/Table/TableContext"; type Props = Omit< TableProps, @@ -23,6 +25,7 @@ type Props = Omit< >; const TableContainer = (props: PropsWithRef) => { + const { columns } = useContext(TableContext); const { title, subtitle, @@ -49,11 +52,12 @@ const TableContainer = (props: PropsWithRef) => { distributeColsEvenly && "distributeColsEvenly", fluid && "fluid", ]); + const hasCellSpan = columns.some(column => column.colSpan !== undefined || column.rowSpan !== undefined); return (
- +
From c5b7f389ff4be5d2b72119568b1d49fd805a2bca Mon Sep 17 00:00:00 2001 From: ZehranurC Date: Wed, 24 Jun 2026 14:41:28 +0300 Subject: [PATCH 10/11] fix: review fixes --- src/lib/components/Table/Table.module.scss | 4 +- .../Table/components/Foot/ColumnFootArea.tsx | 60 ++++++++----------- .../Table/components/TableContainer.tsx | 6 +- 3 files changed, 29 insertions(+), 41 deletions(-) diff --git a/src/lib/components/Table/Table.module.scss b/src/lib/components/Table/Table.module.scss index 4478887..1f05c66 100644 --- a/src/lib/components/Table/Table.module.scss +++ b/src/lib/components/Table/Table.module.scss @@ -133,7 +133,7 @@ $row-color-variants: "primary", "secondary", "light", "success", "danger", "warn .bordered { &.cellBorders { - table.hasCellSpan { + table:has(thead [colspan], thead [rowspan], tbody [colspan], tbody [rowspan]) { border-collapse: collapse; } td, @@ -206,7 +206,7 @@ $row-color-variants: "primary", "secondary", "light", "success", "danger", "warn } &.rowBorders { - table.hasCellSpan { + table:has(thead [colspan], thead [rowspan], tbody [colspan], tbody [rowspan]) { border-collapse: collapse; } tr { diff --git a/src/lib/components/Table/components/Foot/ColumnFootArea.tsx b/src/lib/components/Table/components/Foot/ColumnFootArea.tsx index 41a2304..d304264 100644 --- a/src/lib/components/Table/components/Foot/ColumnFootArea.tsx +++ b/src/lib/components/Table/components/Foot/ColumnFootArea.tsx @@ -1,7 +1,7 @@ import { ReactNode, useContext, useMemo } from "react"; import { TableContext } from "@/components/Table/TableContext"; import { Column, RowBackground } from "@/components/Table/types"; -import { getColSpan, getRenderableHeaderColumns, getSpanProps } from "@/components/Table/helper"; +import { getRenderableHeaderColumns, getSpanProps } from "@/components/Table/helper"; import { getValueByChainedKey } from "../../../../../utils/utils"; import styles from "../../Table.module.scss"; @@ -21,25 +21,6 @@ const ColumnFootArea = ({ background, customFooter }: Props) => { [columns, showFixedRowNumbers, selectable], ); - const colspanInfoList = useMemo( - () => - columnsConsideringExtraCols?.reduceRight( - (acc, column, index, arr) => { - const columnSpan = getColSpan(column, arr.length - index) ?? 1; - if (column.footer) { - return [{ colSpan: 0, title: column.footer.title }, ...acc]; - } else { - if (!acc.length) return [{ colSpan: columnSpan }]; - - const [lastItem, ...rest] = acc; - return [{ colSpan: lastItem.colSpan + columnSpan, title: lastItem.title }, { colSpan: 0 }, ...rest]; - } - }, - [] as { colSpan: number; title?: string }[], - ), - [columnsConsideringExtraCols], - ); - const getFooterValue = ({ title, dataKey, footer }: Column) => { const { type } = footer || {}; switch (type) { @@ -58,21 +39,32 @@ const ColumnFootArea = ({ background, customFooter }: Props) => { } }; - const footerCells = columnsConsideringExtraCols - ? getRenderableHeaderColumns(columnsConsideringExtraCols).map(({ column, index }) => { - const info = colspanInfoList?.[index]; - if (!info || (!column.footer && !info.colSpan)) { - return null; - } + const buildFooterCells = (columns: ReturnType) => + columns.reduce<{ cells: ReactNode[]; pending: number }>( + (acc, { column, index, colSpan }) => { + return !column.footer + ? { ...acc, pending: acc.pending + (colSpan ?? 1) } + : { + cells: [ + ...acc.cells, + ...(acc.pending + ? [ + , + ] + : []), + , + ], + pending: 0, + }; + }, + { cells: [], pending: 0 }, + ).cells; - const { colSpan, title } = info; - return ( - - ); - }) - : undefined; + const footerCells = columnsConsideringExtraCols ? buildFooterCells(getRenderableHeaderColumns(columnsConsideringExtraCols)) : undefined; return ( diff --git a/src/lib/components/Table/components/TableContainer.tsx b/src/lib/components/Table/components/TableContainer.tsx index 6a532c2..3546166 100644 --- a/src/lib/components/Table/components/TableContainer.tsx +++ b/src/lib/components/Table/components/TableContainer.tsx @@ -7,8 +7,6 @@ import FooterForNumbers from "@/components/Table/components/Foot/FooterForNumber import { PropsWithRef } from "../../../types"; import { sanitizeModuleRootClasses } from "../../../../utils/cssUtils"; import TableTitleSection from "./TableTitleSection"; -import { useContext } from "react"; -import { TableContext } from "@/components/Table/TableContext"; type Props = Omit< TableProps, @@ -25,7 +23,6 @@ type Props = Omit< >; const TableContainer = (props: PropsWithRef) => { - const { columns } = useContext(TableContext); const { title, subtitle, @@ -52,12 +49,11 @@ const TableContainer = (props: PropsWithRef) => { distributeColsEvenly && "distributeColsEvenly", fluid && "fluid", ]); - const hasCellSpan = columns.some(column => column.colSpan !== undefined || column.rowSpan !== undefined); return (
-
+ {column.footer.title} + + {column.footer.render?.(getFooterValue(column)) ?? getFooterValue(column)} + - {column.footer ? (column.footer.render?.(getFooterValue(column)) ?? getFooterValue(column)) : colSpan > 0 && title ? title : ""} -
+
From a8d9fae4f9b4a0252e2160af0ca3a7457c6d746f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Akta=C5=9F?= Date: Thu, 25 Jun 2026 10:19:41 +0300 Subject: [PATCH 11/11] refactor fixes --- src/lib/components/Table/Table.mdx | 55 +++++++++ src/lib/components/Table/Table.stories.tsx | 44 ++------ src/lib/components/Table/Table.test.tsx | 105 +++++++++++++++++- .../Table/components/Body/DataRow.tsx | 2 +- .../Table/components/Foot/ColumnFootArea.tsx | 68 ++++++++---- src/lib/components/Table/helper.ts | 2 +- 6 files changed, 219 insertions(+), 57 deletions(-) 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.stories.tsx b/src/lib/components/Table/Table.stories.tsx index cdb77b3..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,18 +551,16 @@ 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 = { +export const Colspan: Story = { render: () => { type RowData = { fullName: string; age: number; city: string; merged: boolean }; const data: RowData[] = [ @@ -596,15 +584,11 @@ export const ColSpan: Story = { dataKey: "city", }, ]; - return ( -
-
- - ); + return
; }, }; -export const RowSpan: Story = { +export const Rowspan: Story = { render: () => { type RowData = { name: string; surname: string; age: number }; const data = [ @@ -625,10 +609,6 @@ export const RowSpan: Story = { { title: "Surname", dataKey: "surname" }, { title: "Age", dataKey: "age" }, ]; - return ( -
-
- - ); + return
; }, }; diff --git a/src/lib/components/Table/Table.test.tsx b/src/lib/components/Table/Table.test.tsx index 9009fdc..38b430c 100644 --- a/src/lib/components/Table/Table.test.tsx +++ b/src/lib/components/Table/Table.test.tsx @@ -725,7 +725,7 @@ describe("Table", () => { const footerCells = getFooterCells(); expect(footerCells).toHaveLength(2); expect(footerCells[0]).toHaveAttribute("colspan", "2"); - expect(footerCells[0]).toHaveTextContent(""); + expect(footerCells[0]).toHaveTextContent("99"); expect(footerCells[1]).toHaveTextContent("10"); }); @@ -882,4 +882,107 @@ describe("Table", () => { 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/components/Body/DataRow.tsx b/src/lib/components/Table/components/Body/DataRow.tsx index dd7bbb1..724d4fc 100644 --- a/src/lib/components/Table/components/Body/DataRow.tsx +++ b/src/lib/components/Table/components/Body/DataRow.tsx @@ -28,7 +28,7 @@ const DataRow = (props: Props) => { {showFixedRowNumbers ? : null} {columns.map((column, cIndex) => { const span = spannedCellsMap.get(`${rowIndex}-${cIndex}`); - return span && ; + return span && ; })} ); diff --git a/src/lib/components/Table/components/Foot/ColumnFootArea.tsx b/src/lib/components/Table/components/Foot/ColumnFootArea.tsx index d304264..22278b0 100644 --- a/src/lib/components/Table/components/Foot/ColumnFootArea.tsx +++ b/src/lib/components/Table/components/Foot/ColumnFootArea.tsx @@ -39,32 +39,56 @@ const ColumnFootArea = ({ background, customFooter }: Props) => { } }; - const buildFooterCells = (columns: ReturnType) => - columns.reduce<{ cells: ReactNode[]; pending: number }>( + const buildFooterCells = (renderableColumns: ReturnType, allColumns: Column[]) => { + const { cells, pending } = renderableColumns.reduce<{ cells: ReactNode[]; pending: number }>( (acc, { column, index, colSpan }) => { - return !column.footer - ? { ...acc, pending: acc.pending + (colSpan ?? 1) } - : { - cells: [ - ...acc.cells, - ...(acc.pending - ? [ - , - ] - : []), - , - ], - pending: 0, - }; + 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, + }; + } + + return { ...acc, pending: acc.pending + spanCount }; }, { cells: [], pending: 0 }, - ).cells; + ); + return pending ? [...cells, diff --git a/src/lib/components/Table/helper.ts b/src/lib/components/Table/helper.ts index 4ca92aa..5d7e365 100644 --- a/src/lib/components/Table/helper.ts +++ b/src/lib/components/Table/helper.ts @@ -7,7 +7,7 @@ export const SORT_DIRECTIONS: ("asc" | "desc" | undefined)[] = ["asc", "desc", u 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 ? Math.min(clamped, Math.max(Math.floor(maxSpan), 1)) : clamped; + return maxSpan != null ? Math.min(clamped, Math.max(Math.floor(maxSpan), 1)) : clamped; }; export const resolveCellSpan = (
{rowNumberStatic}
- {column.footer.title} - - {column.footer.render?.(getFooterValue(column)) ?? getFooterValue(column)} - + {column.footer.title} + + {column.footer.render?.(value) ?? value} + ] : []), + + {absorbedFooterCol.footer!.render?.(value) ?? value} + ] : cells; + }; - const footerCells = columnsConsideringExtraCols ? buildFooterCells(getRenderableHeaderColumns(columnsConsideringExtraCols)) : undefined; + const footerCells = columnsConsideringExtraCols + ? buildFooterCells(getRenderableHeaderColumns(columnsConsideringExtraCols), columnsConsideringExtraCols) + : undefined; return (