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; + } + } +}