diff --git a/packages/core/src/docs/examples/multi-level-column-groups.stories.tsx b/packages/core/src/docs/examples/multi-level-column-groups.stories.tsx
new file mode 100644
index 000000000..ee8db7602
--- /dev/null
+++ b/packages/core/src/docs/examples/multi-level-column-groups.stories.tsx
@@ -0,0 +1,236 @@
+import React from "react";
+import { DataEditorAll as DataEditor } from "../../data-editor-all.js";
+import {
+ BeautifulWrapper,
+ Description,
+ PropName,
+ useMockDataGenerator,
+ defaultProps,
+} from "../../data-editor/stories/utils.js";
+import { GridColumnIcon, type GridColumn, GridCellKind, type GridCell, type Item } from "../../internal/data-grid/data-grid-types.js";
+import { SimpleThemeWrapper } from "../../stories/story-utils.js";
+
+export default {
+ title: "Glide-Data-Grid/DataEditor Demos",
+
+ decorators: [
+ (Story: React.ComponentType) => (
+
+
+ Columns can be organized into multiple hierarchical levels using the groupPath{" "}
+ property. This allows for complex nested column structures while maintaining backward compatibility with the legacy group property.
+
+ }>
+
+
+
+ ),
+ ],
+};
+
+interface SalesData {
+ product: string;
+ region: string;
+ q1Jan: number;
+ q1Feb: number;
+ q1Mar: number;
+ q2Apr: number;
+ q2May: number;
+ q2Jun: number;
+ q3Jul: number;
+ q3Aug: number;
+ q3Sep: number;
+ q4Oct: number;
+ q4Nov: number;
+ q4Dec: number;
+}
+
+const salesData: SalesData[] = [
+ {
+ product: "Widget A",
+ region: "North",
+ q1Jan: 1200, q1Feb: 1350, q1Mar: 1100,
+ q2Apr: 1400, q2May: 1250, q2Jun: 1600,
+ q3Jul: 1800, q3Aug: 1750, q3Sep: 1900,
+ q4Oct: 2100, q4Nov: 2000, q4Dec: 2300,
+ },
+ {
+ product: "Widget B",
+ region: "South",
+ q1Jan: 900, q1Feb: 1050, q1Mar: 800,
+ q2Apr: 1100, q2May: 950, q2Jun: 1200,
+ q3Jul: 1400, q3Aug: 1350, q3Sep: 1500,
+ q4Oct: 1700, q4Nov: 1600, q4Dec: 1900,
+ },
+ {
+ product: "Widget C",
+ region: "East",
+ q1Jan: 1500, q1Feb: 1650, q1Mar: 1400,
+ q2Apr: 1700, q2May: 1550, q2Jun: 1900,
+ q3Jul: 2100, q3Aug: 2050, q3Sep: 2200,
+ q4Oct: 2400, q4Nov: 2300, q4Dec: 2600,
+ },
+];
+
+export const MultiLevelColumnGroups: React.VFC = () => {
+ const getContent = React.useCallback((cell: Item): GridCell => {
+ const [col, row] = cell;
+ const dataRow = salesData[row];
+
+ if (col === 0) {
+ return {
+ kind: GridCellKind.Text,
+ allowOverlay: true,
+ displayData: dataRow.product,
+ data: dataRow.product,
+ };
+ }
+
+ if (col === 1) {
+ return {
+ kind: GridCellKind.Text,
+ allowOverlay: true,
+ displayData: dataRow.region,
+ data: dataRow.region,
+ };
+ }
+
+ const monthKeys: (keyof SalesData)[] = [
+ "q1Jan", "q1Feb", "q1Mar",
+ "q2Apr", "q2May", "q2Jun",
+ "q3Jul", "q3Aug", "q3Sep",
+ "q4Oct", "q4Nov", "q4Dec"
+ ];
+
+ const value = dataRow[monthKeys[col - 2]] as number;
+
+ return {
+ kind: GridCellKind.Number,
+ allowOverlay: true,
+ displayData: value.toLocaleString(),
+ data: value,
+ };
+ }, []);
+
+ const columns = React.useMemo(() => {
+ return [
+ {
+ title: "Product",
+ id: "product",
+ width: 120,
+ },
+ {
+ title: "Region",
+ id: "region",
+ width: 100,
+ },
+ // Q1 months
+ {
+ title: "January",
+ id: "q1-jan",
+ width: 100,
+ groupPath: ["Sales Data", "Q1 2024", "Months"],
+ },
+ {
+ title: "February",
+ id: "q1-feb",
+ width: 100,
+ groupPath: ["Sales Data", "Q1 2024", "Months"],
+ },
+ {
+ title: "March",
+ id: "q1-mar",
+ width: 100,
+ groupPath: ["Sales Data", "Q1 2024", "Months"],
+ },
+ // Q2 months
+ {
+ title: "April",
+ id: "q2-apr",
+ width: 100,
+ groupPath: ["Sales Data", "Q2 2024", "Months"],
+ },
+ {
+ title: "May",
+ id: "q2-may",
+ width: 100,
+ groupPath: ["Sales Data", "Q2 2024", "Months"],
+ },
+ {
+ title: "June",
+ id: "q2-jun",
+ width: 100,
+ groupPath: ["Sales Data", "Q2 2024", "Months"],
+ },
+ // Q3 months
+ {
+ title: "July",
+ id: "q3-jul",
+ width: 100,
+ groupPath: ["Sales Data", "Q3 2024", "Months"],
+ },
+ {
+ title: "August",
+ id: "q3-aug",
+ width: 100,
+ groupPath: ["Sales Data", "Q3 2024", "Months"],
+ },
+ {
+ title: "September",
+ id: "q3-sep",
+ width: 100,
+ groupPath: ["Sales Data", "Q3 2024", "Months"],
+ },
+ // Q4 months
+ {
+ title: "October",
+ id: "q4-oct",
+ width: 100,
+ groupPath: ["Sales Data", "Q4 2024", "Months"],
+ },
+ {
+ title: "November",
+ id: "q4-nov",
+ width: 100,
+ groupPath: ["Sales Data", "Q4 2024", "Months"],
+ },
+ {
+ title: "December",
+ id: "q4-dec",
+ width: 100,
+ groupPath: ["Sales Data", "Q4 2024", "Months"],
+ },
+ ];
+ }, []);
+
+ return (
+ {
+ if (level === 0) {
+ return {
+ name: groupName,
+ icon: GridColumnIcon.HeaderArray,
+ };
+ }
+ if (level === 1) {
+ return {
+ name: groupName,
+ icon: GridColumnIcon.HeaderCalendar,
+ };
+ }
+ return {
+ name: groupName,
+ icon: GridColumnIcon.HeaderCode,
+ };
+ }}
+ rowMarkers="both"
+ />
+ );
+};
\ No newline at end of file
diff --git a/packages/core/src/internal/data-grid/data-grid-types.ts b/packages/core/src/internal/data-grid/data-grid-types.ts
index af64d3aef..01f83437c 100644
--- a/packages/core/src/internal/data-grid/data-grid-types.ts
+++ b/packages/core/src/internal/data-grid/data-grid-types.ts
@@ -148,6 +148,13 @@ export type Item = readonly [col: number, row: number];
export interface BaseGridColumn {
readonly title: string;
readonly group?: string;
+ /**
+ * Multi-level group path for hierarchical column grouping.
+ * When provided, takes precedence over the group property.
+ * Example: ["Sales", "Q1", "Products"] creates a 3-level hierarchy.
+ * @group Grouping
+ */
+ readonly groupPath?: readonly string[];
readonly icon?: GridColumnIcon | string;
readonly overlayIcon?: GridColumnIcon | string;
readonly menuIcon?: GridColumnMenuIcon | string;
diff --git a/packages/core/src/internal/data-grid/render/data-grid-lib.ts b/packages/core/src/internal/data-grid/render/data-grid-lib.ts
index 96eb1d90f..916cf8b11 100644
--- a/packages/core/src/internal/data-grid/render/data-grid-lib.ts
+++ b/packages/core/src/internal/data-grid/render/data-grid-lib.ts
@@ -28,6 +28,7 @@ export function useMappedColumns(
columns.map(
(c, i): MappedGridColumn => ({
group: c.group,
+ groupPath: c.groupPath,
grow: c.grow,
hasMenu: c.hasMenu,
icon: c.icon,
@@ -72,6 +73,65 @@ export function isGroupEqual(left: string | undefined, right: string | undefined
return (left ?? "") === (right ?? "");
}
+/**
+ * Gets the effective group path for a column, handling backward compatibility
+ * with the legacy group property.
+ */
+export function getEffectiveGroupPath(column: MappedGridColumn): readonly string[] {
+ if (column.groupPath !== undefined && column.groupPath.length > 0) {
+ return column.groupPath;
+ }
+ if (column.group !== undefined && column.group !== "") {
+ return [column.group];
+ }
+ return [];
+}
+
+/**
+ * Gets the maximum group depth across all columns.
+ */
+export function getMaxGroupDepth(columns: readonly MappedGridColumn[]): number {
+ let maxDepth = 0;
+ for (const col of columns) {
+ const path = getEffectiveGroupPath(col);
+ maxDepth = Math.max(maxDepth, path.length);
+ }
+ return maxDepth;
+}
+
+/**
+ * Checks if any columns use multi-level grouping (groupPath).
+ */
+export function hasMultiLevelGroups(columns: readonly MappedGridColumn[]): boolean {
+ for (const col of columns) {
+ if (col.groupPath !== undefined && col.groupPath.length > 1) {
+ return true;
+ }
+ }
+ return false;
+}
+
+/**
+ * Checks if two group paths are equal up to a specific level (inclusive).
+ */
+export function areGroupPathsEqualUpToLevel(
+ left: readonly string[] | undefined,
+ right: readonly string[] | undefined,
+ level: number
+): boolean {
+ const leftPath = left ?? [];
+ const rightPath = right ?? [];
+
+ for (let i = 0; i <= level; i++) {
+ const leftValue = i < leftPath.length ? leftPath[i] : "";
+ const rightValue = i < rightPath.length ? rightPath[i] : "";
+ if (leftValue !== rightValue) {
+ return false;
+ }
+ }
+ return true;
+}
+
export function cellIsSelected(location: Item, cell: InnerGridCell, selection: GridSelection): boolean {
if (selection.current === undefined) return false;
diff --git a/packages/core/src/internal/data-grid/render/data-grid-render.cells.ts b/packages/core/src/internal/data-grid/render/data-grid-render.cells.ts
index dc84326c2..3e13c8326 100644
--- a/packages/core/src/internal/data-grid/render/data-grid-render.cells.ts
+++ b/packages/core/src/internal/data-grid/render/data-grid-render.cells.ts
@@ -50,7 +50,7 @@ export interface GroupDetails {
}[];
}
-export type GroupDetailsCallback = (groupName: string) => GroupDetails;
+export type GroupDetailsCallback = (groupName: string, level?: number) => GroupDetails;
export type GetRowThemeCallback = (row: number) => Partial | undefined;
export interface Highlight {
diff --git a/packages/core/src/internal/data-grid/render/data-grid-render.header.ts b/packages/core/src/internal/data-grid/render/data-grid-render.header.ts
index 47117f530..467a48e29 100644
--- a/packages/core/src/internal/data-grid/render/data-grid-render.header.ts
+++ b/packages/core/src/internal/data-grid/render/data-grid-render.header.ts
@@ -13,9 +13,10 @@ import {
measureTextCached,
roundedPoly,
type MappedGridColumn,
+ hasMultiLevelGroups,
} from "./data-grid-lib.js";
import type { GroupDetails, GroupDetailsCallback } from "./data-grid-render.cells.js";
-import { walkColumns, walkGroups } from "./data-grid-render.walk.js";
+import { walkColumns, walkGroups, walkMultiLevelGroups } from "./data-grid-render.walk.js";
import { drawCheckbox } from "./draw-checkbox.js";
import type { DragAndDropState, HoverInfo } from "./draw-grid-arg.js";
@@ -165,115 +166,222 @@ export function drawGroups(
const [hCol, hRow] = hovered?.[0] ?? [];
let finalX = 0;
- walkGroups(effectiveCols, width, translateX, groupHeaderHeight, (span, groupName, x, y, w, h) => {
- if (
- damage !== undefined &&
- !damage.hasItemInRectangle({
- x: span[0],
- y: -2,
- width: span[1] - span[0] + 1,
- height: 1,
- })
- )
- return;
- ctx.save();
- ctx.beginPath();
- ctx.rect(x, y, w, h);
- ctx.clip();
+
+ // Use multi-level rendering if any columns have groupPath with multiple levels
+ if (hasMultiLevelGroups(effectiveCols)) {
+ walkMultiLevelGroups(effectiveCols, width, translateX, groupHeaderHeight, (span, groupName, level, x, y, w, h) => {
+ if (
+ damage !== undefined &&
+ !damage.hasItemInRectangle({
+ x: span[0],
+ y: -2 - level,
+ width: span[1] - span[0] + 1,
+ height: 1,
+ })
+ )
+ return;
+ ctx.save();
+ ctx.beginPath();
+ ctx.rect(x, y, w, h);
+ ctx.clip();
+
+ const group = getGroupDetails(groupName, level);
+ const groupTheme =
+ group?.overrideTheme === undefined ? theme : mergeAndRealizeTheme(theme, group.overrideTheme);
+ const isHovered = hRow === (-2 - level) && hCol !== undefined && hCol >= span[0] && hCol <= span[1];
+ const fillColor = isHovered
+ ? (groupTheme.bgGroupHeaderHovered ?? groupTheme.bgHeaderHovered)
+ : (groupTheme.bgGroupHeader ?? groupTheme.bgHeader);
+
+ if (fillColor !== theme.bgHeader) {
+ ctx.fillStyle = fillColor;
+ ctx.fill();
+ }
- const group = getGroupDetails(groupName);
- const groupTheme =
- group?.overrideTheme === undefined ? theme : mergeAndRealizeTheme(theme, group.overrideTheme);
- const isHovered = hRow === -2 && hCol !== undefined && hCol >= span[0] && hCol <= span[1];
- const fillColor = isHovered
- ? (groupTheme.bgGroupHeaderHovered ?? groupTheme.bgHeaderHovered)
- : (groupTheme.bgGroupHeader ?? groupTheme.bgHeader);
+ ctx.fillStyle = groupTheme.textGroupHeader ?? groupTheme.textHeader;
+ if (group !== undefined) {
+ let drawX = x;
+ if (group.icon !== undefined) {
+ spriteManager.drawSprite(
+ group.icon,
+ "normal",
+ ctx,
+ drawX + xPad,
+ y + (h - 20) / 2,
+ 20,
+ groupTheme
+ );
+ drawX += 26;
+ }
- if (fillColor !== theme.bgHeader) {
- ctx.fillStyle = fillColor;
- ctx.fill();
- }
+ ctx.fillText(group.name, drawX + xPad, y + h / 2 + getMiddleCenterBias(ctx, theme));
+
+ // Handle group actions
+ if (group.actions !== undefined && isHovered) {
+ const actionBoxes = getActionBoundsForGroup({ x, y, width: w, height: h }, group.actions);
+
+ ctx.beginPath();
+ const fadeStartX = actionBoxes[0].x - 10;
+ const fadeWidth = x + w - fadeStartX;
+ ctx.rect(fadeStartX, y, fadeWidth, h);
+ const grad = ctx.createLinearGradient(fadeStartX, 0, fadeStartX + fadeWidth, 0);
+ const trans = withAlpha(fillColor, 0);
+ grad.addColorStop(0, trans);
+ grad.addColorStop(10 / fadeWidth, fillColor);
+ grad.addColorStop(1, fillColor);
+ ctx.fillStyle = grad;
+ ctx.fill();
+
+ ctx.globalAlpha = 0.6;
+
+ // eslint-disable-next-line prefer-const
+ const [mouseX, mouseY] = hovered?.[1] ?? [-1, -1];
+ for (let i = 0; i < group.actions.length; i++) {
+ const action = group.actions[i];
+ const box = actionBoxes[i];
+ const actionHovered = pointInRect(box, mouseX + x, mouseY);
+ if (actionHovered) {
+ ctx.globalAlpha = 1;
+ }
+ spriteManager.drawSprite(
+ action.icon,
+ "normal",
+ ctx,
+ box.x + box.width / 2 - 10,
+ box.y + box.height / 2 - 10,
+ 20,
+ groupTheme
+ );
+ if (actionHovered) {
+ ctx.globalAlpha = 0.6;
+ }
+ }
- ctx.fillStyle = groupTheme.textGroupHeader ?? groupTheme.textHeader;
- if (group !== undefined) {
- let drawX = x;
- if (group.icon !== undefined) {
- spriteManager.drawSprite(
- group.icon,
- "normal",
- ctx,
- drawX + xPad,
- (groupHeaderHeight - 20) / 2,
- 20,
- groupTheme
- );
- drawX += 26;
+ ctx.globalAlpha = 1;
+ }
}
- ctx.fillText(
- group.name,
- drawX + xPad,
- groupHeaderHeight / 2 + getMiddleCenterBias(ctx, theme.headerFontFull)
- );
-
- if (group.actions !== undefined && isHovered) {
- const actionBoxes = getActionBoundsForGroup({ x, y, width: w, height: h }, group.actions);
+ if (verticalBorder(span[1] + 1) && span[1] + 1 < effectiveCols.length) {
ctx.beginPath();
- const fadeStartX = actionBoxes[0].x - 10;
- const fadeWidth = x + w - fadeStartX;
- ctx.rect(fadeStartX, 0, fadeWidth, groupHeaderHeight);
- const grad = ctx.createLinearGradient(fadeStartX, 0, fadeStartX + fadeWidth, 0);
- const trans = withAlpha(fillColor, 0);
- grad.addColorStop(0, trans);
- grad.addColorStop(10 / fadeWidth, fillColor);
- grad.addColorStop(1, fillColor);
- ctx.fillStyle = grad;
+ ctx.moveTo(x + w, y);
+ ctx.lineTo(x + w, y + h);
+ ctx.strokeStyle = theme.borderColor;
+ ctx.stroke();
+ }
- ctx.fill();
+ ctx.restore();
- ctx.globalAlpha = 0.6;
+ finalX = Math.max(finalX, x + w);
+ });
+ } else {
+ // Fallback to legacy single-level rendering
+ walkGroups(effectiveCols, width, translateX, groupHeaderHeight, (span, groupName, x, y, w, h) => {
+ if (
+ damage !== undefined &&
+ !damage.hasItemInRectangle({
+ x: span[0],
+ y: -2,
+ width: span[1] - span[0] + 1,
+ height: 1,
+ })
+ )
+ return;
+ ctx.save();
+ ctx.beginPath();
+ ctx.rect(x, y, w, h);
+ ctx.clip();
+
+ const group = getGroupDetails(groupName);
+ const groupTheme =
+ group?.overrideTheme === undefined ? theme : mergeAndRealizeTheme(theme, group.overrideTheme);
+ const isHovered = hRow === -2 && hCol !== undefined && hCol >= span[0] && hCol <= span[1];
+ const fillColor = isHovered
+ ? (groupTheme.bgGroupHeaderHovered ?? groupTheme.bgHeaderHovered)
+ : (groupTheme.bgGroupHeader ?? groupTheme.bgHeader);
+
+ if (fillColor !== theme.bgHeader) {
+ ctx.fillStyle = fillColor;
+ ctx.fill();
+ }
- // eslint-disable-next-line prefer-const
- const [mouseX, mouseY] = hovered?.[1] ?? [-1, -1];
- for (let i = 0; i < group.actions.length; i++) {
- const action = group.actions[i];
- const box = actionBoxes[i];
- const actionHovered = pointInRect(box, mouseX + x, mouseY);
- if (actionHovered) {
- ctx.globalAlpha = 1;
- }
+ ctx.fillStyle = groupTheme.textGroupHeader ?? groupTheme.textHeader;
+ if (group !== undefined) {
+ let drawX = x;
+ if (group.icon !== undefined) {
spriteManager.drawSprite(
- action.icon,
+ group.icon,
"normal",
ctx,
- box.x + box.width / 2 - 10,
- box.y + box.height / 2 - 10,
+ drawX + xPad,
+ y + (h - 20) / 2,
20,
groupTheme
);
- if (actionHovered) {
- ctx.globalAlpha = 0.6;
- }
+ drawX += 26;
}
- ctx.globalAlpha = 1;
+ ctx.fillText(group.name, drawX + xPad, y + h / 2 + getMiddleCenterBias(ctx, theme));
+
+ // Handle group actions
+ if (group.actions !== undefined && isHovered) {
+ const actionBoxes = getActionBoundsForGroup({ x, y, width: w, height: h }, group.actions);
+
+ ctx.beginPath();
+ const fadeStartX = actionBoxes[0].x - 10;
+ const fadeWidth = x + w - fadeStartX;
+ ctx.rect(fadeStartX, y, fadeWidth, h);
+ const grad = ctx.createLinearGradient(fadeStartX, 0, fadeStartX + fadeWidth, 0);
+ const trans = withAlpha(fillColor, 0);
+ grad.addColorStop(0, trans);
+ grad.addColorStop(10 / fadeWidth, fillColor);
+ grad.addColorStop(1, fillColor);
+ ctx.fillStyle = grad;
+ ctx.fill();
+
+ ctx.globalAlpha = 0.6;
+
+ // eslint-disable-next-line prefer-const
+ const [mouseX, mouseY] = hovered?.[1] ?? [-1, -1];
+ for (let i = 0; i < group.actions.length; i++) {
+ const action = group.actions[i];
+ const box = actionBoxes[i];
+ const actionHovered = pointInRect(box, mouseX + x, mouseY);
+ if (actionHovered) {
+ ctx.globalAlpha = 1;
+ }
+ spriteManager.drawSprite(
+ action.icon,
+ "normal",
+ ctx,
+ box.x + box.width / 2 - 10,
+ box.y + box.height / 2 - 10,
+ 20,
+ groupTheme
+ );
+ if (actionHovered) {
+ ctx.globalAlpha = 0.6;
+ }
+ }
+
+ ctx.globalAlpha = 1;
+ }
}
- }
- if (x !== 0 && verticalBorder(span[0])) {
- ctx.beginPath();
- ctx.moveTo(x + 0.5, 0);
- ctx.lineTo(x + 0.5, groupHeaderHeight);
- ctx.strokeStyle = theme.borderColor;
- ctx.lineWidth = 1;
- ctx.stroke();
- }
+ if (verticalBorder(span[1] + 1) && span[1] + 1 < effectiveCols.length) {
+ ctx.beginPath();
+ ctx.moveTo(x + w, y);
+ ctx.lineTo(x + w, y + h);
+ ctx.strokeStyle = theme.borderColor;
+ ctx.stroke();
+ }
- ctx.restore();
+ ctx.restore();
- finalX = x + w;
- });
+ finalX = Math.max(finalX, x + w);
+ });
+ }
+ // Draw final borders
ctx.beginPath();
ctx.moveTo(finalX + 0.5, 0);
ctx.lineTo(finalX + 0.5, groupHeaderHeight);
@@ -283,6 +391,8 @@ export function drawGroups(
ctx.strokeStyle = theme.borderColor;
ctx.lineWidth = 1;
ctx.stroke();
+
+ return finalX;
}
const menuButtonSize = 30;
diff --git a/packages/core/src/internal/data-grid/render/data-grid-render.ts b/packages/core/src/internal/data-grid/render/data-grid-render.ts
index 310c3511d..582e2cd4c 100644
--- a/packages/core/src/internal/data-grid/render/data-grid-render.ts
+++ b/packages/core/src/internal/data-grid/render/data-grid-render.ts
@@ -2,11 +2,11 @@
/* eslint-disable unicorn/no-for-loop */
import { type Rectangle } from "../data-grid-types.js";
import { CellSet } from "../cell-set.js";
-import { getEffectiveColumns, type MappedGridColumn, rectBottomRight } from "./data-grid-lib.js";
+import { getEffectiveColumns, type MappedGridColumn, rectBottomRight, hasMultiLevelGroups } from "./data-grid-lib.js";
import { blend } from "../color-parser.js";
import { assert } from "../../../common/support.js";
import type { DrawGridArg } from "./draw-grid-arg.js";
-import { walkColumns, walkGroups, walkRowsInCol } from "./data-grid-render.walk.js";
+import { walkColumns, walkGroups, walkRowsInCol, walkMultiLevelGroups } from "./data-grid-render.walk.js";
import { drawCells } from "./data-grid-render.cells.js";
import { drawGridHeaders } from "./data-grid-render.header.js";
import { drawGridLines, overdrawStickyBoundaries, drawBlanks, drawExtraRowThemes } from "./data-grid-render.lines.js";
@@ -24,7 +24,7 @@ import { drawHighlightRings, drawFillHandle, drawColumnResizeOutline } from "./d
// structure which contains all operations to perform, then sort them all by "prep" requirement, then do
// all like operations at once.
-function clipHeaderDamage(
+function clipDamage(
ctx: CanvasRenderingContext2D,
effectiveColumns: readonly MappedGridColumn[],
width: number,
@@ -39,17 +39,32 @@ function clipHeaderDamage(
ctx.beginPath();
- walkGroups(effectiveColumns, width, translateX, groupHeaderHeight, (span, _group, x, y, w, h) => {
- const hasItemInSpan = damage.hasItemInRectangle({
- x: span[0],
- y: -2,
- width: span[1] - span[0] + 1,
- height: 1,
+ // Handle group header damage - use multi-level if available
+ if (hasMultiLevelGroups(effectiveColumns)) {
+ walkMultiLevelGroups(effectiveColumns, width, translateX, groupHeaderHeight, (span, _group, level, x, y, w, h) => {
+ const hasItemInSpan = damage.hasItemInRectangle({
+ x: span[0],
+ y: -2 - level,
+ width: span[1] - span[0] + 1,
+ height: 1,
+ });
+ if (hasItemInSpan) {
+ ctx.rect(x, y, w, h);
+ }
});
- if (hasItemInSpan) {
- ctx.rect(x, y, w, h);
- }
- });
+ } else {
+ walkGroups(effectiveColumns, width, translateX, groupHeaderHeight, (span, _group, x, y, w, h) => {
+ const hasItemInSpan = damage.hasItemInRectangle({
+ x: span[0],
+ y: -2,
+ width: span[1] - span[0] + 1,
+ height: 1,
+ });
+ if (hasItemInSpan) {
+ ctx.rect(x, y, w, h);
+ }
+ });
+ }
walkColumns(
effectiveColumns,
@@ -482,7 +497,7 @@ export function drawGrid(arg: DrawGridArg, lastArg: DrawGridArg | undefined) {
const doHeaders = damage.hasHeader();
if (doHeaders) {
- clipHeaderDamage(
+ clipDamage(
overlayCtx,
effectiveCols,
width,
diff --git a/packages/core/src/internal/data-grid/render/data-grid-render.walk.ts b/packages/core/src/internal/data-grid/render/data-grid-render.walk.ts
index e00ffa4b2..5ffea918d 100644
--- a/packages/core/src/internal/data-grid/render/data-grid-render.walk.ts
+++ b/packages/core/src/internal/data-grid/render/data-grid-render.walk.ts
@@ -1,5 +1,11 @@
import { type Item, type Rectangle } from "../data-grid-types.js";
-import { type MappedGridColumn, isGroupEqual } from "./data-grid-lib.js";
+import {
+ type MappedGridColumn,
+ isGroupEqual,
+ getEffectiveGroupPath,
+ getMaxGroupDepth,
+ areGroupPathsEqualUpToLevel
+} from "./data-grid-lib.js";
export function getSkipPoint(drawRegions: readonly Rectangle[]): number | undefined {
if (drawRegions.length === 0) return undefined;
@@ -198,3 +204,91 @@ export function getSpanBounds(
return [frozenRect, contentRect];
}
+
+export type WalkMultiLevelGroupsCallback = (
+ colSpan: Item,
+ groupName: string,
+ level: number,
+ x: number,
+ y: number,
+ width: number,
+ height: number
+) => void;
+
+/**
+ * Walks through multi-level column groups, calling the callback for each group header at each level.
+ */
+export function walkMultiLevelGroups(
+ effectiveCols: readonly MappedGridColumn[],
+ width: number,
+ translateX: number,
+ groupHeaderHeight: number,
+ cb: WalkMultiLevelGroupsCallback
+): void {
+ if (effectiveCols.length === 0) return;
+
+ const maxDepth = getMaxGroupDepth(effectiveCols);
+ if (maxDepth === 0) return;
+
+ const headerHeightPerLevel = groupHeaderHeight / maxDepth;
+
+ // Process each level from top to bottom
+ for (let level = 0; level < maxDepth; level++) {
+ let x = 0;
+ let clipX = 0;
+
+ for (let index = 0; index < effectiveCols.length; index++) {
+ const startCol = effectiveCols[index];
+ const startPath = getEffectiveGroupPath(startCol);
+
+ // Skip if this column doesn't have a group at this level
+ if (startPath.length <= level) {
+ x += startCol.width;
+ if (startCol.sticky) {
+ clipX += startCol.width;
+ }
+ continue;
+ }
+
+ let end = index + 1;
+ let boxWidth = startCol.width;
+ if (startCol.sticky) {
+ clipX += boxWidth;
+ }
+
+ // Find all consecutive columns that belong to the same group path up to this level
+ while (
+ end < effectiveCols.length &&
+ areGroupPathsEqualUpToLevel(getEffectiveGroupPath(effectiveCols[end]), startPath, level) &&
+ effectiveCols[end].sticky === effectiveCols[index].sticky
+ ) {
+ const endCol = effectiveCols[end];
+ boxWidth += endCol.width;
+ end++;
+ index++;
+ if (endCol.sticky) {
+ clipX += endCol.width;
+ }
+ }
+
+ const t = startCol.sticky ? 0 : translateX;
+ const localX = x + t;
+ const delta = startCol.sticky ? 0 : Math.max(0, clipX - localX);
+ const w = Math.min(boxWidth - delta, width - (localX + delta));
+
+ if (w > 0) {
+ cb(
+ [startCol.sourceIndex, effectiveCols[end - 1].sourceIndex],
+ startPath[level] || "",
+ level,
+ localX + delta,
+ level * headerHeightPerLevel,
+ w,
+ headerHeightPerLevel
+ );
+ }
+
+ x += boxWidth;
+ }
+ }
+}