{
  "name": "PivotGrid",
  "category": "Data Display",
  "subcategory": null,
  "description": "Cross-tab / pivot table that summarizes flat data into rows x columns x aggregated measures.",
  "thumbnail": "/preview-cards/pivot-grid.png",
  "hasDocsPage": true,
  "nugetPackage": "Lumeo",
  "files": [
    "UI/PivotGrid/PivotCellClickArgs.cs",
    "UI/PivotGrid/PivotField.cs",
    "UI/PivotGrid/PivotGrid.razor",
    "UI/PivotGrid/PivotMeasure.cs"
  ],
  "dependencies": [
    "list"
  ],
  "packageDependencies": [
    "Blazicons.Lucide"
  ],
  "cssVars": [
    "--color-border",
    "--color-card",
    "--color-foreground",
    "--color-muted",
    "--color-muted-foreground",
    "--color-primary",
    "--radius"
  ],
  "api": {
    "name": "PivotGrid",
    "category": "Data Display",
    "subcategory": null,
    "description": "Cross-tab / pivot table that summarizes flat data into rows x columns x aggregated measures.",
    "nugetPackage": "Lumeo",
    "files": [
      "UI/PivotGrid/PivotCellClickArgs.cs",
      "UI/PivotGrid/PivotField.cs",
      "UI/PivotGrid/PivotGrid.razor",
      "UI/PivotGrid/PivotMeasure.cs"
    ],
    "namespace": "Lumeo",
    "inheritsFrom": null,
    "implements": [],
    "parameters": [
      {
        "name": "Items",
        "type": "IEnumerable<TItem>?",
        "default": null,
        "description": null,
        "isCascading": false,
        "captureUnmatched": false
      },
      {
        "name": "RowFields",
        "type": "IReadOnlyList<PivotField<TItem>>",
        "default": "Array.Empty<PivotField<TItem>>()",
        "description": null,
        "isCascading": false,
        "captureUnmatched": false
      },
      {
        "name": "ColumnFields",
        "type": "IReadOnlyList<PivotField<TItem>>",
        "default": "Array.Empty<PivotField<TItem>>()",
        "description": null,
        "isCascading": false,
        "captureUnmatched": false
      },
      {
        "name": "Measures",
        "type": "IReadOnlyList<PivotMeasure<TItem>>",
        "default": "Array.Empty<PivotMeasure<TItem>>()",
        "description": null,
        "isCascading": false,
        "captureUnmatched": false
      },
      {
        "name": "ShowRowGrandTotal",
        "type": "bool",
        "default": "true",
        "description": null,
        "isCascading": false,
        "captureUnmatched": false
      },
      {
        "name": "ShowColumnGrandTotal",
        "type": "bool",
        "default": "true",
        "description": null,
        "isCascading": false,
        "captureUnmatched": false
      },
      {
        "name": "ShowSubtotals",
        "type": "bool",
        "default": "true",
        "description": null,
        "isCascading": false,
        "captureUnmatched": false
      },
      {
        "name": "Collapsible",
        "type": "bool",
        "default": "true",
        "description": null,
        "isCascading": false,
        "captureUnmatched": false
      },
      {
        "name": "Compact",
        "type": "bool",
        "default": null,
        "description": null,
        "isCascading": false,
        "captureUnmatched": false
      },
      {
        "name": "GrandTotalLabel",
        "type": "string",
        "default": "\"Grand Total\"",
        "description": null,
        "isCascading": false,
        "captureUnmatched": false
      },
      {
        "name": "EmptyContent",
        "type": "RenderFragment?",
        "default": null,
        "description": null,
        "isCascading": false,
        "captureUnmatched": false
      },
      {
        "name": "OnCellClick",
        "type": "EventCallback<PivotCellClickArgs>",
        "default": null,
        "description": "Invoked when a data cell is clicked. Carries the clicked cell context for drill-down.",
        "isCascading": false,
        "captureUnmatched": false
      },
      {
        "name": "Class",
        "type": "string?",
        "default": null,
        "description": null,
        "isCascading": false,
        "captureUnmatched": false
      },
      {
        "name": "AdditionalAttributes",
        "type": "Dictionary<string, object>?",
        "default": null,
        "description": null,
        "isCascading": false,
        "captureUnmatched": true
      }
    ],
    "events": [
      {
        "name": "OnCellClick",
        "type": "EventCallback<PivotCellClickArgs>",
        "description": "Invoked when a data cell is clicked. Carries the clicked cell context for drill-down."
      }
    ],
    "enums": [
      {
        "name": "DisplayRowKind",
        "values": [
          "GroupHeader",
          "Leaf",
          "Subtotal"
        ],
        "description": null
      }
    ],
    "records": [
      {
        "name": "ColumnLeaf",
        "signature": "ColumnLeaf(\n        string Label,\n        string PathKey,\n        object?[] Path,\n        IReadOnlyList<TItem> Items,\n        bool IsGrandTotal)",
        "description": null
      },
      {
        "name": "HeaderEntry",
        "signature": "HeaderEntry(string Label, int ColSpan, int RowSpan, bool IsGrandTotal)",
        "description": null
      },
      {
        "name": "DisplayRow",
        "signature": "DisplayRow(RowNode Node, DisplayRowKind Kind, bool Collapsed, int Level, string Label)",
        "description": null
      }
    ],
    "cssVars": [
      "--color-border",
      "--color-card",
      "--color-foreground",
      "--color-muted",
      "--color-muted-foreground",
      "--color-primary",
      "--radius"
    ],
    "examples": [
      {
        "title": "Sales by region × year",
        "code": "<PivotGrid TItem=\"Sale\"\n           Items=\"_sales\"\n           RowFields=\"@(new List<PivotField<Sale>> { new(\"Region\", s => s.Region) })\"\n           ColumnFields=\"@(new List<PivotField<Sale>> { new(\"Year\", s => s.Year, v => \"FY \" + v) })\"\n           Measures=\"@(new List<PivotMeasure<Sale>> { new(\"Revenue\", s => s.Amount, PivotAggregate.Sum, d => \"$\" + d.ToString(\"N0\")) })\" />\n\n@code {\n    record Sale(string Region, string Country, int Year, int Quarter, decimal Amount, int Units);\n    List<Sale> _sales = new() { /* ... */ };\n}"
      },
      {
        "title": "Multiple measures (sum + count)",
        "code": "<PivotGrid TItem=\"Sale\"\n           Items=\"_sales\"\n           RowFields=\"_regionField\"\n           ColumnFields=\"_yearField\"\n           Measures=\"@(new List<PivotMeasure<Sale>> {\n               new(\"Revenue\", s => s.Amount, PivotAggregate.Sum, d => \"$\" + d.ToString(\"N0\")),\n               new(\"Deals\", s => s.Units, PivotAggregate.Count),\n           })\" />"
      },
      {
        "title": "Collapsible nested row groups",
        "code": "<PivotGrid TItem=\"Sale\"\n           Items=\"_sales\"\n           RowFields=\"@(new List<PivotField<Sale>> {\n               new(\"Region\", s => s.Region),\n               new(\"Country\", s => s.Country),\n           })\"\n           ColumnFields=\"_yearField\"\n           Measures=\"_amountMeasure\"\n           Collapsible=\"true\"\n           Compact=\"true\" />"
      },
      {
        "title": "Multi-level column hierarchy (Year &gt; Quarter)",
        "code": "<PivotGrid TItem=\"Sale\"\n           Items=\"_sales\"\n           RowFields=\"_regionCountryFields\"\n           ColumnFields=\"@(new List<PivotField<Sale>> {\n               new(\"Year\",    s => s.Year, v => \"FY \" + v),\n               new(\"Quarter\", s => s.Quarter, v => \"Q\" + v),\n           })\"\n           Measures=\"_amountMeasure\"\n           Compact=\"true\" />"
      }
    ],
    "subComponents": {},
    "parseFailed": false,
    "parseError": null
  },
  "$schema": "https://lumeo.nativ.sh/registry-component-schema.json",
  "key": "pivot-grid",
  "docsUrl": "https://lumeo.nativ.sh/components/pivot-grid",
  "sourceUrl": [
    {
      "file": "UI/PivotGrid/PivotCellClickArgs.cs",
      "url": "https://github.com/Brain2k-0005/Lumeo/tree/master/src/Lumeo/UI/PivotGrid/PivotCellClickArgs.cs"
    },
    {
      "file": "UI/PivotGrid/PivotField.cs",
      "url": "https://github.com/Brain2k-0005/Lumeo/tree/master/src/Lumeo/UI/PivotGrid/PivotField.cs"
    },
    {
      "file": "UI/PivotGrid/PivotGrid.razor",
      "url": "https://github.com/Brain2k-0005/Lumeo/tree/master/src/Lumeo/UI/PivotGrid/PivotGrid.razor"
    },
    {
      "file": "UI/PivotGrid/PivotMeasure.cs",
      "url": "https://github.com/Brain2k-0005/Lumeo/tree/master/src/Lumeo/UI/PivotGrid/PivotMeasure.cs"
    }
  ],
  "source": [
    {
      "path": "UI/PivotGrid/PivotCellClickArgs.cs",
      "content": "namespace Lumeo;\n\n/// <summary>\n/// Context for a clicked data cell in a <see cref=\"PivotGrid{TItem}\"/>, useful for drill-down.\n/// </summary>\npublic sealed class PivotCellClickArgs\n{\n    /// <summary>The row-hierarchy path (one value per row field) identifying the clicked row.</summary>\n    public IReadOnlyList<object?> RowKeys { get; }\n\n    /// <summary>The column-hierarchy path identifying the clicked column. Empty when there is no column field.</summary>\n    public IReadOnlyList<object?> ColumnKeys { get; }\n\n    /// <summary>Header of the measure that was clicked.</summary>\n    public string Measure { get; }\n\n    /// <summary>The underlying source rows that contributed to the clicked cell.</summary>\n    public IReadOnlyList<object?> Items { get; }\n\n    public PivotCellClickArgs(IReadOnlyList<object?> rowKeys, IReadOnlyList<object?> columnKeys,\n        string measure, IReadOnlyList<object?> items)\n    {\n        RowKeys = rowKeys;\n        ColumnKeys = columnKeys;\n        Measure = measure;\n        Items = items;\n    }\n}\n"
    },
    {
      "path": "UI/PivotGrid/PivotField.cs",
      "content": "namespace Lumeo;\n\n/// <summary>\n/// Describes a field used to build a row- or column-hierarchy level in a\n/// <see cref=\"PivotGrid{TItem}\"/>. The distinct values produced by\n/// <see cref=\"Selector\"/> form the groups at that level.\n/// </summary>\n/// <typeparam name=\"TItem\">The flat source row type.</typeparam>\npublic sealed class PivotField<TItem>\n{\n    /// <summary>Header label shown for this field level.</summary>\n    public string Header { get; init; } = \"\";\n\n    /// <summary>Extracts the grouping value for this field from a source row.</summary>\n    public Func<TItem, object?> Selector { get; init; } = _ => null;\n\n    /// <summary>Optional formatter for the displayed group label. Receives the raw value.</summary>\n    public Func<object?, string>? Format { get; init; }\n\n    /// <summary>Optional custom ordering of the distinct values. When null, values are sorted naturally.</summary>\n    public IComparer<object?>? SortComparer { get; init; }\n\n    public PivotField() { }\n\n    public PivotField(string header, Func<TItem, object?> selector,\n        Func<object?, string>? format = null, IComparer<object?>? sortComparer = null)\n    {\n        Header = header;\n        Selector = selector;\n        Format = format;\n        SortComparer = sortComparer;\n    }\n\n    internal string FormatValue(object? value)\n        => Format is not null ? Format(value) : (value?.ToString() ?? \"—\");\n}\n"
    },
    {
      "path": "UI/PivotGrid/PivotGrid.razor",
      "content": "@namespace Lumeo\n@typeparam TItem\n\n<div class=\"@WrapperClass\" @attributes=\"AdditionalAttributes\">\n    @if (_isEmpty)\n    {\n        <div class=\"p-6 text-sm text-muted-foreground text-center\">\n            @if (EmptyContent is not null)\n            {\n                @EmptyContent\n            }\n            else\n            {\n                <span>No data to display.</span>\n            }\n        </div>\n    }\n    else\n    {\n        <div class=\"relative w-full overflow-auto\">\n            <table role=\"table\" class=\"w-full caption-bottom text-sm border-collapse\">\n                <thead>\n                    @* --- column-field levels (one <tr> per level, top-down) --- *@\n                    @for (var levelIdx = 0; levelIdx < _columnHeaderRows.Count; levelIdx++)\n                    {\n                        var row = _columnHeaderRows[levelIdx];\n                        <tr role=\"row\" class=\"border-b border-border/40\">\n                            @if (levelIdx == 0)\n                            {\n                                @foreach (var rf in _rowFields)\n                                {\n                                    <th role=\"columnheader\" rowspan=\"@HeaderRowSpan\"\n                                        class=\"@HeaderCellClass align-bottom text-left\">@rf.Header</th>\n                                }\n                            }\n                            @foreach (var entry in row)\n                            {\n                                <th role=\"columnheader\"\n                                    colspan=\"@entry.ColSpan\"\n                                    rowspan=\"@entry.RowSpan\"\n                                    class=\"@HeaderClassFor(entry)\">@entry.Label</th>\n                            }\n                        </tr>\n                    }\n                    @* --- measure-name row (always shown when we have a column tree OR when no column fields and multiple measures need labels) --- *@\n                    @if (_hasColumnField)\n                    {\n                        <tr role=\"row\" class=\"border-b border-border/40\">\n                            @foreach (var leaf in _columnLeaves)\n                            {\n                                @foreach (var m in _measures)\n                                {\n                                    <th role=\"columnheader\"\n                                        class=\"@($\"{HeaderCellClass} text-right {(leaf.IsGrandTotal ? \"bg-muted/50\" : \"\")}\")\">@m.Header</th>\n                                }\n                            }\n                        </tr>\n                    }\n                    else\n                    {\n                        @* No column fields: measure headers in the single top-row of the thead *@\n                        @if (_columnHeaderRows.Count == 0)\n                        {\n                            <tr role=\"row\" class=\"border-b border-border/40\">\n                                @foreach (var rf in _rowFields)\n                                {\n                                    <th role=\"columnheader\" class=\"@HeaderCellClass align-bottom text-left\">@rf.Header</th>\n                                }\n                                @foreach (var m in _measures)\n                                {\n                                    <th role=\"columnheader\" class=\"@HeaderCellClass text-right\">@m.Header</th>\n                                }\n                            </tr>\n                        }\n                    }\n                </thead>\n                <tbody>\n                    @foreach (var row in _displayRows)\n                    {\n                        <tr role=\"row\" class=\"@RowClass(row)\">\n                            @* --- row-header columns --- *@\n                            @if (row.Kind == DisplayRowKind.Leaf)\n                            {\n                                @for (var i = 0; i < _rowFields.Count; i++)\n                                {\n                                    <td role=\"rowheader\" class=\"@DataCellClass\">@(i == row.Level ? row.Label : \"\")</td>\n                                }\n                            }\n                            else\n                            {\n                                @for (var i = 0; i < row.Level; i++)\n                                {\n                                    <td role=\"rowheader\" class=\"@DataCellClass\"></td>\n                                }\n                                <td role=\"rowheader\" colspan=\"@(_rowFields.Count - row.Level)\"\n                                    class=\"@DataCellClass font-medium\">\n                                    @if (row.Kind == DisplayRowKind.GroupHeader && Collapsible)\n                                    {\n                                        <button type=\"button\" aria-expanded=\"@((!row.Collapsed).ToString().ToLowerInvariant())\"\n                                                class=\"inline-flex items-center gap-1 hover:text-primary transition-colors\"\n                                                @onclick=\"() => ToggleCollapse(row.Node)\">\n                                            <Blazicon Svg=\"@(row.Collapsed ? Lucide.ChevronRight : Lucide.ChevronDown)\" class=\"w-3.5 h-3.5 shrink-0\" />\n                                            <span>@row.Label</span>\n                                        </button>\n                                    }\n                                    else if (row.Kind == DisplayRowKind.Subtotal)\n                                    {\n                                        <span>@row.Label Total</span>\n                                    }\n                                    else\n                                    {\n                                        <span>@row.Label</span>\n                                    }\n                                </td>\n                            }\n                            @* --- data columns --- *@\n                            @if (_hasColumnField)\n                            {\n                                @foreach (var col in _columnLeaves)\n                                {\n                                    @for (var mi = 0; mi < _measures.Count; mi++)\n                                    {\n                                        var measure = _measures[mi];\n                                        var items = LookupCellItems(row.Node.Key, col.PathKey);\n                                        var mIndex = mi;\n                                        var theCol = col;\n                                        <td role=\"cell\"\n                                            class=\"@($\"{DataCellClass} tabular-nums text-right {(col.IsGrandTotal ? \"bg-muted/30 font-medium\" : \"\")}\")\"\n                                            @onclick=\"() => OnCellClickHandler(row, theCol, mIndex)\">@measure.FormatValue(measure.Compute(items))</td>\n                                    }\n                                }\n                            }\n                            else\n                            {\n                                @for (var mi = 0; mi < _measures.Count; mi++)\n                                {\n                                    var measure = _measures[mi];\n                                    var mIndex = mi;\n                                    <td role=\"cell\" class=\"@DataCellClass tabular-nums text-right\"\n                                        @onclick=\"() => OnCellClickHandler(row, null, mIndex)\">@measure.FormatValue(measure.Compute(row.Node.Items))</td>\n                                }\n                            }\n                        </tr>\n                    }\n                    @if (ShowColumnGrandTotal)\n                    {\n                        <tr role=\"row\" class=\"bg-muted/50 font-semibold border-t border-border/40\">\n                            <td role=\"rowheader\" colspan=\"@_rowFields.Count\" class=\"@DataCellClass\">@GrandTotalLabel</td>\n                            @if (_hasColumnField)\n                            {\n                                @foreach (var col in _columnLeaves)\n                                {\n                                    @foreach (var m in _measures)\n                                    {\n                                        <td role=\"cell\"\n                                            class=\"@($\"{DataCellClass} tabular-nums text-right {(col.IsGrandTotal ? \"bg-muted/50\" : \"\")}\")\">@m.FormatValue(m.Compute(col.Items))</td>\n                                    }\n                                }\n                            }\n                            else\n                            {\n                                @foreach (var m in _measures)\n                                {\n                                    <td role=\"cell\" class=\"@DataCellClass tabular-nums text-right\">@m.FormatValue(m.Compute(_allItems))</td>\n                                }\n                            }\n                        </tr>\n                    }\n                </tbody>\n            </table>\n        </div>\n    }\n</div>\n\n@code {\n    // ---- Parameters ----------------------------------------------------\n\n    [Parameter] public IEnumerable<TItem>? Items { get; set; }\n    [Parameter] public IReadOnlyList<PivotField<TItem>> RowFields { get; set; } = Array.Empty<PivotField<TItem>>();\n    [Parameter] public IReadOnlyList<PivotField<TItem>> ColumnFields { get; set; } = Array.Empty<PivotField<TItem>>();\n    [Parameter] public IReadOnlyList<PivotMeasure<TItem>> Measures { get; set; } = Array.Empty<PivotMeasure<TItem>>();\n\n    [Parameter] public bool ShowRowGrandTotal { get; set; } = true;\n    [Parameter] public bool ShowColumnGrandTotal { get; set; } = true;\n    [Parameter] public bool ShowSubtotals { get; set; } = true;\n    [Parameter] public bool Collapsible { get; set; } = true;\n    [Parameter] public bool Compact { get; set; }\n    [Parameter] public string GrandTotalLabel { get; set; } = \"Grand Total\";\n\n    [Parameter] public RenderFragment? EmptyContent { get; set; }\n\n    /// <summary>Invoked when a data cell is clicked. Carries the clicked cell context for drill-down.</summary>\n    [Parameter] public EventCallback<PivotCellClickArgs> OnCellClick { get; set; }\n\n    [Parameter] public string? Class { get; set; }\n    [Parameter(CaptureUnmatchedValues = true)] public Dictionary<string, object>? AdditionalAttributes { get; set; }\n\n    // ---- Cached state (computed in OnParametersSet) --------------------\n\n    private List<TItem> _allItems = new();\n    private IReadOnlyList<PivotField<TItem>> _rowFields = Array.Empty<PivotField<TItem>>();\n    private IReadOnlyList<PivotField<TItem>> _columnFields = Array.Empty<PivotField<TItem>>();\n    private IReadOnlyList<PivotMeasure<TItem>> _measures = Array.Empty<PivotMeasure<TItem>>();\n    private bool _hasColumnField;\n    private bool _isEmpty;\n\n    // Multi-level column structures\n    private PivotColumnNode _columnRoot = new(\"\", \"\", \"\", -1, isGrandTotal: false);\n    private List<ColumnLeaf> _columnLeaves = new();\n    private List<List<HeaderEntry>> _columnHeaderRows = new();\n\n    // Row hierarchy\n    private RowNode _root = new(Array.Empty<object?>(), -1);\n    private List<DisplayRow> _displayRows = new();\n\n    // Aggregation cache: keyed by row-node key, then by column-leaf path-key, holds the matched item list\n    private Dictionary<string, Dictionary<string, IReadOnlyList<TItem>>> _cellItems = new();\n    private static readonly IReadOnlyList<TItem> EmptyItems = Array.Empty<TItem>();\n\n    private readonly HashSet<string> _collapsed = new();\n\n    // Short-circuit guard for OnParametersSet: BuildColumnRoot + BuildNode +\n    // BuildCellLookup are O(N · fields) and rerunning them on every render\n    // (e.g. when a parent passes a freshly-projected IEnumerable that's\n    // logically the same data) is wasteful. We track the last-seen Items\n    // reference + a hash of the dimension references; if both match we skip\n    // the rebuild. Toggle-collapse paths still recompute _displayRows\n    // explicitly via FlattenRows — they don't depend on this gate.\n    private object? _lastItemsRef;\n    private int _lastDimsHash;\n    private bool _lastShowRowGrandTotal;\n    private bool _lastShowSubtotals;\n\n    private string WrapperClass => $\"rounded-lg border border-border/40 bg-card text-foreground overflow-hidden {Class}\".Trim();\n    private string HeaderCellClass => (Compact ? \"px-2 py-1.5\" : \"px-3 py-2\") + \" text-xs font-medium text-foreground bg-muted/40 border border-border/40 whitespace-nowrap\";\n    private string DataCellClass => (Compact ? \"px-2 py-1.5\" : \"px-3 py-2\") + \" border border-border/40 whitespace-nowrap\";\n\n    // Total row-span for the top-left corner: column-tree depth + 1 (for the measure-name row).\n    private int HeaderRowSpan => _hasColumnField ? _columnHeaderRows.Count + 1 : 1;\n\n    private string HeaderClassFor(HeaderEntry entry)\n    {\n        var bg = entry.IsGrandTotal ? \"bg-muted/50\" : \"\";\n        return $\"{HeaderCellClass} text-center {bg}\".Trim();\n    }\n\n    protected override void OnParametersSet()\n    {\n        // Cheap short-circuit: if every input that drives the tree-build is\n        // reference-identical to last time, and the flags that influence\n        // ShowRowGrandTotal/ShowSubtotals (which feed into _columnRoot and\n        // _displayRows) match too, skip the rebuild entirely. The hash uses\n        // reference identity of the parameter values (RowFields/ColumnFields/\n        // Measures are typically Array.Empty or stable consumer-side lists),\n        // so we don't pay an O(N) equality check.\n        // Reference-identity hashes: cheap and stable even when consumers\n        // pass Array.Empty<T>() (same singleton). Null-safe wrapper avoids\n        // RuntimeHelpers.GetHashCode's ArgumentNullException.\n        static int IdHash(object? o) =>\n            o is null ? 0 : System.Runtime.CompilerServices.RuntimeHelpers.GetHashCode(o);\n        var dimsHash = HashCode.Combine(IdHash(RowFields), IdHash(ColumnFields), IdHash(Measures));\n\n        if (ReferenceEquals(Items, _lastItemsRef)\n            && dimsHash == _lastDimsHash\n            && ShowRowGrandTotal == _lastShowRowGrandTotal\n            && ShowSubtotals == _lastShowSubtotals)\n        {\n            return;\n        }\n\n        _lastItemsRef = Items;\n        _lastDimsHash = dimsHash;\n        _lastShowRowGrandTotal = ShowRowGrandTotal;\n        _lastShowSubtotals = ShowSubtotals;\n\n        _allItems = (Items ?? Enumerable.Empty<TItem>()).ToList();\n        _rowFields = RowFields ?? Array.Empty<PivotField<TItem>>();\n        _columnFields = ColumnFields ?? Array.Empty<PivotField<TItem>>();\n        _measures = Measures ?? Array.Empty<PivotMeasure<TItem>>();\n        _hasColumnField = _columnFields.Count > 0;\n        _isEmpty = _allItems.Count == 0;\n\n        if (_isEmpty)\n        {\n            _columnLeaves = new();\n            _columnHeaderRows = new();\n            _displayRows = new();\n            _cellItems = new();\n            return;\n        }\n\n        // --- build column tree (multi-level) ---\n        _columnRoot = BuildColumnRoot();\n        _columnLeaves = new();\n        CollectColumnLeaves(_columnRoot, _columnLeaves);\n\n        // --- build column header rows (one entry per level of the column tree) ---\n        _columnHeaderRows = BuildColumnHeaderRows(_columnRoot);\n\n        // --- row hierarchy tree ---\n        _root = BuildNode(_allItems, Array.Empty<object?>(), -1);\n\n        // --- aggregation lookup: rowKey -> colKey -> items ---\n        _cellItems = BuildCellLookup(_root);\n\n        // --- flatten into display rows respecting collapse ---\n        _displayRows = new();\n        FlattenRows(_root);\n    }\n\n    // ---- Column-tree construction --------------------------------------\n\n    private PivotColumnNode BuildColumnRoot()\n    {\n        var root = new PivotColumnNode(label: \"\", key: \"\", pathKey: \"\", level: -1, isGrandTotal: false)\n        {\n            Items = _allItems,\n        };\n\n        BuildColumnChildren(root, _allItems, 0);\n\n        // Append grand-total leaf at the top level if requested and column fields exist.\n        if (ShowRowGrandTotal && _hasColumnField)\n        {\n            var gt = new PivotColumnNode(\n                label: GrandTotalLabel,\n                key: \"__grand_total__\",\n                pathKey: \"__GT__\",\n                level: 0,\n                isGrandTotal: true)\n            {\n                Items = _allItems,\n                LeafCount = 1,\n            };\n            root.Children.Add(gt);\n        }\n\n        // Compute leaf counts bottom-up.\n        ComputeLeafCounts(root);\n        return root;\n    }\n\n    private void BuildColumnChildren(PivotColumnNode parent, IReadOnlyList<TItem> items, int level)\n    {\n        if (level >= _columnFields.Count) return;\n        var field = _columnFields[level];\n        var groups = items\n            .GroupBy(i => field.Selector(i))\n            .Select(g => (Key: g.Key, Items: (IReadOnlyList<TItem>)g.ToList()))\n            .ToList();\n        groups.Sort((a, b) => CompareValues(a.Key, b.Key));\n        if (field.SortComparer is { } sc)\n            groups = groups.OrderBy(x => x.Key, sc).ToList();\n\n        foreach (var g in groups)\n        {\n            var label = field.FormatValue(g.Key);\n            var keyStr = g.Key?.ToString() ?? \"_\";\n            var pathKey = parent.PathKey.Length == 0 ? $\"L{level}={keyStr}\" : $\"{parent.PathKey}|L{level}={keyStr}\";\n            var node = new PivotColumnNode(label, keyStr, pathKey, level, isGrandTotal: false)\n            {\n                RawKey = g.Key,\n                Items = g.Items,\n            };\n            parent.Children.Add(node);\n            BuildColumnChildren(node, g.Items, level + 1);\n        }\n    }\n\n    private static int ComputeLeafCounts(PivotColumnNode node)\n    {\n        if (node.Children.Count == 0)\n        {\n            node.LeafCount = 1;\n            return 1;\n        }\n        var sum = 0;\n        foreach (var c in node.Children)\n            sum += ComputeLeafCounts(c);\n        node.LeafCount = sum;\n        return sum;\n    }\n\n    private static void CollectColumnLeaves(PivotColumnNode node, List<ColumnLeaf> leaves)\n    {\n        if (node.Level >= 0 && node.Children.Count == 0)\n        {\n            // Build path-key array from root-down: this leaf already has a PathKey.\n            leaves.Add(new ColumnLeaf(\n                Label: node.Label,\n                PathKey: node.PathKey,\n                Path: BuildPathFromNode(node),\n                Items: node.Items,\n                IsGrandTotal: node.IsGrandTotal));\n            return;\n        }\n        foreach (var c in node.Children)\n            CollectColumnLeaves(c, leaves);\n    }\n\n    private static object?[] BuildPathFromNode(PivotColumnNode leaf)\n    {\n        // The leaf's PathKey encodes ancestor segments — but we need RawKeys.\n        // Easier: walk the tree, since we built linearly, recreate via segments.\n        // Since we don't keep a parent ref, derive from the PathKey segments isn't ideal.\n        // For click-args we'll fall back to splitting PathKey, but raw click info uses Path stored on leaf.\n        // We populate Path on leaves below in another pass.\n        return leaf.PathOverride ?? Array.Empty<object?>();\n    }\n\n    private List<List<HeaderEntry>> BuildColumnHeaderRows(PivotColumnNode root)\n    {\n        // Number of column header rows == depth of column tree from level-0 onwards.\n        // Even for ShowRowGrandTotal with single-level columns, depth is 1.\n        if (root.Children.Count == 0) return new();\n\n        // Compute max depth of any path (1-based count of column-field levels involved).\n        var depth = MaxDepth(root, 0);\n        var rows = new List<List<HeaderEntry>>();\n        for (var i = 0; i < depth; i++) rows.Add(new());\n\n        // Per-row width counter for sanity (no longer strictly needed but kept consistent).\n        AppendHeaderEntries(root, 0, depth, rows);\n        return rows;\n    }\n\n    private static int MaxDepth(PivotColumnNode node, int currentDepth)\n    {\n        if (node.Children.Count == 0) return currentDepth;\n        var max = currentDepth;\n        foreach (var c in node.Children)\n            max = Math.Max(max, MaxDepth(c, currentDepth + 1));\n        return max;\n    }\n\n    private void AppendHeaderEntries(PivotColumnNode parent, int parentDepth, int totalDepth, List<List<HeaderEntry>> rows)\n    {\n        foreach (var child in parent.Children)\n        {\n            var span = child.LeafCount * _measures.Count;\n            if (child.Children.Count == 0)\n            {\n                // Leaf column-field node. Render at parentDepth and add a rowspan filling the remaining depth.\n                var remainingDepth = totalDepth - parentDepth;\n                rows[parentDepth].Add(new HeaderEntry(\n                    Label: child.Label,\n                    ColSpan: span,\n                    RowSpan: remainingDepth,\n                    IsGrandTotal: child.IsGrandTotal));\n            }\n            else\n            {\n                rows[parentDepth].Add(new HeaderEntry(\n                    Label: child.Label,\n                    ColSpan: span,\n                    RowSpan: 1,\n                    IsGrandTotal: child.IsGrandTotal));\n                AppendHeaderEntries(child, parentDepth + 1, totalDepth, rows);\n            }\n        }\n    }\n\n    // ---- Row-tree (unchanged in shape) ---------------------------------\n\n    private RowNode BuildNode(IReadOnlyList<TItem> items, object?[] path, int level)\n    {\n        var labels = new string[path.Length];\n        for (var i = 0; i < path.Length; i++)\n            labels[i] = _rowFields[i].FormatValue(path[i]);\n        var node = new RowNode(path, level) { Items = items, Labels = labels };\n\n        var childLevel = level + 1;\n        if (childLevel < _rowFields.Count)\n        {\n            var field = _rowFields[childLevel];\n            var groups = items\n                .GroupBy(i => field.Selector(i))\n                .Select(g => (Key: g.Key, Items: (IReadOnlyList<TItem>)g.ToList()))\n                .ToList();\n            groups.Sort((a, b) => CompareValues(a.Key, b.Key));\n            if (field.SortComparer is { } sc)\n                groups = groups.OrderBy(x => x.Key, sc).ToList();\n            foreach (var g in groups)\n            {\n                var childPath = path.Append(g.Key).ToArray();\n                node.Children.Add(BuildNode(g.Items, childPath, childLevel));\n            }\n        }\n        return node;\n    }\n\n    private void FlattenRows(RowNode node)\n    {\n        foreach (var child in node.Children)\n        {\n            if (child.Children.Count == 0)\n            {\n                _displayRows.Add(new DisplayRow(child, DisplayRowKind.Leaf, false, child.Level, LeafLabel(child)));\n            }\n            else\n            {\n                var collapsed = _collapsed.Contains(child.Key);\n                _displayRows.Add(new DisplayRow(child, DisplayRowKind.GroupHeader, collapsed, child.Level, LeafLabel(child)));\n                if (!collapsed)\n                    FlattenRows(child);\n                if (ShowSubtotals)\n                    _displayRows.Add(new DisplayRow(child, DisplayRowKind.Subtotal, false, child.Level, LeafLabel(child)));\n            }\n        }\n    }\n\n    private static string LeafLabel(RowNode n) => n.Labels.Length == 0 ? \"\" : n.Labels[^1];\n\n    // ---- Aggregation lookup --------------------------------------------\n\n    /// <summary>\n    /// Build a cache that, for every row node (group, leaf, subtotal — all share the same .Key),\n    /// gives the matched items per column-leaf. The grand-total column leaf gets the full row-node Items.\n    /// </summary>\n    private Dictionary<string, Dictionary<string, IReadOnlyList<TItem>>> BuildCellLookup(RowNode root)\n    {\n        var cache = new Dictionary<string, Dictionary<string, IReadOnlyList<TItem>>>();\n        if (!_hasColumnField) return cache;\n        BuildCellLookupRecursive(root, cache);\n        return cache;\n    }\n\n    private void BuildCellLookupRecursive(RowNode node, Dictionary<string, Dictionary<string, IReadOnlyList<TItem>>> cache)\n    {\n        // Compute once for this row node.\n        var perCol = new Dictionary<string, IReadOnlyList<TItem>>(_columnLeaves.Count);\n        // Initialise empty buckets for every column leaf so lookup is O(1) without nulls.\n        for (var i = 0; i < _columnLeaves.Count; i++)\n        {\n            var leaf = _columnLeaves[i];\n            if (leaf.IsGrandTotal)\n            {\n                perCol[leaf.PathKey] = node.Items;\n            }\n            else\n            {\n                perCol[leaf.PathKey] = EmptyItems;\n            }\n        }\n\n        // Bucket items into column leaves by walking the column tree.\n        // For each row item, find its leaf path-key.\n        if (node.Items.Count > 0)\n        {\n            // Group the row items into per-column-leaf lists.\n            var buckets = new Dictionary<string, List<TItem>>();\n            foreach (var item in node.Items)\n            {\n                var pathKey = ComputeItemColumnPathKey(item);\n                if (!buckets.TryGetValue(pathKey, out var list))\n                {\n                    list = new List<TItem>();\n                    buckets[pathKey] = list;\n                }\n                list.Add(item);\n            }\n            foreach (var kv in buckets)\n            {\n                perCol[kv.Key] = kv.Value;\n            }\n        }\n\n        cache[node.Key] = perCol;\n\n        foreach (var child in node.Children)\n            BuildCellLookupRecursive(child, cache);\n    }\n\n    private string ComputeItemColumnPathKey(TItem item)\n    {\n        // Walk the column-field chain for this item and produce its leaf path-key,\n        // formatted identically to BuildColumnChildren.\n        var sb = new System.Text.StringBuilder();\n        for (var level = 0; level < _columnFields.Count; level++)\n        {\n            if (level > 0) sb.Append('|');\n            var key = _columnFields[level].Selector(item);\n            var keyStr = key?.ToString() ?? \"_\";\n            sb.Append('L').Append(level).Append('=').Append(keyStr);\n        }\n        return sb.ToString();\n    }\n\n    private IReadOnlyList<TItem> LookupCellItems(string rowKey, string columnPathKey)\n    {\n        if (_cellItems.TryGetValue(rowKey, out var perCol) &&\n            perCol.TryGetValue(columnPathKey, out var items))\n            return items;\n        return EmptyItems;\n    }\n\n    private void ToggleCollapse(RowNode node)\n    {\n        if (!Collapsible || node.Children.Count == 0) return;\n        if (!_collapsed.Add(node.Key)) _collapsed.Remove(node.Key);\n        _displayRows = new();\n        FlattenRows(_root);\n        StateHasChanged();\n    }\n\n    private async Task OnCellClickHandler(DisplayRow row, ColumnLeaf? col, int measureIndex)\n    {\n        if (!OnCellClick.HasDelegate) return;\n        var matched = col is not null\n            ? LookupCellItems(row.Node.Key, col.PathKey)\n            : row.Node.Items;\n        // Decode raw column-keys from the leaf path (skip if grand-total or no columns).\n        var columnKeys = (col is null || col.IsGrandTotal)\n            ? Array.Empty<object?>()\n            : DecodeColumnKeys(col);\n        var args = new PivotCellClickArgs(\n            row.Node.Path.ToArray(),\n            columnKeys,\n            (measureIndex >= 0 && measureIndex < _measures.Count) ? _measures[measureIndex].Header : \"\",\n            matched.Cast<object?>().ToArray());\n        await OnCellClick.InvokeAsync(args);\n    }\n\n    private object?[] DecodeColumnKeys(ColumnLeaf leaf)\n    {\n        // PathKey format: \"L0=val0|L1=val1|...\" — return raw string segments per level.\n        if (string.IsNullOrEmpty(leaf.PathKey)) return Array.Empty<object?>();\n        var segments = leaf.PathKey.Split('|');\n        var result = new object?[segments.Length];\n        for (var i = 0; i < segments.Length; i++)\n        {\n            var seg = segments[i];\n            var eq = seg.IndexOf('=');\n            result[i] = eq >= 0 ? seg.Substring(eq + 1) : seg;\n        }\n        return result;\n    }\n\n    private static int CompareValues(object? a, object? b)\n    {\n        if (a is null && b is null) return 0;\n        if (a is null) return -1;\n        if (b is null) return 1;\n        if (a is IComparable ca && a.GetType() == b.GetType()) return ca.CompareTo(b);\n        return string.Compare(a.ToString(), b.ToString(), StringComparison.OrdinalIgnoreCase);\n    }\n\n    private string RowClass(DisplayRow row) => row.Kind switch\n    {\n        DisplayRowKind.Subtotal => \"bg-muted/30 font-medium border-b border-border/40\",\n        DisplayRowKind.GroupHeader => \"bg-muted/20 font-medium border-b border-border/40\",\n        _ => \"border-b border-border/40 hover:bg-muted/30 transition-colors\"\n    };\n\n    // ---- Internal model -----------------------------------------------\n\n    private sealed class RowNode\n    {\n        public object?[] Path { get; }\n        public int Level { get; }\n        public IReadOnlyList<TItem> Items { get; set; } = Array.Empty<TItem>();\n        public string[] Labels { get; set; } = Array.Empty<string>();\n        public List<RowNode> Children { get; } = new();\n        public string Key { get; }\n\n        public RowNode(object?[] path, int level)\n        {\n            Path = path;\n            Level = level;\n            Key = \"L\" + level + \"|\" + string.Join(\"\", path.Select(p => p?.ToString() ?? \" \"));\n        }\n    }\n\n    /// <summary>\n    /// A node in the column-field hierarchy. Internal node => header group; leaf node (Children empty) => actual data column.\n    /// </summary>\n    private sealed class PivotColumnNode\n    {\n        public string Label { get; }\n        public string Key { get; }\n        public string PathKey { get; }\n        public int Level { get; }\n        public bool IsGrandTotal { get; }\n        public object? RawKey { get; set; }\n        public object?[]? PathOverride { get; set; }\n        public IReadOnlyList<TItem> Items { get; set; } = Array.Empty<TItem>();\n        public List<PivotColumnNode> Children { get; } = new();\n        public int LeafCount { get; set; }\n\n        public PivotColumnNode(string label, string key, string pathKey, int level, bool isGrandTotal)\n        {\n            Label = label;\n            Key = key;\n            PathKey = pathKey;\n            Level = level;\n            IsGrandTotal = isGrandTotal;\n        }\n    }\n\n    private sealed record ColumnLeaf(\n        string Label,\n        string PathKey,\n        object?[] Path,\n        IReadOnlyList<TItem> Items,\n        bool IsGrandTotal);\n\n    private sealed record HeaderEntry(string Label, int ColSpan, int RowSpan, bool IsGrandTotal);\n\n    private enum DisplayRowKind { GroupHeader, Leaf, Subtotal }\n\n    private sealed record DisplayRow(RowNode Node, DisplayRowKind Kind, bool Collapsed, int Level, string Label);\n}\n"
    },
    {
      "path": "UI/PivotGrid/PivotMeasure.cs",
      "content": "namespace Lumeo;\n\n/// <summary>How a <see cref=\"PivotMeasure{TItem}\"/> aggregates the matching source rows.</summary>\npublic enum PivotAggregate\n{\n    Sum,\n    Average,\n    Count,\n    Min,\n    Max\n}\n\n/// <summary>\n/// A value field for a <see cref=\"PivotGrid{TItem}\"/> together with the\n/// aggregation applied across the source rows in each cell.\n/// </summary>\n/// <typeparam name=\"TItem\">The flat source row type.</typeparam>\npublic sealed class PivotMeasure<TItem>\n{\n    /// <summary>Header label for this measure.</summary>\n    public string Header { get; init; } = \"\";\n\n    /// <summary>Extracts the numeric value to aggregate from a source row.</summary>\n    public Func<TItem, decimal> Value { get; init; } = _ => 0m;\n\n    /// <summary>The aggregation function. Default is <see cref=\"PivotAggregate.Sum\"/>.</summary>\n    public PivotAggregate Aggregate { get; init; } = PivotAggregate.Sum;\n\n    /// <summary>Optional formatter for the aggregated number. When null, a sensible default is used.</summary>\n    public Func<decimal, string>? Format { get; init; }\n\n    public PivotMeasure() { }\n\n    public PivotMeasure(string header, Func<TItem, decimal> value,\n        PivotAggregate aggregate = PivotAggregate.Sum, Func<decimal, string>? format = null)\n    {\n        Header = header;\n        Value = value;\n        Aggregate = aggregate;\n        Format = format;\n    }\n\n    internal decimal Compute(IReadOnlyList<TItem> items)\n    {\n        if (Aggregate == PivotAggregate.Count)\n            return items.Count;\n        if (items.Count == 0)\n            return 0m;\n        return Aggregate switch\n        {\n            PivotAggregate.Sum => items.Sum(Value),\n            PivotAggregate.Average => items.Average(Value),\n            PivotAggregate.Min => items.Min(Value),\n            PivotAggregate.Max => items.Max(Value),\n            _ => items.Sum(Value)\n        };\n    }\n\n    internal string FormatValue(decimal value)\n    {\n        if (Format is not null) return Format(value);\n        // Counts are whole; everything else gets two decimals only when it isn't integral.\n        if (Aggregate == PivotAggregate.Count) return value.ToString(\"N0\");\n        return value == decimal.Truncate(value) ? value.ToString(\"N0\") : value.ToString(\"N2\");\n    }\n}\n"
    }
  ],
  "slots": [
    {
      "name": "EmptyContent",
      "owner": "PivotGrid",
      "type": "RenderFragment?",
      "description": null
    }
  ],
  "serviceDependencies": [],
  "cascadingDependencies": [],
  "relatedComponents": [
    {
      "name": "List",
      "reason": "used internally"
    }
  ],
  "keyboardInteractions": [],
  "tests": [
    "tests/Lumeo.Tests/Components/PivotGrid/PivotGridTests.cs"
  ],
  "mdSummary": "# PivotGrid\n\nCross-tab / pivot table that summarizes flat data into rows x columns x aggregated measures.\n\n**Package:** `Lumeo` · **Category:** Data Display\n**Docs:** https://lumeo.nativ.sh/components/pivot-grid\n\n## Parameters\n| Owner | Name | Type | Default | Description |\n|---|---|---|---|---|\n| PivotGrid | Items | `IEnumerable<TItem>?` | `` |  |\n| PivotGrid | RowFields | `IReadOnlyList<PivotField<TItem>>` | `Array.Empty<PivotField<TItem>>()` |  |\n| PivotGrid | ColumnFields | `IReadOnlyList<PivotField<TItem>>` | `Array.Empty<PivotField<TItem>>()` |  |\n| PivotGrid | Measures | `IReadOnlyList<PivotMeasure<TItem>>` | `Array.Empty<PivotMeasure<TItem>>()` |  |\n| PivotGrid | ShowRowGrandTotal | `bool` | `true` |  |\n| PivotGrid | ShowColumnGrandTotal | `bool` | `true` |  |\n| PivotGrid | ShowSubtotals | `bool` | `true` |  |\n| PivotGrid | Collapsible | `bool` | `true` |  |\n| PivotGrid | Compact | `bool` | `` |  |\n| PivotGrid | GrandTotalLabel | `string` | `\"Grand Total\"` |  |\n| PivotGrid | EmptyContent | `RenderFragment?` | `` |  |\n| PivotGrid | OnCellClick | `EventCallback<PivotCellClickArgs>` | `` | Invoked when a data cell is clicked. Carries the clicked cell context for drill-down. |\n| PivotGrid | Class | `string?` | `` |  |\n| PivotGrid | AdditionalAttributes | `Dictionary<string, object>?` | `` |  |\n\n## Slots\n- `EmptyContent` (PivotGrid) — RenderFragment slot\n\n## Events\n- `OnCellClick` (`EventCallback<PivotCellClickArgs>`) — Invoked when a data cell is clicked. Carries the clicked cell context for drill-down.\n\n## Sub-components\n- (none)\n\n## Cascading values it expects\n- (none)\n\n## Services it injects\n- (none)\n\n## Keyboard\n—\n\n## Example\n```razor\n<PivotGrid />\n```\n\n## CSS variables\n`--color-border`, `--color-card`, `--color-foreground`, `--color-muted`, `--color-muted-foreground`, `--color-primary`, `--radius`\n\n## Files\n- `UI/PivotGrid/PivotCellClickArgs.cs`\n- `UI/PivotGrid/PivotField.cs`\n- `UI/PivotGrid/PivotGrid.razor`\n- `UI/PivotGrid/PivotMeasure.cs`\n"
}