{
  "name": "DataGrid",
  "category": "Data Display",
  "subcategory": "Tables",
  "description": "Enterprise grid: sort, filter, inline edit, multi-level group (client + server), pin, virtualize, export.",
  "thumbnail": "/preview-cards/data-grid.png",
  "hasDocsPage": true,
  "nugetPackage": "Lumeo.DataGrid",
  "files": [
    "UI/DataGrid/DataGrid.razor",
    "UI/DataGrid/DataGridBody.razor",
    "UI/DataGrid/DataGridCell.razor",
    "UI/DataGrid/DataGridColumn.cs",
    "UI/DataGrid/DataGridColumnDef.razor",
    "UI/DataGrid/DataGridColumnFilter.razor",
    "UI/DataGrid/DataGridColumnVisibility.razor",
    "UI/DataGrid/DataGridContext.cs",
    "UI/DataGrid/DataGridDetailRow.razor",
    "UI/DataGrid/DataGridDragState.cs",
    "UI/DataGrid/DataGridEnums.cs",
    "UI/DataGrid/DataGridFilterOperator.cs",
    "UI/DataGrid/DataGridFooter.razor",
    "UI/DataGrid/DataGridGroupRow.razor",
    "UI/DataGrid/DataGridHeader.razor",
    "UI/DataGrid/DataGridHeaderCell.razor",
    "UI/DataGrid/DataGridLayoutService.cs",
    "UI/DataGrid/DataGridPagination.razor",
    "UI/DataGrid/DataGridRow.razor",
    "UI/DataGrid/DataGridServerService.cs",
    "UI/DataGrid/DataGridState.cs",
    "UI/DataGrid/DataGridToolbar.razor",
    "UI/DataGrid/DataGridToolbarColumns.razor",
    "UI/DataGrid/DataGridToolbarContext.cs",
    "UI/DataGrid/DataGridToolbarCopySelected.razor",
    "UI/DataGrid/DataGridToolbarExport.razor",
    "UI/DataGrid/DataGridToolbarFullscreen.razor",
    "UI/DataGrid/DataGridToolbarLayouts.razor",
    "UI/DataGrid/ToolbarContent.razor"
  ],
  "dependencies": [
    "button",
    "checkbox",
    "filter",
    "heading",
    "list",
    "pagination",
    "select",
    "skeleton",
    "toolbar"
  ],
  "packageDependencies": [
    "Blazicons.Lucide"
  ],
  "cssVars": [
    "--color-accent",
    "--color-accent-foreground",
    "--color-background",
    "--color-border",
    "--color-card",
    "--color-card-foreground",
    "--color-destructive",
    "--color-foreground",
    "--color-input",
    "--color-muted",
    "--color-muted-foreground",
    "--color-popover",
    "--color-popover-foreground",
    "--color-primary",
    "--color-primary-foreground",
    "--color-ring",
    "--radius"
  ],
  "api": {
    "name": "DataGrid",
    "category": "Data Display",
    "subcategory": "Tables",
    "description": "Enterprise grid: sort, filter, inline edit, multi-level group (client + server), pin, virtualize, export.",
    "nugetPackage": "Lumeo.DataGrid",
    "files": [
      "UI/DataGrid/DataGrid.razor",
      "UI/DataGrid/DataGridBody.razor",
      "UI/DataGrid/DataGridCell.razor",
      "UI/DataGrid/DataGridColumn.cs",
      "UI/DataGrid/DataGridColumnDef.razor",
      "UI/DataGrid/DataGridColumnFilter.razor",
      "UI/DataGrid/DataGridColumnVisibility.razor",
      "UI/DataGrid/DataGridContext.cs",
      "UI/DataGrid/DataGridDetailRow.razor",
      "UI/DataGrid/DataGridDragState.cs",
      "UI/DataGrid/DataGridEnums.cs",
      "UI/DataGrid/DataGridFilterOperator.cs",
      "UI/DataGrid/DataGridFooter.razor",
      "UI/DataGrid/DataGridGroupRow.razor",
      "UI/DataGrid/DataGridHeader.razor",
      "UI/DataGrid/DataGridHeaderCell.razor",
      "UI/DataGrid/DataGridLayoutService.cs",
      "UI/DataGrid/DataGridPagination.razor",
      "UI/DataGrid/DataGridRow.razor",
      "UI/DataGrid/DataGridServerService.cs",
      "UI/DataGrid/DataGridState.cs",
      "UI/DataGrid/DataGridToolbar.razor",
      "UI/DataGrid/DataGridToolbarColumns.razor",
      "UI/DataGrid/DataGridToolbarContext.cs",
      "UI/DataGrid/DataGridToolbarCopySelected.razor",
      "UI/DataGrid/DataGridToolbarExport.razor",
      "UI/DataGrid/DataGridToolbarFullscreen.razor",
      "UI/DataGrid/DataGridToolbarLayouts.razor",
      "UI/DataGrid/ToolbarContent.razor"
    ],
    "namespace": "Lumeo",
    "inheritsFrom": null,
    "implements": [
      "IAsyncDisposable"
    ],
    "parameters": [
      {
        "name": "Items",
        "type": "IEnumerable<TItem>?",
        "default": null,
        "description": null,
        "isCascading": false,
        "captureUnmatched": false
      },
      {
        "name": "Columns",
        "type": "List<DataGridColumn<TItem>>?",
        "default": null,
        "description": null,
        "isCascading": false,
        "captureUnmatched": false
      },
      {
        "name": "Striped",
        "type": "bool",
        "default": null,
        "description": null,
        "isCascading": false,
        "captureUnmatched": false
      },
      {
        "name": "Bordered",
        "type": "bool",
        "default": null,
        "description": null,
        "isCascading": false,
        "captureUnmatched": false
      },
      {
        "name": "Compact",
        "type": "bool",
        "default": null,
        "description": null,
        "isCascading": false,
        "captureUnmatched": false
      },
      {
        "name": "Hoverable",
        "type": "bool",
        "default": "true",
        "description": null,
        "isCascading": false,
        "captureUnmatched": false
      },
      {
        "name": "Height",
        "type": "string?",
        "default": null,
        "description": null,
        "isCascading": false,
        "captureUnmatched": false
      },
      {
        "name": "SelectionMode",
        "type": "DataGridSelectionMode",
        "default": "DataGridSelectionMode.None",
        "description": null,
        "isCascading": false,
        "captureUnmatched": false
      },
      {
        "name": "EditMode",
        "type": "DataGridEditMode",
        "default": "DataGridEditMode.None",
        "description": null,
        "isCascading": false,
        "captureUnmatched": false
      },
      {
        "name": "PageSize",
        "type": "int",
        "default": "10",
        "description": null,
        "isCascading": false,
        "captureUnmatched": false
      },
      {
        "name": "PageSizeOptions",
        "type": "int[]",
        "default": "new[] { 10, 25, 50, 100 }",
        "description": "The list of page-size options shown in the rows-per-page selector. Defaults to { 10, 25, 50, 100 }. Set to e.g. { 10, 50, 100, 200, 300 } for larger datasets, or to an empty array to hide the selector entirely.",
        "isCascading": false,
        "captureUnmatched": false
      },
      {
        "name": "ShowPagination",
        "type": "bool",
        "default": "true",
        "description": null,
        "isCascading": false,
        "captureUnmatched": false
      },
      {
        "name": "ShowToolbar",
        "type": "bool",
        "default": null,
        "description": null,
        "isCascading": false,
        "captureUnmatched": false
      },
      {
        "name": "ShowSearch",
        "type": "bool",
        "default": "true",
        "description": "Show the global search box in the toolbar. Default true.",
        "isCascading": false,
        "captureUnmatched": false
      },
      {
        "name": "ShowColumnChooser",
        "type": "bool",
        "default": "true",
        "description": "Show the Columns button (visibility/reorder menu). Default true.",
        "isCascading": false,
        "captureUnmatched": false
      },
      {
        "name": "ShowExport",
        "type": "bool",
        "default": "true",
        "description": "Show the Export button and its format dropdown. Default true. Set false to hide export entirely. For fine-grained per-format control see ExportFormats.",
        "isCascading": false,
        "captureUnmatched": false
      },
      {
        "name": "ExportFormats",
        "type": "DataGridExportFormat",
        "default": "DataGridExportFormat.All",
        "description": "Which export formats appear in the Export dropdown. Default All. Set to e.g. Csv | Excel to hide PDF in WebAssembly where QuestPDF isn't supported. When no flags are set (or ShowExport is false), the Export button is hidden.",
        "isCascading": false,
        "captureUnmatched": false
      },
      {
        "name": "VirtualizeThreshold",
        "type": "int",
        "default": "500",
        "description": "Item count above which Blazor's &lt;Virtualize&gt; kicks in. Only relevant when ShowPagination is false (or PageSize &gt; this threshold) — paged grids show at most PageSize rows at once and never trigger virtualization. Default 500.",
        "isCascading": false,
        "captureUnmatched": false
      },
      {
        "name": "VirtualItemSize",
        "type": "float",
        "default": "41f",
        "description": "Estimated row height in CSS pixels — used by &lt;Virtualize&gt; to size the scroll spacers. Tune if your rows are taller (dense badges, two-line cells). Default 41px.",
        "isCascading": false,
        "captureUnmatched": false
      },
      {
        "name": "VirtualOverscanCount",
        "type": "int",
        "default": "3",
        "description": "Extra rows rendered above and below the viewport to hide pop-in during fast scrolling. Default 3.",
        "isCascading": false,
        "captureUnmatched": false
      },
      {
        "name": "Reorderable",
        "type": "bool",
        "default": null,
        "description": null,
        "isCascading": false,
        "captureUnmatched": false
      },
      {
        "name": "RowReorderable",
        "type": "bool",
        "default": null,
        "description": null,
        "isCascading": false,
        "captureUnmatched": false
      },
      {
        "name": "GroupBy",
        "type": "string?",
        "default": null,
        "description": null,
        "isCascading": false,
        "captureUnmatched": false
      },
      {
        "name": "GroupsExpandedByDefault",
        "type": "bool",
        "default": "true",
        "description": null,
        "isCascading": false,
        "captureUnmatched": false
      },
      {
        "name": "GroupByFields",
        "type": "IReadOnlyList<string>?",
        "default": null,
        "description": "Ordered list of fields for multi-level grouping. When set (non-empty) this takes precedence over the single GroupBy parameter. Each entry must be a column Field. The grid renders nested group rows with increasing indentation per level; per-level aggregates are shown on each group header row.",
        "isCascading": false,
        "captureUnmatched": false
      },
      {
        "name": "ShowGroupPanel",
        "type": "bool",
        "default": null,
        "description": "Shows a slim bar above the grid that lists the current grouping levels as removable chips, plus a dropdown to add a new level from any Groupable column. Default false.",
        "isCascading": false,
        "captureUnmatched": false
      },
      {
        "name": "GroupPanelText",
        "type": "string?",
        "default": null,
        "description": "Placeholder/hint text shown in the group panel when no fields are grouped.",
        "isCascading": false,
        "captureUnmatched": false
      },
      {
        "name": "ChildItemsSelector",
        "type": "Func<TItem, IEnumerable<TItem>?>?",
        "default": null,
        "description": "When set, the grid renders a hierarchical tree-grid: this selector returns a row's child rows (null/empty = leaf). Each parent row gets an expand chevron in the tree column (see TreeColumnField). Distinct from DetailTemplate, which is a flat expand-for-detail fragment. Tree-grid mode and grouping are mutually exclusive — when both are configured, tree-grid mode wins.",
        "isCascading": false,
        "captureUnmatched": false
      },
      {
        "name": "TreeGridDefaultExpanded",
        "type": "bool",
        "default": null,
        "description": "Whether tree nodes start expanded. Default false.",
        "isCascading": false,
        "captureUnmatched": false
      },
      {
        "name": "TreeColumnField",
        "type": "string?",
        "default": null,
        "description": "Field of the column that carries the tree chevron + indentation. Null (default) uses the first visible column.",
        "isCascading": false,
        "captureUnmatched": false
      },
      {
        "name": "OnTreeNodeExpand",
        "type": "EventCallback<TItem>",
        "default": null,
        "description": "Raised when a tree node is expanded or collapsed (the item, plus its new expanded state).",
        "isCascading": false,
        "captureUnmatched": false
      },
      {
        "name": "Expandable",
        "type": "bool",
        "default": null,
        "description": "When true, adds a small Expand icon to the toolbar. Clicking it renders the grid in a fullscreen modal overlay. Default false.",
        "isCascading": false,
        "captureUnmatched": false
      },
      {
        "name": "IsExpanded",
        "type": "bool",
        "default": null,
        "description": "When true, the grid is currently rendered in fullscreen. Bind this for programmatic control, or leave unbound to let the grid manage its own state via the toolbar button.",
        "isCascading": false,
        "captureUnmatched": false
      },
      {
        "name": "IsExpandedChanged",
        "type": "EventCallback<bool>",
        "default": null,
        "description": null,
        "isCascading": false,
        "captureUnmatched": false
      },
      {
        "name": "FullscreenTitle",
        "type": "string?",
        "default": null,
        "description": "Optional title shown in the fullscreen header bar. Defaults to \"Data Grid\".",
        "isCascading": false,
        "captureUnmatched": false
      },
      {
        "name": "RowClass",
        "type": "Func<TItem, string>?",
        "default": null,
        "description": null,
        "isCascading": false,
        "captureUnmatched": false
      },
      {
        "name": "RowStyle",
        "type": "Func<TItem, string>?",
        "default": null,
        "description": null,
        "isCascading": false,
        "captureUnmatched": false
      },
      {
        "name": "EnableLayoutPersistence",
        "type": "bool",
        "default": null,
        "description": null,
        "isCascading": false,
        "captureUnmatched": false
      },
      {
        "name": "LayoutStorageKey",
        "type": "string?",
        "default": null,
        "description": null,
        "isCascading": false,
        "captureUnmatched": false
      },
      {
        "name": "OnLayoutSave",
        "type": "EventCallback<DataGridLayout>",
        "default": null,
        "description": null,
        "isCascading": false,
        "captureUnmatched": false
      },
      {
        "name": "SavedLayout",
        "type": "DataGridLayout?",
        "default": null,
        "description": null,
        "isCascading": false,
        "captureUnmatched": false
      },
      {
        "name": "GlobalLayouts",
        "type": "List<DataGridNamedLayout>?",
        "default": null,
        "description": null,
        "isCascading": false,
        "captureUnmatched": false
      },
      {
        "name": "OnSaveNamedLayout",
        "type": "EventCallback<DataGridNamedLayout>",
        "default": null,
        "description": null,
        "isCascading": false,
        "captureUnmatched": false
      },
      {
        "name": "OnDeleteNamedLayout",
        "type": "EventCallback<string>",
        "default": null,
        "description": null,
        "isCascading": false,
        "captureUnmatched": false
      },
      {
        "name": "ServerMode",
        "type": "bool",
        "default": null,
        "description": null,
        "isCascading": false,
        "captureUnmatched": false
      },
      {
        "name": "OnServerRequest",
        "type": "EventCallback<DataGridServerRequest>",
        "default": null,
        "description": null,
        "isCascading": false,
        "captureUnmatched": false
      },
      {
        "name": "TotalCount",
        "type": "int",
        "default": null,
        "description": null,
        "isCascading": false,
        "captureUnmatched": false
      },
      {
        "name": "IsLoading",
        "type": "bool",
        "default": null,
        "description": null,
        "isCascading": false,
        "captureUnmatched": false
      },
      {
        "name": "Virtualized",
        "type": "bool",
        "default": null,
        "description": "Enables Microsoft.AspNetCore.Components.Web.Virtualization.Virtualize{TItem} with an ItemsProvider — rows are fetched from the backend in scroll-driven windows instead of paged. Requires OnRangeRequest. When active, pagination is hidden automatically and the grid renders a single tall scrollable body.",
        "isCascading": false,
        "captureUnmatched": false
      },
      {
        "name": "OnRangeRequest",
        "type": "Func<DataGridRangeRequest, ValueTask<DataGridRangeResponse<TItem>>>?",
        "default": null,
        "description": "Range-fetch callback used in virtualised server mode. The grid invokes this whenever the viewport scrolls into an unloaded window. The callback must return the requested slice plus the current total row count so the scroll spacers stay accurate.",
        "isCascading": false,
        "captureUnmatched": false
      },
      {
        "name": "ChildContent",
        "type": "RenderFragment?",
        "default": null,
        "description": null,
        "isCascading": false,
        "captureUnmatched": false
      },
      {
        "name": "DetailTemplate",
        "type": "RenderFragment<TItem>?",
        "default": null,
        "description": null,
        "isCascading": false,
        "captureUnmatched": false
      },
      {
        "name": "EmptyContent",
        "type": "RenderFragment?",
        "default": null,
        "description": null,
        "isCascading": false,
        "captureUnmatched": false
      },
      {
        "name": "RowContextMenu",
        "type": "RenderFragment<TItem>?",
        "default": null,
        "description": null,
        "isCascading": false,
        "captureUnmatched": false
      },
      {
        "name": "OnRowClick",
        "type": "EventCallback<TItem>",
        "default": null,
        "description": null,
        "isCascading": false,
        "captureUnmatched": false
      },
      {
        "name": "OnRowDoubleClick",
        "type": "EventCallback<TItem>",
        "default": null,
        "description": null,
        "isCascading": false,
        "captureUnmatched": false
      },
      {
        "name": "OnCellEdit",
        "type": "EventCallback<CellEditEventArgs<TItem>>",
        "default": null,
        "description": null,
        "isCascading": false,
        "captureUnmatched": false
      },
      {
        "name": "OnRowEdit",
        "type": "EventCallback<RowEditEventArgs<TItem>>",
        "default": null,
        "description": null,
        "isCascading": false,
        "captureUnmatched": false
      },
      {
        "name": "OnBatchSave",
        "type": "EventCallback<DataGridBatchSaveEventArgs<TItem>>",
        "default": null,
        "description": "Fired when the user clicks \"Save all\" while EditMode is Batch. Args carry the buffered modifications + added rows. On a clean callback return (no exception), the grid's pending-changes buffer is cleared automatically.",
        "isCascading": false,
        "captureUnmatched": false
      },
      {
        "name": "ShowAddRow",
        "type": "bool",
        "default": null,
        "description": "When true and NewItemFactory is set, renders a thin \"+ Add row\" trigger below the body. Each click creates a TItem via the factory, pushes it into the added-rows buffer, and immediately shows it inline so cells can be filled in. Only meaningful in Batch.",
        "isCascading": false,
        "captureUnmatched": false
      },
      {
        "name": "NewItemFactory",
        "type": "Func<TItem>?",
        "default": null,
        "description": "Factory used by the \"+ Add row\" trigger to produce a fresh TItem. Required when ShowAddRow is true. The returned item is stored in the added-rows buffer; the user fills its cells inline and the row is delivered via OnBatchSave.",
        "isCascading": false,
        "captureUnmatched": false
      },
      {
        "name": "ColumnVirtualize",
        "type": "bool",
        "default": null,
        "description": "Simplified column virtualization: caps the number of horizontally- rendered data columns at MaxVisibleColumns. Pinned columns always make the cut. Default false.",
        "isCascading": false,
        "captureUnmatched": false
      },
      {
        "name": "MaxVisibleColumns",
        "type": "int",
        "default": "30",
        "description": "Maximum columns rendered when ColumnVirtualize is true. Default 30.",
        "isCascading": false,
        "captureUnmatched": false
      },
      {
        "name": "BatchSaveAllText",
        "type": "string?",
        "default": "\"Save all\"",
        "description": "Label for the batch-mode \"Save all\" button. Override for localization.",
        "isCascading": false,
        "captureUnmatched": false
      },
      {
        "name": "BatchDiscardText",
        "type": "string?",
        "default": "\"Discard\"",
        "description": "Label for the batch-mode \"Discard\" button. Override for localization.",
        "isCascading": false,
        "captureUnmatched": false
      },
      {
        "name": "BatchAddRowText",
        "type": "string?",
        "default": "\"Add row\"",
        "description": "Label for the batch-mode \"+ Add row\" trigger. Override for localization.",
        "isCascading": false,
        "captureUnmatched": false
      },
      {
        "name": "OnColumnReorder",
        "type": "EventCallback<ColumnReorderEventArgs>",
        "default": null,
        "description": null,
        "isCascading": false,
        "captureUnmatched": false
      },
      {
        "name": "OnRowReorder",
        "type": "EventCallback<RowReorderEventArgs<TItem>>",
        "default": null,
        "description": null,
        "isCascading": false,
        "captureUnmatched": false
      },
      {
        "name": "OnError",
        "type": "EventCallback<Exception>",
        "default": null,
        "description": null,
        "isCascading": false,
        "captureUnmatched": false
      },
      {
        "name": "SelectedItems",
        "type": "IReadOnlyList<TItem>?",
        "default": null,
        "description": null,
        "isCascading": false,
        "captureUnmatched": false
      },
      {
        "name": "SelectedItemsChanged",
        "type": "EventCallback<IReadOnlyList<TItem>>",
        "default": null,
        "description": null,
        "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
      },
      {
        "name": "Culture",
        "type": "System.Globalization.CultureInfo?",
        "default": null,
        "description": "CultureInfo used for cell value formatting (dates/numbers) and exports. When null (default), falls back to CurrentCulture. Useful when your app sets a different culture than the UI locale — e.g. force de-DE formatting in an English UI.",
        "isCascading": false,
        "captureUnmatched": false
      }
    ],
    "events": [
      {
        "name": "OnTreeNodeExpand",
        "type": "EventCallback<TItem>",
        "description": "Raised when a tree node is expanded or collapsed (the item, plus its new expanded state)."
      },
      {
        "name": "IsExpandedChanged",
        "type": "EventCallback<bool>",
        "description": null
      },
      {
        "name": "OnLayoutSave",
        "type": "EventCallback<DataGridLayout>",
        "description": null
      },
      {
        "name": "OnSaveNamedLayout",
        "type": "EventCallback<DataGridNamedLayout>",
        "description": null
      },
      {
        "name": "OnDeleteNamedLayout",
        "type": "EventCallback<string>",
        "description": null
      },
      {
        "name": "OnServerRequest",
        "type": "EventCallback<DataGridServerRequest>",
        "description": null
      },
      {
        "name": "OnRowClick",
        "type": "EventCallback<TItem>",
        "description": null
      },
      {
        "name": "OnRowDoubleClick",
        "type": "EventCallback<TItem>",
        "description": null
      },
      {
        "name": "OnCellEdit",
        "type": "EventCallback<CellEditEventArgs<TItem>>",
        "description": null
      },
      {
        "name": "OnRowEdit",
        "type": "EventCallback<RowEditEventArgs<TItem>>",
        "description": null
      },
      {
        "name": "OnBatchSave",
        "type": "EventCallback<DataGridBatchSaveEventArgs<TItem>>",
        "description": "Fired when the user clicks \"Save all\" while EditMode is Batch. Args carry the buffered modifications + added rows. On a clean callback return (no exception), the grid's pending-changes buffer is cleared automatically."
      },
      {
        "name": "OnColumnReorder",
        "type": "EventCallback<ColumnReorderEventArgs>",
        "description": null
      },
      {
        "name": "OnRowReorder",
        "type": "EventCallback<RowReorderEventArgs<TItem>>",
        "description": null
      },
      {
        "name": "OnError",
        "type": "EventCallback<Exception>",
        "description": null
      },
      {
        "name": "SelectedItemsChanged",
        "type": "EventCallback<IReadOnlyList<TItem>>",
        "description": null
      }
    ],
    "enums": [],
    "records": [],
    "cssVars": [
      "--color-accent",
      "--color-accent-foreground",
      "--color-background",
      "--color-border",
      "--color-card",
      "--color-card-foreground",
      "--color-destructive",
      "--color-foreground",
      "--color-input",
      "--color-muted",
      "--color-muted-foreground",
      "--color-popover",
      "--color-popover-foreground",
      "--color-primary",
      "--color-primary-foreground",
      "--color-ring",
      "--radius"
    ],
    "examples": [
      {
        "title": "Basic DataGrid",
        "code": "<DataGrid TItem=\"Employee\" Items=\"@employees\" PageSize=\"5\" ShowPagination=\"true\">\n    <DataGridColumnDef TItem=\"Employee\" Title=\"Name\" Field=\"Name\" Sortable=\"true\" />\n    <DataGridColumnDef TItem=\"Employee\" Title=\"Department\" Field=\"Department\" Sortable=\"true\" />\n    <DataGridColumnDef TItem=\"Employee\" Title=\"Salary\" Field=\"Salary\" Sortable=\"true\" Format=\"C0\" />\n    <DataGridColumnDef TItem=\"Employee\" Title=\"Start Date\" Field=\"StartDate\" Format=\"yyyy-MM-dd\" />\n</DataGrid>"
      },
      {
        "title": "Toolbar, Search & Filtering",
        "code": "<DataGrid TItem=\"Employee\" Items=\"@employees\" PageSize=\"5\"\n          ShowToolbar=\"true\" ShowPagination=\"true\">\n    <DataGridColumnDef TItem=\"Employee\" Title=\"Name\" Field=\"Name\"\n                       Sortable=\"true\" Filterable=\"true\" />\n    <DataGridColumnDef TItem=\"Employee\" Title=\"Salary\" Field=\"Salary\"\n                       Filterable=\"true\" FilterType=\"DataGridFilterType.Number\" Format=\"C0\" />\n    <DataGridColumnDef TItem=\"Employee\" Title=\"Status\" Field=\"Status\"\n                       Filterable=\"true\" />\n</DataGrid>"
      },
      {
        "title": "Minimal toolbar (search only)",
        "code": "<DataGrid TItem=\"Employee\" Items=\"@employees\" PageSize=\"5\"\n          ShowToolbar=\"true\"\n          ShowColumnChooser=\"false\"\n          ShowExport=\"false\">\n    <DataGridColumnDef TItem=\"Employee\" Title=\"Name\" Field=\"Name\" Sortable=\"true\" Filterable=\"true\" />\n    <DataGridColumnDef TItem=\"Employee\" Title=\"Department\" Field=\"Department\" Sortable=\"true\" Filterable=\"true\" />\n    <DataGridColumnDef TItem=\"Employee\" Title=\"Salary\" Field=\"Salary\" Sortable=\"true\" Format=\"C0\" />\n</DataGrid>"
      },
      {
        "title": "Export-only toolbar",
        "code": "<DataGrid TItem=\"Employee\" Items=\"@employees\" PageSize=\"5\"\n          ShowToolbar=\"true\"\n          ShowSearch=\"false\"\n          ShowColumnChooser=\"false\">\n    <DataGridColumnDef TItem=\"Employee\" Title=\"Name\" Field=\"Name\" Sortable=\"true\" />\n    <DataGridColumnDef TItem=\"Employee\" Title=\"Department\" Field=\"Department\" Sortable=\"true\" />\n    <DataGridColumnDef TItem=\"Employee\" Title=\"Salary\" Field=\"Salary\" Sortable=\"true\" Format=\"C0\" />\n</DataGrid>"
      }
    ],
    "subComponents": {
      "DataGridBody": {
        "componentName": "DataGridBody",
        "fileName": "DataGridBody.razor",
        "namespace": "Lumeo",
        "inheritsFrom": null,
        "implements": [],
        "parameters": [
          {
            "name": "Context",
            "type": "DataGridContext<TItem>?",
            "default": null,
            "description": null,
            "isCascading": true,
            "captureUnmatched": false
          },
          {
            "name": "DetailTemplate",
            "type": "RenderFragment<TItem>?",
            "default": null,
            "description": null,
            "isCascading": false,
            "captureUnmatched": false
          },
          {
            "name": "EmptyContent",
            "type": "RenderFragment?",
            "default": null,
            "description": null,
            "isCascading": false,
            "captureUnmatched": false
          },
          {
            "name": "UseVirtualization",
            "type": "bool",
            "default": null,
            "description": null,
            "isCascading": false,
            "captureUnmatched": false
          },
          {
            "name": "VirtualItemSize",
            "type": "float",
            "default": "41f",
            "description": null,
            "isCascading": false,
            "captureUnmatched": false
          },
          {
            "name": "VirtualOverscanCount",
            "type": "int",
            "default": "3",
            "description": null,
            "isCascading": false,
            "captureUnmatched": false
          },
          {
            "name": "ServerVirtualizationProvider",
            "type": "Microsoft.AspNetCore.Components.Web.Virtualization.ItemsProviderDelegate<IndexedItem>?",
            "default": null,
            "description": "When set, switches the body to ItemsProvider-mode virtualisation (window-fetched server scrolling). Null disables.",
            "isCascading": false,
            "captureUnmatched": false
          },
          {
            "name": "HasDetailTemplate",
            "type": "bool",
            "default": null,
            "description": null,
            "isCascading": false,
            "captureUnmatched": false
          },
          {
            "name": "SkeletonRowCount",
            "type": "int",
            "default": "5",
            "description": null,
            "isCascading": false,
            "captureUnmatched": false
          },
          {
            "name": "OnContextMenu",
            "type": "EventCallback<(TItem, double, double)>",
            "default": null,
            "description": null,
            "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": "OnContextMenu",
            "type": "EventCallback<(TItem, double, double)>",
            "description": null
          }
        ],
        "enums": [],
        "records": [
          {
            "name": "IndexedItem",
            "signature": "IndexedItem(TItem Item, int Index)",
            "description": null
          }
        ],
        "parseFailed": false,
        "parseError": null
      },
      "DataGridCell": {
        "componentName": "DataGridCell",
        "fileName": "DataGridCell.razor",
        "namespace": "Lumeo",
        "inheritsFrom": null,
        "implements": [],
        "parameters": [
          {
            "name": "Context",
            "type": "DataGridContext<TItem>?",
            "default": null,
            "description": null,
            "isCascading": true,
            "captureUnmatched": false
          },
          {
            "name": "Item",
            "type": "TItem",
            "default": "default!",
            "description": null,
            "isCascading": false,
            "captureUnmatched": false
          },
          {
            "name": "Column",
            "type": "DataGridColumn<TItem>",
            "default": "default!",
            "description": null,
            "isCascading": false,
            "captureUnmatched": false
          },
          {
            "name": "RowIndex",
            "type": "int",
            "default": null,
            "description": "0-based index of the owning row within DisplayedItems (non-grouped).",
            "isCascading": false,
            "captureUnmatched": false
          },
          {
            "name": "ColumnIndex",
            "type": "int",
            "default": null,
            "description": "0-based index of this column within the visible columns.",
            "isCascading": false,
            "captureUnmatched": false
          },
          {
            "name": "AriaColIndex",
            "type": "int",
            "default": null,
            "description": "1-based ARIA column index (includes any leading non-data columns).",
            "isCascading": false,
            "captureUnmatched": false
          },
          {
            "name": "Navigable",
            "type": "bool",
            "default": "true",
            "description": "Whether this cell participates in roving-tabindex keyboard navigation. False in grouped mode where row indices are not globally unique.",
            "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": [],
        "enums": [],
        "records": [],
        "parseFailed": false,
        "parseError": null
      },
      "DataGridColumnDef": {
        "componentName": "DataGridColumnDef",
        "fileName": "DataGridColumnDef.razor",
        "namespace": "Lumeo",
        "inheritsFrom": null,
        "implements": [],
        "parameters": [
          {
            "name": "ParentGrid",
            "type": "DataGrid<TItem>?",
            "default": null,
            "description": null,
            "isCascading": true,
            "captureUnmatched": false
          },
          {
            "name": "Title",
            "type": "string?",
            "default": null,
            "description": null,
            "isCascading": false,
            "captureUnmatched": false
          },
          {
            "name": "Field",
            "type": "string?",
            "default": null,
            "description": null,
            "isCascading": false,
            "captureUnmatched": false
          },
          {
            "name": "FieldSelector",
            "type": "Func<TItem, object?>?",
            "default": null,
            "description": null,
            "isCascading": false,
            "captureUnmatched": false
          },
          {
            "name": "Width",
            "type": "double?",
            "default": null,
            "description": null,
            "isCascading": false,
            "captureUnmatched": false
          },
          {
            "name": "Sortable",
            "type": "bool",
            "default": null,
            "description": null,
            "isCascading": false,
            "captureUnmatched": false
          },
          {
            "name": "Filterable",
            "type": "bool",
            "default": null,
            "description": null,
            "isCascading": false,
            "captureUnmatched": false
          },
          {
            "name": "Resizable",
            "type": "bool",
            "default": "true",
            "description": null,
            "isCascading": false,
            "captureUnmatched": false
          },
          {
            "name": "FilterType",
            "type": "DataGridFilterType",
            "default": "DataGridFilterType.Text",
            "description": null,
            "isCascading": false,
            "captureUnmatched": false
          },
          {
            "name": "Aggregate",
            "type": "AggregateType",
            "default": "AggregateType.None",
            "description": null,
            "isCascading": false,
            "captureUnmatched": false
          },
          {
            "name": "Format",
            "type": "string?",
            "default": null,
            "description": null,
            "isCascading": false,
            "captureUnmatched": false
          },
          {
            "name": "Pin",
            "type": "PinDirection",
            "default": "PinDirection.None",
            "description": null,
            "isCascading": false,
            "captureUnmatched": false
          },
          {
            "name": "CellTemplate",
            "type": "RenderFragment<TItem>?",
            "default": null,
            "description": null,
            "isCascading": false,
            "captureUnmatched": false
          },
          {
            "name": "HeaderTemplate",
            "type": "RenderFragment?",
            "default": null,
            "description": null,
            "isCascading": false,
            "captureUnmatched": false
          },
          {
            "name": "EditTemplate",
            "type": "RenderFragment<CellEditContext<TItem>>?",
            "default": null,
            "description": null,
            "isCascading": false,
            "captureUnmatched": false
          },
          {
            "name": "MinWidth",
            "type": "double?",
            "default": null,
            "description": null,
            "isCascading": false,
            "captureUnmatched": false
          },
          {
            "name": "MaxWidth",
            "type": "double?",
            "default": null,
            "description": null,
            "isCascading": false,
            "captureUnmatched": false
          },
          {
            "name": "Pinnable",
            "type": "bool",
            "default": null,
            "description": null,
            "isCascading": false,
            "captureUnmatched": false
          },
          {
            "name": "Groupable",
            "type": "bool",
            "default": null,
            "description": null,
            "isCascading": false,
            "captureUnmatched": false
          },
          {
            "name": "Reorderable",
            "type": "bool",
            "default": "true",
            "description": null,
            "isCascading": false,
            "captureUnmatched": false
          },
          {
            "name": "CellClass",
            "type": "Func<TItem, string>?",
            "default": null,
            "description": null,
            "isCascading": false,
            "captureUnmatched": false
          },
          {
            "name": "HeaderCssClass",
            "type": "string?",
            "default": null,
            "description": null,
            "isCascading": false,
            "captureUnmatched": false
          },
          {
            "name": "CustomSort",
            "type": "Comparison<object?>?",
            "default": null,
            "description": null,
            "isCascading": false,
            "captureUnmatched": false
          },
          {
            "name": "FilterOptions",
            "type": "List<FilterOption>?",
            "default": null,
            "description": null,
            "isCascading": false,
            "captureUnmatched": false
          },
          {
            "name": "Operators",
            "type": "List<FilterOperator>?",
            "default": null,
            "description": null,
            "isCascading": false,
            "captureUnmatched": false
          },
          {
            "name": "FilterTemplate",
            "type": "RenderFragment<DataGridFilterTemplateContext>?",
            "default": null,
            "description": null,
            "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": [],
        "enums": [],
        "records": [],
        "parseFailed": false,
        "parseError": null
      },
      "DataGridColumnFilter": {
        "componentName": "DataGridColumnFilter",
        "fileName": "DataGridColumnFilter.razor",
        "namespace": "Lumeo",
        "inheritsFrom": null,
        "implements": [],
        "parameters": [
          {
            "name": "Column",
            "type": "DataGridColumn<TItem>?",
            "default": null,
            "description": null,
            "isCascading": false,
            "captureUnmatched": false
          },
          {
            "name": "FilterOptions",
            "type": "List<FilterOption>?",
            "default": null,
            "description": null,
            "isCascading": false,
            "captureUnmatched": false
          },
          {
            "name": "CurrentFilter",
            "type": "FilterDescriptor?",
            "default": null,
            "description": null,
            "isCascading": false,
            "captureUnmatched": false
          },
          {
            "name": "OnApply",
            "type": "EventCallback<FilterDescriptor?>",
            "default": null,
            "description": null,
            "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": "OnApply",
            "type": "EventCallback<FilterDescriptor?>",
            "description": null
          }
        ],
        "enums": [],
        "records": [],
        "parseFailed": false,
        "parseError": null
      },
      "DataGridColumnVisibility": {
        "componentName": "DataGridColumnVisibility",
        "fileName": "DataGridColumnVisibility.razor",
        "namespace": "Lumeo",
        "inheritsFrom": null,
        "implements": [],
        "parameters": [
          {
            "name": "Columns",
            "type": "IReadOnlyList<DataGridColumn<TItem>>",
            "default": "Array.Empty<DataGridColumn<TItem>>()",
            "description": null,
            "isCascading": false,
            "captureUnmatched": false
          },
          {
            "name": "OnColumnToggle",
            "type": "EventCallback<DataGridColumn<TItem>>",
            "default": null,
            "description": null,
            "isCascading": false,
            "captureUnmatched": false
          },
          {
            "name": "OnColumnReorder",
            "type": "EventCallback<ColumnReorderEventArgs>",
            "default": null,
            "description": null,
            "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": "OnColumnToggle",
            "type": "EventCallback<DataGridColumn<TItem>>",
            "description": null
          },
          {
            "name": "OnColumnReorder",
            "type": "EventCallback<ColumnReorderEventArgs>",
            "description": null
          }
        ],
        "enums": [],
        "records": [],
        "parseFailed": false,
        "parseError": null
      },
      "DataGridDetailRow": {
        "componentName": "DataGridDetailRow",
        "fileName": "DataGridDetailRow.razor",
        "namespace": "Lumeo",
        "inheritsFrom": null,
        "implements": [],
        "parameters": [
          {
            "name": "Item",
            "type": "TItem",
            "default": "default!",
            "description": null,
            "isCascading": false,
            "captureUnmatched": false
          },
          {
            "name": "DetailTemplate",
            "type": "RenderFragment<TItem>?",
            "default": null,
            "description": null,
            "isCascading": false,
            "captureUnmatched": false
          },
          {
            "name": "ColSpan",
            "type": "int",
            "default": "1",
            "description": null,
            "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": [],
        "enums": [],
        "records": [],
        "parseFailed": false,
        "parseError": null
      },
      "DataGridFooter": {
        "componentName": "DataGridFooter",
        "fileName": "DataGridFooter.razor",
        "namespace": "Lumeo",
        "inheritsFrom": null,
        "implements": [],
        "parameters": [
          {
            "name": "Context",
            "type": "DataGridContext<TItem>?",
            "default": null,
            "description": null,
            "isCascading": true,
            "captureUnmatched": false
          },
          {
            "name": "AllItems",
            "type": "IReadOnlyList<TItem>?",
            "default": null,
            "description": null,
            "isCascading": false,
            "captureUnmatched": false
          },
          {
            "name": "HasDetailTemplate",
            "type": "bool",
            "default": null,
            "description": null,
            "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": [],
        "enums": [],
        "records": [],
        "parseFailed": false,
        "parseError": null
      },
      "DataGridGroupRow": {
        "componentName": "DataGridGroupRow",
        "fileName": "DataGridGroupRow.razor",
        "namespace": "Lumeo",
        "inheritsFrom": null,
        "implements": [],
        "parameters": [
          {
            "name": "Context",
            "type": "DataGridContext<TItem>?",
            "default": null,
            "description": null,
            "isCascading": true,
            "captureUnmatched": false
          },
          {
            "name": "GroupKey",
            "type": "string?",
            "default": null,
            "description": null,
            "isCascading": false,
            "captureUnmatched": false
          },
          {
            "name": "Count",
            "type": "int",
            "default": null,
            "description": null,
            "isCascading": false,
            "captureUnmatched": false
          },
          {
            "name": "IsExpanded",
            "type": "bool",
            "default": null,
            "description": null,
            "isCascading": false,
            "captureUnmatched": false
          },
          {
            "name": "OnToggle",
            "type": "EventCallback",
            "default": null,
            "description": null,
            "isCascading": false,
            "captureUnmatched": false
          },
          {
            "name": "ColSpan",
            "type": "int",
            "default": "1",
            "description": null,
            "isCascading": false,
            "captureUnmatched": false
          },
          {
            "name": "Level",
            "type": "int",
            "default": null,
            "description": "Nesting depth for multi-level grouping (0 = top level). Controls indentation.",
            "isCascading": false,
            "captureUnmatched": false
          },
          {
            "name": "Aggregates",
            "type": "IReadOnlyList<DataGridGroupAggregate>?",
            "default": null,
            "description": "Per-group aggregate strip rendered next to the group label.",
            "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": "OnToggle",
            "type": "EventCallback",
            "description": null
          }
        ],
        "enums": [],
        "records": [],
        "parseFailed": false,
        "parseError": null
      },
      "DataGridHeader": {
        "componentName": "DataGridHeader",
        "fileName": "DataGridHeader.razor",
        "namespace": "Lumeo",
        "inheritsFrom": null,
        "implements": [],
        "parameters": [
          {
            "name": "Context",
            "type": "DataGridContext<TItem>?",
            "default": null,
            "description": null,
            "isCascading": true,
            "captureUnmatched": false
          },
          {
            "name": "HasDetailTemplate",
            "type": "bool",
            "default": null,
            "description": null,
            "isCascading": false,
            "captureUnmatched": false
          },
          {
            "name": "ActiveFilterColumn",
            "type": "string?",
            "default": null,
            "description": null,
            "isCascading": false,
            "captureUnmatched": false
          },
          {
            "name": "Reorderable",
            "type": "bool",
            "default": null,
            "description": null,
            "isCascading": false,
            "captureUnmatched": false
          },
          {
            "name": "OnFilterToggle",
            "type": "EventCallback<string?>",
            "default": null,
            "description": null,
            "isCascading": false,
            "captureUnmatched": false
          },
          {
            "name": "OnFilterApply",
            "type": "EventCallback<FilterApplyEventArgs>",
            "default": null,
            "description": null,
            "isCascading": false,
            "captureUnmatched": false
          },
          {
            "name": "OnColumnReorder",
            "type": "EventCallback<ColumnReorderEventArgs>",
            "default": null,
            "description": null,
            "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": "OnFilterToggle",
            "type": "EventCallback<string?>",
            "description": null
          },
          {
            "name": "OnFilterApply",
            "type": "EventCallback<FilterApplyEventArgs>",
            "description": null
          },
          {
            "name": "OnColumnReorder",
            "type": "EventCallback<ColumnReorderEventArgs>",
            "description": null
          }
        ],
        "enums": [],
        "records": [],
        "parseFailed": false,
        "parseError": null
      },
      "DataGridHeaderCell": {
        "componentName": "DataGridHeaderCell",
        "fileName": "DataGridHeaderCell.razor",
        "namespace": "Lumeo",
        "inheritsFrom": null,
        "implements": [
          "IAsyncDisposable"
        ],
        "parameters": [
          {
            "name": "Context",
            "type": "DataGridContext<TItem>?",
            "default": null,
            "description": null,
            "isCascading": true,
            "captureUnmatched": false
          },
          {
            "name": "ParentGrid",
            "type": "DataGrid<TItem>?",
            "default": null,
            "description": null,
            "isCascading": true,
            "captureUnmatched": false
          },
          {
            "name": "DragState",
            "type": "DataGridDragState?",
            "default": null,
            "description": null,
            "isCascading": true,
            "captureUnmatched": false
          },
          {
            "name": "Column",
            "type": "DataGridColumn<TItem>",
            "default": "default!",
            "description": null,
            "isCascading": false,
            "captureUnmatched": false
          },
          {
            "name": "ColumnIndex",
            "type": "int",
            "default": null,
            "description": null,
            "isCascading": false,
            "captureUnmatched": false
          },
          {
            "name": "AriaColIndex",
            "type": "int",
            "default": null,
            "description": "1-based ARIA column index (includes any leading non-data columns).",
            "isCascading": false,
            "captureUnmatched": false
          },
          {
            "name": "SortDirection",
            "type": "SortDirection",
            "default": "SortDirection.None",
            "description": null,
            "isCascading": false,
            "captureUnmatched": false
          },
          {
            "name": "HasActiveFilter",
            "type": "bool",
            "default": null,
            "description": null,
            "isCascading": false,
            "captureUnmatched": false
          },
          {
            "name": "IsFilterOpen",
            "type": "bool",
            "default": null,
            "description": null,
            "isCascading": false,
            "captureUnmatched": false
          },
          {
            "name": "Reorderable",
            "type": "bool",
            "default": null,
            "description": null,
            "isCascading": false,
            "captureUnmatched": false
          },
          {
            "name": "OnFilterToggle",
            "type": "EventCallback",
            "default": null,
            "description": null,
            "isCascading": false,
            "captureUnmatched": false
          },
          {
            "name": "OnFilterApply",
            "type": "EventCallback<FilterApplyEventArgs>",
            "default": null,
            "description": null,
            "isCascading": false,
            "captureUnmatched": false
          },
          {
            "name": "OnColumnReorder",
            "type": "EventCallback<ColumnReorderEventArgs>",
            "default": null,
            "description": null,
            "isCascading": false,
            "captureUnmatched": false
          },
          {
            "name": "SortPriority",
            "type": "int",
            "default": null,
            "description": null,
            "isCascading": false,
            "captureUnmatched": false
          },
          {
            "name": "TotalSortCount",
            "type": "int",
            "default": null,
            "description": null,
            "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": "OnFilterToggle",
            "type": "EventCallback",
            "description": null
          },
          {
            "name": "OnFilterApply",
            "type": "EventCallback<FilterApplyEventArgs>",
            "description": null
          },
          {
            "name": "OnColumnReorder",
            "type": "EventCallback<ColumnReorderEventArgs>",
            "description": null
          }
        ],
        "enums": [],
        "records": [],
        "parseFailed": false,
        "parseError": null
      },
      "DataGridPagination": {
        "componentName": "DataGridPagination",
        "fileName": "DataGridPagination.razor",
        "namespace": "Lumeo",
        "inheritsFrom": null,
        "implements": [],
        "parameters": [
          {
            "name": "Context",
            "type": "DataGridContext<TItem>?",
            "default": null,
            "description": null,
            "isCascading": true,
            "captureUnmatched": false
          },
          {
            "name": "PageChanged",
            "type": "EventCallback<int>",
            "default": null,
            "description": null,
            "isCascading": false,
            "captureUnmatched": false
          },
          {
            "name": "PageSizeChanged",
            "type": "EventCallback<int>",
            "default": null,
            "description": null,
            "isCascading": false,
            "captureUnmatched": false
          },
          {
            "name": "PageSizeOptions",
            "type": "int[]",
            "default": "new[] { 10, 25, 50, 100 }",
            "description": "Page-size options shown in the rows-per-page selector. Empty array hides the selector entirely.",
            "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": "PageChanged",
            "type": "EventCallback<int>",
            "description": null
          },
          {
            "name": "PageSizeChanged",
            "type": "EventCallback<int>",
            "description": null
          }
        ],
        "enums": [],
        "records": [],
        "parseFailed": false,
        "parseError": null
      },
      "DataGridRow": {
        "componentName": "DataGridRow",
        "fileName": "DataGridRow.razor",
        "namespace": "Lumeo",
        "inheritsFrom": null,
        "implements": [],
        "parameters": [
          {
            "name": "Context",
            "type": "DataGridContext<TItem>?",
            "default": null,
            "description": null,
            "isCascading": true,
            "captureUnmatched": false
          },
          {
            "name": "ParentGrid",
            "type": "DataGrid<TItem>?",
            "default": null,
            "description": null,
            "isCascading": true,
            "captureUnmatched": false
          },
          {
            "name": "DragStateCtx",
            "type": "DataGridDragState?",
            "default": null,
            "description": null,
            "isCascading": true,
            "captureUnmatched": false
          },
          {
            "name": "Item",
            "type": "TItem",
            "default": "default!",
            "description": null,
            "isCascading": false,
            "captureUnmatched": false
          },
          {
            "name": "RowIndex",
            "type": "int",
            "default": null,
            "description": null,
            "isCascading": false,
            "captureUnmatched": false
          },
          {
            "name": "DetailTemplate",
            "type": "RenderFragment<TItem>?",
            "default": null,
            "description": null,
            "isCascading": false,
            "captureUnmatched": false
          },
          {
            "name": "OnContextMenu",
            "type": "EventCallback<(TItem, double, double)>",
            "default": null,
            "description": null,
            "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": "OnContextMenu",
            "type": "EventCallback<(TItem, double, double)>",
            "description": null
          }
        ],
        "enums": [],
        "records": [],
        "parseFailed": false,
        "parseError": null
      },
      "DataGridToolbar": {
        "componentName": "DataGridToolbar",
        "fileName": "DataGridToolbar.razor",
        "namespace": "Lumeo",
        "inheritsFrom": null,
        "implements": [],
        "parameters": [
          {
            "name": "Context",
            "type": "DataGridContext<TItem>?",
            "default": null,
            "description": null,
            "isCascading": true,
            "captureUnmatched": false
          },
          {
            "name": "SearchText",
            "type": "string?",
            "default": null,
            "description": null,
            "isCascading": false,
            "captureUnmatched": false
          },
          {
            "name": "SearchTextChanged",
            "type": "EventCallback<string>",
            "default": null,
            "description": null,
            "isCascading": false,
            "captureUnmatched": false
          },
          {
            "name": "RegisteredContent",
            "type": "ToolbarContent<TItem>?",
            "default": null,
            "description": "The ToolbarContent{TItem} component the consumer placed inside the DataGrid, forwarded by the parent grid. When null, the toolbar's left slot renders just the search + filter chips; when non-null, its Class styles the wrapper and its ChildContent renders alongside the chips.",
            "isCascading": false,
            "captureUnmatched": false
          },
          {
            "name": "OnColumnVisibilityToggle",
            "type": "EventCallback",
            "default": null,
            "description": null,
            "isCascading": false,
            "captureUnmatched": false
          },
          {
            "name": "OnColumnToggle",
            "type": "EventCallback<DataGridColumn<TItem>>",
            "default": null,
            "description": null,
            "isCascading": false,
            "captureUnmatched": false
          },
          {
            "name": "OnColumnReorder",
            "type": "EventCallback<ColumnReorderEventArgs>",
            "default": null,
            "description": null,
            "isCascading": false,
            "captureUnmatched": false
          },
          {
            "name": "OnExport",
            "type": "EventCallback<string>",
            "default": null,
            "description": null,
            "isCascading": false,
            "captureUnmatched": false
          },
          {
            "name": "OnRemoveFilter",
            "type": "EventCallback<FilterDescriptor>",
            "default": null,
            "description": null,
            "isCascading": false,
            "captureUnmatched": false
          },
          {
            "name": "SelectedItems",
            "type": "List<TItem>?",
            "default": null,
            "description": null,
            "isCascading": false,
            "captureUnmatched": false
          },
          {
            "name": "AllItems",
            "type": "List<TItem>?",
            "default": null,
            "description": null,
            "isCascading": false,
            "captureUnmatched": false
          },
          {
            "name": "EffectiveColumns",
            "type": "IReadOnlyList<DataGridColumn<TItem>>?",
            "default": null,
            "description": null,
            "isCascading": false,
            "captureUnmatched": false
          },
          {
            "name": "EnableLayoutPersistence",
            "type": "bool",
            "default": null,
            "description": null,
            "isCascading": false,
            "captureUnmatched": false
          },
          {
            "name": "OnSaveLayout",
            "type": "EventCallback",
            "default": null,
            "description": null,
            "isCascading": false,
            "captureUnmatched": false
          },
          {
            "name": "OnResetLayout",
            "type": "EventCallback",
            "default": null,
            "description": null,
            "isCascading": false,
            "captureUnmatched": false
          },
          {
            "name": "GlobalLayouts",
            "type": "List<DataGridNamedLayout>?",
            "default": null,
            "description": null,
            "isCascading": false,
            "captureUnmatched": false
          },
          {
            "name": "OnSaveNamedLayout",
            "type": "EventCallback<DataGridNamedLayout>",
            "default": null,
            "description": null,
            "isCascading": false,
            "captureUnmatched": false
          },
          {
            "name": "OnDeleteNamedLayout",
            "type": "EventCallback<string>",
            "default": null,
            "description": null,
            "isCascading": false,
            "captureUnmatched": false
          },
          {
            "name": "OnApplyNamedLayout",
            "type": "EventCallback<DataGridNamedLayout>",
            "default": null,
            "description": null,
            "isCascading": false,
            "captureUnmatched": false
          },
          {
            "name": "LayoutStorageKey",
            "type": "string?",
            "default": null,
            "description": null,
            "isCascading": false,
            "captureUnmatched": false
          },
          {
            "name": "OnError",
            "type": "EventCallback<Exception>",
            "default": null,
            "description": null,
            "isCascading": false,
            "captureUnmatched": false
          },
          {
            "name": "Expandable",
            "type": "bool",
            "default": null,
            "description": null,
            "isCascading": false,
            "captureUnmatched": false
          },
          {
            "name": "IsExpanded",
            "type": "bool",
            "default": null,
            "description": null,
            "isCascading": false,
            "captureUnmatched": false
          },
          {
            "name": "OnToggleExpanded",
            "type": "EventCallback",
            "default": null,
            "description": null,
            "isCascading": false,
            "captureUnmatched": false
          },
          {
            "name": "ShowSearch",
            "type": "bool",
            "default": "true",
            "description": "Show the global search box. Default true.",
            "isCascading": false,
            "captureUnmatched": false
          },
          {
            "name": "ShowColumnChooser",
            "type": "bool",
            "default": "true",
            "description": "Show the Columns (visibility/reorder) button. Default true.",
            "isCascading": false,
            "captureUnmatched": false
          },
          {
            "name": "ShowExport",
            "type": "bool",
            "default": "true",
            "description": "Show the Export dropdown. Default true. Combined with ExportFormats.",
            "isCascading": false,
            "captureUnmatched": false
          },
          {
            "name": "ExportFormats",
            "type": "DataGridExportFormat",
            "default": "DataGridExportFormat.All",
            "description": "Which export formats to include in the dropdown. Default All.",
            "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": "SearchTextChanged",
            "type": "EventCallback<string>",
            "description": null
          },
          {
            "name": "OnColumnVisibilityToggle",
            "type": "EventCallback",
            "description": null
          },
          {
            "name": "OnColumnToggle",
            "type": "EventCallback<DataGridColumn<TItem>>",
            "description": null
          },
          {
            "name": "OnColumnReorder",
            "type": "EventCallback<ColumnReorderEventArgs>",
            "description": null
          },
          {
            "name": "OnExport",
            "type": "EventCallback<string>",
            "description": null
          },
          {
            "name": "OnRemoveFilter",
            "type": "EventCallback<FilterDescriptor>",
            "description": null
          },
          {
            "name": "OnSaveLayout",
            "type": "EventCallback",
            "description": null
          },
          {
            "name": "OnResetLayout",
            "type": "EventCallback",
            "description": null
          },
          {
            "name": "OnSaveNamedLayout",
            "type": "EventCallback<DataGridNamedLayout>",
            "description": null
          },
          {
            "name": "OnDeleteNamedLayout",
            "type": "EventCallback<string>",
            "description": null
          },
          {
            "name": "OnApplyNamedLayout",
            "type": "EventCallback<DataGridNamedLayout>",
            "description": null
          },
          {
            "name": "OnError",
            "type": "EventCallback<Exception>",
            "description": null
          },
          {
            "name": "OnToggleExpanded",
            "type": "EventCallback",
            "description": null
          }
        ],
        "enums": [],
        "records": [],
        "parseFailed": false,
        "parseError": null
      },
      "DataGridToolbarColumns": {
        "componentName": "DataGridToolbarColumns",
        "fileName": "DataGridToolbarColumns.razor",
        "namespace": "Lumeo",
        "inheritsFrom": null,
        "implements": [],
        "parameters": [
          {
            "name": "Context",
            "type": "DataGridToolbarContext<TItem>?",
            "default": null,
            "description": null,
            "isCascading": true,
            "captureUnmatched": false
          }
        ],
        "events": [],
        "enums": [],
        "records": [],
        "parseFailed": false,
        "parseError": null
      },
      "DataGridToolbarCopySelected": {
        "componentName": "DataGridToolbarCopySelected",
        "fileName": "DataGridToolbarCopySelected.razor",
        "namespace": "Lumeo",
        "inheritsFrom": null,
        "implements": [],
        "parameters": [
          {
            "name": "Context",
            "type": "DataGridToolbarContext<TItem>?",
            "default": null,
            "description": null,
            "isCascading": true,
            "captureUnmatched": false
          }
        ],
        "events": [],
        "enums": [],
        "records": [],
        "parseFailed": false,
        "parseError": null
      },
      "DataGridToolbarExport": {
        "componentName": "DataGridToolbarExport",
        "fileName": "DataGridToolbarExport.razor",
        "namespace": "Lumeo",
        "inheritsFrom": null,
        "implements": [],
        "parameters": [
          {
            "name": "Context",
            "type": "DataGridToolbarContext<TItem>?",
            "default": null,
            "description": null,
            "isCascading": true,
            "captureUnmatched": false
          }
        ],
        "events": [],
        "enums": [],
        "records": [],
        "parseFailed": false,
        "parseError": null
      },
      "DataGridToolbarFullscreen": {
        "componentName": "DataGridToolbarFullscreen",
        "fileName": "DataGridToolbarFullscreen.razor",
        "namespace": "Lumeo",
        "inheritsFrom": null,
        "implements": [],
        "parameters": [
          {
            "name": "Context",
            "type": "DataGridToolbarContext<TItem>?",
            "default": null,
            "description": null,
            "isCascading": true,
            "captureUnmatched": false
          }
        ],
        "events": [],
        "enums": [],
        "records": [],
        "parseFailed": false,
        "parseError": null
      },
      "DataGridToolbarLayouts": {
        "componentName": "DataGridToolbarLayouts",
        "fileName": "DataGridToolbarLayouts.razor",
        "namespace": "Lumeo",
        "inheritsFrom": null,
        "implements": [],
        "parameters": [
          {
            "name": "Context",
            "type": "DataGridToolbarContext<TItem>?",
            "default": null,
            "description": null,
            "isCascading": true,
            "captureUnmatched": false
          }
        ],
        "events": [],
        "enums": [],
        "records": [],
        "parseFailed": false,
        "parseError": null
      },
      "ToolbarContent": {
        "componentName": "ToolbarContent",
        "fileName": "ToolbarContent.razor",
        "namespace": "Lumeo",
        "inheritsFrom": null,
        "implements": [
          "IDisposable"
        ],
        "parameters": [
          {
            "name": "Parent",
            "type": "DataGrid<TItem>?",
            "default": null,
            "description": null,
            "isCascading": true,
            "captureUnmatched": false
          },
          {
            "name": "Class",
            "type": "string?",
            "default": null,
            "description": "Custom CSS classes applied to the wrapper around the toolbar content (the flex row holding your custom buttons + the filter chips).",
            "isCascading": false,
            "captureUnmatched": false
          },
          {
            "name": "ChildContent",
            "type": "RenderFragment?",
            "default": null,
            "description": null,
            "isCascading": false,
            "captureUnmatched": false
          }
        ],
        "events": [],
        "enums": [],
        "records": [],
        "parseFailed": false,
        "parseError": null
      }
    },
    "parseFailed": false,
    "parseError": null
  },
  "$schema": "https://lumeo.nativ.sh/registry-component-schema.json",
  "key": "data-grid",
  "docsUrl": "https://lumeo.nativ.sh/components/data-grid",
  "sourceUrl": [
    {
      "file": "UI/DataGrid/DataGrid.razor",
      "url": "https://github.com/Brain2k-0005/Lumeo/tree/master/src/Lumeo.DataGrid/UI/DataGrid/DataGrid.razor"
    },
    {
      "file": "UI/DataGrid/DataGridBody.razor",
      "url": "https://github.com/Brain2k-0005/Lumeo/tree/master/src/Lumeo.DataGrid/UI/DataGrid/DataGridBody.razor"
    },
    {
      "file": "UI/DataGrid/DataGridCell.razor",
      "url": "https://github.com/Brain2k-0005/Lumeo/tree/master/src/Lumeo.DataGrid/UI/DataGrid/DataGridCell.razor"
    },
    {
      "file": "UI/DataGrid/DataGridColumn.cs",
      "url": "https://github.com/Brain2k-0005/Lumeo/tree/master/src/Lumeo.DataGrid/UI/DataGrid/DataGridColumn.cs"
    },
    {
      "file": "UI/DataGrid/DataGridColumnDef.razor",
      "url": "https://github.com/Brain2k-0005/Lumeo/tree/master/src/Lumeo.DataGrid/UI/DataGrid/DataGridColumnDef.razor"
    },
    {
      "file": "UI/DataGrid/DataGridColumnFilter.razor",
      "url": "https://github.com/Brain2k-0005/Lumeo/tree/master/src/Lumeo.DataGrid/UI/DataGrid/DataGridColumnFilter.razor"
    },
    {
      "file": "UI/DataGrid/DataGridColumnVisibility.razor",
      "url": "https://github.com/Brain2k-0005/Lumeo/tree/master/src/Lumeo.DataGrid/UI/DataGrid/DataGridColumnVisibility.razor"
    },
    {
      "file": "UI/DataGrid/DataGridContext.cs",
      "url": "https://github.com/Brain2k-0005/Lumeo/tree/master/src/Lumeo.DataGrid/UI/DataGrid/DataGridContext.cs"
    },
    {
      "file": "UI/DataGrid/DataGridDetailRow.razor",
      "url": "https://github.com/Brain2k-0005/Lumeo/tree/master/src/Lumeo.DataGrid/UI/DataGrid/DataGridDetailRow.razor"
    },
    {
      "file": "UI/DataGrid/DataGridDragState.cs",
      "url": "https://github.com/Brain2k-0005/Lumeo/tree/master/src/Lumeo.DataGrid/UI/DataGrid/DataGridDragState.cs"
    },
    {
      "file": "UI/DataGrid/DataGridEnums.cs",
      "url": "https://github.com/Brain2k-0005/Lumeo/tree/master/src/Lumeo.DataGrid/UI/DataGrid/DataGridEnums.cs"
    },
    {
      "file": "UI/DataGrid/DataGridFilterOperator.cs",
      "url": "https://github.com/Brain2k-0005/Lumeo/tree/master/src/Lumeo.DataGrid/UI/DataGrid/DataGridFilterOperator.cs"
    },
    {
      "file": "UI/DataGrid/DataGridFooter.razor",
      "url": "https://github.com/Brain2k-0005/Lumeo/tree/master/src/Lumeo.DataGrid/UI/DataGrid/DataGridFooter.razor"
    },
    {
      "file": "UI/DataGrid/DataGridGroupRow.razor",
      "url": "https://github.com/Brain2k-0005/Lumeo/tree/master/src/Lumeo.DataGrid/UI/DataGrid/DataGridGroupRow.razor"
    },
    {
      "file": "UI/DataGrid/DataGridHeader.razor",
      "url": "https://github.com/Brain2k-0005/Lumeo/tree/master/src/Lumeo.DataGrid/UI/DataGrid/DataGridHeader.razor"
    },
    {
      "file": "UI/DataGrid/DataGridHeaderCell.razor",
      "url": "https://github.com/Brain2k-0005/Lumeo/tree/master/src/Lumeo.DataGrid/UI/DataGrid/DataGridHeaderCell.razor"
    },
    {
      "file": "UI/DataGrid/DataGridLayoutService.cs",
      "url": "https://github.com/Brain2k-0005/Lumeo/tree/master/src/Lumeo.DataGrid/UI/DataGrid/DataGridLayoutService.cs"
    },
    {
      "file": "UI/DataGrid/DataGridPagination.razor",
      "url": "https://github.com/Brain2k-0005/Lumeo/tree/master/src/Lumeo.DataGrid/UI/DataGrid/DataGridPagination.razor"
    },
    {
      "file": "UI/DataGrid/DataGridRow.razor",
      "url": "https://github.com/Brain2k-0005/Lumeo/tree/master/src/Lumeo.DataGrid/UI/DataGrid/DataGridRow.razor"
    },
    {
      "file": "UI/DataGrid/DataGridServerService.cs",
      "url": "https://github.com/Brain2k-0005/Lumeo/tree/master/src/Lumeo.DataGrid/UI/DataGrid/DataGridServerService.cs"
    },
    {
      "file": "UI/DataGrid/DataGridState.cs",
      "url": "https://github.com/Brain2k-0005/Lumeo/tree/master/src/Lumeo.DataGrid/UI/DataGrid/DataGridState.cs"
    },
    {
      "file": "UI/DataGrid/DataGridToolbar.razor",
      "url": "https://github.com/Brain2k-0005/Lumeo/tree/master/src/Lumeo.DataGrid/UI/DataGrid/DataGridToolbar.razor"
    },
    {
      "file": "UI/DataGrid/DataGridToolbarColumns.razor",
      "url": "https://github.com/Brain2k-0005/Lumeo/tree/master/src/Lumeo.DataGrid/UI/DataGrid/DataGridToolbarColumns.razor"
    },
    {
      "file": "UI/DataGrid/DataGridToolbarContext.cs",
      "url": "https://github.com/Brain2k-0005/Lumeo/tree/master/src/Lumeo.DataGrid/UI/DataGrid/DataGridToolbarContext.cs"
    },
    {
      "file": "UI/DataGrid/DataGridToolbarCopySelected.razor",
      "url": "https://github.com/Brain2k-0005/Lumeo/tree/master/src/Lumeo.DataGrid/UI/DataGrid/DataGridToolbarCopySelected.razor"
    },
    {
      "file": "UI/DataGrid/DataGridToolbarExport.razor",
      "url": "https://github.com/Brain2k-0005/Lumeo/tree/master/src/Lumeo.DataGrid/UI/DataGrid/DataGridToolbarExport.razor"
    },
    {
      "file": "UI/DataGrid/DataGridToolbarFullscreen.razor",
      "url": "https://github.com/Brain2k-0005/Lumeo/tree/master/src/Lumeo.DataGrid/UI/DataGrid/DataGridToolbarFullscreen.razor"
    },
    {
      "file": "UI/DataGrid/DataGridToolbarLayouts.razor",
      "url": "https://github.com/Brain2k-0005/Lumeo/tree/master/src/Lumeo.DataGrid/UI/DataGrid/DataGridToolbarLayouts.razor"
    },
    {
      "file": "UI/DataGrid/ToolbarContent.razor",
      "url": "https://github.com/Brain2k-0005/Lumeo/tree/master/src/Lumeo.DataGrid/UI/DataGrid/ToolbarContent.razor"
    }
  ],
  "source": [
    {
      "path": "UI/DataGrid/DataGrid.razor",
      "content": "@namespace Lumeo\n@typeparam TItem\n@attribute [CascadingTypeParameter(nameof(TItem))]\n@implements IAsyncDisposable\n@inject Lumeo.Services.Localization.ILumeoLocalizer L\n@inject Lumeo.Services.ToastService Toast\n\n@{\n    // Defensive: keep the roving-focus cell within the current data extents.\n    // This is a pure clamp of two ints — it never triggers a re-render — so it's\n    // safe to run during render and covers every path that mutates _displayedItems\n    // (sort, filter, paging, grouping, external data changes).\n    ClampFocusedCell();\n    var context = new DataGridContext<TItem>(\n        Columns: _visibleColumns,\n        // _visibleColumns is itself the filtered cache (Visible == true) — we\n        // expose it explicitly under VisibleColumns so descendants don't have\n        // to re-filter via Columns.Where(c => c.Visible) every render.\n        VisibleColumns: _visibleColumns,\n        DisplayedItems: _displayedItems.AsReadOnly(),\n        SelectedItems: _selectedItems.AsReadOnly(),\n        Sorts: _sorts,\n        Filters: _filters,\n        GlobalSearch: _globalSearch,\n        CurrentPage: _currentPage,\n        PageSize: PageSize,\n        TotalCount: EffectiveTotalCount,\n        SelectionMode: SelectionMode,\n        EditMode: EditMode,\n        IsLoading: IsLoading || (_serverService?.IsLoading ?? false),\n        RowReorderable: RowReorderable,\n        RowContextMenuEnabled: RowContextMenu is not null,\n        OnRowClick: OnRowClick,\n        OnRowDoubleClick: OnRowDoubleClick,\n        OnSort: EventCallback.Factory.Create<SortDescriptor>(this, HandleSort),\n        OnFilter: EventCallback.Factory.Create<FilterDescriptor>(this, HandleFilter),\n        OnSelectionChanged: EventCallback.Factory.Create<TItem>(this, HandleSelectionChanged),\n        OnCellEdit: OnCellEdit,\n        IsRowSelected: IsRowSelected,\n        IsRowExpanded: IsRowExpanded,\n        IsRowEditing: IsRowEditing,\n        ToggleSelection: ToggleSelection,\n        SelectAll: SelectAll,\n        ClearSelection: ClearSelection,\n        ToggleRowExpand: ToggleRowExpand,\n        StartRowEdit: StartRowEdit,\n        CancelRowEdit: CancelRowEdit,\n        CommitRowEdit: CommitRowEditAsync,\n        GetEditValue: GetEditValue,\n        SetEditValue: SetEditValue,\n        RowClass: RowClass,\n        RowStyle: RowStyle,\n        PinnedLeftOffsets: ComputePinnedLeftOffsets(),\n        PinnedRightOffsets: ComputePinnedRightOffsets(),\n        GroupedSections: _groupedSections.Count > 0 ? _groupedSections.AsReadOnly() : null,\n        GroupBy: GroupBy,\n        IsGrouped: !IsTreeGridMode && EffectiveGroupFields.Count == 1 && _groupedSections.Count > 0,\n        IsGroupExpanded: IsGroupExpanded,\n        ToggleGroupExpand: ToggleGroupExpand,\n        Culture: EffectiveCulture,\n        GroupFields: _runtimeGroupFields.AsReadOnly(),\n        GroupTree: (!IsTreeGridMode && EffectiveGroupFields.Count > 1 && _groupedTree.Count > 0)\n            ? _groupedTree.AsReadOnly() : null,\n        IsGroupPathExpanded: IsGroupPathExpanded,\n        ToggleGroupPath: ToggleGroupPath,\n        AddGroupField: AddGroupField,\n        RemoveGroupField: RemoveGroupField,\n        ShowGroupPanel: ShowGroupPanel,\n        ChildItemsSelector: ChildItemsSelector,\n        IsTreeGrid: IsTreeGridMode,\n        TreeColumnField: EffectiveTreeColumnField,\n        IsTreeNodeExpanded: IsTreeNodeExpanded,\n        ToggleTreeNode: it => _ = ToggleTreeNodeAsync(it),\n        TreeLevelOf: TreeLevelOf,\n        TreeHasChildren: TreeHasChildrenOf,\n        // --- Batch-edit context fields — wired to the per-cell pending-changes\n        // buffer (_batchChanges) and the additive new-row buffer (_batchAdded).\n        IsBatchEdit: EditMode == DataGridEditMode.Batch,\n        IsCellDirty: IsCellDirty,\n        GetBatchValue: GetBatchValue,\n        SetBatchValue: SetBatchValue,\n        GridId: _gridId,\n        FocusedRow: _focusedRow,\n        FocusedCol: _focusedCol,\n        RowCount: _displayedItems.Count,\n        ColCount: _visibleColumns.Count,\n        OnCellKeyDown: HandleCellKeyDownAsync,\n        OnHeaderKeyDown: HandleHeaderKeyDownAsync\n    );\n}\n\n@{\n    RenderFragment gridRoot = @<div data-slot=\"datagrid\" class=\"@RootCssClass\" style=\"@HeightStyle\" @attributes=\"AdditionalAttributes\">\n\n        @* Declarative column definitions *@\n        <div class=\"hidden\">@ChildContent</div>\n\n        @if (_serverService?.Error is not null)\n        {\n            <div class=\"flex items-center gap-2 px-4 py-2.5 border-b border-destructive/30 bg-destructive/10 text-sm\">\n                <Blazicon Svg=\"Lucide.CircleAlert\" class=\"h-4 w-4 shrink-0 text-destructive\" />\n                <span class=\"flex-1 text-destructive-text\">@L[\"DataGrid.ErrorLoadingData\", _serverService.Error ?? \"\"]</span>\n                <button type=\"button\"\n                        class=\"text-xs text-destructive underline hover:no-underline\"\n                        @onclick=\"@(async () => { _serverService!.ClearError(); await RequestServerData(); })\">\n                    @L[\"DataGrid.Retry\"]\n                </button>\n            </div>\n        }\n\n        @if (ShowToolbar)\n        {\n            <DataGridToolbar TItem=\"TItem\"\n                             SearchText=\"@_globalSearch\"\n                             SearchTextChanged=\"HandleGlobalSearch\"\n                             RegisteredContent=\"@RegisteredToolbarContent\"\n                             OnColumnToggle=\"HandleColumnVisibilityChanged\"\n                             OnColumnReorder=\"HandleColumnReorder\"\n                             OnExport=\"HandleExport\"\n                             OnRemoveFilter=\"RemoveFilter\"\n                             SelectedItems=\"_selectedItems\"\n                             AllItems=\"@(ServerMode ? _displayedItems : _processedItems)\"\n                             EffectiveColumns=\"EffectiveColumns.AsReadOnly()\"\n                             EnableLayoutPersistence=\"EnableLayoutPersistence\"\n                             OnSaveLayout=\"HandleSaveLayout\"\n                             OnResetLayout=\"HandleResetLayout\"\n                             GlobalLayouts=\"GlobalLayouts\"\n                             OnSaveNamedLayout=\"HandleSaveNamedLayout\"\n                             OnDeleteNamedLayout=\"OnDeleteNamedLayout\"\n                             OnApplyNamedLayout=\"HandleApplyNamedLayout\"\n                             LayoutStorageKey=\"LayoutStorageKey\"\n                             OnError=\"OnError\"\n                             Expandable=\"Expandable\"\n                             IsExpanded=\"_isExpanded\"\n                             OnToggleExpanded=\"ToggleExpanded\"\n                             ShowSearch=\"ShowSearch\"\n                             ShowColumnChooser=\"ShowColumnChooser\"\n                             ShowExport=\"ShowExport\"\n                             ExportFormats=\"ExportFormats\" />\n        }\n\n        @if (_contextMenuVisible)\n        {\n            <div id=\"@_contextMenuId\"\n                 class=\"fixed z-50 min-w-[160px] rounded-md border border-border/40 bg-popover p-1 shadow-md\"\n                 style=\"left: @(_contextMenuX)px; top: @(_contextMenuY)px;\"\n                 @onclick:stopPropagation=\"true\">\n                @if (RowContextMenu is not null && _contextMenuItem is not null)\n                {\n                    @RowContextMenu(_contextMenuItem)\n                }\n            </div>\n        }\n\n        @if (EditMode == DataGridEditMode.Batch && HasPendingChanges)\n        {\n            @* Batch-edit pending-changes strip. Uses warning/amber theme tokens\n               from lumeo.css (--color-warning, --color-warning-text). Sits\n               above the table body so it's visually associated with dirty cells. *@\n            <div data-slot=\"datagrid-batch-strip\"\n                 class=\"flex items-center gap-2 px-4 py-2 border-b border-warning/40 bg-warning/10 text-xs\"\n                 role=\"status\"\n                 aria-live=\"polite\">\n                <Blazicon Svg=\"Lucide.CircleAlert\" class=\"h-4 w-4 text-warning-text shrink-0\" />\n                <span class=\"text-warning-text font-medium\">\n                    @($\"{_batchChanges.Count + _batchAdded.Count} pending change(s)\")\n                </span>\n                <div class=\"ml-auto flex items-center gap-2\">\n                    <button type=\"button\"\n                            data-slot=\"datagrid-batch-save\"\n                            class=\"inline-flex h-7 items-center rounded-md bg-primary px-3 text-xs font-medium text-primary-foreground hover:bg-primary/90\"\n                            @onclick=\"SaveBatchAsync\">\n                        @BatchSaveAllText\n                    </button>\n                    <button type=\"button\"\n                            data-slot=\"datagrid-batch-discard\"\n                            class=\"inline-flex h-7 items-center rounded-md border border-border/60 bg-background px-3 text-xs font-medium hover:bg-muted\"\n                            @onclick=\"DiscardBatch\">\n                        @BatchDiscardText\n                    </button>\n                </div>\n            </div>\n        }\n\n        @if (ShowGroupPanel)\n        {\n            @* rc.41: real drop zone — header DnD now lands here. Visual highlight\n               via _isGroupPanelDragOver. The dropped column's field comes from\n               _dragState.SourceColumnId (set by HeaderCell.HandleDragStart). *@\n            <div data-slot=\"datagrid-group-panel\"\n                 class=\"@($\"flex flex-wrap items-center gap-2 px-3 py-2 border-b border-dashed text-xs transition-colors {(_isGroupPanelDragOver ? \"border-primary bg-primary/10\" : \"border-border/40 bg-muted/30\")}\")\"\n                 @ondragover=\"HandleGroupPanelDragOver\"\n                 @ondragover:preventDefault=\"true\"\n                 @ondragenter=\"HandleGroupPanelDragEnter\"\n                 @ondragleave=\"HandleGroupPanelDragLeave\"\n                 @ondrop=\"HandleGroupPanelDrop\">\n                @if (_runtimeGroupFields.Count == 0)\n                {\n                    <span class=\"text-muted-foreground select-none pointer-events-none\">\n                        @(GroupPanelText ?? \"Drag a Groupable column header here, or use the dropdown\")\n                    </span>\n                }\n                else\n                {\n                    @for (var gi = 0; gi < _runtimeGroupFields.Count; gi++)\n                    {\n                        var field = _runtimeGroupFields[gi];\n                        var col = EffectiveColumns.FirstOrDefault(c => c.Field == field);\n                        var label = col?.Title ?? field;\n                        <span class=\"inline-flex items-center gap-1 rounded bg-card border border-border/60 px-2 py-0.5\">\n                            <Blazicon Svg=\"Lucide.Group\" class=\"h-3 w-3 text-muted-foreground\" />\n                            <span>@label</span>\n                            <button type=\"button\"\n                                    class=\"inline-flex items-center justify-center h-4 w-4 rounded hover:bg-muted text-muted-foreground\"\n                                    title=\"Remove grouping\"\n                                    @onclick=\"@(() => RemoveGroupField(field))\">\n                                <Blazicon Svg=\"Lucide.X\" class=\"h-3 w-3\" />\n                            </button>\n                        </span>\n                        @if (gi < _runtimeGroupFields.Count - 1)\n                        {\n                            <Blazicon Svg=\"Lucide.ChevronRight\" class=\"h-3 w-3 text-muted-foreground/60\" />\n                        }\n                    }\n                }\n                @{\n                    var addableCols = EffectiveColumns\n                        .Where(c => c.Groupable && c.Field is not null && !_runtimeGroupFields.Contains(c.Field!))\n                        .ToList();\n                }\n                @if (_runtimeGroupFields.Count > 0)\n                {\n                    @* Clear-all button: lives next to the chips so it's discoverable\n                       and matches the per-chip X visual. rc.41 polish. *@\n                    <button type=\"button\"\n                            class=\"ml-1 inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-xs text-muted-foreground hover:bg-muted hover:text-foreground transition-colors\"\n                            title=\"Clear all grouping\"\n                            @onclick=\"ClearGroupFields\">\n                        <Blazicon Svg=\"Lucide.X\" class=\"h-3 w-3\" />\n                        Clear\n                    </button>\n                }\n                @if (addableCols.Count > 0)\n                {\n                    @* @key on the select forces a fresh DOM element every time the\n                       group-fields count changes — fixes the rc.39 bug where the\n                       <select>'s native DOM value stuck on the last-picked option\n                       and the placeholder option (\"+ Add group level\") wouldn't\n                       re-appear, making the dropdown feel broken after the second\n                       add. (rc.41) *@\n                    <select @key=\"@($\"group-add-{_runtimeGroupFields.Count}\")\"\n                            class=\"ml-auto h-6 rounded border border-border/60 bg-card px-1 text-xs text-muted-foreground\"\n                            value=\"\"\n                            @onchange=\"@(e => { var v = e.Value?.ToString(); if (!string.IsNullOrEmpty(v)) AddGroupField(v!); })\">\n                        <option value=\"\">+ Add group level</option>\n                        @foreach (var c in addableCols)\n                        {\n                            <option value=\"@c.Field\">@(c.Title ?? c.Field)</option>\n                        }\n                    </select>\n                }\n            </div>\n        }\n\n        <div class=\"overflow-auto flex-1\" @onclick=\"CloseContextMenu\">\n            <table class=\"w-full caption-bottom text-sm\"\n                   id=\"@_gridId\"\n                   role=\"@(IsTreeGridMode ? \"treegrid\" : \"grid\")\"\n                   aria-rowcount=\"@(_displayedItems.Count + 1)\"\n                   aria-colcount=\"@_visibleColumns.Count\"\n                   aria-readonly=\"@(EditMode == DataGridEditMode.None ? \"true\" : null)\"\n                   aria-multiselectable=\"@(SelectionMode == DataGridSelectionMode.Multiple ? \"true\" : null)\">\n                <DataGridHeader TItem=\"TItem\"\n                                HasDetailTemplate=\"@(DetailTemplate is not null)\"\n                                ActiveFilterColumn=\"@_activeFilterColumn\"\n                                OnFilterToggle=\"HandleFilterToggle\"\n                                OnFilterApply=\"HandleFilterApply\"\n                                Reorderable=\"Reorderable\"\n                                OnColumnReorder=\"HandleColumnReorder\" />\n\n                <DataGridBody TItem=\"TItem\"\n                              @ref=\"_bodyRef\"\n                              DetailTemplate=\"DetailTemplate\"\n                              EmptyContent=\"EmptyContent\"\n                              UseVirtualization=\"@UseVirtualization\"\n                              VirtualItemSize=\"@VirtualItemSize\"\n                              VirtualOverscanCount=\"@VirtualOverscanCount\"\n                              ServerVirtualizationProvider=\"@(_useServerVirtualization ? ServerVirtualizationProviderImpl : null)\"\n                              HasDetailTemplate=\"@(DetailTemplate is not null)\"\n                              SkeletonRowCount=\"@PageSize\"\n                              OnContextMenu=\"@(args => HandleContextMenu(args.Item1, args.Item2, args.Item3))\" />\n\n                <DataGridFooter TItem=\"TItem\"\n                                AllItems=\"@_processedItems\"\n                                HasDetailTemplate=\"@(DetailTemplate is not null)\" />\n            </table>\n            @if (EditMode == DataGridEditMode.Batch && ShowAddRow && NewItemFactory is not null)\n            {\n                <div data-slot=\"datagrid-add-row\"\n                     class=\"flex items-center border-t border-border/40 bg-muted/20 px-3 py-1.5\">\n                    <button type=\"button\"\n                            data-slot=\"datagrid-add-row-button\"\n                            class=\"inline-flex h-7 items-center gap-1 rounded-md border border-dashed border-border/60 bg-card px-2 text-xs text-muted-foreground hover:bg-muted hover:text-foreground\"\n                            @onclick=\"AddBatchRow\">\n                        <Blazicon Svg=\"Lucide.Plus\" class=\"h-3.5 w-3.5\" />\n                        <span>@BatchAddRowText</span>\n                    </button>\n                </div>\n            }\n        </div>\n\n        @if (ShowPagination && !IsLoading && !_useServerVirtualization)\n        {\n            <DataGridPagination TItem=\"TItem\"\n                                PageChanged=\"HandlePageChanged\"\n                                PageSizeChanged=\"HandlePageSizeChanged\"\n                                PageSizeOptions=\"@PageSizeOptions\" />\n        }\n    </div>;\n}\n\n<CascadingValue Value=\"this\" IsFixed=\"true\">\n<CascadingValue Value=\"_dragState\" IsFixed=\"true\">\n<CascadingValue Value=\"context\" IsFixed=\"false\">\n<CascadingValue Value=\"_toolbarContext\" IsFixed=\"false\">\n    @if (_isExpanded)\n    {\n        <div class=\"fixed inset-0 lumeo-datagrid-fullscreen-backdrop\"\n             style=\"z-index: 99;\"\n             aria-hidden=\"true\"\n             @onclick=\"ToggleExpanded\"></div>\n        <div class=\"fixed bg-background flex flex-col border border-border/60 rounded-xl shadow-2xl overflow-hidden lumeo-datagrid-fullscreen lumeo-datagrid-fullscreen-panel\"\n             style=\"z-index: 100; inset: 2.5rem;\"\n             role=\"dialog\"\n             aria-modal=\"true\"\n             aria-label=\"@(FullscreenTitle ?? \"Expanded data grid\")\"\n             tabindex=\"-1\"\n             @ref=\"_fullscreenRef\"\n             @onkeydown=\"HandleFullscreenKeyDown\">\n            @* Title strip — only shown when FullscreenTitle is set. The X was removed because the toolbar\n               already renders a Minimize icon (DataGridToolbarFullscreen) that does the same thing. Users\n               without that tool in a custom ToolbarContent can still press Esc. *@\n            @if (!string.IsNullOrEmpty(FullscreenTitle))\n            {\n                <div class=\"flex items-center border-b border-border/40 px-5 py-2.5 shrink-0 bg-muted/30\">\n                    <Heading Level=\"3\" Class=\"text-sm font-semibold m-0 tracking-tight\">@FullscreenTitle</Heading>\n                </div>\n            }\n            @* No extra padding — the grid component carries its own borders/rounded corners and fills the\n               remaining modal height via CSS (see `.lumeo-datagrid-fullscreen-panel [data-slot=datagrid]`). *@\n            <div class=\"flex-1 min-h-0 flex flex-col\">\n                @gridRoot\n            </div>\n        </div>\n    }\n    else\n    {\n        @gridRoot\n    }\n</CascadingValue>\n</CascadingValue>\n</CascadingValue>\n</CascadingValue>\n\n@code {\n    // --- Data ---\n    [Parameter] public IEnumerable<TItem>? Items { get; set; }\n    [Parameter] public List<DataGridColumn<TItem>>? Columns { get; set; }\n\n    // --- Appearance ---\n    [Parameter] public bool Striped { get; set; }\n    [Parameter] public bool Bordered { get; set; }\n    [Parameter] public bool Compact { get; set; }\n    [Parameter] public bool Hoverable { get; set; } = true;\n    [Parameter] public string? Height { get; set; }\n\n    // --- Features ---\n    [Parameter] public DataGridSelectionMode SelectionMode { get; set; } = DataGridSelectionMode.None;\n    [Parameter] public DataGridEditMode EditMode { get; set; } = DataGridEditMode.None;\n    [Parameter] public int PageSize { get; set; } = 10;\n    /// <summary>The list of page-size options shown in the rows-per-page\n    /// selector. Defaults to <c>{ 10, 25, 50, 100 }</c>. Set to e.g.\n    /// <c>{ 10, 50, 100, 200, 300 }</c> for larger datasets, or to an\n    /// empty array to hide the selector entirely.</summary>\n    [Parameter] public int[] PageSizeOptions { get; set; } = new[] { 10, 25, 50, 100 };\n    [Parameter] public bool ShowPagination { get; set; } = true;\n    [Parameter] public bool ShowToolbar { get; set; }\n\n    /// <summary>Show the global search box in the toolbar. Default true.</summary>\n    [Parameter] public bool ShowSearch { get; set; } = true;\n\n    /// <summary>Show the Columns button (visibility/reorder menu). Default true.</summary>\n    [Parameter] public bool ShowColumnChooser { get; set; } = true;\n\n    /// <summary>\n    /// Show the Export button and its format dropdown. Default true.\n    /// Set false to hide export entirely. For fine-grained per-format control see\n    /// <see cref=\"ExportFormats\"/>.\n    /// </summary>\n    [Parameter] public bool ShowExport { get; set; } = true;\n\n    /// <summary>\n    /// Which export formats appear in the Export dropdown. Default <see cref=\"DataGridExportFormat.All\"/>.\n    /// Set to e.g. <c>Csv | Excel</c> to hide PDF in WebAssembly where QuestPDF isn't supported.\n    /// When no flags are set (or <see cref=\"ShowExport\"/> is false), the Export button is hidden.\n    /// </summary>\n    [Parameter] public DataGridExportFormat ExportFormats { get; set; } = DataGridExportFormat.All;\n    /// <summary>Item count above which Blazor's <c>&lt;Virtualize&gt;</c>\n    /// kicks in. Only relevant when <see cref=\"ShowPagination\"/> is false\n    /// (or <see cref=\"PageSize\"/> &gt; this threshold) — paged grids show\n    /// at most <see cref=\"PageSize\"/> rows at once and never trigger\n    /// virtualization. Default 500.</summary>\n    [Parameter] public int VirtualizeThreshold { get; set; } = 500;\n    /// <summary>Estimated row height in CSS pixels — used by\n    /// <c>&lt;Virtualize&gt;</c> to size the scroll spacers. Tune if your\n    /// rows are taller (dense badges, two-line cells). Default 41px.</summary>\n    [Parameter] public float VirtualItemSize { get; set; } = 41f;\n    /// <summary>Extra rows rendered above and below the viewport to\n    /// hide pop-in during fast scrolling. Default 3.</summary>\n    [Parameter] public int VirtualOverscanCount { get; set; } = 3;\n    [Parameter] public bool Reorderable { get; set; }\n    [Parameter] public bool RowReorderable { get; set; }\n    [Parameter] public string? GroupBy { get; set; }\n    [Parameter] public bool GroupsExpandedByDefault { get; set; } = true;\n\n    /// <summary>\n    /// Ordered list of fields for multi-level grouping. When set (non-empty) this\n    /// takes precedence over the single <see cref=\"GroupBy\"/> parameter. Each entry\n    /// must be a column <c>Field</c>. The grid renders nested group rows with\n    /// increasing indentation per level; per-level aggregates are shown on each\n    /// group header row.\n    /// </summary>\n    [Parameter] public IReadOnlyList<string>? GroupByFields { get; set; }\n\n    /// <summary>\n    /// Shows a slim bar above the grid that lists the current grouping levels as\n    /// removable chips, plus a dropdown to add a new level from any\n    /// <see cref=\"DataGridColumn{TItem}.Groupable\"/> column. Default false.\n    /// </summary>\n    [Parameter] public bool ShowGroupPanel { get; set; }\n\n    /// <summary>Placeholder/hint text shown in the group panel when no fields are grouped.</summary>\n    [Parameter] public string? GroupPanelText { get; set; }\n\n    // --- Tree-grid mode ---\n    /// <summary>\n    /// When set, the grid renders a hierarchical tree-grid: this selector returns a\n    /// row's child rows (null/empty = leaf). Each parent row gets an expand chevron\n    /// in the tree column (see <see cref=\"TreeColumnField\"/>). Distinct from\n    /// <see cref=\"DetailTemplate\"/>, which is a flat expand-for-detail fragment.\n    /// Tree-grid mode and grouping are mutually exclusive — when both are configured,\n    /// tree-grid mode wins.\n    /// </summary>\n    [Parameter] public Func<TItem, IEnumerable<TItem>?>? ChildItemsSelector { get; set; }\n\n    /// <summary>Whether tree nodes start expanded. Default false.</summary>\n    [Parameter] public bool TreeGridDefaultExpanded { get; set; }\n\n    /// <summary>Field of the column that carries the tree chevron + indentation.\n    /// Null (default) uses the first visible column.</summary>\n    [Parameter] public string? TreeColumnField { get; set; }\n\n    /// <summary>Raised when a tree node is expanded or collapsed (the item, plus its new expanded state).</summary>\n    [Parameter] public EventCallback<TItem> OnTreeNodeExpand { get; set; }\n\n    // --- Fullscreen / Expandable ---\n    /// <summary>\n    /// When true, adds a small Expand icon to the toolbar. Clicking it\n    /// renders the grid in a fullscreen modal overlay. Default false.\n    /// </summary>\n    [Parameter] public bool Expandable { get; set; }\n\n    /// <summary>\n    /// When true, the grid is currently rendered in fullscreen. Bind this\n    /// for programmatic control, or leave unbound to let the grid manage\n    /// its own state via the toolbar button.\n    /// </summary>\n    [Parameter] public bool IsExpanded { get; set; }\n\n    [Parameter] public EventCallback<bool> IsExpandedChanged { get; set; }\n\n    /// <summary>\n    /// Optional title shown in the fullscreen header bar. Defaults to \"Data Grid\".\n    /// </summary>\n    [Parameter] public string? FullscreenTitle { get; set; }\n\n    // --- Conditional Row Styling ---\n    [Parameter] public Func<TItem, string>? RowClass { get; set; }\n    [Parameter] public Func<TItem, string>? RowStyle { get; set; }\n\n    // --- Layout Persistence ---\n    [Parameter] public bool EnableLayoutPersistence { get; set; }\n    [Parameter] public string? LayoutStorageKey { get; set; }\n    [Parameter] public EventCallback<DataGridLayout> OnLayoutSave { get; set; }\n    [Parameter] public DataGridLayout? SavedLayout { get; set; }\n\n    // --- Named Layouts ---\n    [Parameter] public List<DataGridNamedLayout>? GlobalLayouts { get; set; }\n    [Parameter] public EventCallback<DataGridNamedLayout> OnSaveNamedLayout { get; set; }\n    [Parameter] public EventCallback<string> OnDeleteNamedLayout { get; set; }\n\n    // --- Server mode ---\n    [Parameter] public bool ServerMode { get; set; }\n    [Parameter] public EventCallback<DataGridServerRequest> OnServerRequest { get; set; }\n    [Parameter] public int TotalCount { get; set; }\n    [Parameter] public bool IsLoading { get; set; }\n\n    // --- Virtualised server mode (infinite-scroll, window-fetched) ---\n    /// <summary>\n    /// Enables <see cref=\"Microsoft.AspNetCore.Components.Web.Virtualization.Virtualize{TItem}\"/>\n    /// with an <c>ItemsProvider</c> — rows are fetched from the backend\n    /// in scroll-driven windows instead of paged. Requires\n    /// <see cref=\"OnRangeRequest\"/>. When active, pagination is hidden\n    /// automatically and the grid renders a single tall scrollable body.\n    /// </summary>\n    [Parameter] public bool Virtualized { get; set; }\n    /// <summary>\n    /// Range-fetch callback used in virtualised server mode. The grid\n    /// invokes this whenever the viewport scrolls into an unloaded\n    /// window. The callback must return the requested slice plus the\n    /// current total row count so the scroll spacers stay accurate.\n    /// </summary>\n    [Parameter] public Func<DataGridRangeRequest, ValueTask<DataGridRangeResponse<TItem>>>? OnRangeRequest { get; set; }\n\n    // --- Templates ---\n    [Parameter] public RenderFragment? ChildContent { get; set; }\n    [Parameter] public RenderFragment<TItem>? DetailTemplate { get; set; }\n    [Parameter] public RenderFragment? EmptyContent { get; set; }\n    [Parameter] public RenderFragment<TItem>? RowContextMenu { get; set; }\n\n    // --- Events ---\n    [Parameter] public EventCallback<TItem> OnRowClick { get; set; }\n    [Parameter] public EventCallback<TItem> OnRowDoubleClick { get; set; }\n    [Parameter] public EventCallback<CellEditEventArgs<TItem>> OnCellEdit { get; set; }\n    [Parameter] public EventCallback<RowEditEventArgs<TItem>> OnRowEdit { get; set; }\n\n    // --- Batch / buffered edit mode (rc.36) ---\n    /// <summary>\n    /// Fired when the user clicks \"Save all\" while <see cref=\"EditMode\"/> is\n    /// <see cref=\"DataGridEditMode.Batch\"/>. Args carry the buffered\n    /// modifications + added rows. On a clean callback return (no exception),\n    /// the grid's pending-changes buffer is cleared automatically.\n    /// </summary>\n    [Parameter] public EventCallback<DataGridBatchSaveEventArgs<TItem>> OnBatchSave { get; set; }\n\n    /// <summary>\n    /// When true and <see cref=\"NewItemFactory\"/> is set, renders a thin\n    /// \"+ Add row\" trigger below the body. Each click creates a TItem via\n    /// the factory, pushes it into the added-rows buffer, and immediately\n    /// shows it inline so cells can be filled in.\n    /// Only meaningful in <see cref=\"DataGridEditMode.Batch\"/>.\n    /// </summary>\n    [Parameter] public bool ShowAddRow { get; set; }\n\n    /// <summary>\n    /// Factory used by the \"+ Add row\" trigger to produce a fresh TItem.\n    /// Required when <see cref=\"ShowAddRow\"/> is true. The returned item\n    /// is stored in the added-rows buffer; the user fills its cells inline\n    /// and the row is delivered via <see cref=\"OnBatchSave\"/>.\n    /// </summary>\n    [Parameter] public Func<TItem>? NewItemFactory { get; set; }\n\n    /// <summary>\n    /// True when the batch-edit buffer carries at least one pending change\n    /// (a buffered cell edit or a new row). Bind via <c>@ref</c> for warn-\n    /// on-navigate workflows.\n    /// </summary>\n    public bool HasPendingChanges => _batchChanges.Count > 0 || _batchAdded.Count > 0;\n\n    /// <summary>Read-only view of currently-buffered modified rows.</summary>\n    public IReadOnlyCollection<TItem> PendingModifiedItems => _batchChanges.Keys;\n\n    /// <summary>Read-only view of currently-buffered new rows.</summary>\n    public IReadOnlyList<TItem> PendingAddedItems => _batchAdded.AsReadOnly();\n\n    // --- Column virtualization (rc.36) ---\n    /// <summary>\n    /// Simplified column virtualization: caps the number of horizontally-\n    /// rendered data columns at <see cref=\"MaxVisibleColumns\"/>. Pinned\n    /// columns always make the cut. Default false.\n    /// </summary>\n    [Parameter] public bool ColumnVirtualize { get; set; }\n\n    /// <summary>Maximum columns rendered when <see cref=\"ColumnVirtualize\"/> is true. Default 30.</summary>\n    [Parameter] public int MaxVisibleColumns { get; set; } = 30;\n\n    /// <summary>Label for the batch-mode \"Save all\" button. Override for localization.</summary>\n    [Parameter] public string? BatchSaveAllText { get; set; } = \"Save all\";\n    /// <summary>Label for the batch-mode \"Discard\" button. Override for localization.</summary>\n    [Parameter] public string? BatchDiscardText { get; set; } = \"Discard\";\n    /// <summary>Label for the batch-mode \"+ Add row\" trigger. Override for localization.</summary>\n    [Parameter] public string? BatchAddRowText { get; set; } = \"Add row\";\n    [Parameter] public EventCallback<ColumnReorderEventArgs> OnColumnReorder { get; set; }\n    [Parameter] public EventCallback<RowReorderEventArgs<TItem>> OnRowReorder { get; set; }\n    [Parameter] public EventCallback<Exception> OnError { get; set; }\n    [Parameter] public IReadOnlyList<TItem>? SelectedItems { get; set; }\n    [Parameter] public EventCallback<IReadOnlyList<TItem>> SelectedItemsChanged { get; set; }\n\n    // --- Standard ---\n    [Parameter] public string? Class { get; set; }\n    [Parameter(CaptureUnmatchedValues = true)] public Dictionary<string, object>? AdditionalAttributes { get; set; }\n\n    // --- Inject ---\n    [Inject] private ComponentInteropService Interop { get; set; } = default!;\n    [Inject] private Lumeo.Services.IDataGridExportService Export { get; set; } = default!;\n\n    // --- Culture ---\n    /// <summary>\n    /// CultureInfo used for cell value formatting (dates/numbers) and exports.\n    /// When null (default), falls back to <see cref=\"System.Globalization.CultureInfo.CurrentCulture\"/>.\n    /// Useful when your app sets a different culture than the UI locale — e.g. force\n    /// <c>de-DE</c> formatting in an English UI.\n    /// </summary>\n    [Parameter] public System.Globalization.CultureInfo? Culture { get; set; }\n\n    internal System.Globalization.CultureInfo EffectiveCulture =>\n        Culture ?? System.Globalization.CultureInfo.CurrentCulture;\n\n    // --- Internal state ---\n    private DataGridBody<TItem>? _bodyRef;\n    private bool _useServerVirtualization => Virtualized && OnRangeRequest is not null;\n\n    // --- Keyboard navigation / roving tabindex (rc.32) ---\n    private readonly string _gridId = $\"dg-{Guid.NewGuid():N}\";\n    // Roving-focus cell coordinates. _focusedRow == -1 → header row.\n    private int _focusedRow = -1;\n    private int _focusedCol = 0;\n    // Approximate \"page\" jump for PageUp/PageDown in rows.\n    private int KeyboardPageStep => Math.Max(1, Math.Min(PageSize, 10));\n\n    private string CellElementId(int row, int col) => $\"{_gridId}-cell-{(row < 0 ? \"h\" : row.ToString())}-{col}\";\n\n    /// <summary>Clamp the roving-focus coordinates to the current data extents.\n    /// Called after any data/sort/filter/page change so the stored cell never\n    /// dangles past the visible range.</summary>\n    private void ClampFocusedCell()\n    {\n        var colCount = _visibleColumns.Count;\n        if (colCount == 0) { _focusedCol = 0; _focusedRow = -1; return; }\n        if (_focusedCol < 0) _focusedCol = 0;\n        if (_focusedCol >= colCount) _focusedCol = colCount - 1;\n        var rowCount = _displayedItems.Count;\n        if (_focusedRow < -1) _focusedRow = -1;\n        if (_focusedRow >= rowCount) _focusedRow = rowCount - 1; // may land on -1 if no rows\n    }\n\n    private async Task MoveFocusAsync(int newRow, int newCol)\n    {\n        var colCount = _visibleColumns.Count;\n        if (colCount == 0) return;\n        newCol = Math.Clamp(newCol, 0, colCount - 1);\n        var rowCount = _displayedItems.Count;\n        // Allow -1 (header). Don't allow going past last body row.\n        if (newRow < -1) newRow = -1;\n        if (newRow > rowCount - 1) newRow = Math.Max(-1, rowCount - 1);\n        if (newRow == _focusedRow && newCol == _focusedCol)\n        {\n            // Still re-focus the DOM element (e.g. Home pressed twice).\n            await FocusCellAsync(newRow, newCol);\n            return;\n        }\n        _focusedRow = newRow;\n        _focusedCol = newCol;\n        StateHasChanged();\n        await FocusCellAsync(newRow, newCol);\n    }\n\n    private async Task FocusCellAsync(int row, int col)\n    {\n        try { await Interop.FocusElement(CellElementId(row, col)); }\n        catch (Microsoft.JSInterop.JSDisconnectedException) { /* circuit gone */ }\n        catch { /* element may not be rendered (virtualized / grouped) — acceptable */ }\n    }\n\n    /// <summary>Keydown handler invoked from a body <see cref=\"DataGridCell{TItem}\"/>\n    /// whose <c>&lt;td&gt;</c> currently has focus.</summary>\n    internal async Task HandleCellKeyDownAsync(int row, int col, KeyboardEventArgs e)\n    {\n        // Keep our notion of the focused cell in sync with where the event came from.\n        _focusedRow = row;\n        _focusedCol = col;\n        var colCount = _visibleColumns.Count;\n        var rowCount = _displayedItems.Count;\n        if (colCount == 0) return;\n\n        switch (e.Key)\n        {\n            case \"ArrowDown\":\n                await MoveFocusAsync(row + 1, col);\n                break;\n            case \"ArrowUp\":\n                // From the first body row, jump up into the header row.\n                await MoveFocusAsync(row <= 0 ? -1 : row - 1, col);\n                break;\n            case \"ArrowRight\":\n                await MoveFocusAsync(row, col + 1);\n                break;\n            case \"ArrowLeft\":\n                await MoveFocusAsync(row, col - 1);\n                break;\n            case \"Home\":\n                if (e.CtrlKey) await MoveFocusAsync(0, 0);\n                else await MoveFocusAsync(row, 0);\n                break;\n            case \"End\":\n                if (e.CtrlKey) await MoveFocusAsync(rowCount - 1, colCount - 1);\n                else await MoveFocusAsync(row, colCount - 1);\n                break;\n            case \"PageDown\":\n                await MoveFocusAsync(row + KeyboardPageStep, col);\n                break;\n            case \"PageUp\":\n                await MoveFocusAsync(Math.Max(0, row - KeyboardPageStep), col);\n                break;\n            case \" \":\n            case \"Spacebar\":\n                if (SelectionMode != DataGridSelectionMode.None && row >= 0 && row < rowCount)\n                {\n                    ToggleSelection(_displayedItems[row]);\n                }\n                break;\n            case \"a\":\n            case \"A\":\n                if (e.CtrlKey && SelectionMode == DataGridSelectionMode.Multiple)\n                {\n                    SelectAll();\n                }\n                break;\n            // Enter / F2 (enter edit mode) are handled inside DataGridCell itself\n            // because that's where the per-cell edit state lives.\n        }\n    }\n\n    /// <summary>Keydown handler invoked from a header <see cref=\"DataGridHeaderCell{TItem}\"/>.</summary>\n    internal async Task HandleHeaderKeyDownAsync(int col, KeyboardEventArgs e)\n    {\n        _focusedRow = -1;\n        _focusedCol = col;\n        var colCount = _visibleColumns.Count;\n        if (colCount == 0) return;\n\n        switch (e.Key)\n        {\n            case \"ArrowRight\":\n                await MoveFocusAsync(-1, col + 1);\n                break;\n            case \"ArrowLeft\":\n                await MoveFocusAsync(-1, col - 1);\n                break;\n            case \"ArrowDown\":\n                await MoveFocusAsync(0, col);\n                break;\n            case \"Home\":\n                await MoveFocusAsync(-1, 0);\n                break;\n            case \"End\":\n                await MoveFocusAsync(-1, colCount - 1);\n                break;\n            // Enter / Space → trigger sort is handled inside DataGridHeaderCell.\n        }\n    }\n\n    private async ValueTask<Microsoft.AspNetCore.Components.Web.Virtualization.ItemsProviderResult<DataGridBody<TItem>.IndexedItem>>\n        ServerVirtualizationProviderImpl(Microsoft.AspNetCore.Components.Web.Virtualization.ItemsProviderRequest req)\n    {\n        if (OnRangeRequest is null)\n        {\n            return new Microsoft.AspNetCore.Components.Web.Virtualization.ItemsProviderResult<DataGridBody<TItem>.IndexedItem>(\n                Array.Empty<DataGridBody<TItem>.IndexedItem>(), 0);\n        }\n        var rangeRequest = new DataGridRangeRequest(\n            req.StartIndex,\n            req.Count,\n            _sorts.Count > 0 ? new List<SortDescriptor>(_sorts) : null,\n            _filters.Count > 0 ? new List<FilterDescriptor>(_filters) : null,\n            _globalSearch,\n            req.CancellationToken);\n        var resp = await OnRangeRequest(rangeRequest);\n        var indexed = resp.Items\n            .Select((it, i) => new DataGridBody<TItem>.IndexedItem(it, req.StartIndex + i))\n            .ToList();\n        TotalCount = resp.TotalCount;\n        return new Microsoft.AspNetCore.Components.Web.Virtualization.ItemsProviderResult<DataGridBody<TItem>.IndexedItem>(\n            indexed, resp.TotalCount);\n    }\n\n    /// <summary>\n    /// Forces the virtualised server view to discard its window cache and\n    /// refetch from the current scroll position. Called automatically when\n    /// sort/filter/search change; expose as <c>@ref</c> if you need to\n    /// trigger it manually (e.g. after an external data mutation).\n    /// </summary>\n    public Task RefreshVirtualizedAsync() =>\n        _bodyRef is not null ? _bodyRef.RefreshVirtualizeAsync() : Task.CompletedTask;\n\n    private List<DataGridColumn<TItem>> _columnDefs = new();\n    private List<DataGridColumn<TItem>> _orderedColumns = new();\n    // Cached visible-columns projection — recomputed only when columns change or\n    // visibility is toggled, not on every Blazor render cycle.\n    private System.Collections.ObjectModel.ReadOnlyCollection<DataGridColumn<TItem>> _visibleColumns =\n        new List<DataGridColumn<TItem>>().AsReadOnly();\n\n    private void RefreshVisibleColumns()\n    {\n        IEnumerable<DataGridColumn<TItem>> q = _orderedColumns.Where(c => c.Visible);\n        // Simplified column virtualization (rc.36): cap the rendered-column\n        // count to keep wide grids cheap. Pinned columns always make the cut;\n        // unpinned columns are taken in order until MaxVisibleColumns is hit.\n        if (ColumnVirtualize && MaxVisibleColumns > 0)\n        {\n            var all = q.ToList();\n            if (all.Count > MaxVisibleColumns)\n            {\n                var pinned = all.Where(c => c.Pin != PinDirection.None).ToList();\n                var unpinned = all.Where(c => c.Pin == PinDirection.None).ToList();\n                var roomForUnpinned = Math.Max(0, MaxVisibleColumns - pinned.Count);\n                q = pinned.Concat(unpinned.Take(roomForUnpinned));\n            }\n            else\n            {\n                q = all;\n            }\n        }\n        _visibleColumns = q.ToList().AsReadOnly();\n    }\n    private List<TItem> _processedItems = new();\n    private List<TItem> _displayedItems = new();\n    private List<TItem> _selectedItems = new();\n    private HashSet<TItem> _selectedSet = new();\n    private HashSet<TItem> _expandedRows = new();\n    private HashSet<TItem> _editingRows = new();\n    // CS8714: Dictionary<TKey,_> wants `TKey : notnull`, which TItem isn't. The grid\n    // only ever uses non-null items as keys; disable from here on so the tree-grid /\n    // batch-edit dictionaries further down don't each need their own pragma.\n#pragma warning disable CS8714\n    private Dictionary<TItem, Dictionary<string, object?>> _rowEditValues = new();\n\n    // --- Batch / buffered edit mode (rc.36) ---\n    // Per-row pending field changes. Outer key = the row instance; inner dict\n    // maps the column's Field to the buffered value. When EditMode == Batch,\n    // cell commits write here instead of mutating the row; \"Save all\" hands\n    // these to the consumer via OnBatchSave.\n    private Dictionary<TItem, Dictionary<string, object?>> _batchChanges = new();\n    // Rows created via the \"+ Add row\" trigger. Each entry is also represented\n    // in _batchChanges (so its dirty fields render the same way). We split\n    // Added vs Modified at save time using this list.\n    private List<TItem> _batchAdded = new();\n    private List<SortDescriptor> _sorts = new();\n    private List<FilterDescriptor> _filters = new();\n    private string? _globalSearch;\n    private int _currentPage = 1;\n    private string? _activeFilterColumn;\n    private bool _contextMenuVisible;\n    private double _contextMenuX;\n    private double _contextMenuY;\n    private TItem? _contextMenuItem;\n    private string _contextMenuId = $\"dg-ctx-{Guid.NewGuid():N}\";\n    private bool _isExpanded;\n    private bool _lastIsExpandedParam;\n    private bool _fullscreenNeedsFocus;\n    private ElementReference _fullscreenRef;\n    private List<DataGridGroupSection<TItem>> _groupedSections = new();\n    private HashSet<string> _expandedGroups = new();\n    private HashSet<string> _knownGroupKeys = new();\n    private string? _lastGroupBy;\n    private bool? _lastGroupsExpandedByDefault;\n\n    // --- Multi-level grouping + group panel (rc.35) ---\n    // Effective grouping levels. Seeded from GroupByFields ?? [GroupBy] but mutable\n    // at runtime via the group panel chips / add-dropdown.\n    private List<string> _runtimeGroupFields = new();\n    // Tracks the (GroupBy, GroupByFields) parameter values last applied so an\n    // external parameter change re-seeds _runtimeGroupFields, but runtime panel\n    // edits are not stomped on the next render.\n    private string? _seededGroupBy;\n    private string? _seededGroupByFieldsKey;\n    private List<DataGridGroupNode<TItem>> _groupedTree = new();\n    private HashSet<string> _expandedGroupPaths = new();\n    private HashSet<string> _knownGroupPaths = new();\n\n    // --- Tree-grid mode (rc.35) ---\n    private HashSet<TItem> _expandedTreeNodes = new();\n    private bool _treeExpandSeeded;\n    // Per-render caches rebuilt by RebuildTreeView().\n    private List<TItem> _treeDisplayRows = new();\n    private Dictionary<TItem, int> _treeLevelOf = new();\n    private Dictionary<TItem, bool> _treeHasChildren = new();\n    private string _layoutId = $\"datagrid-{Guid.NewGuid():N}\";\n    private DataGridLayout? _defaultLayout; // snapshot of original column state for reset\n    private bool _savedLayoutApplied; // tracks whether the SavedLayout parameter has been applied\n    private DataGridLayoutService? _layoutService;\n    private DataGridServerService? _serverService;\n    private DataGridToolbarContext<TItem> _toolbarContext = new();\n\n    /// <summary>\n    /// Stable per-instance id used to scope drag state. Allocated once per\n    /// grid instance and cascaded via <see cref=\"DataGridDragState\"/> so\n    /// header cells / rows can reject drops that originated in a different\n    /// grid instance on the same page.\n    /// </summary>\n    private readonly string _gridInstanceId = Guid.NewGuid().ToString(\"N\");\n    private readonly DataGridDragState _dragState;\n\n    public DataGrid()\n    {\n        _dragState = new DataGridDragState(_gridInstanceId);\n    }\n\n    // --- ToolbarContent component registration ---\n\n    /// <summary>\n    /// The <see cref=\"ToolbarContent{TItem}\"/> component currently registered inside\n    /// this grid (at most one). Forwarded to <see cref=\"DataGridToolbar\"/> so it can\n    /// render the custom ChildContent + Class in the toolbar's left-hand slot.\n    /// </summary>\n    internal ToolbarContent<TItem>? RegisteredToolbarContent { get; private set; }\n\n    internal void RegisterToolbarContent(ToolbarContent<TItem> tc)\n    {\n        // Re-registration from the child's OnParametersSet happens on every render;\n        // only flag a re-render when the instance actually changed, otherwise we'd\n        // loop (StateHasChanged → child re-renders → re-registers → StateHasChanged).\n        // Class/ChildContent updates on the SAME instance are already read by the\n        // next natural parent render (we dereference them at render time).\n        if (!ReferenceEquals(RegisteredToolbarContent, tc))\n        {\n            RegisteredToolbarContent = tc;\n            InvokeAsync(StateHasChanged);\n        }\n    }\n\n    internal void UnregisterToolbarContent(ToolbarContent<TItem> tc)\n    {\n        if (RegisteredToolbarContent == tc)\n        {\n            RegisteredToolbarContent = null;\n            InvokeAsync(StateHasChanged);\n        }\n    }\n\n    private List<DataGridColumn<TItem>> SourceColumns =>\n        Columns is not null && Columns.Count > 0 ? Columns : _columnDefs;\n\n    private List<DataGridColumn<TItem>> EffectiveColumns =>\n        _orderedColumns.Count > 0 ? _orderedColumns : SourceColumns;\n\n    private int EffectiveTotalCount => ServerMode ? TotalCount : _processedItems.Count;\n\n    // Row virtualization is incompatible with the recursive group-tree / tree-grid\n    // renderers (they need every group/parent row present to compute indentation\n    // and expand state), so it's disabled when those modes are active.\n    private bool UseVirtualization =>\n        _displayedItems.Count > VirtualizeThreshold\n        && !IsTreeGridMode\n        && EffectiveGroupFields.Count <= 1;\n\n    private string? HeightStyle => Height is not null ? $\"height: {Height};\" : null;\n\n    private string RootCssClass\n    {\n        get\n        {\n            return string.Join(\" \", new[]\n            {\n                \"w-full overflow-auto border border-border/40 rounded-lg bg-card text-card-foreground flex flex-col\",\n                Compact ? \"text-sm\" : null,\n                Bordered ? \"[&_td]:border [&_td]:border-border/40 [&_th]:border [&_th]:border-border/40\" : null,\n                Striped ? \"[&_tbody>tr:nth-child(even)]:bg-muted/30\" : null,\n                Class\n            }.Where(s => !string.IsNullOrWhiteSpace(s)));\n        }\n    }\n\n    /// <summary>\n    /// Registers a column definition with the grid. Idempotent: if a column with\n    /// the same stable key (Field when available, else Id) is already registered\n    /// the existing entry is replaced in-place. This matters because Blazor can\n    /// re-initialize <see cref=\"DataGridColumnDef{TItem}\"/> children on every\n    /// parent render when the ChildContent is captured inside a RenderFragment\n    /// local — without this dedup the column list would accumulate duplicates\n    /// (causing the filter popover to open N times, the \"Toggle Columns\" menu\n    /// to list every column N times, etc.).\n    /// </summary>\n    public void AddColumnDef(DataGridColumn<TItem> column)\n    {\n        var key = ColumnKey(column);\n        var existingIndex = _columnDefs.FindIndex(c => ColumnKey(c) == key);\n        if (existingIndex >= 0)\n        {\n            // Preserve user-mutable state on the existing column object so a\n            // re-init from the razor child doesn't blow away width/visibility/pin.\n            var existing = _columnDefs[existingIndex];\n            existing.Title = column.Title;\n            existing.Field = column.Field;\n            existing.FieldSelector = column.FieldSelector;\n            existing.Sortable = column.Sortable;\n            existing.Filterable = column.Filterable;\n            existing.Resizable = column.Resizable;\n            existing.FilterType = column.FilterType;\n            existing.Aggregate = column.Aggregate;\n            existing.Format = column.Format;\n            existing.CellTemplate = column.CellTemplate;\n            existing.HeaderTemplate = column.HeaderTemplate;\n            existing.EditTemplate = column.EditTemplate;\n            existing.FilterOptions = column.FilterOptions;\n            existing.Operators = column.Operators;\n            existing.FilterTemplate = column.FilterTemplate;\n            existing.MinWidth = column.MinWidth;\n            existing.MaxWidth = column.MaxWidth;\n            existing.Pinnable = column.Pinnable;\n            existing.Groupable = column.Groupable;\n            existing.Reorderable = column.Reorderable;\n            existing.CellClass = column.CellClass;\n            existing.HeaderCssClass = column.HeaderCssClass;\n            existing.CustomSort = column.CustomSort;\n            existing.CssClass = column.CssClass;\n            // Only apply width/pin from the redeclared column if the user has\n            // not customised them at runtime (e.g. via resize/toggle).\n            if (!existing.Visible && column.Visible) { /* keep user hidden */ }\n            return;\n        }\n\n        _columnDefs.Add(column);\n        SyncOrderedColumns();\n        // DataGridColumnDef children register themselves from their own\n        // OnInitialized, which runs AFTER the parent has already built its\n        // render tree with an empty column list. Without this nudge the\n        // header + body render blank until some other state change retriggers\n        // a render. InvokeAsync defers the re-render to the next dispatcher\n        // cycle so we don't recurse during the child's init.\n        _ = InvokeAsync(StateHasChanged);\n    }\n\n    /// <summary>\n    /// Removes a previously-registered column definition (called when a\n    /// <see cref=\"DataGridColumnDef{TItem}\"/> child is disposed).\n    /// </summary>\n    public void RemoveColumnDef(DataGridColumn<TItem> column)\n    {\n        var key = ColumnKey(column);\n        var removed = _columnDefs.RemoveAll(c => ColumnKey(c) == key) > 0;\n        if (removed)\n        {\n            _orderedColumns.RemoveAll(c => ColumnKey(c) == key);\n            RefreshVisibleColumns();\n            _ = InvokeAsync(StateHasChanged);\n        }\n    }\n\n    /// <summary>Stable identity for a column: prefer Field (user-declared), fallback to Id.</summary>\n    private static string ColumnKey(DataGridColumn<TItem> column) =>\n        !string.IsNullOrWhiteSpace(column.Field) ? column.Field! : column.Id;\n\n    public void UpdateColumnWidth(string columnId, double width)\n    {\n        var column = EffectiveColumns.FirstOrDefault(c => c.Id == columnId);\n        if (column is not null)\n        {\n            column.Width = Math.Max(column.MinWidth ?? 50, width);\n            if (column.MaxWidth.HasValue)\n                column.Width = Math.Min(column.MaxWidth.Value, column.Width.Value);\n            ScheduleAutoSave();\n            StateHasChanged();\n        }\n    }\n\n    private void SyncOrderedColumns()\n    {\n        var source = SourceColumns;\n\n        // If ordered columns has different column set (by ID), rebuild from source\n        // Preserve user-reordered order for existing columns\n        var sourceIds = source.Select(c => c.Id).ToHashSet();\n        var orderedIds = _orderedColumns.Select(c => c.Id).ToHashSet();\n\n        if (sourceIds.SetEquals(orderedIds)) return; // Same columns, keep user order\n\n        // Columns changed — rebuild keeping existing order for known columns, append new ones\n        var newOrder = _orderedColumns.Where(c => sourceIds.Contains(c.Id)).ToList();\n        var added = source.Where(c => !orderedIds.Contains(c.Id)).ToList();\n        newOrder.AddRange(added);\n        _orderedColumns = newOrder;\n        RefreshVisibleColumns();\n    }\n\n    protected override async Task OnParametersSetAsync()\n    {\n        // Sync IsExpanded parameter with internal state (allow external binding to drive it)\n        if (IsExpanded != _lastIsExpandedParam)\n        {\n            _lastIsExpandedParam = IsExpanded;\n            if (_isExpanded != IsExpanded)\n            {\n                _isExpanded = IsExpanded;\n                if (_isExpanded)\n                {\n                    _fullscreenNeedsFocus = true;\n                    try { await Interop.LockScroll(); } catch { /* best-effort */ }\n                }\n                else\n                {\n                    try { await Interop.UnlockScroll(); } catch { /* best-effort */ }\n                }\n            }\n        }\n\n        SyncOrderedColumns();\n        // Keep the cached visible-columns list in sync whenever parameters are evaluated.\n        // SyncOrderedColumns only calls RefreshVisibleColumns when the column-ID set changes;\n        // calling it here covers the first render when Columns are provided via the Columns\n        // parameter rather than via declarative ColumnDef children.\n        RefreshVisibleColumns();\n\n        // Re-seed runtime grouping levels from GroupBy / GroupByFields if those params changed.\n        SyncGroupFieldsFromParameters();\n\n        // Snapshot default layout once (before any external SavedLayout is applied)\n        if (_defaultLayout is null && EffectiveColumns.Count > 0)\n        {\n            _defaultLayout = SnapshotCurrentLayout();\n        }\n\n        // Apply SavedLayout when provided externally and not yet applied\n        if (SavedLayout is not null && !_savedLayoutApplied)\n        {\n            _savedLayoutApplied = true;\n            await ApplyLayoutAsync(SavedLayout);\n            return; // ApplyLayoutAsync calls ProcessClientData\n        }\n\n        if (ServerMode)\n        {\n            _displayedItems = Items?.ToList() ?? new List<TItem>();\n\n            // Apply client-side grouping on server-returned items when GroupBy is set\n            if (!string.IsNullOrEmpty(GroupBy) && _displayedItems.Count > 0)\n            {\n                var groupCol = EffectiveColumns.FirstOrDefault(c => c.Field == GroupBy);\n                if (groupCol is not null)\n                {\n                    var serverGroupByChanged = GroupBy != _lastGroupBy;\n                    _lastGroupBy = GroupBy;\n\n                    _groupedSections = _displayedItems\n                        .GroupBy(item => groupCol.GetFormattedValue(item))\n                        .OrderBy(g => g.Key)\n                        .Select(g => new DataGridGroupSection<TItem>(g.Key, g.ToList(), g.Count()))\n                        .ToList();\n\n                    var serverDefaultChanged = _lastGroupsExpandedByDefault is not null && _lastGroupsExpandedByDefault != GroupsExpandedByDefault;\n                    _lastGroupsExpandedByDefault = GroupsExpandedByDefault;\n\n                    if (serverGroupByChanged || serverDefaultChanged)\n                    {\n                        _expandedGroups.Clear();\n                        _knownGroupKeys.Clear();\n                        foreach (var section in _groupedSections)\n                        {\n                            _knownGroupKeys.Add(section.Key);\n                            if (GroupsExpandedByDefault)\n                                _expandedGroups.Add(section.Key);\n                        }\n                    }\n                    else\n                    {\n                        var validKeys = _groupedSections.Select(s => s.Key).ToHashSet();\n                        _expandedGroups.IntersectWith(validKeys);\n                        _knownGroupKeys.IntersectWith(validKeys);\n                        foreach (var section in _groupedSections)\n                        {\n                            if (_knownGroupKeys.Contains(section.Key)) continue;\n                            _knownGroupKeys.Add(section.Key);\n                            if (GroupsExpandedByDefault)\n                                _expandedGroups.Add(section.Key);\n                        }\n                    }\n                }\n            }\n            else\n            {\n                _lastGroupBy = null;\n            }\n        }\n        else\n        {\n            ProcessClientData();\n        }\n\n        if (SelectedItems is not null)\n        {\n            _selectedItems = SelectedItems.ToList();\n            _selectedSet = new HashSet<TItem>(_selectedItems);\n        }\n\n        PopulateToolbarContext();\n    }\n\n    /// <summary>\n    /// Rebuilds the cascading <see cref=\"DataGridToolbarContext{TItem}\"/> used by the\n    /// extracted toolbar tool components (Fullscreen, CopySelected, Columns, Export,\n    /// Layouts) as well as any custom tools placed inside <see cref=\"ToolbarContent{TItem}\"/>.\n    /// Called from OnParametersSetAsync so dependent values reflect the latest parameters.\n    /// </summary>\n    private void PopulateToolbarContext()\n    {\n        _toolbarContext.IsExpanded = _isExpanded;\n        _toolbarContext.Expandable = Expandable;\n        _toolbarContext.OnToggleExpanded = EventCallback.Factory.Create(this, ToggleExpanded);\n\n        _toolbarContext.SelectedItems = _selectedItems;\n        _toolbarContext.EffectiveColumns = EffectiveColumns.AsReadOnly();\n        _toolbarContext.OnColumnToggle = EventCallback.Factory.Create<DataGridColumn<TItem>>(this, HandleColumnVisibilityChanged);\n        _toolbarContext.OnColumnReorder = EventCallback.Factory.Create<ColumnReorderEventArgs>(this, HandleColumnReorder);\n\n        _toolbarContext.ExportFormats = ExportFormats;\n        _toolbarContext.OnExport = EventCallback.Factory.Create<string>(this, HandleExport);\n\n        _toolbarContext.EnableLayoutPersistence = EnableLayoutPersistence;\n        _toolbarContext.GlobalLayouts = GlobalLayouts;\n        _toolbarContext.OnSaveLayout = EventCallback.Factory.Create(this, HandleSaveLayout);\n        _toolbarContext.OnResetLayout = EventCallback.Factory.Create(this, HandleResetLayout);\n        _toolbarContext.OnSaveNamedLayout = EventCallback.Factory.Create<DataGridNamedLayout>(this, HandleSaveNamedLayout);\n        _toolbarContext.OnDeleteNamedLayout = OnDeleteNamedLayout;\n        _toolbarContext.OnApplyNamedLayout = EventCallback.Factory.Create<DataGridNamedLayout>(this, HandleApplyNamedLayout);\n        _toolbarContext.LayoutStorageKey = LayoutStorageKey;\n        _toolbarContext.OnError = OnError;\n\n        _toolbarContext.Interop = Interop;\n        _toolbarContext.Localizer = L;\n    }\n\n    protected override async Task OnAfterRenderAsync(bool firstRender)\n    {\n        if (firstRender)\n        {\n            _layoutService ??= new DataGridLayoutService(Interop, ex =>\n            {\n                if (OnError.HasDelegate)\n                    InvokeAsync(() => OnError.InvokeAsync(ex));\n            });\n            _serverService ??= new DataGridServerService();\n\n            // Snapshot default layout now that columns are registered (via ColumnDef)\n            if (_defaultLayout is null && EffectiveColumns.Count > 0)\n            {\n                _defaultLayout = SnapshotCurrentLayout();\n            }\n\n            if (EnableLayoutPersistence && !_layoutService.LayoutLoaded)\n            {\n                var key = LayoutStorageKey ?? _layoutId;\n                await _layoutService.LoadPersistedLayoutAsync(key, ApplyLayoutAsync);\n            }\n        }\n\n        if (_isExpanded && _fullscreenNeedsFocus)\n        {\n            _fullscreenNeedsFocus = false;\n            try { await _fullscreenRef.FocusAsync(); } catch { /* element may not be ready */ }\n        }\n    }\n\n    /// <summary>\n    /// Toggles fullscreen expand mode for the grid. No-op when Expandable is false.\n    /// Preserves grid state (selection, filters, sorts, pagination) across the transition\n    /// because the underlying grid markup is rendered as the same render-tree sub-tree.\n    /// </summary>\n    public async Task ToggleExpanded()\n    {\n        _isExpanded = !_isExpanded;\n        _lastIsExpandedParam = _isExpanded;\n        IsExpanded = _isExpanded;\n\n        if (_isExpanded)\n        {\n            _fullscreenNeedsFocus = true;\n            try { await Interop.LockScroll(); } catch { /* best-effort */ }\n            try { await Interop.SetHtmlClass(\"lumeo-fullscreen-active\", true); } catch { /* best-effort */ }\n        }\n        else\n        {\n            try { await Interop.UnlockScroll(); } catch { /* best-effort */ }\n            try { await Interop.SetHtmlClass(\"lumeo-fullscreen-active\", false); } catch { /* best-effort */ }\n        }\n\n        if (IsExpandedChanged.HasDelegate)\n            await IsExpandedChanged.InvokeAsync(_isExpanded);\n\n        // Rebuild the cascading toolbar context so DataGridToolbarFullscreen sees the updated\n        // IsExpanded and swaps its Maximize2/Minimize2 icon. Without this, the context was\n        // only refreshed on parameter changes and the fullscreen icon stayed stale.\n        PopulateToolbarContext();\n        StateHasChanged();\n    }\n\n    private async Task HandleFullscreenKeyDown(KeyboardEventArgs e)\n    {\n        if (e.Key == \"Escape\" && _isExpanded)\n        {\n            await ToggleExpanded();\n        }\n    }\n\n    protected override async Task OnInitializedAsync()\n    {\n        _layoutService ??= new DataGridLayoutService(Interop, ex =>\n        {\n            if (OnError.HasDelegate)\n                InvokeAsync(() => OnError.InvokeAsync(ex));\n        });\n        _serverService ??= new DataGridServerService();\n\n        if (ServerMode)\n        {\n            await RequestServerData();\n        }\n    }\n\n    private void ProcessClientData()\n    {\n        if (Items is null)\n        {\n            _processedItems = new List<TItem>();\n            _displayedItems = new List<TItem>();\n            return;\n        }\n\n        // Clean stale selections that reference items no longer in the data source\n        if (_selectedItems.Count > 0)\n        {\n            var currentItems = new HashSet<TItem>(Items);\n            var staleCount = _selectedItems.RemoveAll(item => !currentItems.Contains(item));\n            if (staleCount > 0)\n            {\n                _selectedSet = new HashSet<TItem>(_selectedItems);\n                NotifySelectionChanged();\n            }\n        }\n\n        var query = Items.AsEnumerable();\n\n        // Global search\n        if (!string.IsNullOrWhiteSpace(_globalSearch))\n        {\n            var search = _globalSearch;\n            query = query.Where(item =>\n                EffectiveColumns.Any(col =>\n                {\n                    var value = col.GetFormattedValue(item);\n                    return value.Contains(search, StringComparison.OrdinalIgnoreCase);\n                }));\n        }\n\n        // Column filters\n        foreach (var filter in _filters)\n        {\n            var column = EffectiveColumns.FirstOrDefault(c => c.Field == filter.Field);\n            if (column is null) continue;\n\n            query = query.Where(item =>\n            {\n                var value = column.GetValue(item);\n                return DataGridFilterOperator.Evaluate(filter, value);\n            });\n        }\n\n        // Sort\n        IOrderedEnumerable<TItem>? ordered = null;\n        foreach (var sort in _sorts.Where(s => s.Direction != SortDirection.None))\n        {\n            var column = EffectiveColumns.FirstOrDefault(c => c.Field == sort.Field);\n            if (column is null) continue;\n\n            if (ordered is null)\n            {\n                if (column.CustomSort is not null)\n                {\n                    var comp = column.CustomSort;\n                    ordered = sort.Direction == SortDirection.Ascending\n                        ? query.OrderBy(item => column.GetValue(item), Comparer<object?>.Create(comp))\n                        : query.OrderByDescending(item => column.GetValue(item), Comparer<object?>.Create(comp));\n                }\n                else\n                {\n                    ordered = sort.Direction == SortDirection.Ascending\n                        ? query.OrderBy(item => column.GetValue(item) as IComparable)\n                        : query.OrderByDescending(item => column.GetValue(item) as IComparable);\n                }\n            }\n            else\n            {\n                if (column.CustomSort is not null)\n                {\n                    va\n… [truncated]"
    },
    {
      "path": "UI/DataGrid/DataGridBody.razor",
      "content": "@namespace Lumeo\n@typeparam TItem\n@inject Lumeo.Services.Localization.ILumeoLocalizer L\n\n@if (Context is not null && Context.IsLoading)\n{\n    <tbody data-slot=\"datagrid-body\" class=\"@CssClass\" @attributes=\"AdditionalAttributes\">\n        @{\n            // Context.VisibleColumns is already filtered + cached at the grid level.\n            var visibleColumns = Context.VisibleColumns;\n            var fallbackCellCount = visibleColumns.Count > 0 ? visibleColumns.Count : (SkeletonRowCount > 0 ? 4 : 4);\n        }\n        @for (var i = 0; i < SkeletonRowCount; i++)\n        {\n            <tr class=\"border-b border-border/40\">\n                @if (Context.SelectionMode != DataGridSelectionMode.None)\n                {\n                    <td class=\"px-3 py-2 w-10\">\n                        <Skeleton Class=\"h-4 w-4 rounded\" />\n                    </td>\n                }\n                @if (HasDetailTemplate)\n                {\n                    <td class=\"px-1 py-2 w-8\">\n                        <Skeleton Class=\"h-4 w-4 rounded\" />\n                    </td>\n                }\n                @if (visibleColumns.Count > 0)\n                {\n                    @foreach (var column in visibleColumns)\n                    {\n                        <td class=\"px-3 py-2\">\n                            <Skeleton Class=\"h-4 w-full rounded\" />\n                        </td>\n                    }\n                }\n                else\n                {\n                    @for (var j = 0; j < fallbackCellCount; j++)\n                    {\n                        <td class=\"px-3 py-2\">\n                            <Skeleton Class=\"h-4 w-full rounded\" />\n                        </td>\n                    }\n                }\n            </tr>\n        }\n    </tbody>\n}\nelse if (Context is not null && Context.IsTreeGrid)\n{\n    <tbody data-slot=\"datagrid-body\" class=\"@CssClass\" @attributes=\"AdditionalAttributes\">\n        @if (Context.DisplayedItems.Count == 0)\n        {\n            <tr>\n                <td colspan=\"@ColSpan\" class=\"px-3 py-8 text-center text-muted-foreground\">\n                    @if (EmptyContent is not null) { @EmptyContent }\n                    else\n                    {\n                        <div class=\"flex flex-col items-center gap-2\">\n                            <Blazicon Svg=\"Lucide.Inbox\" class=\"h-8 w-8 text-muted-foreground/50\" />\n                            <span class=\"text-sm\">@L[\"DataGrid.NoData\"]</span>\n                        </div>\n                    }\n                </td>\n            </tr>\n        }\n        else\n        {\n            var ti = 0;\n            foreach (var item in Context.DisplayedItems)\n            {\n                <DataGridRow @key=\"item\" TItem=\"TItem\"\n                             Item=\"item\"\n                             RowIndex=\"ti\"\n                             DetailTemplate=\"DetailTemplate\"\n                             OnContextMenu=\"OnContextMenu\" />\n                ti++;\n            }\n        }\n    </tbody>\n}\nelse if (Context is not null && Context.GroupTree is not null && Context.GroupTree.Count > 0)\n{\n    // Multi-level grouping: render a nested forest of group rows.\n    RenderFragment<DataGridGroupNode<TItem>>? renderNode = null;\n    renderNode = node => __builder =>\n    {\n        var expanded = Context.IsGroupPathExpanded(node.Path);\n        <DataGridGroupRow @key=\"@(\"g:\" + node.Path)\" TItem=\"TItem\"\n                          GroupKey=\"@node.Key\"\n                          Count=\"@node.TotalCount\"\n                          Level=\"@node.Level\"\n                          IsExpanded=\"@expanded\"\n                          Aggregates=\"@node.Aggregates\"\n                          ColSpan=\"@ColSpan\"\n                          OnToggle=\"@(() => Context.ToggleGroupPath(node.Path))\" />\n        if (expanded)\n        {\n            if (node.Children.Count > 0)\n            {\n                foreach (var child in node.Children)\n                {\n                    @renderNode!(child)\n                }\n            }\n            else\n            {\n                var li = 0;\n                foreach (var item in node.Items)\n                {\n                    <DataGridRow @key=\"item\" TItem=\"TItem\"\n                                 Item=\"item\"\n                                 RowIndex=\"li\"\n                                 DetailTemplate=\"DetailTemplate\"\n                                 OnContextMenu=\"OnContextMenu\" />\n                    li++;\n                }\n            }\n        }\n    };\n    <tbody data-slot=\"datagrid-body\" class=\"@CssClass\" @attributes=\"AdditionalAttributes\">\n        @foreach (var root in Context.GroupTree)\n        {\n            @renderNode(root)\n        }\n    </tbody>\n}\nelse if (Context is not null && Context.IsGrouped && Context.GroupedSections is not null && Context.GroupedSections.Count > 0)\n{\n    <tbody data-slot=\"datagrid-body\" class=\"@CssClass\" @attributes=\"AdditionalAttributes\">\n        @foreach (var grp in Context.GroupedSections)\n        {\n            var groupKey = grp.Key;\n            var expanded = Context.IsGroupExpanded(groupKey);\n            <DataGridGroupRow @key=\"groupKey\" TItem=\"TItem\"\n                              GroupKey=\"@groupKey\"\n                              Count=\"@grp.TotalCount\"\n                              IsExpanded=\"@expanded\"\n                              Aggregates=\"@grp.Aggregates\"\n                              ColSpan=\"@ColSpan\"\n                              OnToggle=\"@(() => Context.ToggleGroupExpand(groupKey))\" />\n            if (expanded)\n            {\n                var grpIndex = 0;\n                foreach (var item in grp.Items)\n                {\n                    <DataGridRow @key=\"item\" TItem=\"TItem\"\n                                 Item=\"item\"\n                                 RowIndex=\"grpIndex\"\n                                 DetailTemplate=\"DetailTemplate\"\n                                 OnContextMenu=\"OnContextMenu\" />\n                    grpIndex++;\n                }\n            }\n        }\n    </tbody>\n}\nelse if (Context is not null && Context.DisplayedItems.Count == 0)\n{\n    <tbody data-slot=\"datagrid-body\" class=\"@CssClass\" @attributes=\"AdditionalAttributes\">\n        <tr>\n            <td colspan=\"@ColSpan\" class=\"px-3 py-8 text-center text-muted-foreground\">\n                @if (EmptyContent is not null)\n                {\n                    @EmptyContent\n                }\n                else\n                {\n                    <div class=\"flex flex-col items-center gap-2\">\n                        <Blazicon Svg=\"Lucide.Inbox\" class=\"h-8 w-8 text-muted-foreground/50\" />\n                        <span class=\"text-sm\">@L[\"DataGrid.NoData\"]</span>\n                    </div>\n                }\n            </td>\n        </tr>\n    </tbody>\n}\nelse if (Context is not null && ServerVirtualizationProvider is not null)\n{\n    @* Virtualised server mode: rows are fetched in scroll-driven windows\n       via ItemsProvider, so the in-memory list is empty until the user\n       scrolls. SpacerElement=\"tr\" is non-negotiable inside <tbody>. *@\n    <tbody data-slot=\"datagrid-body\" class=\"@CssClass\" @attributes=\"AdditionalAttributes\">\n        <Virtualize @ref=\"_virtualize\" TItem=\"IndexedItem\"\n                    ItemsProvider=\"ServerVirtualizationProvider\"\n                    Context=\"entry\"\n                    SpacerElement=\"tr\"\n                    ItemSize=\"@VirtualItemSize\"\n                    OverscanCount=\"@VirtualOverscanCount\">\n            <ItemContent>\n                <DataGridRow TItem=\"TItem\"\n                             Item=\"entry.Item\"\n                             RowIndex=\"entry.Index\"\n                             DetailTemplate=\"DetailTemplate\"\n                             OnContextMenu=\"OnContextMenu\" />\n            </ItemContent>\n            <Placeholder>\n                <tr class=\"border-b border-border/40\">\n                    <td colspan=\"@ColSpan\" class=\"px-3 py-2\">\n                        <Skeleton Class=\"h-4 w-full rounded\" />\n                    </td>\n                </tr>\n            </Placeholder>\n        </Virtualize>\n    </tbody>\n}\nelse if (Context is not null && UseVirtualization)\n{\n    <tbody data-slot=\"datagrid-body\" class=\"@CssClass\" @attributes=\"AdditionalAttributes\">\n        @* SpacerElement=\"tr\" is required: <Virtualize> defaults to <div>\n           spacers, but <div> is invalid as a direct child of <tbody> and the\n           browser hoists it out of the table, breaking layout. *@\n        <Virtualize Items=\"@_indexedItems\" Context=\"entry\"\n                    SpacerElement=\"tr\"\n                    ItemSize=\"@VirtualItemSize\"\n                    OverscanCount=\"@VirtualOverscanCount\">\n            <DataGridRow TItem=\"TItem\"\n                         Item=\"entry.Item\"\n                         RowIndex=\"entry.Index\"\n                         DetailTemplate=\"DetailTemplate\"\n                         OnContextMenu=\"OnContextMenu\" />\n        </Virtualize>\n    </tbody>\n}\nelse if (Context is not null)\n{\n    <tbody data-slot=\"datagrid-body\" class=\"@CssClass\" @attributes=\"AdditionalAttributes\">\n        @if (Context.IsGrouped && Context.GroupedSections is not null)\n        {\n            @foreach (var grp in Context.GroupedSections)\n            {\n                var groupKey = grp.Key;\n                var expanded = Context.IsGroupExpanded(groupKey);\n                <DataGridGroupRow @key=\"groupKey\" TItem=\"TItem\"\n                                  GroupKey=\"@groupKey\"\n                                  Count=\"@grp.TotalCount\"\n                                  IsExpanded=\"@expanded\"\n                                  ColSpan=\"@ColSpan\"\n                                  OnToggle=\"@(() => Context.ToggleGroupExpand(groupKey))\" />\n                if (expanded)\n                {\n                    var grpIndex = 0;\n                    foreach (var item in grp.Items)\n                    {\n                        <DataGridRow @key=\"item\" TItem=\"TItem\"\n                                     Item=\"item\"\n                                     RowIndex=\"grpIndex\"\n                                     DetailTemplate=\"DetailTemplate\"\n                                     OnContextMenu=\"OnContextMenu\" />\n                        grpIndex++;\n                    }\n                }\n            }\n        }\n        else\n        {\n            var idx = 0;\n            foreach (var item in Context.DisplayedItems)\n            {\n                <DataGridRow @key=\"item\" TItem=\"TItem\"\n                             Item=\"item\"\n                             RowIndex=\"idx\"\n                             DetailTemplate=\"DetailTemplate\"\n                             OnContextMenu=\"OnContextMenu\" />\n                idx++;\n            }\n        }\n    </tbody>\n}\n\n@code {\n    [CascadingParameter] private DataGridContext<TItem>? Context { get; set; }\n    [Parameter] public RenderFragment<TItem>? DetailTemplate { get; set; }\n    [Parameter] public RenderFragment? EmptyContent { get; set; }\n    [Parameter] public bool UseVirtualization { get; set; }\n    [Parameter] public float VirtualItemSize { get; set; } = 41f;\n    [Parameter] public int VirtualOverscanCount { get; set; } = 3;\n    /// <summary>When set, switches the body to ItemsProvider-mode\n    /// virtualisation (window-fetched server scrolling). Null disables.</summary>\n    [Parameter] public Microsoft.AspNetCore.Components.Web.Virtualization.ItemsProviderDelegate<IndexedItem>? ServerVirtualizationProvider { get; set; }\n    [Parameter] public bool HasDetailTemplate { get; set; }\n    [Parameter] public int SkeletonRowCount { get; set; } = 5;\n    [Parameter] public EventCallback<(TItem, double, double)> OnContextMenu { get; set; }\n    [Parameter] public string? Class { get; set; }\n    [Parameter(CaptureUnmatchedValues = true)] public Dictionary<string, object>? AdditionalAttributes { get; set; }\n\n    private string CssClass => $\"{Class}\".Trim();\n\n    private List<IndexedItem> _indexedItems = new();\n    private Microsoft.AspNetCore.Components.Web.Virtualization.Virtualize<IndexedItem>? _virtualize;\n\n    /// <summary>Tells the inner <c>Virtualize</c> component to discard its\n    /// window cache and refetch via <see cref=\"ServerVirtualizationProvider\"/>.\n    /// No-op when not in ItemsProvider mode (no virtualizer in tree).</summary>\n    public Task RefreshVirtualizeAsync() =>\n        _virtualize is not null ? _virtualize.RefreshDataAsync() : Task.CompletedTask;\n\n    protected override void OnParametersSet()\n    {\n        if (UseVirtualization && Context is not null)\n        {\n            _indexedItems = Context.DisplayedItems\n                .Select((item, idx) => new IndexedItem(item, idx))\n                .ToList();\n        }\n    }\n\n    private int ColSpan\n    {\n        get\n        {\n            var count = Context?.VisibleColumns.Count ?? 1;\n            if (Context?.SelectionMode != DataGridSelectionMode.None) count++;\n            if (HasDetailTemplate) count++;\n            return count;\n        }\n    }\n\n    public record IndexedItem(TItem Item, int Index);\n}\n"
    },
    {
      "path": "UI/DataGrid/DataGridCell.razor",
      "content": "@namespace Lumeo\n@typeparam TItem\n\n<td data-slot=\"datagrid-cell\" class=\"@CssClass\" style=\"@PinStyle\"\n    id=\"@CellId\"\n    role=\"gridcell\"\n    aria-colindex=\"@AriaColIndex\"\n    aria-readonly=\"@AriaReadonly\"\n    tabindex=\"@TabIndexValue\"\n    @onclick=\"HandleClick\"\n    @ondblclick=\"HandleDoubleClick\"\n    @onkeydown=\"HandleCellKeyDown\"\n    @attributes=\"AdditionalAttributes\">\n    @if (_isEditing && Column.EditTemplate is not null)\n    {\n        @Column.EditTemplate(_editContext!)\n    }\n    else if (_isEditing)\n    {\n        <input class=\"w-full bg-transparent border-none outline-none text-sm\"\n               value=\"@_editValue\"\n               @oninput=\"OnEditInput\"\n               @onblur=\"CommitEdit\"\n               @onkeydown=\"OnEditKeyDown\" />\n    }\n    else if (Column.CellTemplate is not null)\n    {\n        @Column.CellTemplate(Item)\n    }\n    else if (IsBatchDirty)\n    {\n        @* Batch-edit: show the buffered value (overrides the row's current\n           value) so the user sees what will be saved. *@\n        @FormatBatchValue(Context!.GetBatchValue(Item, Column))\n    }\n    else\n    {\n        @Column.GetFormattedValue(Item, Context?.Culture ?? System.Globalization.CultureInfo.CurrentCulture)\n    }\n</td>\n\n@code {\n    [CascadingParameter] private DataGridContext<TItem>? Context { get; set; }\n    [Parameter] public TItem Item { get; set; } = default!;\n    [Parameter] public DataGridColumn<TItem> Column { get; set; } = default!;\n    /// <summary>0-based index of the owning row within DisplayedItems (non-grouped).</summary>\n    [Parameter] public int RowIndex { get; set; }\n    /// <summary>0-based index of this column within the visible columns.</summary>\n    [Parameter] public int ColumnIndex { get; set; }\n    /// <summary>1-based ARIA column index (includes any leading non-data columns).</summary>\n    [Parameter] public int AriaColIndex { get; set; }\n    /// <summary>Whether this cell participates in roving-tabindex keyboard navigation.\n    /// False in grouped mode where row indices are not globally unique.</summary>\n    [Parameter] public bool Navigable { get; set; } = true;\n    [Parameter] public string? Class { get; set; }\n    [Parameter(CaptureUnmatchedValues = true)] public Dictionary<string, object>? AdditionalAttributes { get; set; }\n\n    private bool _isEditing;\n    private string? _editValue;\n    private object? _originalValue;\n    private CellEditContext<TItem>? _editContext;\n\n    // --- Keyboard navigation / ARIA (rc.32) ---\n    // Cell-level inline editing applies in both Cell and Batch modes — the\n    // difference is where the commit lands (immediate vs buffered).\n    private bool CellEditable =>\n        (Context?.EditMode == DataGridEditMode.Cell || Context?.EditMode == DataGridEditMode.Batch)\n        && Column.Field is not null;\n\n    // True when this (row, column) currently carries a buffered batch edit.\n    // Drives the dirty marker (left border accent) and the display override.\n    private bool IsBatchDirty =>\n        Context?.IsBatchEdit == true\n        && Column.Field is not null\n        && Context.IsCellDirty(Item, Column.Field!);\n\n    private string? AriaReadonly => CellEditable ? null : \"true\";\n\n    private string FormatBatchValue(object? value)\n    {\n        if (value is null) return \"\";\n        var culture = Context?.Culture ?? System.Globalization.CultureInfo.CurrentCulture;\n        if (value is IFormattable f)\n            return f.ToString(Column.Format, culture);\n        return value.ToString() ?? \"\";\n    }\n\n    private string? CellId =>\n        Navigable && Context is not null ? $\"{Context.GridId}-cell-{RowIndex}-{ColumnIndex}\" : null;\n\n    // Roving tabindex: exactly one cell across header + body carries tabindex=\"0\".\n    private int TabIndexValue =>\n        Navigable && Context is not null && Context.FocusedRow == RowIndex && Context.FocusedCol == ColumnIndex\n            ? 0 : -1;\n\n    private async Task HandleCellKeyDown(KeyboardEventArgs e)\n    {\n        if (!Navigable || Context is null) return;\n\n        // Enter / F2 → enter edit mode for this cell (when editable). Edit-mode key\n        // handling (Escape/Enter/Tab) lives in the <input>'s own OnEditKeyDown.\n        if ((e.Key == \"Enter\" || e.Key == \"F2\") && CellEditable && !_isEditing)\n        {\n            StartEdit();\n            return;\n        }\n\n        // Navigation / selection keys → delegate to the grid.\n        await Context.OnCellKeyDown(RowIndex, ColumnIndex, e);\n    }\n\n    // Memoization mirror of DataGridRow.CssClass: 10k cells × N renders worth\n    // of string.Join allocations. The key carries every input that influences\n    // the result; we recompute only when something actually changes.\n    private (object Key, string Css)? _cssCache;\n\n    private string CssClass\n    {\n        get\n        {\n            var cellClass = Column.CellClass?.Invoke(Item);\n            var pin = Column.Pin;\n            var dirty = IsBatchDirty;\n            var key = (pin, dirty, Column.CssClass, cellClass, Class);\n            if (_cssCache is { } c && c.Key.Equals(key)) return c.Css;\n\n            var pinClass = pin switch\n            {\n                PinDirection.Left => \"sticky z-10 bg-inherit\",\n                PinDirection.Right => \"sticky z-10 bg-inherit\",\n                _ => (string?)null\n            };\n            var css = string.Join(\" \", new[]\n            {\n                \"px-3 py-2 text-sm\",\n                \"outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset\",\n                pinClass,\n                // Batch-edit dirty marker: subtle left border accent + warning\n                // background tint. Uses the lumeo.css --color-warning token so\n                // it stays theme-aware in dark mode.\n                dirty ? \"border-l-2 border-warning bg-warning/10\" : null,\n                Column.CssClass,\n                cellClass,\n                Class\n            }.Where(s => !string.IsNullOrWhiteSpace(s)));\n            _cssCache = (key, css);\n            return css;\n        }\n    }\n\n    private string PinStyle\n    {\n        get\n        {\n            if (Column.Pin == PinDirection.Left && Context?.PinnedLeftOffsets.TryGetValue(Column.Id, out var leftOff) == true)\n                return $\"left: {leftOff}px;\";\n            if (Column.Pin == PinDirection.Right && Context?.PinnedRightOffsets.TryGetValue(Column.Id, out var rightOff) == true)\n                return $\"right: {rightOff}px;\";\n            return \"\";\n        }\n    }\n\n    private void HandleClick()\n    {\n        if (CellEditable && !_isEditing)\n        {\n            StartEdit();\n        }\n    }\n\n    private void HandleDoubleClick()\n    {\n        if (CellEditable && !_isEditing)\n        {\n            StartEdit();\n        }\n    }\n\n    private void StartEdit()\n    {\n        // In batch mode, seed from the pending buffer if there's one — otherwise\n        // the user would have to retype an edit they just committed.\n        _originalValue = Column.GetValue(Item);\n        var seedValue = Context?.IsBatchEdit == true\n            ? Context.GetBatchValue(Item, Column)\n            : _originalValue;\n        _editValue = seedValue?.ToString() ?? \"\";\n        _editContext = new CellEditContext<TItem>\n        {\n            Item = Item,\n            Column = Column,\n            Value = seedValue,\n            ValueChanged = () =>\n            {\n                _editValue = _editContext?.Value?.ToString();\n                StateHasChanged();\n            }\n        };\n        _isEditing = true;\n    }\n\n    private void OnEditInput(ChangeEventArgs e)\n    {\n        _editValue = e.Value?.ToString();\n        if (_editContext is not null)\n        {\n            _editContext.Value = _editValue;\n        }\n    }\n\n    private async Task OnEditKeyDown(KeyboardEventArgs e)\n    {\n        if (e.Key == \"Enter\")\n        {\n            await CommitEdit();\n            // Excel-like: after committing, move focus down one row.\n            if (Navigable && Context is not null)\n            {\n                await Context.OnCellKeyDown(RowIndex, ColumnIndex, new KeyboardEventArgs { Key = \"ArrowDown\" });\n            }\n        }\n        else if (e.Key == \"Escape\")\n        {\n            CancelEdit();\n            if (Navigable && Context is not null)\n            {\n                // Re-focus the cell <td> so arrow nav resumes immediately.\n                await Context.OnCellKeyDown(RowIndex, ColumnIndex, new KeyboardEventArgs { Key = \"Home\" });\n                await Context.OnCellKeyDown(RowIndex, ColumnIndex, new KeyboardEventArgs { Key = \"End\" });\n            }\n        }\n        else if (e.Key == \"Tab\")\n        {\n            await CommitEdit();\n            // Move to the next editable cell to the right (browser handles plain\n            // Tab too, but committing first keeps state consistent).\n            if (Navigable && Context is not null)\n            {\n                await Context.OnCellKeyDown(RowIndex, ColumnIndex, new KeyboardEventArgs { Key = \"ArrowRight\" });\n            }\n        }\n    }\n\n    private async Task CommitEdit()\n    {\n        if (!_isEditing) return;\n        _isEditing = false;\n\n        if (Context is null || Column.Field is null) return;\n\n        // Batch mode → write into the pending-changes buffer; don't mutate the\n        // row and don't fire OnCellEdit (that fires later on Save all via the\n        // consumer's OnBatchSave handler).\n        if (Context.IsBatchEdit)\n        {\n            Context.SetBatchValue(Item, Column, _editValue);\n            return;\n        }\n\n        if (_editValue != _originalValue?.ToString())\n        {\n            var args = new CellEditEventArgs<TItem>(Item, Column.Field, _originalValue, _editValue);\n            await Context.OnCellEdit.InvokeAsync(args);\n        }\n    }\n\n    private void CancelEdit()\n    {\n        _isEditing = false;\n        _editValue = null;\n    }\n}\n"
    },
    {
      "path": "UI/DataGrid/DataGridColumn.cs",
      "content": "using System.Collections.Concurrent;\nusing System.Globalization;\nusing System.Linq.Expressions;\nusing System.Reflection;\nusing Microsoft.AspNetCore.Components;\n\nnamespace Lumeo;\n\npublic class DataGridColumn<TItem>\n{\n    public string Id { get; set; } = Guid.NewGuid().ToString(\"N\")[..8];\n    public string? Title { get; set; }\n    public string? Field { get; set; }\n    public Func<TItem, object?>? FieldSelector { get; set; }\n    public double? Width { get; set; }\n    public double? MinWidth { get; set; }\n    public double? MaxWidth { get; set; }\n    public bool Sortable { get; set; }\n    public bool Filterable { get; set; }\n    public bool Pinnable { get; set; }\n    public bool Resizable { get; set; } = true;\n    public bool Visible { get; set; } = true;\n    public bool Groupable { get; set; }\n    /// <summary>\n    /// Whether this column can be reordered via header drag-and-drop or the\n    /// Toggle Columns menu arrows. When false, the column is pinned to its\n    /// current position regardless of the grid-level <c>Reorderable</c> flag.\n    /// Defaults to <c>true</c>.\n    /// </summary>\n    public bool Reorderable { get; set; } = true;\n    public PinDirection Pin { get; set; } = PinDirection.None;\n    public DataGridFilterType FilterType { get; set; } = DataGridFilterType.Text;\n    public AggregateType Aggregate { get; set; } = AggregateType.None;\n    public string? Format { get; set; }\n    public string? CssClass { get; set; }\n    public string? HeaderCssClass { get; set; }\n    public RenderFragment<TItem>? CellTemplate { get; set; }\n    public RenderFragment? HeaderTemplate { get; set; }\n    public RenderFragment<CellEditContext<TItem>>? EditTemplate { get; set; }\n    public Func<TItem, string>? CellClass { get; set; }\n    public Comparison<object?>? CustomSort { get; set; }\n    public List<FilterOption>? FilterOptions { get; set; }\n\n    /// <summary>\n    /// Optional whitelist of filter operators shown in the column filter UI.\n    /// When null, the grid exposes the default set for the column's <see cref=\"FilterType\"/>.\n    /// When set, only operators present in this list are offered (still intersected with the\n    /// built-in defaults so unsupported combinations are not surfaced accidentally).\n    /// </summary>\n    public List<FilterOperator>? Operators { get; set; }\n\n    /// <summary>\n    /// Optional custom filter UI for this column. When provided, the default filter body\n    /// (operator + value inputs) is replaced with this render fragment, allowing the consumer\n    /// to implement any filter experience (sliders, multi-selects, relative-date pickers, ...).\n    /// The context exposes the current <see cref=\"FilterDescriptor\"/> for the column\n    /// (or null) and an Apply callback the consumer invokes to commit the filter.\n    /// Passing null to Apply clears the filter.\n    /// </summary>\n    public RenderFragment<DataGridFilterTemplateContext>? FilterTemplate { get; set; }\n\n    // Per-column cache of compiled getters, keyed by field name. A compiled\n    // Func<TItem, object?> is ~10x faster than PropertyInfo.GetValue and the\n    // delta matters: GetValue runs once per cell per render.\n    //\n    // Behavior preservation vs. the previous reflection-based path:\n    //   * Unknown field on TItem → PropertyInfo.GetProperty(...) returned null and\n    //     GetValue returned null. The compiled path mirrors this by caching a\n    //     null delegate and returning null.\n    //   * Nested paths (e.g. \"Address.City\") are supported with null-safe\n    //     traversal: any null intermediate yields null instead of throwing\n    //     NullReferenceException — the previous code would have thrown on\n    //     nested paths since GetProperty doesn't traverse dots; this is a\n    //     net behavior gain and does not regress any existing flat path.\n    private readonly ConcurrentDictionary<string, Func<TItem, object?>?> _gettersByField = new(StringComparer.Ordinal);\n\n    public object? GetValue(TItem item)\n    {\n        if (FieldSelector is not null) return FieldSelector(item);\n        if (Field is null || item is null) return null;\n\n        var getter = _gettersByField.GetOrAdd(Field, static f => BuildGetter(f));\n        return getter?.Invoke(item);\n    }\n\n    private static Func<TItem, object?>? BuildGetter(string fieldPath)\n    {\n        var param = Expression.Parameter(typeof(TItem), \"x\");\n        Expression current = param;\n        var currentType = typeof(TItem);\n\n        // Walk dotted segments, chaining .Property accesses. Track each segment\n        // so we can guard against null intermediates with Expression.Condition.\n        var segments = fieldPath.Split('.');\n        var nullChecks = new List<Expression>();\n\n        foreach (var segment in segments)\n        {\n            var prop = currentType.GetProperty(segment, BindingFlags.Public | BindingFlags.Instance);\n            if (prop is null)\n            {\n                // Unknown property at any depth → match reflection's null return.\n                return null;\n            }\n\n            // For reference-typed intermediates, add a null-check on the value\n            // BEFORE we descend into it. Value-typed intermediates can't be null\n            // (Nullable<T> is handled via .Value but is uncommon for these paths;\n            // we leave that to ordinary access — matches old single-level behavior).\n            if (!current.Type.IsValueType && !ReferenceEquals(current, param))\n            {\n                nullChecks.Add(Expression.Equal(current, Expression.Constant(null, current.Type)));\n            }\n\n            current = Expression.Property(current, prop);\n            currentType = prop.PropertyType;\n        }\n\n        // Box the final value to object so the delegate returns object?.\n        Expression body = currentType.IsValueType\n            ? Expression.Convert(current, typeof(object))\n            : current;\n\n        // If we accumulated null-guards, fold them into a single short-circuit:\n        // (intermediate1 == null || intermediate2 == null || ...) ? null : body\n        if (nullChecks.Count > 0)\n        {\n            Expression anyNull = nullChecks[0];\n            for (var i = 1; i < nullChecks.Count; i++)\n                anyNull = Expression.OrElse(anyNull, nullChecks[i]);\n\n            body = Expression.Condition(\n                anyNull,\n                Expression.Constant(null, typeof(object)),\n                body);\n        }\n\n        return Expression.Lambda<Func<TItem, object?>>(body, param).Compile();\n    }\n\n    /// <summary>\n    /// Formats a row's value for display, using <see cref=\"CultureInfo.CurrentCulture\"/>\n    /// so ASP.NET request localization affects dates/numbers automatically.\n    /// </summary>\n    public string GetFormattedValue(TItem item)\n        => GetFormattedValue(item, CultureInfo.CurrentCulture);\n\n    /// <summary>\n    /// Formats a row's value for display using the supplied culture. Prefers\n    /// <see cref=\"IFormattable\"/> to guarantee culture-aware output for numbers\n    /// and dates; falls back to <see cref=\"object.ToString\"/> for other types.\n    /// </summary>\n    public string GetFormattedValue(TItem item, CultureInfo culture)\n    {\n        var value = GetValue(item);\n        if (value is null) return \"\";\n        if (value is IFormattable f)\n            return f.ToString(Format, culture);\n        return value.ToString() ?? \"\";\n    }\n}\n\npublic class CellEditContext<TItem>\n{\n    public TItem Item { get; init; } = default!;\n    public DataGridColumn<TItem> Column { get; init; } = default!;\n    public object? Value { get; set; }\n    public Action ValueChanged { get; init; } = default!;\n}\n"
    },
    {
      "path": "UI/DataGrid/DataGridColumnDef.razor",
      "content": "@namespace Lumeo\n@typeparam TItem\n\n@code {\n    [CascadingParameter] private DataGrid<TItem>? ParentGrid { get; set; }\n\n    [Parameter] public string? Title { get; set; }\n    [Parameter] public string? Field { get; set; }\n    [Parameter] public Func<TItem, object?>? FieldSelector { get; set; }\n    [Parameter] public double? Width { get; set; }\n    [Parameter] public bool Sortable { get; set; }\n    [Parameter] public bool Filterable { get; set; }\n    [Parameter] public bool Resizable { get; set; } = true;\n    [Parameter] public DataGridFilterType FilterType { get; set; } = DataGridFilterType.Text;\n    [Parameter] public AggregateType Aggregate { get; set; } = AggregateType.None;\n    [Parameter] public string? Format { get; set; }\n    [Parameter] public PinDirection Pin { get; set; } = PinDirection.None;\n    [Parameter] public RenderFragment<TItem>? CellTemplate { get; set; }\n    [Parameter] public RenderFragment? HeaderTemplate { get; set; }\n    [Parameter] public RenderFragment<CellEditContext<TItem>>? EditTemplate { get; set; }\n    [Parameter] public double? MinWidth { get; set; }\n    [Parameter] public double? MaxWidth { get; set; }\n    [Parameter] public bool Pinnable { get; set; }\n    [Parameter] public bool Groupable { get; set; }\n    [Parameter] public bool Reorderable { get; set; } = true;\n    [Parameter] public Func<TItem, string>? CellClass { get; set; }\n    [Parameter] public string? HeaderCssClass { get; set; }\n    [Parameter] public Comparison<object?>? CustomSort { get; set; }\n    [Parameter] public List<FilterOption>? FilterOptions { get; set; }\n    [Parameter] public List<FilterOperator>? Operators { get; set; }\n    [Parameter] public RenderFragment<DataGridFilterTemplateContext>? FilterTemplate { get; set; }\n    [Parameter] public string? Class { get; set; }\n    [Parameter(CaptureUnmatchedValues = true)] public Dictionary<string, object>? AdditionalAttributes { get; set; }\n\n    protected override void OnInitialized()\n    {\n        ParentGrid?.AddColumnDef(new DataGridColumn<TItem>\n        {\n            Title = Title,\n            Field = Field,\n            FieldSelector = FieldSelector,\n            Width = Width,\n            Sortable = Sortable,\n            Filterable = Filterable,\n            Resizable = Resizable,\n            FilterType = FilterType,\n            Aggregate = Aggregate,\n            Format = Format,\n            Pin = Pin,\n            CellTemplate = CellTemplate,\n            HeaderTemplate = HeaderTemplate,\n            EditTemplate = EditTemplate,\n            FilterOptions = FilterOptions,\n            Operators = Operators,\n            FilterTemplate = FilterTemplate,\n            MinWidth = MinWidth,\n            MaxWidth = MaxWidth,\n            Pinnable = Pinnable,\n            Groupable = Groupable,\n            Reorderable = Reorderable,\n            CellClass = CellClass,\n            HeaderCssClass = HeaderCssClass,\n            CustomSort = CustomSort,\n            CssClass = Class\n        });\n    }\n}\n"
    },
    {
      "path": "UI/DataGrid/DataGridColumnFilter.razor",
      "content": "@namespace Lumeo\n@typeparam TItem\n@inject Lumeo.Services.Localization.ILumeoLocalizer L\n\n<div class=\"@CssClass\" @attributes=\"AdditionalAttributes\">\n    @if (Column?.FilterTemplate is not null)\n    {\n        <div class=\"p-3 space-y-3\" data-slot=\"datagrid-filter-custom\">\n            @Column.FilterTemplate(new DataGridFilterTemplateContext(\n                Column.Field,\n                CurrentFilter,\n                async desc => await OnApply.InvokeAsync(desc)))\n        </div>\n    }\n    else\n    {\n    <div class=\"p-3 space-y-3\">\n        <div class=\"text-xs font-medium text-muted-foreground uppercase tracking-wider\">\n            @L[\"Filter.FilterTitle\", Column?.Title ?? \"\"]\n        </div>\n\n        @if (Column?.FilterType == DataGridFilterType.Boolean)\n        {\n            <label class=\"flex items-center gap-2 text-sm\">\n                <Checkbox Checked=\"@_boolValue\" CheckedChanged=\"@OnBoolChanged\" />\n                <span>@(Column?.Title ?? \"Value\")</span>\n            </label>\n        }\n        else\n        {\n            <Select Value=\"@_selectedOperator.ToString()\" ValueChanged=\"@OnOperatorSelected\">\n                <SelectTrigger Class=\"h-8 w-full\">\n                    <span class=\"truncate text-sm\">@GetOperatorLabel(_selectedOperator)</span>\n                </SelectTrigger>\n                <SelectContent>\n                    @foreach (var op in GetAvailableOperators())\n                    {\n                        <SelectItem Value=\"@op.ToString()\">@GetOperatorLabel(op)</SelectItem>\n                    }\n                </SelectContent>\n            </Select>\n\n            @if (Column?.FilterType == DataGridFilterType.Number)\n            {\n                <input type=\"number\"\n                       class=\"h-8 w-full rounded-md border border-input bg-transparent px-3 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-[1.5px] focus-visible:ring-ring\"\n                       placeholder=\"@L[\"Filter.ValuePlaceholder\"]\"\n                       value=\"@_filterValue\"\n                       @oninput=\"OnValueInput\" />\n                @if (_selectedOperator == FilterOperator.Between)\n                {\n                    <input type=\"number\"\n                           class=\"h-8 w-full rounded-md border border-input bg-transparent px-3 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-[1.5px] focus-visible:ring-ring\"\n                           placeholder=\"@L[\"Filter.ToValuePlaceholder\"]\"\n                           value=\"@_filterValueTo\"\n                           @oninput=\"OnValueToInput\" />\n                }\n            }\n            else if (Column?.FilterType == DataGridFilterType.Date)\n            {\n                <input type=\"date\"\n                       class=\"h-8 w-full rounded-md border border-input bg-transparent px-3 text-sm focus-visible:outline-none focus-visible:ring-[1.5px] focus-visible:ring-ring\"\n                       value=\"@_filterValue\"\n                       @oninput=\"OnValueInput\" />\n                @if (_selectedOperator == FilterOperator.Between)\n                {\n                    <input type=\"date\"\n                           class=\"h-8 w-full rounded-md border border-input bg-transparent px-3 text-sm focus-visible:outline-none focus-visible:ring-[1.5px] focus-visible:ring-ring\"\n                           value=\"@_filterValueTo\"\n                           @oninput=\"OnValueToInput\" />\n                }\n            }\n            else if (Column?.FilterType == DataGridFilterType.Select)\n            {\n                @if (FilterOptions is not null && FilterOptions.Count > 1)\n                {\n                    <div class=\"flex items-center justify-between mb-1\">\n                        <button type=\"button\" class=\"text-xs text-primary hover:underline\" @onclick=\"SelectAll\">@L[\"Filter.SelectAll\"]</button>\n                        <button type=\"button\" class=\"text-xs text-muted-foreground hover:underline\" @onclick=\"ClearAll\">@L[\"Filter.Clear\"]</button>\n                    </div>\n                }\n                <div class=\"max-h-40 overflow-auto space-y-1\">\n                    @if (FilterOptions is not null)\n                    {\n                        @foreach (var option in FilterOptions)\n                        {\n                            <label class=\"flex items-center gap-2 text-sm py-0.5\">\n                                <Checkbox Checked=\"@(_selectedOptions.Contains(option.Value?.ToString() ?? \"\"))\"\n                                          CheckedChanged=\"@(v => ToggleOption(option.Value?.ToString() ?? \"\", v))\" />\n                                <span>@option.Label</span>\n                            </label>\n                        }\n                    }\n                </div>\n            }\n            else\n            {\n                <input type=\"text\"\n                       class=\"h-8 w-full rounded-md border border-input bg-transparent px-3 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-[1.5px] focus-visible:ring-ring\"\n                       placeholder=\"@L[\"Filter.ValuePlaceholder\"]\"\n                       value=\"@_filterValue\"\n                       @oninput=\"OnValueInput\" />\n            }\n        }\n\n        <div class=\"flex items-center gap-2 pt-1\">\n            <button type=\"button\"\n                    class=\"flex-1 h-8 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors\"\n                    @onclick=\"Apply\">\n                @L[\"Filter.Apply\"]\n            </button>\n            <button type=\"button\"\n                    class=\"flex-1 h-8 rounded-md border border-input bg-background text-sm hover:bg-accent hover:text-accent-foreground transition-colors\"\n                    @onclick=\"Clear\">\n                @L[\"Filter.Clear\"]\n            </button>\n        </div>\n    </div>\n    }\n</div>\n\n@code {\n    [Parameter] public DataGridColumn<TItem>? Column { get; set; }\n    [Parameter] public List<FilterOption>? FilterOptions { get; set; }\n    [Parameter] public FilterDescriptor? CurrentFilter { get; set; }\n    [Parameter] public EventCallback<FilterDescriptor?> OnApply { get; set; }\n    [Parameter] public string? Class { get; set; }\n    [Parameter(CaptureUnmatchedValues = true)] public Dictionary<string, object>? AdditionalAttributes { get; set; }\n\n    private string CssClass => $\"w-64 rounded-lg border border-border/40 bg-popover text-popover-foreground shadow-md {Class}\".Trim();\n\n    private FilterOperator _selectedOperator = FilterOperator.Contains;\n    private string? _filterValue;\n    private string? _filterValueTo;\n    private bool _boolValue;\n    private HashSet<string> _selectedOptions = new();\n\n    protected override void OnParametersSet()\n    {\n        if (Column?.FilterType == DataGridFilterType.Number)\n            _selectedOperator = FilterOperator.Equals;\n        else if (Column?.FilterType == DataGridFilterType.Date)\n            _selectedOperator = FilterOperator.Equals;\n    }\n\n    private void OnOperatorSelected(string value)\n    {\n        if (Enum.TryParse<FilterOperator>(value, out var op))\n        {\n            _selectedOperator = op;\n        }\n    }\n\n    private void OnValueInput(ChangeEventArgs e)\n    {\n        _filterValue = e.Value?.ToString();\n    }\n\n    private void OnValueToInput(ChangeEventArgs e)\n    {\n        _filterValueTo = e.Value?.ToString();\n    }\n\n    private void OnBoolChanged(bool value)\n    {\n        _boolValue = value;\n    }\n\n    private void ToggleOption(string value, bool isChecked)\n    {\n        if (isChecked)\n            _selectedOptions.Add(value);\n        else\n            _selectedOptions.Remove(value);\n    }\n\n    private void SelectAll()\n    {\n        if (FilterOptions is null) return;\n        _selectedOptions = new HashSet<string>(FilterOptions.Select(o => o.Value?.ToString() ?? \"\"));\n    }\n\n    private void ClearAll()\n    {\n        _selectedOptions.Clear();\n    }\n\n    private async Task Apply()\n    {\n        if (Column?.Field is null) return;\n\n        object? filterValue;\n        if (Column.FilterType == DataGridFilterType.Boolean)\n        {\n            filterValue = _boolValue;\n            _selectedOperator = FilterOperator.Equals;\n        }\n        else if (Column.FilterType == DataGridFilterType.Select)\n        {\n            filterValue = string.Join(\",\", _selectedOptions);\n            _selectedOperator = FilterOperator.Contains;\n        }\n        else\n        {\n            filterValue = _filterValue;\n        }\n\n        var descriptor = new FilterDescriptor(\n            Column.Field,\n            _selectedOperator,\n            filterValue,\n            _filterValueTo,\n            Column.FilterType\n        );\n\n        await OnApply.InvokeAsync(descriptor);\n    }\n\n    private async Task Clear()\n    {\n        _filterValue = null;\n        _filterValueTo = null;\n        _boolValue = false;\n        _selectedOptions.Clear();\n        await OnApply.InvokeAsync(null);\n    }\n\n    private List<FilterOperator> GetAvailableOperators()\n    {\n        var defaults = Column?.FilterType switch\n        {\n            DataGridFilterType.Number => new List<FilterOperator>\n            {\n                FilterOperator.Equals, FilterOperator.NotEquals,\n                FilterOperator.GreaterThan, FilterOperator.GreaterThanOrEqual,\n                FilterOperator.LessThan, FilterOperator.LessThanOrEqual,\n                FilterOperator.Between, FilterOperator.IsEmpty, FilterOperator.IsNotEmpty\n            },\n            DataGridFilterType.Date => new List<FilterOperator>\n            {\n                FilterOperator.Equals, FilterOperator.NotEquals,\n                FilterOperator.GreaterThan, FilterOperator.GreaterThanOrEqual,\n                FilterOperator.LessThan, FilterOperator.LessThanOrEqual,\n                FilterOperator.Between, FilterOperator.IsEmpty, FilterOperator.IsNotEmpty\n            },\n            DataGridFilterType.Select => new List<FilterOperator>\n            {\n                FilterOperator.Contains, FilterOperator.Equals\n            },\n            _ => new List<FilterOperator>\n            {\n                FilterOperator.Contains, FilterOperator.NotContains,\n                FilterOperator.Equals, FilterOperator.NotEquals,\n                FilterOperator.StartsWith, FilterOperator.EndsWith,\n                FilterOperator.IsEmpty, FilterOperator.IsNotEmpty\n            }\n        };\n\n        // If the column restricts operators, intersect with the default set so\n        // unsupported combinations (e.g. StartsWith on a Number) aren't surfaced.\n        if (Column?.Operators is { Count: > 0 } whitelist)\n        {\n            var allowed = new HashSet<FilterOperator>(whitelist);\n            var filtered = defaults.Where(o => allowed.Contains(o)).ToList();\n            // If the intersection is empty, fall back to the whitelist verbatim\n            // (consumer clearly wants those operators even if we think they're odd).\n            return filtered.Count > 0 ? filtered : whitelist;\n        }\n\n        return defaults;\n    }\n\n    private string GetOperatorLabel(FilterOperator op) => op switch\n    {\n        FilterOperator.Contains => L[\"Filter.Contains\"],\n        FilterOperator.NotContains => L[\"Filter.DoesNotContain\"],\n        FilterOperator.Equals => L[\"Filter.Equals\"],\n        FilterOperator.NotEquals => L[\"Filter.NotEquals\"],\n        FilterOperator.StartsWith => L[\"Filter.StartsWith\"],\n        FilterOperator.EndsWith => L[\"Filter.EndsWith\"],\n        FilterOperator.GreaterThan => L[\"Filter.GreaterThan\"],\n        FilterOperator.GreaterThanOrEqual => L[\"Filter.GreaterThanOrEqual\"],\n        FilterOperator.LessThan => L[\"Filter.LessThan\"],\n        FilterOperator.LessThanOrEqual => L[\"Filter.LessThanOrEqual\"],\n        FilterOperator.IsEmpty => L[\"Filter.IsEmpty\"],\n        FilterOperator.IsNotEmpty => L[\"Filter.IsNotEmpty\"],\n        FilterOperator.Between => L[\"Filter.Between\"],\n        _ => op.ToString()\n    };\n}\n"
    },
    {
      "path": "UI/DataGrid/DataGridColumnVisibility.razor",
      "content": "@namespace Lumeo\n@typeparam TItem\n@inject Lumeo.Services.Localization.ILumeoLocalizer L\n\n<div class=\"@CssClass\" @attributes=\"AdditionalAttributes\">\n    <div class=\"p-3 space-y-1\">\n        <div class=\"text-xs font-medium text-muted-foreground uppercase tracking-wider mb-2\">\n            @L[\"DataGrid.ToggleColumns\"]\n        </div>\n        @{\n            var columnList = Columns.ToList();\n        }\n        @for (int i = 0; i < columnList.Count; i++)\n        {\n            var column = columnList[i];\n            var index = i;\n            var isFirst = index == 0;\n            var isLast = index == columnList.Count - 1;\n\n            <div class=\"flex items-center gap-2 py-0.5\">\n                <label class=\"flex items-center gap-2 text-sm cursor-pointer flex-1 min-w-0\">\n                    <Checkbox Checked=\"@column.Visible\" CheckedChanged=\"@(v => ToggleColumn(column, v))\" />\n                    <span class=\"truncate\">@(column.Title ?? column.Field ?? \"Column\")</span>\n                </label>\n                <div class=\"flex items-center gap-0.5 shrink-0\">\n                    <button type=\"button\"\n                            class=\"inline-flex items-center justify-center h-6 w-6 rounded text-muted-foreground hover:bg-accent hover:text-accent-foreground transition-colors disabled:opacity-30 disabled:cursor-not-allowed\"\n                            title=\"@L[\"DataGrid.MoveUp\"]\"\n                            disabled=\"@isFirst\"\n                            @onclick=\"@(async () => await MoveColumn(index, index - 1))\">\n                        <Blazicon Svg=\"Lucide.ArrowUp\" class=\"h-3.5 w-3.5\" />\n                    </button>\n                    <button type=\"button\"\n                            class=\"inline-flex items-center justify-center h-6 w-6 rounded text-muted-foreground hover:bg-accent hover:text-accent-foreground transition-colors disabled:opacity-30 disabled:cursor-not-allowed\"\n                            title=\"@L[\"DataGrid.MoveDown\"]\"\n                            disabled=\"@isLast\"\n                            @onclick=\"@(async () => await MoveColumn(index, index + 1))\">\n                        <Blazicon Svg=\"Lucide.ArrowDown\" class=\"h-3.5 w-3.5\" />\n                    </button>\n                </div>\n            </div>\n        }\n    </div>\n</div>\n\n@code {\n    [Parameter] public IReadOnlyList<DataGridColumn<TItem>> Columns { get; set; } = Array.Empty<DataGridColumn<TItem>>();\n    [Parameter] public EventCallback<DataGridColumn<TItem>> OnColumnToggle { get; set; }\n    [Parameter] public EventCallback<ColumnReorderEventArgs> OnColumnReorder { get; set; }\n    [Parameter] public string? Class { get; set; }\n    [Parameter(CaptureUnmatchedValues = true)] public Dictionary<string, object>? AdditionalAttributes { get; set; }\n\n    private string CssClass => $\"w-64 rounded-lg border border-border/40 bg-popover text-popover-foreground shadow-md {Class}\".Trim();\n\n    private async Task ToggleColumn(DataGridColumn<TItem> column, bool visible)\n    {\n        column.Visible = visible;\n        await OnColumnToggle.InvokeAsync(column);\n    }\n\n    private async Task MoveColumn(int oldIndex, int newIndex)\n    {\n        var column = Columns[oldIndex];\n        var args = new ColumnReorderEventArgs(column.Id, oldIndex, newIndex);\n        await OnColumnReorder.InvokeAsync(args);\n    }\n}\n"
    },
    {
      "path": "UI/DataGrid/DataGridContext.cs",
      "content": "using System.Globalization;\nusing Microsoft.AspNetCore.Components;\nusing Microsoft.AspNetCore.Components.Web;\n\nnamespace Lumeo;\n\npublic record DataGridContext<TItem>(\n    IReadOnlyList<DataGridColumn<TItem>> Columns,\n    /// <summary>\n    /// Pre-filtered view of <see cref=\"Columns\"/> containing only columns with\n    /// <c>Visible == true</c>. Cached at the grid level (one list per render),\n    /// so descendants (Row, Cell, Header, Footer, Body) can iterate without\n    /// running <c>Columns.Where(c =&gt; c.Visible)</c> per render. At 1k rows ×\n    /// 10 cols, this saves the per-render allocation/iteration of the filter.\n    /// </summary>\n    IReadOnlyList<DataGridColumn<TItem>> VisibleColumns,\n    IReadOnlyList<TItem> DisplayedItems,\n    IReadOnlyList<TItem> SelectedItems,\n    List<SortDescriptor> Sorts,\n    List<FilterDescriptor> Filters,\n    string? GlobalSearch,\n    int CurrentPage,\n    int PageSize,\n    int TotalCount,\n    DataGridSelectionMode SelectionMode,\n    DataGridEditMode EditMode,\n    bool IsLoading,\n    bool RowReorderable,\n    bool RowContextMenuEnabled,\n    EventCallback<TItem> OnRowClick,\n    EventCallback<TItem> OnRowDoubleClick,\n    EventCallback<SortDescriptor> OnSort,\n    EventCallback<FilterDescriptor> OnFilter,\n    EventCallback<TItem> OnSelectionChanged,\n    EventCallback<CellEditEventArgs<TItem>> OnCellEdit,\n    Func<TItem, bool> IsRowSelected,\n    Func<TItem, bool> IsRowExpanded,\n    Func<TItem, bool> IsRowEditing,\n    Action<TItem> ToggleSelection,\n    Action SelectAll,\n    Action ClearSelection,\n    Action<TItem> ToggleRowExpand,\n    Action<TItem> StartRowEdit,\n    Action<TItem> CancelRowEdit,\n    Func<TItem, Task> CommitRowEdit,\n    Func<TItem, DataGridColumn<TItem>, object?> GetEditValue,\n    Action<TItem, DataGridColumn<TItem>, object?> SetEditValue,\n    Func<TItem, string>? RowClass,\n    Func<TItem, string>? RowStyle,\n    IReadOnlyDictionary<string, double> PinnedLeftOffsets,\n    IReadOnlyDictionary<string, double> PinnedRightOffsets,\n    IReadOnlyList<DataGridGroupSection<TItem>>? GroupedSections,\n    string? GroupBy,\n    bool IsGrouped,\n    Func<string, bool> IsGroupExpanded,\n    Action<string> ToggleGroupExpand,\n    CultureInfo Culture,\n\n    // --- Multi-level grouping + drag-to-group panel (rc.35) ---\n    /// <summary>Ordered list of fields the grid is currently grouped by (1 entry =\n    /// classic single-level grouping, &gt;1 = nested tree). Empty when not grouped.</summary>\n    IReadOnlyList<string> GroupFields,\n    /// <summary>Multi-level group tree. Non-null only when <see cref=\"GroupFields\"/>\n    /// has more than one entry; single-level grouping still uses <see cref=\"GroupedSections\"/>.</summary>\n    IReadOnlyList<DataGridGroupNode<TItem>>? GroupTree,\n    /// <summary>Tests whether a group node at the given \"/\"-joined path is expanded.</summary>\n    Func<string, bool> IsGroupPathExpanded,\n    /// <summary>Toggles the expand state of the group node at the given path.</summary>\n    Action<string> ToggleGroupPath,\n    /// <summary>Adds a field as a new (deepest) grouping level — called from the group panel.</summary>\n    Action<string> AddGroupField,\n    /// <summary>Removes a field from the grouping levels.</summary>\n    Action<string> RemoveGroupField,\n    /// <summary>When true, the grid renders the group-panel strip and Groupable\n    /// column headers are drag-source-eligible (drag-to-group target is the panel\n    /// itself). Header cells read this to decide whether to set draggable=true\n    /// even for columns that aren't Reorderable. (rc.41)</summary>\n    bool ShowGroupPanel,\n\n    // --- Tree-grid mode (rc.35) ---\n    /// <summary>When non-null, the grid is in tree-grid mode: this returns a row's\n    /// child rows (or null/empty for leaves).</summary>\n    Func<TItem, IEnumerable<TItem>?>? ChildItemsSelector,\n    /// <summary>True when the grid is rendering hierarchical (tree-grid) rows.</summary>\n    bool IsTreeGrid,\n    /// <summary>Field of the column that carries the tree chevron + indentation\n    /// (null = first visible column).</summary>\n    string? TreeColumnField,\n    /// <summary>Tests whether a tree node is expanded.</summary>\n    Func<TItem, bool> IsTreeNodeExpanded,\n    /// <summary>Toggles a tree node's expand state.</summary>\n    Action<TItem> ToggleTreeNode,\n    /// <summary>Per-row tree depth (0 = root). Only meaningful in tree-grid mode.</summary>\n    Func<TItem, int> TreeLevelOf,\n    /// <summary>True when a row has child rows (used to show the chevron).</summary>\n    Func<TItem, bool> TreeHasChildren,\n\n    // --- Batch / buffered edit mode (rc.35) ---\n    /// <summary>True when <see cref=\"EditMode\"/> is <see cref=\"DataGridEditMode.Batch\"/>.</summary>\n    bool IsBatchEdit,\n    /// <summary>Tests whether a (row, field) pair currently has a buffered edit.</summary>\n    Func<TItem, string, bool> IsCellDirty,\n    /// <summary>Returns the buffered value for (row, field) if dirty, else the row's current value.</summary>\n    Func<TItem, DataGridColumn<TItem>, object?> GetBatchValue,\n    /// <summary>Writes a buffered value for (row, column) into the pending-changes buffer.</summary>\n    Action<TItem, DataGridColumn<TItem>, object?> SetBatchValue,\n\n    // --- Keyboard navigation / ARIA (rc.32) ---\n    /// <summary>Stable element-id prefix for the grid. Cells get id\n    /// <c>{GridId}-cell-{row}-{col}</c> (row = \"h\" for the header row).</summary>\n    string GridId,\n    /// <summary>Currently roving-tabindex row. -1 = header row, 0..RowCount-1 = body rows.</summary>\n    int FocusedRow,\n    /// <summary>Currently roving-tabindex column index (into the visible-columns list).</summary>\n    int FocusedCol,\n    /// <summary>Number of navigable body rows (= DisplayedItems.Count, flattened groups).</summary>\n    int RowCount,\n    /// <summary>Number of navigable columns (= visible data columns).</summary>\n    int ColCount,\n    /// <summary>Invoked by a body cell on keydown. Args: (rowIndex, colIndex, KeyboardEventArgs).</summary>\n    Func<int, int, KeyboardEventArgs, Task> OnCellKeyDown,\n    /// <summary>Invoked by a header cell on keydown. Args: (colIndex, KeyboardEventArgs).</summary>\n    Func<int, KeyboardEventArgs, Task> OnHeaderKeyDown\n);\n"
    },
    {
      "path": "UI/DataGrid/DataGridDetailRow.razor",
      "content": "@namespace Lumeo\n@typeparam TItem\n\n<tr class=\"@CssClass\" @attributes=\"AdditionalAttributes\">\n    <td colspan=\"@ColSpan\" class=\"p-4\">\n        @if (DetailTemplate is not null)\n        {\n            @DetailTemplate(Item)\n        }\n    </td>\n</tr>\n\n@code {\n    [Parameter] public TItem Item { get; set; } = default!;\n    [Parameter] public RenderFragment<TItem>? DetailTemplate { get; set; }\n    [Parameter] public int ColSpan { get; set; } = 1;\n    [Parameter] public string? Class { get; set; }\n    [Parameter(CaptureUnmatchedValues = true)] public Dictionary<string, object>? AdditionalAttributes { get; set; }\n\n    private string CssClass => $\"bg-muted/20 {Class}\".Trim();\n}\n"
    },
    {
      "path": "UI/DataGrid/DataGridDragState.cs",
      "content": "namespace Lumeo;\n\n/// <summary>Instance-bound drag state for one DataGrid&lt;TItem&gt;. Replaces the\n/// static ColumnDragState / DragState holders that previously lived in\n/// DataGridHeaderCell + DataGridRow. Each DataGrid creates exactly one\n/// instance and cascades it; cells / rows guard their drop logic with\n/// SourceGridId == OwnerGridId so cross-instance drags are rejected.</summary>\npublic sealed class DataGridDragState\n{\n    public string OwnerGridId { get; }\n\n    public int SourceColumnIndex { get; set; } = -1;\n    public string? SourceColumnId { get; set; }\n\n    public int SourceRowIndex { get; set; } = -1;\n\n    /// <summary>The grid that originated the drag. Cells / rows MUST\n    /// verify this matches their owner before applying a drop. Reset to\n    /// null on Reset().</summary>\n    public string? SourceGridId { get; set; }\n\n    public DataGridDragState(string ownerGridId)\n    {\n        OwnerGridId = ownerGridId;\n    }\n\n    public void StartColumnDrag(int sourceIndex, string sourceColumnId)\n    {\n        SourceColumnIndex = sourceIndex;\n        SourceColumnId = sourceColumnId;\n        SourceGridId = OwnerGridId;\n    }\n\n    public void StartRowDrag(int sourceIndex)\n    {\n        SourceRowIndex = sourceIndex;\n        SourceGridId = OwnerGridId;\n    }\n\n    public void ResetColumn()\n    {\n        SourceColumnIndex = -1;\n        SourceColumnId = null;\n        if (SourceRowIndex < 0) SourceGridId = null;\n    }\n\n    public void ResetRow()\n    {\n        SourceRowIndex = -1;\n        if (SourceColumnIndex < 0) SourceGridId = null;\n    }\n\n    /// <summary>True only if a drag is in progress AND it originated in this\n    /// grid instance. Drop handlers should bail out when this is false.</summary>\n    public bool IsOwnDragColumn(int columnIndex) =>\n        SourceGridId == OwnerGridId && SourceColumnIndex >= 0;\n\n    public bool IsOwnDragRow() =>\n        SourceGridId == OwnerGridId && SourceRowIndex >= 0;\n}\n"
    },
    {
      "path": "UI/DataGrid/DataGridEnums.cs",
      "content": "namespace Lumeo;\n\npublic enum SortDirection\n{\n    None,\n    Ascending,\n    Descending\n}\n\npublic enum DataGridSelectionMode\n{\n    None,\n    Single,\n    Multiple\n}\n\npublic enum PinDirection\n{\n    None,\n    Left,\n    Right\n}\n\npublic enum DataGridEditMode\n{\n    None,\n    Cell,\n    Row,\n    /// <summary>\n    /// Buffered editing: cell edits are collected into a per-cell pending-changes\n    /// buffer instead of committing immediately. Dirty cells are visually marked,\n    /// and \"Save all\" / \"Discard\" controls appear above the grid. Saving fires\n    /// <see cref=\"DataGrid{TItem}.OnBatchSave\"/> with the modified/added rows.\n    /// </summary>\n    Batch\n}\n\npublic enum DataGridFilterType\n{\n    Text,\n    Number,\n    Date,\n    Select,\n    Boolean\n}\n\npublic enum AggregateType\n{\n    None,\n    Sum,\n    Average,\n    Count,\n    Min,\n    Max\n}\n\npublic enum FilterOperator\n{\n    Contains,\n    NotContains,\n    Equals,\n    NotEquals,\n    StartsWith,\n    EndsWith,\n    GreaterThan,\n    GreaterThanOrEqual,\n    LessThan,\n    LessThanOrEqual,\n    IsEmpty,\n    IsNotEmpty,\n    Between\n}\n"
    },
    {
      "path": "UI/DataGrid/DataGridFilterOperator.cs",
      "content": "namespace Lumeo;\n\npublic static class DataGridFilterOperator\n{\n    public static bool Evaluate(FilterDescriptor filter, object? value)\n    {\n        if (filter.Operator == FilterOperator.IsEmpty) return value is null || value.ToString() == \"\";\n        if (filter.Operator == FilterOperator.IsNotEmpty) return value is not null && value.ToString() != \"\";\n        if (value is null) return false;\n\n        var strValue = value.ToString() ?? \"\";\n        var filterStr = filter.Value?.ToString() ?? \"\";\n\n        // Handle Select filter: value must match one of the comma-separated options\n        if (filter.FilterType == DataGridFilterType.Select)\n        {\n            var options = filter.Value?.ToString()\n                ?.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)\n                ?? Array.Empty<string>();\n            return options.Length == 0 || options.Any(opt => opt.Equals(strValue, StringComparison.OrdinalIgnoreCase));\n        }\n\n        return filter.Operator switch\n        {\n            FilterOperator.Contains => strValue.Contains(filterStr, StringComparison.OrdinalIgnoreCase),\n            FilterOperator.NotContains => !strValue.Contains(filterStr, StringComparison.OrdinalIgnoreCase),\n            FilterOperator.Equals => filter.FilterType is DataGridFilterType.Number or DataGridFilterType.Date\n                ? CompareValues(value, filter.Value) == 0\n                : strValue.Equals(filterStr, StringComparison.OrdinalIgnoreCase),\n            FilterOperator.NotEquals => filter.FilterType is DataGridFilterType.Number or DataGridFilterType.Date\n                ? CompareValues(value, filter.Value) != 0\n                : !strValue.Equals(filterStr, StringComparison.OrdinalIgnoreCase),\n            FilterOperator.StartsWith => strValue.StartsWith(filterStr, StringComparison.OrdinalIgnoreCase),\n            FilterOperator.EndsWith => strValue.EndsWith(filterStr, StringComparison.OrdinalIgnoreCase),\n            FilterOperator.GreaterThan => CompareValues(value, filter.Value) > 0,\n            FilterOperator.GreaterThanOrEqual => CompareValues(value, filter.Value) >= 0,\n            FilterOperator.LessThan => CompareValues(value, filter.Value) < 0,\n            FilterOperator.LessThanOrEqual => CompareValues(value, filter.Value) <= 0,\n            FilterOperator.Between => CompareValues(value, filter.Value) >= 0 && CompareValues(value, filter.ValueTo) <= 0,\n            _ => true\n        };\n    }\n\n    private static int CompareValues(object? a, object? b)\n    {\n        if (a is IComparable ca && b is IComparable cb)\n        {\n            try { return ca.CompareTo(Convert.ChangeType(cb, ca.GetType())); }\n            catch (Exception) { /* Type conversion failed — fall back to string comparison */ return string.Compare(a?.ToString(), b?.ToString(), StringComparison.OrdinalIgnoreCase); }\n        }\n        return string.Compare(a?.ToString(), b?.ToString(), StringComparison.OrdinalIgnoreCase);\n    }\n}\n"
    },
    {
      "path": "UI/DataGrid/DataGridFooter.razor",
      "content": "@namespace Lumeo\n@typeparam TItem\n@inject Lumeo.Services.Localization.ILumeoLocalizer L\n\n@if (Context is not null && HasAggregates)\n{\n    <tfoot data-slot=\"datagrid-footer\" class=\"@CssClass\" @attributes=\"AdditionalAttributes\">\n        <tr class=\"border-t border-border/40\">\n            @if (Context.SelectionMode != DataGridSelectionMode.None)\n            {\n                <td class=\"px-3 py-2 w-10\"></td>\n            }\n            @if (HasDetailTemplate)\n            {\n                <td class=\"px-1 py-2 w-8\"></td>\n            }\n            @foreach (var column in Context.VisibleColumns)\n            {\n                <td @key=\"column.Id\" class=\"px-3 py-2 text-xs font-medium text-muted-foreground bg-muted/30\">\n                    @if (column.Aggregate != AggregateType.None)\n                    {\n                        <span>@GetAggregateLabel(column.Aggregate): @ComputeAggregate(column)</span>\n                    }\n                </td>\n            }\n        </tr>\n    </tfoot>\n}\n\n@code {\n    [CascadingParameter] private DataGridContext<TItem>? Context { get; set; }\n    [Parameter] public IReadOnlyList<TItem>? AllItems { get; set; }\n    [Parameter] public bool HasDetailTemplate { get; set; }\n    [Parameter] public string? Class { get; set; }\n    [Parameter(CaptureUnmatchedValues = true)] public Dictionary<string, object>? AdditionalAttributes { get; set; }\n\n    private string CssClass => $\"{Class}\".Trim();\n\n    private bool HasAggregates => Context?.Columns.Any(c => c.Visible && c.Aggregate != AggregateType.None) ?? false;\n\n    private IEnumerable<TItem> ItemsForAggregate => AllItems ?? Context?.DisplayedItems ?? Enumerable.Empty<TItem>();\n\n    private string ComputeAggregate(DataGridColumn<TItem> column)\n    {\n        var items = ItemsForAggregate.ToList();\n        if (items.Count == 0) return \"-\";\n\n        var values = items.Select(i => column.GetValue(i)).Where(v => v is not null).ToList();\n\n        return column.Aggregate switch\n        {\n            AggregateType.Count => values.Count.ToString(),\n            AggregateType.Sum => ComputeNumeric(values, vals => vals.Sum()),\n            AggregateType.Average => ComputeNumeric(values, vals => vals.Average()),\n            AggregateType.Min => ComputeNumeric(values, vals => vals.Min()),\n            AggregateType.Max => ComputeNumeric(values, vals => vals.Max()),\n            _ => \"\"\n        };\n    }\n\n    private string ComputeNumeric(List<object?> values, Func<IEnumerable<double>, double> aggregator)\n    {\n        var numericValues = new List<double>();\n        foreach (var v in values)\n        {\n            if (v is IConvertible conv)\n            {\n                try\n                {\n                    numericValues.Add(conv.ToDouble(System.Globalization.CultureInfo.InvariantCulture));\n                }\n                catch\n                {\n                    // Skip non-numeric values\n                }\n            }\n        }\n\n        if (numericValues.Count == 0) return \"-\";\n        var result = aggregator(numericValues);\n        return result.ToString(\"N2\");\n    }\n\n    private string GetAggregateLabel(AggregateType aggregate) => aggregate switch\n    {\n        AggregateType.Sum => L[\"DataGrid.AggregateSum\"],\n        AggregateType.Average => L[\"DataGrid.AggregateAvg\"],\n        AggregateType.Count => L[\"DataGrid.AggregateCount\"],\n        AggregateType.Min => L[\"DataGrid.AggregateMin\"],\n        AggregateType.Max => L[\"DataGrid.AggregateMax\"],\n        _ => \"\"\n    };\n}\n"
    },
    {
      "path": "UI/DataGrid/DataGridGroupRow.razor",
      "content": "@namespace Lumeo\n@typeparam TItem\n@inject Lumeo.Services.Localization.ILumeoLocalizer L\n\n<tr data-slot=\"datagrid-group-row\" class=\"@CssClass\"\n    role=\"row\"\n    @onclick=\"Toggle\" @attributes=\"AdditionalAttributes\">\n    <td colspan=\"@ColSpan\" class=\"px-3 py-2 font-medium\" style=\"@IndentStyle\">\n        <div class=\"flex items-center gap-2\">\n            <button type=\"button\" class=\"inline-flex items-center justify-center h-5 w-5 rounded hover:bg-muted\">\n                @if (IsExpanded)\n                {\n                    <Blazicon Svg=\"Lucide.ChevronDown\" class=\"h-4 w-4 text-muted-foreground\" />\n                }\n                else\n                {\n                    <Blazicon Svg=\"Lucide.ChevronRight\" class=\"h-4 w-4 text-muted-foreground\" />\n                }\n            </button>\n            <span class=\"text-sm\">@GroupKey</span>\n            <span class=\"text-xs text-muted-foreground\">@L[Lumeo.Services.Localization.LumeoPlurals.PluralKey(Count, \"DataGrid.ItemsCount\", System.Globalization.CultureInfo.CurrentUICulture), Count]</span>\n            @if (Aggregates is { Count: > 0 })\n            {\n                <span class=\"ml-2 flex flex-wrap items-center gap-x-3 gap-y-0.5 text-xs text-muted-foreground font-normal\">\n                    @foreach (var agg in Aggregates)\n                    {\n                        var colTitle = ColumnTitle(agg.ColumnId);\n                        <span>@(string.IsNullOrEmpty(colTitle) ? \"\" : colTitle + \" \")@agg.Label: <span class=\"font-medium text-foreground\">@agg.Value</span></span>\n                    }\n                </span>\n            }\n        </div>\n    </td>\n</tr>\n\n@code {\n    [CascadingParameter] private DataGridContext<TItem>? Context { get; set; }\n    [Parameter] public string? GroupKey { get; set; }\n    [Parameter] public int Count { get; set; }\n    [Parameter] public bool IsExpanded { get; set; }\n    [Parameter] public EventCallback OnToggle { get; set; }\n    [Parameter] public int ColSpan { get; set; } = 1;\n    /// <summary>Nesting depth for multi-level grouping (0 = top level). Controls indentation.</summary>\n    [Parameter] public int Level { get; set; }\n    /// <summary>Per-group aggregate strip rendered next to the group label.</summary>\n    [Parameter] public IReadOnlyList<DataGridGroupAggregate>? Aggregates { get; set; }\n    [Parameter] public string? Class { get; set; }\n    [Parameter(CaptureUnmatchedValues = true)] public Dictionary<string, object>? AdditionalAttributes { get; set; }\n\n    private string CssClass => $\"bg-muted/40 border-b border-border/40 cursor-pointer hover:bg-muted/60 transition-colors {Class}\".Trim();\n\n    private string? IndentStyle => Level > 0 ? $\"padding-left: {0.75 + Level * 1.25:0.##}rem;\" : null;\n\n    private string ColumnTitle(string columnId)\n    {\n        var col = Context?.Columns.FirstOrDefault(c => c.Id == columnId);\n        return col?.Title ?? col?.Field ?? \"\";\n    }\n\n    private async Task Toggle()\n    {\n        await OnToggle.InvokeAsync();\n    }\n}\n"
    },
    {
      "path": "UI/DataGrid/DataGridHeader.razor",
      "content": "@namespace Lumeo\n@typeparam TItem\n\n<thead data-slot=\"datagrid-header\" class=\"@CssClass\" @attributes=\"AdditionalAttributes\">\n    <tr role=\"row\" aria-rowindex=\"1\">\n        @* Row reorder handle column *@\n        @if (Context?.RowReorderable == true)\n        {\n            <th role=\"columnheader\" class=\"px-1 py-2 w-8 bg-muted/30\"></th>\n        }\n\n        @if (Context?.SelectionMode == DataGridSelectionMode.Multiple)\n        {\n            <th role=\"columnheader\" class=\"px-3 py-2 w-10 bg-muted/30\">\n                <Checkbox Checked=\"@AllSelected\"\n                          IsIndeterminate=\"@SomeSelected\"\n                          CheckedChanged=\"@OnSelectAllChanged\" />\n            </th>\n        }\n        else if (Context?.SelectionMode == DataGridSelectionMode.Single)\n        {\n            <th role=\"columnheader\" class=\"px-3 py-2 w-10 bg-muted/30\"></th>\n        }\n\n        @if (HasDetailTemplate)\n        {\n            <th role=\"columnheader\" class=\"px-1 py-2 w-8 bg-muted/30\"></th>\n        }\n\n        @if (Context is not null)\n        {\n            var colIndex = 0;\n            var leading = LeadingColumnCount;\n            @foreach (var column in Context.VisibleColumns)\n            {\n                var idx = colIndex;\n                var sortDir = GetSortDirection(column);\n                var hasFilter = HasFilter(column);\n                var isFilterOpen = ActiveFilterColumn == column.Field;\n                <DataGridHeaderCell @key=\"column.Id\" TItem=\"TItem\"\n                                    Column=\"column\"\n                                    ColumnIndex=\"idx\"\n                                    AriaColIndex=\"@(leading + idx + 1)\"\n                                    SortDirection=\"sortDir\"\n                                    HasActiveFilter=\"hasFilter\"\n                                    IsFilterOpen=\"isFilterOpen\"\n                                    Reorderable=\"Reorderable\"\n                                    SortPriority=\"GetSortPriority(column)\"\n                                    TotalSortCount=\"GetTotalSortCount()\"\n                                    OnFilterToggle=\"@(() => OnFilterToggle.InvokeAsync(column.Field))\"\n                                    OnFilterApply=\"OnFilterApply\"\n                                    OnColumnReorder=\"OnColumnReorder\" />\n                colIndex++;\n            }\n        }\n\n        @* Row edit action column placeholder in header *@\n        @if (Context?.EditMode == DataGridEditMode.Row)\n        {\n            <th role=\"columnheader\" class=\"px-3 py-2 w-10 bg-muted/30\"></th>\n        }\n    </tr>\n</thead>\n\n@code {\n    [CascadingParameter] private DataGridContext<TItem>? Context { get; set; }\n    [Parameter] public bool HasDetailTemplate { get; set; }\n    [Parameter] public string? ActiveFilterColumn { get; set; }\n    [Parameter] public bool Reorderable { get; set; }\n    [Parameter] public EventCallback<string?> OnFilterToggle { get; set; }\n    [Parameter] public EventCallback<FilterApplyEventArgs> OnFilterApply { get; set; }\n    [Parameter] public EventCallback<ColumnReorderEventArgs> OnColumnReorder { get; set; }\n    [Parameter] public string? Class { get; set; }\n    [Parameter(CaptureUnmatchedValues = true)] public Dictionary<string, object>? AdditionalAttributes { get; set; }\n\n    private string CssClass => $\"sticky top-0 z-20 bg-card [&_tr]:border-b [&_tr]:border-border/40 {Class}\".Trim();\n\n    /// <summary>Number of non-data leading columns (reorder handle, selection,\n    /// detail toggle) rendered before the first data column. Used to offset\n    /// 1-based <c>aria-colindex</c> values.</summary>\n    private int LeadingColumnCount\n    {\n        get\n        {\n            var n = 0;\n            if (Context?.RowReorderable == true) n++;\n            if (Context?.SelectionMode != DataGridSelectionMode.None) n++;\n            if (HasDetailTemplate) n++;\n            return n;\n        }\n    }\n\n    private bool AllSelected =>\n        Context is not null &&\n        Context.DisplayedItems.Count > 0 &&\n        Context.SelectedItems.Count >= Context.DisplayedItems.Count;\n\n    private bool SomeSelected =>\n        Context is not null &&\n        Context.SelectedItems.Count > 0 &&\n        Context.SelectedItems.Count < Context.DisplayedItems.Count;\n\n    private SortDirection GetSortDirection(DataGridColumn<TItem> column)\n    {\n        if (Context is null || column.Field is null) return SortDirection.None;\n        var sort = Context.Sorts.FirstOrDefault(s => s.Field == column.Field);\n        return sort?.Direction ?? SortDirection.None;\n    }\n\n    private bool HasFilter(DataGridColumn<TItem> column)\n    {\n        if (Context is null || column.Field is null) return false;\n        return Context.Filters.Any(f => f.Field == column.Field);\n    }\n\n    private int GetSortPriority(DataGridColumn<TItem> column)\n    {\n        if (Context is null || column.Field is null) return 0;\n        var idx = Context.Sorts.FindIndex(s => s.Field == column.Field && s.Direction != SortDirection.None);\n        return idx >= 0 ? idx + 1 : 0;\n    }\n\n    private int GetTotalSortCount() => Context?.Sorts.Count(s => s.Direction != SortDirection.None) ?? 0;\n\n    private void OnSelectAllChanged(bool value)\n    {\n        if (Context is null) return;\n        if (value)\n            Context.SelectAll();\n        else\n            Context.ClearSelection();\n    }\n}\n"
    },
    {
      "path": "UI/DataGrid/DataGridHeaderCell.razor",
      "content": "@namespace Lumeo\n@typeparam TItem\n@implements IAsyncDisposable\n\n<th data-slot=\"datagrid-header-cell\" class=\"@CssClass\" style=\"@StyleString\"\n    id=\"@CellId\"\n    role=\"columnheader\"\n    aria-colindex=\"@AriaColIndex\"\n    aria-sort=\"@AriaSort\"\n    tabindex=\"@TabIndexValue\"\n    @onkeydown=\"HandleKeyDown\"\n    draggable=\"@(IsDraggable ? \"true\" : \"false\")\"\n    @ondragstart=\"HandleDragStart\"\n    @ondragover=\"HandleDragOver\"\n    @ondragover:preventDefault=\"true\"\n    @ondrop=\"HandleDrop\"\n    @ondragenter=\"HandleDragEnter\"\n    @ondragleave=\"HandleDragLeave\"\n    @attributes=\"AdditionalAttributes\">\n    <div class=\"flex items-center gap-1\">\n        @if (Column.HeaderTemplate is not null)\n        {\n            @Column.HeaderTemplate\n        }\n        else\n        {\n            <button type=\"button\"\n                    class=\"flex items-center gap-1 select-none @(Column.Sortable ? \"cursor-pointer hover:text-foreground\" : \"cursor-default\")\"\n                    @onclick=\"HandleSort\"\n                    disabled=\"@(!Column.Sortable)\">\n                <span>@(Column.Title ?? Column.Field ?? \"\")</span>\n                @if (Column.Sortable && SortDirection != SortDirection.None)\n                {\n                    @if (SortDirection == SortDirection.Ascending)\n                    {\n                        <Blazicon Svg=\"Lucide.ArrowUp\" class=\"h-3.5 w-3.5\" />\n                    }\n                    else\n                    {\n                        <Blazicon Svg=\"Lucide.ArrowDown\" class=\"h-3.5 w-3.5\" />\n                    }\n                    @if (TotalSortCount > 1 && SortPriority > 0)\n                    {\n                        <span class=\"flex items-center justify-center h-3.5 w-3.5 rounded-full bg-primary text-primary-foreground text-[9px] font-bold leading-none\">@SortPriority</span>\n                    }\n                }\n                else if (Column.Sortable)\n                {\n                    <Blazicon Svg=\"Lucide.ArrowUpDown\" class=\"h-3.5 w-3.5 opacity-30\" />\n                }\n            </button>\n        }\n\n        @if (Column.Filterable)\n        {\n            <button type=\"button\"\n                    class=\"inline-flex items-center justify-center h-5 w-5 rounded hover:bg-muted/60 @(HasActiveFilter ? \"text-primary\" : \"text-muted-foreground opacity-0 group-hover/th:opacity-100\")\"\n                    @onclick=\"ToggleFilter\"\n                    @onclick:stopPropagation=\"true\">\n                <Blazicon Svg=\"Lucide.ListFilter\" class=\"h-3 w-3\" />\n            </button>\n        }\n    </div>\n\n    @if (IsFilterOpen && Column.Filterable)\n    {\n        <div class=\"absolute top-full left-0 z-50 mt-1\">\n            <DataGridColumnFilter TItem=\"TItem\"\n                                  Column=\"Column\"\n                                  FilterOptions=\"Column.FilterOptions\"\n                                  CurrentFilter=\"@CurrentFilter\"\n                                  OnApply=\"HandleFilterApply\" />\n        </div>\n    }\n\n    @if (Column.Resizable)\n    {\n        <div id=\"@_resizeHandleId\"\n             class=\"absolute right-0 top-0 bottom-0 w-1 cursor-col-resize hover:bg-primary/30 active:bg-primary/50\"\n             style=\"touch-action: none;\"\n             @onpointerdown=\"OnResizeStart\"\n             @onpointerdown:stopPropagation=\"true\"\n             @onpointerdown:preventDefault=\"true\"></div>\n    }\n\n    @if (_isDragOver && EffectiveReorderable && _dropSide is not null)\n    {\n        <div class=\"lumeo-datagrid-drop-indicator absolute top-0 bottom-0 w-0.5 bg-primary pointer-events-none\"\n             style=\"@(_dropSide == \"left\" ? \"left: 0;\" : \"right: 0;\")\"></div>\n    }\n</th>\n\n@code {\n    [CascadingParameter] private DataGridContext<TItem>? Context { get; set; }\n    [CascadingParameter] private DataGrid<TItem>? ParentGrid { get; set; }\n    [CascadingParameter] private DataGridDragState? DragState { get; set; }\n    [Inject] private ComponentInteropService Interop { get; set; } = default!;\n\n    [Parameter] public DataGridColumn<TItem> Column { get; set; } = default!;\n    [Parameter] public int ColumnIndex { get; set; }\n    /// <summary>1-based ARIA column index (includes any leading non-data columns).</summary>\n    [Parameter] public int AriaColIndex { get; set; }\n    [Parameter] public SortDirection SortDirection { get; set; } = SortDirection.None;\n    [Parameter] public bool HasActiveFilter { get; set; }\n    [Parameter] public bool IsFilterOpen { get; set; }\n    [Parameter] public bool Reorderable { get; set; }\n    [Parameter] public EventCallback OnFilterToggle { get; set; }\n    [Parameter] public EventCallback<FilterApplyEventArgs> OnFilterApply { get; set; }\n    [Parameter] public EventCallback<ColumnReorderEventArgs> OnColumnReorder { get; set; }\n    [Parameter] public int SortPriority { get; set; }\n    [Parameter] public int TotalSortCount { get; set; }\n    [Parameter] public string? Class { get; set; }\n    [Parameter(CaptureUnmatchedValues = true)] public Dictionary<string, object>? AdditionalAttributes { get; set; }\n\n    // --- Keyboard navigation / ARIA (rc.32) ---\n    private string? CellId => Context is null ? null : $\"{Context.GridId}-cell-h-{ColumnIndex}\";\n\n    private string? AriaSort => Column.Sortable\n        ? SortDirection switch\n        {\n            SortDirection.Ascending => \"ascending\",\n            SortDirection.Descending => \"descending\",\n            _ => \"none\"\n        }\n        : null;\n\n    // Roving tabindex: exactly one header/body cell carries tabindex=\"0\".\n    private int TabIndexValue =>\n        Context is not null && Context.FocusedRow == -1 && Context.FocusedCol == ColumnIndex ? 0 : -1;\n\n    private async Task HandleKeyDown(KeyboardEventArgs e)\n    {\n        // Note: we intentionally do NOT preventDefault here. Doing it conditionally\n        // per-key in Blazor requires the directive value to be known at render time,\n        // which would also block Tab from leaving the grid. Arrow keys may scroll the\n        // container a little, but FocusElement() scrolls the target cell back into\n        // view, so the net effect is correct.\n        if (Context is null) return;\n\n        if ((e.Key == \"Enter\" || e.Key == \" \" || e.Key == \"Spacebar\") && Column.Sortable)\n        {\n            await HandleSort();\n            return;\n        }\n\n        await Context.OnHeaderKeyDown(ColumnIndex, e);\n    }\n\n    private FilterDescriptor? CurrentFilter =>\n        Column?.Field is null || Context is null\n            ? null\n            : Context.Filters.FirstOrDefault(f => f.Field == Column.Field);\n\n    private string _resizeHandleId = $\"dg-resize-{Guid.NewGuid():N}\";\n    private bool _resizeRegistered;\n    private bool _isDragOver;\n    private string? _dropSide; // \"left\" or \"right\" — where the drop indicator is rendered\n\n    /// <summary>\n    /// Drag-reorder is only active when both the grid-level <c>Reorderable</c>\n    /// flag and the per-column <c>Reorderable</c> flag are true.\n    /// </summary>\n    private bool EffectiveReorderable => Reorderable && Column.Reorderable;\n\n    /// <summary>True when this header is HTML5-draggable. Two reasons to drag a\n    /// header: reorder columns (Reorderable, drop on another header) or add the\n    /// column as a new grouping level (Groupable, drop on the group panel —\n    /// rc.41). The two pathways share <see cref=\"DataGridDragState\"/> for the\n    /// source-column record; drop targets decide which action to take.</summary>\n    private bool IsDraggable =>\n        EffectiveReorderable\n        || (Context?.ShowGroupPanel == true && Column.Groupable);\n\n    private string CssClass\n    {\n        get\n        {\n            var pinClass = Column.Pin switch\n            {\n                PinDirection.Left => \"sticky left-0 z-20 bg-muted/30\",\n                PinDirection.Right => \"sticky right-0 z-20 bg-muted/30\",\n                _ => \"bg-muted/30\"\n            };\n            return string.Join(\" \", new[]\n            {\n                \"group/th relative px-3 py-2 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider\",\n                \"outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset\",\n                pinClass,\n                EffectiveReorderable ? \"cursor-grab\" : null,\n                Column.HeaderCssClass,\n                Class\n            }.Where(s => !string.IsNullOrWhiteSpace(s)));\n        }\n    }\n\n    private string? StyleString\n    {\n        get\n        {\n            var parts = new List<string>();\n            if (Column.Width.HasValue)\n            {\n                parts.Add($\"width: {Column.Width.Value}px\");\n                parts.Add($\"min-width: {Column.Width.Value}px\");\n            }\n            if (Column.Pin == PinDirection.Left && Context?.PinnedLeftOffsets.TryGetValue(Column.Id, out var leftOff) == true)\n                parts.Add($\"left: {leftOff}px\");\n            else if (Column.Pin == PinDirection.Right && Context?.PinnedRightOffsets.TryGetValue(Column.Id, out var rightOff) == true)\n                parts.Add($\"right: {rightOff}px\");\n            return parts.Count > 0 ? string.Join(\"; \", parts) + \";\" : null;\n        }\n    }\n\n    private async Task HandleSort()\n    {\n        if (!Column.Sortable || Context is null || Column.Field is null) return;\n\n        var newDirection = SortDirection switch\n        {\n            SortDirection.None => SortDirection.Ascending,\n            SortDirection.Ascending => SortDirection.Descending,\n            SortDirection.Descending => SortDirection.None,\n            _ => SortDirection.None\n        };\n\n        await Context.OnSort.InvokeAsync(new SortDescriptor(Column.Field, newDirection));\n    }\n\n    private async Task ToggleFilter()\n    {\n        await OnFilterToggle.InvokeAsync();\n    }\n\n    private async Task HandleFilterApply(FilterDescriptor? descriptor)\n    {\n        var args = new FilterApplyEventArgs(Column.Field, descriptor);\n        await OnFilterApply.InvokeAsync(args);\n    }\n\n    private async Task OnResizeStart(PointerEventArgs e)\n    {\n        if (!Column.Resizable) return;\n\n        if (!_resizeRegistered)\n        {\n            // Register once, pass the column's min/max constraints up-front. JS drags the\n            // header + body cells in the DOM directly (smooth, no Blazor round-trips per\n            // pixel) and invokes the commit callback with the final width on mouseup.\n            await Interop.RegisterColumnResize(\n                _resizeHandleId,\n                Column.MinWidth ?? 50,\n                Column.MaxWidth,\n                async finalWidth =>\n                {\n                    ParentGrid?.UpdateColumnWidth(Column.Id, finalWidth);\n                    await InvokeAsync(StateHasChanged);\n                });\n            _resizeRegistered = true;\n        }\n    }\n\n    // --- Column Drag & Drop Reordering ---\n    //\n    // Drag state is held by an instance-bound DataGridDragState cascaded from\n    // the owning DataGrid<TItem>. Replaces the previous static-class holder\n    // which was process-global and cross-talked between grid instances on the\n    // same page (drag started in grid A, dropped on grid B → grid B applied\n    // grid A's source index). Drop handler verifies SourceGridId matches via\n    // DragState.IsOwnDragColumn, otherwise the drop is rejected.\n\n    private void HandleDragStart(DragEventArgs e)\n    {\n        // rc.41: also start the drag when the column is Groupable + ShowGroupPanel\n        // (drag target = the group panel, not another header). The single\n        // StartColumnDrag call covers both code paths — reorder vs group is\n        // decided at the drop site.\n        if (!IsDraggable || DragState is null) return;\n        DragState.StartColumnDrag(ColumnIndex, Column.Id);\n    }\n\n    private void HandleDragOver(DragEventArgs e)\n    {\n        if (!EffectiveReorderable || DragState is null) return;\n        if (!DragState.IsOwnDragColumn(ColumnIndex)) return;\n        if (DragState.SourceColumnId == Column.Id) return;\n        // Choose the drop side based on drag direction:\n        // dragging from a lower index → drop AFTER this column (right edge).\n        // dragging from a higher index → drop BEFORE this column (left edge).\n        var src = DragState.SourceColumnIndex;\n        _dropSide = src < ColumnIndex ? \"right\" : \"left\";\n    }\n\n    private void HandleDragEnter(DragEventArgs e)\n    {\n        if (!EffectiveReorderable || DragState is null) return;\n        if (!DragState.IsOwnDragColumn(ColumnIndex)) return;\n        if (DragState.SourceColumnId == Column.Id) return;\n        _isDragOver = true;\n        var src = DragState.SourceColumnIndex;\n        _dropSide = src >= 0 && src < ColumnIndex ? \"right\" : \"left\";\n    }\n\n    private void HandleDragLeave(DragEventArgs e)\n    {\n        _isDragOver = false;\n        _dropSide = null;\n    }\n\n    private async Task HandleDrop(DragEventArgs e)\n    {\n        _isDragOver = false;\n        _dropSide = null;\n        if (!EffectiveReorderable || DragState is null) return;\n\n        // Reject drops from drags that originated in a different grid instance.\n        if (!DragState.IsOwnDragColumn(ColumnIndex))\n        {\n            DragState.ResetColumn();\n            return;\n        }\n\n        var sourceIndex = DragState.SourceColumnIndex;\n        var sourceId = DragState.SourceColumnId ?? Column.Id;\n\n        // Reset BEFORE invoking the consumer callback so re-entrant drops don't\n        // see a stale source index, and so a faulting consumer can't leave the\n        // grid in a \"drag in progress\" state.\n        DragState.ResetColumn();\n\n        if (sourceIndex < 0 || sourceIndex == ColumnIndex) return;\n\n        var args = new ColumnReorderEventArgs(sourceId, sourceIndex, ColumnIndex);\n        await OnColumnReorder.InvokeAsync(args);\n    }\n\n    public async ValueTask DisposeAsync()\n    {\n        if (_resizeRegistered)\n        {\n            try\n            {\n                await Interop.UnregisterColumnResize(_resizeHandleId);\n            }\n            catch (JSDisconnectedException) { }\n        }\n    }\n}\n"
    },
    {
      "path": "UI/DataGrid/DataGridLayoutService.cs",
      "content": "using Lumeo.Services;\n\nnamespace Lumeo;\n\ninternal sealed class DataGridLayoutService : IDisposable\n{\n    private readonly ComponentInteropService _interop;\n    private readonly Action<Exception>? _onError;\n    private System.Threading.Timer? _autoSaveTimer;\n    private int _saveGeneration;\n    private bool _layoutLoaded;\n    private DataGridLayout? _defaultLayout;\n\n    internal DataGridLayoutService(ComponentInteropService interop, Action<Exception>? onError = null)\n    {\n        _interop = interop;\n        _onError = onError;\n    }\n\n    internal bool LayoutLoaded => _layoutLoaded;\n    internal DataGridLayout? DefaultLayout { get => _defaultLayout; set => _defaultLayout = value; }\n\n    internal async Task LoadPersistedLayoutAsync(string key, Func<DataGridLayout, Task> applyAction)\n    {\n        if (_layoutLoaded) return;\n        try\n        {\n            var json = await _interop.LoadFromLocalStorage(key);\n            if (!string.IsNullOrEmpty(json))\n            {\n                var layout = System.Text.Json.JsonSerializer.Deserialize<DataGridLayout>(json);\n                if (layout is not null)\n                {\n                    _layoutLoaded = true;\n                    await applyAction(layout);\n                }\n            }\n        }\n        catch (Exception ex) { Console.Error.WriteLine($\"[Lumeo DataGrid] Failed to load persisted layout: {ex.Message}\"); _onError?.Invoke(ex); }\n    }\n\n    internal async Task PersistAsync(string key, DataGridLayout layout, int? generation = null)\n    {\n        // If a generation is provided, only persist if it's still current\n        if (generation.HasValue && generation.Value != _saveGeneration) return;\n        try\n        {\n            var json = System.Text.Json.JsonSerializer.Serialize(layout);\n            await _interop.SaveToLocalStorage(key, json);\n        }\n        catch (Exception ex) { Console.Error.WriteLine($\"[Lumeo DataGrid] Failed to save layout: {ex.Message}\"); _onError?.Invoke(ex); }\n    }\n\n    internal async Task ClearPersistedAsync(string key)\n    {\n        try { await _interop.RemoveFromLocalStorage(key); }\n        catch (Exception ex) { Console.Error.WriteLine($\"[Lumeo DataGrid] Failed to clear persisted layout: {ex.Message}\"); _onError?.Invoke(ex); }\n    }\n\n    internal async Task<List<DataGridNamedLayout>> GetPersonalLayoutsAsync(string key)\n    {\n        try\n        {\n            var json = await _interop.LoadFromLocalStorage(key);\n            if (!string.IsNullOrEmpty(json))\n                return System.Text.Json.JsonSerializer.Deserialize<List<DataGridNamedLayout>>(json) ?? new();\n        }\n        catch (Exception ex) { Console.Error.WriteLine($\"[Lumeo DataGrid] Failed to load named layouts: {ex.Message}\"); _onError?.Invoke(ex); }\n        return new();\n    }\n\n    internal async Task SaveNamedLayoutsAsync(string key, List<DataGridNamedLayout> layouts)\n    {\n        try\n        {\n            var json = System.Text.Json.JsonSerializer.Serialize(layouts);\n            await _interop.SaveToLocalStorage(key, json);\n        }\n        catch (Exception ex) { Console.Error.WriteLine($\"[Lumeo DataGrid] Failed to save named layouts: {ex.Message}\"); _onError?.Invoke(ex); }\n    }\n\n    internal int CurrentSaveGeneration => _saveGeneration;\n\n    internal void ScheduleAutoSave(Func<Task> persistCallback)\n    {\n        _autoSaveTimer?.Dispose();\n        ++_saveGeneration;\n        _autoSaveTimer = new System.Threading.Timer(_ =>\n        {\n            _ = Task.Run(async () =>\n            {\n                try { await persistCallback(); }\n                catch (Exception ex) { Console.Error.WriteLine($\"[Lumeo DataGrid] Autosave failed: {ex.Message}\"); }\n            });\n        }, null, 500, System.Threading.Timeout.Infinite);\n    }\n\n    public void Dispose()\n    {\n        _autoSaveTimer?.Dispose();\n    }\n}\n"
    },
    {
      "path": "UI/DataGrid/DataGridPagination.razor",
      "content": "@namespace Lumeo\n@typeparam TItem\n@inject Lumeo.Services.Localization.ILumeoLocalizer L\n\n@if (Context is not null)\n{\n    <div data-slot=\"datagrid-pagination\" class=\"@CssClass\" @attributes=\"AdditionalAttributes\">\n        @if (PageSizeOptions is { Length: > 0 })\n        {\n            <div class=\"flex items-center gap-2 text-sm text-muted-foreground whitespace-nowrap\">\n                <span class=\"text-xs\">@L[\"Pagination.RowsPerPage\"]</span>\n                <Select Value=\"@Context.PageSize.ToString()\" ValueChanged=\"@OnPageSizeSelected\">\n                    <SelectTrigger Class=\"h-8 w-[70px]\">\n                        <span class=\"text-sm\">@Context.PageSize</span>\n                    </SelectTrigger>\n                    <SelectContent>\n                        @foreach (var size in PageSizeOptions)\n                        {\n                            <SelectItem Value=\"@size.ToString()\">@size</SelectItem>\n                        }\n                    </SelectContent>\n                </Select>\n            </div>\n        }\n\n        <div class=\"ml-auto flex items-center gap-4 text-sm text-muted-foreground whitespace-nowrap\">\n            <span class=\"tabular-nums\">@L[\"Pagination.RangeOfTotal\", StartItem, EndItem, Context.TotalCount]</span>\n            <Pagination Class=\"!mx-0 !w-auto\">\n                <PaginationContent>\n                    <PaginationPrevious OnClick=\"@GoToPrevious\" Disabled=\"@(Context.CurrentPage <= 1)\" />\n                    @foreach (var page in GetPageNumbers())\n                    {\n                        if (page == -1)\n                        {\n                            <PaginationEllipsis />\n                        }\n                        else\n                        {\n                            var pageNum = page;\n                            <PaginationItem IsActive=\"@(pageNum == Context.CurrentPage)\" OnClick=\"@(() => GoToPage(pageNum))\">\n                                @(pageNum)\n                            </PaginationItem>\n                        }\n                    }\n                    <PaginationNext OnClick=\"@GoToNext\" Disabled=\"@(Context.CurrentPage >= TotalPages)\" />\n                </PaginationContent>\n            </Pagination>\n        </div>\n    </div>\n}\n\n@code {\n    [CascadingParameter] private DataGridContext<TItem>? Context { get; set; }\n    [Parameter] public EventCallback<int> PageChanged { get; set; }\n    [Parameter] public EventCallback<int> PageSizeChanged { get; set; }\n    /// <summary>Page-size options shown in the rows-per-page selector.\n    /// Empty array hides the selector entirely.</summary>\n    [Parameter] public int[] PageSizeOptions { get; set; } = new[] { 10, 25, 50, 100 };\n    [Parameter] public string? Class { get; set; }\n    [Parameter(CaptureUnmatchedValues = true)] public Dictionary<string, object>? AdditionalAttributes { get; set; }\n\n    private string CssClass => $\"flex flex-wrap items-center gap-3 px-4 py-3 border-t border-border/40 {Class}\".Trim();\n\n    private int TotalPages => Context is not null && Context.PageSize > 0\n        ? Math.Max(1, (int)Math.Ceiling((double)Context.TotalCount / Context.PageSize))\n        : 1;\n\n    private int StartItem => Context is not null && Context.TotalCount > 0\n        ? (Context.CurrentPage - 1) * Context.PageSize + 1\n        : 0;\n\n    private int EndItem => Context is not null\n        ? Math.Min(Context.CurrentPage * Context.PageSize, Context.TotalCount)\n        : 0;\n\n    private async Task GoToPage(int page)\n    {\n        await PageChanged.InvokeAsync(page);\n    }\n\n    private async Task GoToPrevious()\n    {\n        if (Context is not null && Context.CurrentPage > 1)\n        {\n            await PageChanged.InvokeAsync(Context.CurrentPage - 1);\n        }\n    }\n\n    private async Task GoToNext()\n    {\n        if (Context is not null && Context.CurrentPage < TotalPages)\n        {\n            await PageChanged.InvokeAsync(Context.CurrentPage + 1);\n        }\n    }\n\n    private async Task OnPageSizeSelected(string value)\n    {\n        if (int.TryParse(value, out var size))\n        {\n            await PageSizeChanged.InvokeAsync(size);\n        }\n    }\n\n    private List<int> GetPageNumbers()\n    {\n        var pages = new List<int>();\n        var total = TotalPages;\n        var current = Context?.CurrentPage ?? 1;\n\n        if (total <= 7)\n        {\n            for (var i = 1; i <= total; i++) pages.Add(i);\n        }\n        else\n        {\n            pages.Add(1);\n            if (current > 3) pages.Add(-1); // ellipsis\n            var start = Math.Max(2, current - 1);\n            var end = Math.Min(total - 1, current + 1);\n            for (var i = start; i <= end; i++) pages.Add(i);\n            if (current < total - 2) pages.Add(-1); // ellipsis\n            pages.Add(total);\n        }\n\n        return pages;\n    }\n}\n"
    },
    {
      "path": "UI/DataGrid/DataGridRow.razor",
      "content": "@namespace Lumeo\n@typeparam TItem\n@inject Lumeo.Services.Localization.ILumeoLocalizer L\n\n<tr data-slot=\"datagrid-row\" class=\"@CssClass\" style=\"@RowStyleString\"\n    role=\"row\"\n    aria-rowindex=\"@AriaRowIndex\"\n    aria-level=\"@AriaLevel\"\n    aria-expanded=\"@AriaExpanded\"\n    aria-selected=\"@(Context?.SelectionMode != DataGridSelectionMode.None ? (IsSelected ? \"true\" : \"false\") : null)\"\n    @onclick=\"HandleClick\"\n    @ondblclick=\"HandleDoubleClick\"\n    @oncontextmenu=\"HandleContextMenu\"\n    @oncontextmenu:preventDefault=\"@(Context?.RowContextMenuEnabled ?? false)\"\n    draggable=\"@(Context?.RowReorderable == true ? \"true\" : \"false\")\"\n    @ondragstart=\"HandleDragStart\"\n    @ondragover:preventDefault=\"true\"\n    @ondrop=\"HandleDrop\"\n    @attributes=\"AdditionalAttributes\">\n\n    @* Row drag handle *@\n    @if (Context?.RowReorderable == true)\n    {\n        <td role=\"gridcell\" class=\"px-1 py-2 w-8 cursor-grab active:cursor-grabbing\" @onclick:stopPropagation=\"true\">\n            <Blazicon Svg=\"Lucide.GripVertical\" class=\"h-4 w-4 text-muted-foreground/60\" />\n        </td>\n    }\n\n    @if (Context?.SelectionMode == DataGridSelectionMode.Multiple)\n    {\n        <td role=\"gridcell\" class=\"px-3 py-2 w-10\">\n            <Checkbox Checked=\"@IsSelected\" CheckedChanged=\"@OnCheckChanged\" />\n        </td>\n    }\n    else if (Context?.SelectionMode == DataGridSelectionMode.Single)\n    {\n        <td role=\"gridcell\" class=\"px-3 py-2 w-10\">\n            <button type=\"button\"\n                    class=\"h-4 w-4 rounded-full border border-primary @(IsSelected ? \"bg-primary\" : \"bg-background\")\"\n                    @onclick=\"@(() => Context?.ToggleSelection(Item))\"\n                    @onclick:stopPropagation=\"true\">\n                @if (IsSelected)\n                {\n                    <span class=\"block h-2 w-2 mx-auto rounded-full bg-primary-foreground\"></span>\n                }\n            </button>\n        </td>\n    }\n\n    @if (HasDetailTemplate)\n    {\n        <td role=\"gridcell\" class=\"px-1 py-2 w-8\">\n            <button type=\"button\"\n                    class=\"inline-flex items-center justify-center h-6 w-6 rounded hover:bg-muted transition-colors\"\n                    @onclick=\"ToggleExpand\"\n                    @onclick:stopPropagation=\"true\">\n                @if (IsExpanded)\n                {\n                    <Blazicon Svg=\"Lucide.ChevronDown\" class=\"h-4 w-4 text-muted-foreground\" />\n                }\n                else\n                {\n                    <Blazicon Svg=\"Lucide.ChevronRight\" class=\"h-4 w-4 text-muted-foreground\" />\n                }\n            </button>\n        </td>\n    }\n\n    @if (Context is not null)\n    {\n        @if (IsEditing)\n        {\n            @foreach (var column in Context.VisibleColumns)\n            {\n                <td @key=\"column.Id\" role=\"gridcell\" class=\"px-3 py-2 text-sm\">\n                    @if (column.EditTemplate is not null)\n                    {\n                        var editCtx = new CellEditContext<TItem>\n                        {\n                            Item = Item,\n                            Column = column,\n                            Value = Context.GetEditValue(Item, column),\n                            ValueChanged = () => StateHasChanged()\n                        };\n                        @column.EditTemplate(editCtx)\n                    }\n                    else\n                    {\n                        <input class=\"w-full h-7 rounded border border-input bg-background px-2 text-sm focus-visible:outline-none focus-visible:ring-[1.5px] focus-visible:ring-ring\"\n                               value=\"@(Context.GetEditValue(Item, column)?.ToString() ?? \"\")\"\n                               @oninput=\"@(e => Context.SetEditValue(Item, column, e.Value?.ToString()))\" />\n                    }\n                </td>\n            }\n            @* Save / Cancel buttons cell *@\n            <td role=\"gridcell\" class=\"px-3 py-2 text-sm w-24\">\n                <div class=\"flex items-center gap-1\">\n                    <button type=\"button\"\n                            class=\"inline-flex items-center justify-center h-6 w-6 rounded bg-primary text-primary-foreground hover:bg-primary/90\"\n                            title=\"@L[\"DataGrid.CommitEdit\"]\"\n                            @onclick=\"SaveRowEdit\"\n                            @onclick:stopPropagation=\"true\">\n                        <Blazicon Svg=\"Lucide.Check\" class=\"h-3.5 w-3.5\" />\n                    </button>\n                    <button type=\"button\"\n                            class=\"inline-flex items-center justify-center h-6 w-6 rounded border border-border hover:bg-muted\"\n                            title=\"@L[\"DataGrid.CancelEdit\"]\"\n                            @onclick=\"CancelRowEdit\"\n                            @onclick:stopPropagation=\"true\">\n                        <Blazicon Svg=\"Lucide.X\" class=\"h-3.5 w-3.5\" />\n                    </button>\n                </div>\n            </td>\n        }\n        else\n        {\n            var colIdx = 0;\n            var leadingCols = LeadingColumnCount;\n            @foreach (var column in Context.VisibleColumns)\n            {\n                var ci = colIdx;\n                <DataGridCell @key=\"column.Id\" TItem=\"TItem\" Item=\"Item\" Column=\"column\"\n                              RowIndex=\"RowIndex\"\n                              ColumnIndex=\"ci\"\n                              AriaColIndex=\"@(leadingCols + ci + 1)\"\n                              Navigable=\"@(!Context.IsGrouped && Context.GroupTree is null && !Context.IsTreeGrid)\" />\n                colIdx++;\n            }\n            @if (Context.EditMode == DataGridEditMode.Row)\n            {\n                <td role=\"gridcell\" class=\"px-3 py-2 text-sm w-10\">\n                    <button type=\"button\"\n                            class=\"inline-flex items-center justify-center h-6 w-6 rounded hover:bg-muted transition-colors\"\n                            title=\"@L[\"DataGrid.Edit\"]\"\n                            @onclick=\"StartRowEdit\"\n                            @onclick:stopPropagation=\"true\">\n                        <Blazicon Svg=\"Lucide.Pencil\" class=\"h-3.5 w-3.5 text-muted-foreground\" />\n                    </button>\n                </td>\n            }\n        }\n    }\n</tr>\n\n@if (IsExpanded && DetailTemplate is not null)\n{\n    <DataGridDetailRow TItem=\"TItem\" Item=\"Item\" DetailTemplate=\"DetailTemplate\" ColSpan=\"@ColSpan\" />\n}\n\n@code {\n    [CascadingParameter] private DataGridContext<TItem>? Context { get; set; }\n    [CascadingParameter] private DataGrid<TItem>? ParentGrid { get; set; }\n    [CascadingParameter] private DataGridDragState? DragStateCtx { get; set; }\n    [Parameter] public TItem Item { get; set; } = default!;\n    [Parameter] public int RowIndex { get; set; }\n    [Parameter] public RenderFragment<TItem>? DetailTemplate { get; set; }\n    [Parameter] public EventCallback<(TItem, double, double)> OnContextMenu { get; set; }\n    [Parameter] public string? Class { get; set; }\n    [Parameter(CaptureUnmatchedValues = true)] public Dictionary<string, object>? AdditionalAttributes { get; set; }\n\n    private bool IsSelected => Context?.IsRowSelected(Item) ?? false;\n    private bool IsExpanded => Context?.IsRowExpanded(Item) ?? false;\n    private bool IsEditing => Context?.IsRowEditing(Item) ?? false;\n    private bool HasDetailTemplate => DetailTemplate is not null;\n\n    // 1-based ARIA row index; header occupies row 1 so body rows start at 2.\n    private int AriaRowIndex => RowIndex + 2;\n\n    // ARIA tree-grid attributes — only meaningful when IsTreeGrid is true.\n    private int? AriaLevel => (Context?.IsTreeGrid == true) ? (Context.TreeLevelOf(Item) + 1) : null;\n    private string? AriaExpanded => (Context?.IsTreeGrid == true) ? (IsExpanded ? \"true\" : \"false\") : null;\n\n    /// <summary>Count of non-data leading columns rendered before the first data\n    /// cell (drag handle, selection, detail toggle). Mirrors DataGridHeader so\n    /// aria-colindex values line up between header and body.</summary>\n    private int LeadingColumnCount\n    {\n        get\n        {\n            var n = 0;\n            if (Context?.RowReorderable == true) n++;\n            if (Context?.SelectionMode != DataGridSelectionMode.None) n++;\n            if (HasDetailTemplate) n++;\n            return n;\n        }\n    }\n\n    private int ColSpan\n    {\n        get\n        {\n            var count = Context?.VisibleColumns.Count ?? 0;\n            if (Context?.SelectionMode != DataGridSelectionMode.None) count++;\n            if (HasDetailTemplate) count++;\n            if (Context?.RowReorderable == true) count++;\n            if (Context?.EditMode == DataGridEditMode.Row) count++;\n            return count;\n        }\n    }\n\n    // Memoization: at 1k rows × N renders, allocating the joined class string\n    // every access is measurable. We hash the inputs that influence the result\n    // into a value tuple and return the cached string when the key matches.\n    // Under-caching is safe (we recompute, same as before); over-caching would\n    // be a regression — every input that mutates the output participates in the key.\n    private (object Key, string Css)? _cssCache;\n\n    private string CssClass\n    {\n        get\n        {\n            var custom = Context?.RowClass?.Invoke(Item);\n            var key = (RowIndex, IsSelected, IsEditing, custom, Class);\n            if (_cssCache is { } c && c.Key.Equals(key)) return c.Css;\n\n            var css = string.Join(\" \", new[]\n            {\n                \"border-b border-border/40 transition-colors hover:bg-muted/50\",\n                IsSelected ? \"bg-primary/5\" : null,\n                IsEditing ? \"bg-muted/20\" : null,\n                custom,\n                Class\n            }.Where(s => !string.IsNullOrWhiteSpace(s)));\n            _cssCache = (key, css);\n            return css;\n        }\n    }\n\n    private string? RowStyleString => Context?.RowStyle?.Invoke(Item);\n\n    private async Task HandleClick()\n    {\n        if (Context is not null)\n        {\n            if (Context.SelectionMode != DataGridSelectionMode.None)\n            {\n                Context.ToggleSelection(Item);\n            }\n            await Context.OnRowClick.InvokeAsync(Item);\n        }\n    }\n\n    private async Task HandleDoubleClick()\n    {\n        if (Context is not null)\n        {\n            await Context.OnRowDoubleClick.InvokeAsync(Item);\n        }\n    }\n\n    private async Task HandleContextMenu(MouseEventArgs e)\n    {\n        if (Context is not null && OnContextMenu.HasDelegate)\n        {\n            await OnContextMenu.InvokeAsync((Item, e.ClientX, e.ClientY));\n        }\n    }\n\n    private void ToggleExpand()\n    {\n        Context?.ToggleRowExpand(Item);\n    }\n\n    private async Task OnCheckChanged(bool value)\n    {\n        Context?.ToggleSelection(Item);\n    }\n\n    // --- Row Editing ---\n\n    private void StartRowEdit()\n    {\n        Context?.StartRowEdit(Item);\n    }\n\n    private async Task SaveRowEdit()\n    {\n        if (Context is not null)\n            await Context.CommitRowEdit(Item);\n    }\n\n    private void CancelRowEdit()\n    {\n        Context?.CancelRowEdit(Item);\n    }\n\n    // --- Row Drag & Drop ---\n    //\n    // Drag state is held by an instance-bound DataGridDragState cascaded from\n    // the owning DataGrid<TItem> (see DataGridHeaderCell for the column drag\n    // counterpart). This replaces the previous static-class holder which was\n    // process-global; cross-grid drops were applied to the wrong grid. The\n    // drop handler now verifies the drag originated in this grid via\n    // DragStateCtx.IsOwnDragRow().\n\n    private void HandleDragStart(DragEventArgs e)\n    {\n        if (DragStateCtx is null) return;\n        DragStateCtx.StartRowDrag(RowIndex);\n    }\n\n    private void HandleDrop(DragEventArgs e)\n    {\n        if (DragStateCtx is null) return;\n\n        // Reject cross-grid drops — only honor drags that originated in our owning grid.\n        if (!DragStateCtx.IsOwnDragRow())\n        {\n            DragStateCtx.ResetRow();\n            return;\n        }\n\n        var sourceIndex = DragStateCtx.SourceRowIndex;\n        DragStateCtx.ResetRow();\n\n        if (sourceIndex < 0 || sourceIndex == RowIndex) return;\n        ParentGrid?.MoveRow(sourceIndex, RowIndex);\n    }\n}\n"
    },
    {
      "path": "UI/DataGrid/DataGridServerService.cs",
      "content": "using Microsoft.AspNetCore.Components;\n\nnamespace Lumeo;\n\ninternal sealed class DataGridServerService : IDisposable\n{\n    private CancellationTokenSource? _requestCts;\n    private CancellationTokenSource? _searchCts;\n    private int _requestGeneration;\n\n    internal bool IsLoading { get; private set; }\n    internal string? Error { get; private set; }\n\n    internal async Task RequestDataAsync(\n        int currentPage,\n        int pageSize,\n        List<SortDescriptor>? sorts,\n        List<FilterDescriptor>? filters,\n        string? globalSearch,\n        string? groupBy,\n        EventCallback<DataGridServerRequest> onServerRequest,\n        EventCallback<Exception>? onError,\n        Action stateChanged)\n    {\n        _requestCts?.Cancel();\n        _requestCts?.Dispose();\n        _requestCts = new CancellationTokenSource();\n        var generation = ++_requestGeneration;\n\n        Error = null;\n        IsLoading = true;\n        stateChanged();\n\n        if (onServerRequest.HasDelegate)\n        {\n            var request = new DataGridServerRequest(\n                currentPage,\n                pageSize,\n                sorts is { Count: > 0 } ? sorts : null,\n                filters is { Count: > 0 } ? filters : null,\n                globalSearch,\n                groupBy,\n                _requestCts.Token);\n            try\n            {\n                await onServerRequest.InvokeAsync(request);\n            }\n            catch (OperationCanceledException)\n            {\n                // Request was superseded — ignore\n                return;\n            }\n            catch (Exception ex)\n            {\n                Error = ex.Message;\n                if (onError?.HasDelegate == true)\n                    await onError.Value.InvokeAsync(ex);\n            }\n            finally\n            {\n                // Only the latest request should reset loading state\n                if (generation == _requestGeneration)\n                {\n                    IsLoading = false;\n                    stateChanged();\n                }\n            }\n        }\n        else\n        {\n            IsLoading = false;\n            stateChanged();\n        }\n    }\n\n    internal void DebounceSearch(Func<CancellationToken, Task> work, int delayMs = 300)\n    {\n        _searchCts?.Cancel();\n        _searchCts?.Dispose();\n        _searchCts = new CancellationTokenSource();\n        var token = _searchCts.Token;\n\n        _ = RunDebouncedAsync(work, delayMs, token);\n    }\n\n    private async Task RunDebouncedAsync(Func<CancellationToken, Task> work, int delayMs, CancellationToken ct)\n    {\n        try\n        {\n            await Task.Delay(delayMs, ct);\n            if (ct.IsCancellationRequested) return;\n            await work(ct);\n        }\n        catch (OperationCanceledException) { /* superseded by newer call or disposed */ }\n        catch (Exception ex)\n        {\n            Console.Error.WriteLine($\"[DataGridServerService] debounced search error: {ex}\");\n        }\n    }\n\n    internal void ClearError() => Error = null;\n\n    public void Dispose()\n    {\n        _searchCts?.Cancel();\n        _searchCts?.Dispose();\n        _searchCts = null;\n        _requestCts?.Cancel();\n        _requestCts?.Dispose();\n    }\n}\n"
    },
    {
      "path": "UI/DataGrid/DataGridState.cs",
      "content": "namespace Lumeo;\n\n/// <summary>\n/// Flags enum controlling which export formats appear in the <see cref=\"DataGrid{TItem}\"/>\n/// toolbar's Export dropdown. Defaults to <see cref=\"All\"/> so existing grids keep\n/// listing every format. Set to a subset (e.g. <c>Csv | Excel</c>) to hide individual\n/// formats — useful in WebAssembly where QuestPDF's PDF export throws\n/// <see cref=\"System.PlatformNotSupportedException\"/>.\n/// </summary>\n[Flags]\npublic enum DataGridExportFormat\n{\n    /// <summary>No formats available — the Export button is hidden entirely.</summary>\n    None = 0,\n    /// <summary>Comma-separated values.</summary>\n    Csv = 1 << 0,\n    /// <summary>Excel spreadsheet (currently emitted as CSV with BOM).</summary>\n    Excel = 1 << 1,\n    /// <summary>Portable Document Format (requires host platform support).</summary>\n    Pdf = 1 << 2,\n    /// <summary>All supported export formats.</summary>\n    All = Csv | Excel | Pdf,\n}\n\npublic record SortDescriptor(string Field, SortDirection Direction);\n\npublic record FilterDescriptor(\n    string Field,\n    FilterOperator Operator,\n    object? Value,\n    object? ValueTo = null,\n    DataGridFilterType FilterType = DataGridFilterType.Text\n);\n\npublic record FilterOption(string Label, object Value);\n\n/// <summary>\n/// Context passed to <see cref=\"DataGridColumn{TItem}.FilterTemplate\"/> when a consumer\n/// provides a custom filter UI for a column. The template is responsible for rendering its\n/// own controls and invoking <see cref=\"Apply\"/> with the new <see cref=\"FilterDescriptor\"/>\n/// when the user commits, or null to clear the filter.\n/// </summary>\npublic record DataGridFilterTemplateContext(\n    string? Field,\n    FilterDescriptor? CurrentFilter,\n    Func<FilterDescriptor?, Task> Apply\n);\n\npublic record DataGridServerRequest(\n    int Page,\n    int PageSize,\n    List<SortDescriptor>? Sorts,\n    List<FilterDescriptor>? Filters,\n    string? GlobalSearch,\n    string? GroupBy = null,\n    CancellationToken CancellationToken = default\n);\n\npublic record DataGridServerResponse<TItem>(\n    IEnumerable<TItem> Items,\n    int TotalCount\n);\n\n/// <summary>\n/// Request issued by virtualised server mode (<c>Virtualized=true</c> +\n/// <c>OnRangeRequest</c>). Unlike <see cref=\"DataGridServerRequest\"/>,\n/// this is range-based — the grid asks for a window of N items starting\n/// at <see cref=\"StartIndex\"/>, driven by the user's scroll position\n/// rather than page numbers. Sort/filter/search context is still passed\n/// so the backend can apply the same query as the rest of the grid.\n/// </summary>\npublic record DataGridRangeRequest(\n    int StartIndex,\n    int Count,\n    List<SortDescriptor>? Sorts,\n    List<FilterDescriptor>? Filters,\n    string? GlobalSearch,\n    CancellationToken CancellationToken = default\n);\n\n/// <summary>\n/// Response from a virtualised server-mode range fetch. <see cref=\"Items\"/>\n/// is the slice the backend returned for the requested window;\n/// <see cref=\"TotalCount\"/> is the total number of matching rows server-side\n/// (used to size the scroll spacers).\n/// </summary>\npublic record DataGridRangeResponse<TItem>(\n    IReadOnlyList<TItem> Items,\n    int TotalCount\n);\n\npublic record CellEditEventArgs<TItem>(\n    TItem Item,\n    string Field,\n    object? OldValue,\n    object? NewValue\n);\n\npublic record RowExpandEventArgs<TItem>(\n    TItem Item,\n    bool IsExpanded\n);\n\npublic record ColumnReorderEventArgs(\n    string ColumnId,\n    int OldIndex,\n    int NewIndex\n);\n\npublic record FilterApplyEventArgs(\n    string? Field,\n    FilterDescriptor? Filter\n);\n\npublic record RowEditEventArgs<TItem>(\n    TItem Item,\n    Dictionary<string, object?> ChangedValues\n);\n\npublic record RowReorderEventArgs<TItem>(\n    TItem Item,\n    int OldIndex,\n    int NewIndex\n);\n\n/// <summary>\n/// A single row's pending change set in <see cref=\"DataGridEditMode.Batch\"/> mode.\n/// <see cref=\"IsNew\"/> is true for rows created via the \"+ Add row\" trigger\n/// (in which case <see cref=\"ChangedFields\"/> still carries every field the user\n/// touched). <see cref=\"ChangedFields\"/> maps the column <c>Field</c> to its new value.\n/// </summary>\npublic record DataGridBatchChange<TItem>(\n    TItem Item,\n    Dictionary<string, object?> ChangedFields,\n    bool IsNew\n);\n\n/// <summary>\n/// Event args for <see cref=\"DataGrid{TItem}.OnBatchSave\"/>. Carries the buffered\n/// edits when the user clicks \"Save all\". <see cref=\"Modified\"/> are existing rows\n/// with pending field changes; <see cref=\"Added\"/> are new rows from the add-row\n/// trigger. <see cref=\"All\"/> is the concatenation for convenience. The consumer\n/// applies these to its backing store; on success the grid's buffer is cleared\n/// automatically once the handler returns without throwing.\n/// </summary>\npublic record DataGridBatchSaveEventArgs<TItem>(\n    IReadOnlyList<DataGridBatchChange<TItem>> Modified,\n    IReadOnlyList<DataGridBatchChange<TItem>> Added\n)\n{\n    public IEnumerable<DataGridBatchChange<TItem>> All => Modified.Concat(Added);\n}\n\npublic class DataGridLayout\n{\n    public string? Name { get; set; }\n    public List<ColumnLayout> Columns { get; set; } = new();\n    public List<SortDescriptor>? Sorts { get; set; }\n    public List<FilterDescriptor>? Filters { get; set; }\n    public int? PageSize { get; set; }\n    public string? GlobalSearch { get; set; }\n}\n\npublic class ColumnLayout\n{\n    public string Field { get; set; } = \"\";\n    public double? Width { get; set; }\n    public bool Visible { get; set; } = true;\n    public PinDirection Pin { get; set; } = PinDirection.None;\n    public int Order { get; set; }\n}\n\npublic record DataGridGroupSection<TItem>(string Key, List<TItem> Items, int TotalCount)\n{\n    /// <summary>Per-group aggregate strip (one entry per column that declares an\n    /// <see cref=\"AggregateType\"/>). Populated by the grid when grouping is active.</summary>\n    public IReadOnlyList<DataGridGroupAggregate> Aggregates { get; init; } = Array.Empty<DataGridGroupAggregate>();\n}\n\n/// <summary>A single per-group aggregate value, keyed by the column it belongs to.</summary>\n/// <param name=\"ColumnId\">The owning <see cref=\"DataGridColumn{TItem}.Id\"/>.</param>\n/// <param name=\"Label\">Localized aggregate label (e.g. \"Sum\").</param>\n/// <param name=\"Value\">Pre-formatted aggregate value.</param>\npublic record DataGridGroupAggregate(string ColumnId, string Label, string Value);\n\n/// <summary>\n/// A node in a multi-level grouping tree. <see cref=\"Children\"/> is non-empty for\n/// every level except the deepest; <see cref=\"Items\"/> carries the leaf rows at the\n/// deepest level (empty for intermediate nodes). <see cref=\"Path\"/> is a stable\n/// \"/\"-joined identity used for expand/collapse state (\"Region/Country\").\n/// </summary>\npublic record DataGridGroupNode<TItem>(\n    string Key,\n    string Path,\n    int Level,\n    string Field,\n    int TotalCount,\n    List<TItem> Items,\n    List<DataGridGroupNode<TItem>> Children)\n{\n    public IReadOnlyList<DataGridGroupAggregate> Aggregates { get; init; } = Array.Empty<DataGridGroupAggregate>();\n}\n\npublic record DataGridNamedLayout(\n    string Id,\n    string Name,\n    string Scope,  // \"Personal\", \"Global\", \"SystemDefault\"\n    DataGridLayout Layout\n);\n\n/// <summary>\n/// Public, JSON-serializable snapshot of a DataGrid's persistable state. This is\n/// the canonical shape used by <c>DataGrid&lt;TItem&gt;.ExportLayout()</c> and\n/// <c>DataGrid&lt;TItem&gt;.ApplyLayoutJsonAsync()</c>. Consumers can store the\n/// resulting JSON in any backend — DB, file, remote API, cookies — and round-trip\n/// it later. The <see cref=\"Version\"/> field is bumped whenever the schema changes\n/// so older payloads can be migrated or rejected.\n/// </summary>\npublic record DataGridLayoutSnapshot(\n    int Version,\n    List<DataGridColumnLayout> Columns,\n    List<SortDescriptor> Sorts,\n    List<FilterDescriptor> Filters,\n    string? GlobalSearch,\n    int CurrentPage,\n    int PageSize,\n    string? GroupBy\n);\n\n/// <summary>\n/// Per-column layout entry inside a <see cref=\"DataGridLayoutSnapshot\"/>.\n/// </summary>\npublic record DataGridColumnLayout(\n    string Field,\n    int Order,\n    bool Visible,\n    double? Width,\n    PinDirection? Pin\n);\n"
    },
    {
      "path": "UI/DataGrid/DataGridToolbar.razor",
      "content": "@namespace Lumeo\n@typeparam TItem\n@inject Lumeo.Services.Localization.ILumeoLocalizer L\n\n<div data-slot=\"datagrid-toolbar\" class=\"@CssClass\" @attributes=\"AdditionalAttributes\">\n    <div class=\"@ContentWrapperClass\">\n        @if (ShowSearch)\n        {\n            <div class=\"relative\">\n                <Blazicon Svg=\"Lucide.Search\" class=\"absolute left-2.5 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground\" />\n                <input type=\"text\"\n                       placeholder=\"@L[\"DataGrid.SearchPlaceholder\"]\"\n                       class=\"h-8 w-56 rounded-md border border-input bg-transparent pl-8 pr-3 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-[1.5px] focus-visible:ring-ring\"\n                       value=\"@SearchText\"\n                       @oninput=\"OnSearchInput\" />\n            </div>\n        }\n\n        @if (RegisteredContent?.ChildContent is not null)\n        {\n            @RegisteredContent.ChildContent\n        }\n\n        @if (Context is not null)\n        {\n            @foreach (var filter in Context.Filters)\n            {\n                <div class=\"flex items-center gap-1 rounded-full bg-muted px-2.5 py-1 text-xs\">\n                    <span class=\"font-medium\">@filter.Field</span>\n                    <span class=\"text-muted-foreground\">@GetOperatorLabel(filter.Operator)</span>\n                    <span>@filter.Value</span>\n                    <button type=\"button\"\n                            class=\"ml-1 inline-flex items-center justify-center h-4 w-4 rounded-full hover:bg-muted-foreground/20\"\n                            @onclick=\"@(() => RemoveFilter(filter))\">\n                        <Blazicon Svg=\"Lucide.X\" class=\"h-3 w-3\" />\n                    </button>\n                </div>\n            }\n        }\n    </div>\n\n    @* When the consumer supplies a <ToolbarContent>, they take full control of the\n       tools — the default right-hand stack is suppressed to avoid duplicates. Each\n       tool inside ToolbarContent renders wherever the consumer places it. *@\n    @if (RegisteredContent is null)\n    {\n        <div class=\"flex items-center gap-2\">\n            @* Expand to fullscreen *@\n            @if (Expandable)\n            {\n                <DataGridToolbarFullscreen TItem=\"TItem\" />\n            }\n\n            @* Copy selected rows to clipboard *@\n            @if (SelectedItems is not null && SelectedItems.Count > 0 && EffectiveColumns is not null)\n            {\n                <DataGridToolbarCopySelected TItem=\"TItem\" />\n            }\n\n            @* Column visibility dropdown *@\n            @if (ShowColumnChooser)\n            {\n                <DataGridToolbarColumns TItem=\"TItem\" />\n            }\n\n            @* Export dropdown — hidden when ShowExport=false OR ExportFormats has no flags set. *@\n            @if (ShowExport && ExportFormats != DataGridExportFormat.None)\n            {\n                <DataGridToolbarExport TItem=\"TItem\" />\n            }\n\n            @* Layouts dropdown *@\n            @if (EnableLayoutPersistence)\n            {\n                <DataGridToolbarLayouts TItem=\"TItem\" />\n            }\n        </div>\n    }\n</div>\n\n@code {\n    [CascadingParameter] private DataGridContext<TItem>? Context { get; set; }\n\n    [Parameter] public string? SearchText { get; set; }\n    [Parameter] public EventCallback<string> SearchTextChanged { get; set; }\n\n    /// <summary>The <see cref=\"ToolbarContent{TItem}\"/> component the consumer placed\n    /// inside the DataGrid, forwarded by the parent grid. When null, the toolbar's left\n    /// slot renders just the search + filter chips; when non-null, its Class styles the\n    /// wrapper and its ChildContent renders alongside the chips.</summary>\n    [Parameter] public ToolbarContent<TItem>? RegisteredContent { get; set; }\n    [Parameter] public EventCallback OnColumnVisibilityToggle { get; set; }\n    [Parameter] public EventCallback<DataGridColumn<TItem>> OnColumnToggle { get; set; }\n    [Parameter] public EventCallback<ColumnReorderEventArgs> OnColumnReorder { get; set; }\n    [Parameter] public EventCallback<string> OnExport { get; set; }\n    [Parameter] public EventCallback<FilterDescriptor> OnRemoveFilter { get; set; }\n    [Parameter] public List<TItem>? SelectedItems { get; set; }\n    [Parameter] public List<TItem>? AllItems { get; set; }\n    [Parameter] public IReadOnlyList<DataGridColumn<TItem>>? EffectiveColumns { get; set; }\n    [Parameter] public bool EnableLayoutPersistence { get; set; }\n    [Parameter] public EventCallback OnSaveLayout { get; set; }\n    [Parameter] public EventCallback OnResetLayout { get; set; }\n    [Parameter] public List<DataGridNamedLayout>? GlobalLayouts { get; set; }\n    [Parameter] public EventCallback<DataGridNamedLayout> OnSaveNamedLayout { get; set; }\n    [Parameter] public EventCallback<string> OnDeleteNamedLayout { get; set; }\n    [Parameter] public EventCallback<DataGridNamedLayout> OnApplyNamedLayout { get; set; }\n    [Parameter] public string? LayoutStorageKey { get; set; }\n    [Parameter] public EventCallback<Exception> OnError { get; set; }\n    [Parameter] public bool Expandable { get; set; }\n    [Parameter] public bool IsExpanded { get; set; }\n    [Parameter] public EventCallback OnToggleExpanded { get; set; }\n\n    /// <summary>Show the global search box. Default true.</summary>\n    [Parameter] public bool ShowSearch { get; set; } = true;\n\n    /// <summary>Show the Columns (visibility/reorder) button. Default true.</summary>\n    [Parameter] public bool ShowColumnChooser { get; set; } = true;\n\n    /// <summary>Show the Export dropdown. Default true. Combined with <see cref=\"ExportFormats\"/>.</summary>\n    [Parameter] public bool ShowExport { get; set; } = true;\n\n    /// <summary>Which export formats to include in the dropdown. Default <see cref=\"DataGridExportFormat.All\"/>.</summary>\n    [Parameter] public DataGridExportFormat ExportFormats { get; set; } = DataGridExportFormat.All;\n\n    [Parameter] public string? Class { get; set; }\n    [Parameter(CaptureUnmatchedValues = true)] public Dictionary<string, object>? AdditionalAttributes { get; set; }\n\n    private string CssClass => $\"flex items-center gap-2 px-4 py-3 border-b border-border/40 {Class}\".Trim();\n    private string ContentWrapperClass => $\"flex items-center gap-2 flex-1 {RegisteredContent?.Class}\".Trim();\n\n    private async Task OnSearchInput(ChangeEventArgs e)\n    {\n        var value = e.Value?.ToString() ?? \"\";\n        await SearchTextChanged.InvokeAsync(value);\n    }\n\n    private async Task RemoveFilter(FilterDescriptor filter)\n    {\n        await OnRemoveFilter.InvokeAsync(filter);\n    }\n\n    private string GetOperatorLabel(FilterOperator op) => op switch\n    {\n        FilterOperator.Contains => L[\"Filter.Contains\"],\n        FilterOperator.NotContains => L[\"Filter.DoesNotContain\"],\n        FilterOperator.Equals => \"=\",\n        FilterOperator.NotEquals => \"!=\",\n        FilterOperator.StartsWith => L[\"Filter.StartsWith\"],\n        FilterOperator.EndsWith => L[\"Filter.EndsWith\"],\n        FilterOperator.GreaterThan => \">\",\n        FilterOperator.GreaterThanOrEqual => \">=\",\n        FilterOperator.LessThan => \"<\",\n        FilterOperator.LessThanOrEqual => \"<=\",\n        FilterOperator.IsEmpty => L[\"Filter.IsEmpty\"],\n        FilterOperator.IsNotEmpty => L[\"Filter.IsNotEmpty\"],\n        FilterOperator.Between => L[\"Filter.Between\"],\n        _ => op.ToString()\n    };\n}\n"
    },
    {
      "path": "UI/DataGrid/DataGridToolbarColumns.razor",
      "content": "@namespace Lumeo\n@typeparam TItem\n\n@if (Context is not null && Context.EffectiveColumns is not null)\n{\n    <div class=\"relative\">\n        <button type=\"button\"\n                class=\"inline-flex items-center justify-center h-8 px-3 rounded-md text-sm border border-input bg-background hover:bg-accent hover:text-accent-foreground transition-colors\"\n                @onclick=\"ToggleColumnPanel\">\n            <Blazicon Svg=\"Lucide.Columns3\" class=\"h-4 w-4 mr-1.5\" />\n            <span>@Context.Localizer[\"DataGrid.Columns\"]</span>\n        </button>\n\n        @if (_columnPanelOpen)\n        {\n            <div class=\"absolute right-0 top-full mt-1 z-50\">\n                <DataGridColumnVisibility TItem=\"TItem\"\n                                          Columns=\"Context.EffectiveColumns\"\n                                          OnColumnToggle=\"Context.OnColumnToggle\"\n                                          OnColumnReorder=\"Context.OnColumnReorder\" />\n            </div>\n        }\n    </div>\n}\n\n@code {\n    [CascadingParameter] public DataGridToolbarContext<TItem>? Context { get; set; }\n\n    private bool _columnPanelOpen;\n\n    private void ToggleColumnPanel()\n    {\n        _columnPanelOpen = !_columnPanelOpen;\n    }\n}\n"
    },
    {
      "path": "UI/DataGrid/DataGridToolbarContext.cs",
      "content": "using Microsoft.AspNetCore.Components;\nusing Lumeo.Services;\n\nnamespace Lumeo;\n\n/// <summary>State + callbacks shared with the extracted toolbar tool components\n/// (<see cref=\"DataGridToolbarFullscreen{TItem}\"/>, <see cref=\"DataGridToolbarExport{TItem}\"/>, etc.)\n/// via <see cref=\"CascadingValue{T}\"/>. The generic\n/// parameter matches the grid's row type so column/selection tools stay type-safe.</summary>\npublic sealed class DataGridToolbarContext<TItem>\n{\n    public bool IsExpanded { get; set; }\n    public bool Expandable { get; set; }\n    public EventCallback OnToggleExpanded { get; set; }\n\n    public List<TItem>? SelectedItems { get; set; }\n    public IReadOnlyList<DataGridColumn<TItem>>? EffectiveColumns { get; set; }\n    public EventCallback<DataGridColumn<TItem>> OnColumnToggle { get; set; }\n    public EventCallback<ColumnReorderEventArgs> OnColumnReorder { get; set; }\n\n    public DataGridExportFormat ExportFormats { get; set; } = DataGridExportFormat.All;\n    public EventCallback<string> OnExport { get; set; }\n\n    public bool EnableLayoutPersistence { get; set; }\n    public List<DataGridNamedLayout>? GlobalLayouts { get; set; }\n    public EventCallback OnSaveLayout { get; set; }\n    public EventCallback OnResetLayout { get; set; }\n    public EventCallback<DataGridNamedLayout> OnSaveNamedLayout { get; set; }\n    public EventCallback<string> OnDeleteNamedLayout { get; set; }\n    public EventCallback<DataGridNamedLayout> OnApplyNamedLayout { get; set; }\n    public string? LayoutStorageKey { get; set; }\n    public EventCallback<Exception> OnError { get; set; }\n\n    public ComponentInteropService Interop { get; set; } = default!;\n    public Services.Localization.ILumeoLocalizer Localizer { get; set; } = default!;\n}\n"
    },
    {
      "path": "UI/DataGrid/DataGridToolbarCopySelected.razor",
      "content": "@namespace Lumeo\n@typeparam TItem\n\n@if (Context is not null && Context.SelectedItems is { Count: > 0 } selected && Context.EffectiveColumns is not null)\n{\n    <button type=\"button\"\n            class=\"inline-flex items-center justify-center h-8 px-3 rounded-md text-sm border border-input bg-background hover:bg-accent hover:text-accent-foreground transition-colors\"\n            title=\"@Context.Localizer[\"DataGrid.CopySelected\", selected.Count]\"\n            @onclick=\"CopySelected\">\n        <Blazicon Svg=\"Lucide.Clipboard\" class=\"h-4 w-4 mr-1.5\" />\n        <span>@Context.Localizer[\"DataGrid.CopySelected\", selected.Count]</span>\n    </button>\n}\n\n@code {\n    [CascadingParameter] public DataGridToolbarContext<TItem>? Context { get; set; }\n\n    private async Task CopySelected()\n    {\n        if (Context is null) return;\n        if (Context.SelectedItems is null || Context.SelectedItems.Count == 0 || Context.EffectiveColumns is null) return;\n\n        var visibleColumns = Context.EffectiveColumns.Where(c => c.Visible).ToList();\n        var lines = new List<string>();\n\n        // Header row\n        lines.Add(string.Join(\"\\t\", visibleColumns.Select(c => c.Title ?? c.Field ?? \"\")));\n\n        // Data rows\n        foreach (var item in Context.SelectedItems)\n        {\n            lines.Add(string.Join(\"\\t\", visibleColumns.Select(c => c.GetFormattedValue(item))));\n        }\n\n        var text = string.Join(\"\\n\", lines);\n        await Context.Interop.CopyToClipboard(text);\n    }\n}\n"
    },
    {
      "path": "UI/DataGrid/DataGridToolbarExport.razor",
      "content": "@namespace Lumeo\n@typeparam TItem\n\n@if (Context is not null && Context.ExportFormats != DataGridExportFormat.None)\n{\n    <div class=\"relative\">\n        <button type=\"button\"\n                class=\"inline-flex items-center justify-center h-8 px-3 rounded-md text-sm border border-input bg-background hover:bg-accent hover:text-accent-foreground transition-colors\"\n                @onclick=\"ToggleExportMenu\">\n            <Blazicon Svg=\"Lucide.Download\" class=\"h-4 w-4 mr-1.5\" />\n            <span>@Context.Localizer[\"DataGrid.Export\"]</span>\n            <Blazicon Svg=\"Lucide.ChevronDown\" class=\"h-3.5 w-3.5 ml-1\" />\n        </button>\n\n        @if (_exportMenuOpen)\n        {\n            <div class=\"absolute right-0 top-full mt-1 z-50 min-w-[140px] rounded-md border border-border/40 bg-popover p-1 shadow-md\">\n                @if (Context.ExportFormats.HasFlag(DataGridExportFormat.Csv))\n                {\n                    <button type=\"button\"\n                            data-export-format=\"csv\"\n                            class=\"w-full flex items-center gap-2 rounded px-3 py-1.5 text-sm hover:bg-accent hover:text-accent-foreground\"\n                            @onclick=\"@(() => Export(\"csv\"))\">\n                        <Blazicon Svg=\"Lucide.FileText\" class=\"h-4 w-4\" />\n                        <span>CSV</span>\n                    </button>\n                }\n                @if (Context.ExportFormats.HasFlag(DataGridExportFormat.Excel))\n                {\n                    <button type=\"button\"\n                            data-export-format=\"excel\"\n                            class=\"w-full flex items-center gap-2 rounded px-3 py-1.5 text-sm hover:bg-accent hover:text-accent-foreground\"\n                            @onclick=\"@(() => Export(\"excel\"))\">\n                        <Blazicon Svg=\"Lucide.Sheet\" class=\"h-4 w-4\" />\n                        <span>Excel</span>\n                    </button>\n                }\n                @if (Context.ExportFormats.HasFlag(DataGridExportFormat.Pdf))\n                {\n                    <button type=\"button\"\n                            data-export-format=\"pdf\"\n                            class=\"w-full flex items-center gap-2 rounded px-3 py-1.5 text-sm hover:bg-accent hover:text-accent-foreground\"\n                            @onclick=\"@(() => Export(\"pdf\"))\">\n                        <Blazicon Svg=\"Lucide.FileDown\" class=\"h-4 w-4\" />\n                        <span>PDF</span>\n                    </button>\n                }\n            </div>\n        }\n    </div>\n}\n\n@code {\n    [CascadingParameter] public DataGridToolbarContext<TItem>? Context { get; set; }\n\n    private bool _exportMenuOpen;\n\n    private void ToggleExportMenu()\n    {\n        _exportMenuOpen = !_exportMenuOpen;\n    }\n\n    private async Task Export(string format)\n    {\n        if (Context is null) return;\n        _exportMenuOpen = false;\n        await Context.OnExport.InvokeAsync(format);\n    }\n}\n"
    },
    {
      "path": "UI/DataGrid/DataGridToolbarFullscreen.razor",
      "content": "@namespace Lumeo\n@typeparam TItem\n\n@* No Expandable gate here — when consumers place this component inside <ToolbarContent>\n   they've already opted in; the Expandable flag on DataGrid only gates the default auto-render. *@\n@if (Context is not null)\n{\n    <Button Variant=\"Button.ButtonVariant.Ghost\" Size=\"Button.ButtonSize.Icon\"\n            OnClick=\"@(() => Context.OnToggleExpanded.InvokeAsync())\"\n            title=\"@(Context.IsExpanded ? Context.Localizer[\"DataGrid.ExitFullscreen\"] : Context.Localizer[\"DataGrid.ExpandFullscreen\"])\"\n            aria-label=\"@(Context.IsExpanded ? Context.Localizer[\"DataGrid.ExitFullscreen\"] : Context.Localizer[\"DataGrid.ExpandFullscreen\"])\">\n        @if (Context.IsExpanded)\n        {\n            <Blazicon Svg=\"Lucide.Minimize2\" class=\"h-4 w-4\" />\n        }\n        else\n        {\n            <Blazicon Svg=\"Lucide.Maximize2\" class=\"h-4 w-4\" />\n        }\n    </Button>\n}\n\n@code {\n    [CascadingParameter] public DataGridToolbarContext<TItem>? Context { get; set; }\n}\n"
    },
    {
      "path": "UI/DataGrid/DataGridToolbarLayouts.razor",
      "content": "@namespace Lumeo\n@typeparam TItem\n\n@* No EnableLayoutPersistence gate — consumers place this component explicitly when they want it. *@\n@if (Context is not null)\n{\n    <div class=\"relative\">\n        <button type=\"button\"\n                class=\"inline-flex items-center justify-center h-8 px-3 rounded-md text-sm border border-input bg-background hover:bg-accent hover:text-accent-foreground transition-colors\"\n                @onclick=\"ToggleLayoutsPanel\">\n            <Blazicon Svg=\"Lucide.LayoutTemplate\" class=\"h-4 w-4 mr-1.5\" />\n            <span>@Context.Localizer[\"DataGrid.Layouts\"]</span>\n            <Blazicon Svg=\"Lucide.ChevronDown\" class=\"h-3.5 w-3.5 ml-1\" />\n        </button>\n\n        @if (_layoutsPanelOpen)\n        {\n            <div class=\"absolute right-0 top-full mt-1 z-50 w-72 rounded-md border border-border/40 bg-popover shadow-md overflow-hidden\">\n\n                @* Personal layouts *@\n                <div class=\"px-3 py-2 text-xs font-semibold text-muted-foreground uppercase tracking-wider border-b border-border/40 bg-muted/30\">\n                    @Context.Localizer[\"DataGrid.Personal\"]\n                </div>\n                <div class=\"p-1\">\n                    @if (_personalLayouts.Count == 0)\n                    {\n                        <div class=\"px-3 py-1.5 text-xs text-muted-foreground italic\">@Context.Localizer[\"DataGrid.NoSavedLayouts\"]</div>\n                    }\n                    else\n                    {\n                        @foreach (var layout in _personalLayouts)\n                        {\n                            var l = layout;\n                            <div class=\"flex items-center gap-1 rounded px-2 py-1 hover:bg-accent group\">\n                                <span class=\"flex-1 text-sm truncate\">@l.Name</span>\n                                <button type=\"button\"\n                                        class=\"inline-flex items-center justify-center h-6 w-6 rounded text-muted-foreground hover:text-foreground hover:bg-accent-foreground/10 opacity-0 group-hover:opacity-100 transition-opacity\"\n                                        title=\"@Context.Localizer[\"DataGrid.ApplyLayout\"]\"\n                                        @onclick=\"@(async () => await ApplyLayout(l))\">\n                                    <Blazicon Svg=\"Lucide.Check\" class=\"h-3.5 w-3.5\" />\n                                </button>\n                                <button type=\"button\"\n                                        class=\"inline-flex items-center justify-center h-6 w-6 rounded text-muted-foreground hover:text-destructive hover:bg-destructive/10 opacity-0 group-hover:opacity-100 transition-opacity\"\n                                        title=\"@Context.Localizer[\"DataGrid.Delete\"]\"\n                                        @onclick=\"@(async () => await DeletePersonalLayout(l.Id))\">\n                                    <Blazicon Svg=\"Lucide.X\" class=\"h-3.5 w-3.5\" />\n                                </button>\n                            </div>\n                        }\n                    }\n\n                    @* Save current layout *@\n                    @if (_showSaveInput)\n                    {\n                        <div class=\"flex items-center gap-1 px-2 py-1 mt-0.5\">\n                            <input type=\"text\"\n                                   class=\"flex-1 h-7 rounded border border-input bg-background px-2 text-xs focus-visible:outline-none focus-visible:ring-[1.5px] focus-visible:ring-ring\"\n                                   placeholder=\"@Context.Localizer[\"DataGrid.LayoutName\"]\"\n                                   @bind=\"_newLayoutName\"\n                                   @bind:event=\"oninput\"\n                                   @onkeydown=\"HandleSaveKeyDown\" />\n                            <button type=\"button\"\n                                    class=\"inline-flex items-center justify-center h-7 w-7 rounded border border-input bg-background hover:bg-accent transition-colors\"\n                                    title=\"@Context.Localizer[\"DataGrid.Save\"]\"\n                                    @onclick=\"CommitSavePersonal\">\n                                <Blazicon Svg=\"Lucide.Check\" class=\"h-3.5 w-3.5\" />\n                            </button>\n                            <button type=\"button\"\n                                    class=\"inline-flex items-center justify-center h-7 w-7 rounded border border-input bg-background hover:bg-accent transition-colors\"\n                                    title=\"@Context.Localizer[\"DataGrid.Cancel\"]\"\n                                    @onclick=\"@(() => { _showSaveInput = false; _newLayoutName = \"\"; })\">\n                                <Blazicon Svg=\"Lucide.X\" class=\"h-3.5 w-3.5\" />\n                            </button>\n                        </div>\n                    }\n                    else\n                    {\n                        <button type=\"button\"\n                                class=\"w-full flex items-center gap-2 rounded px-3 py-1.5 mt-0.5 text-sm text-muted-foreground hover:bg-accent hover:text-accent-foreground transition-colors\"\n                                @onclick=\"@(() => _showSaveInput = true)\">\n                            <Blazicon Svg=\"Lucide.Plus\" class=\"h-3.5 w-3.5\" />\n                            <span>@Context.Localizer[\"DataGrid.SaveCurrentLayout\"]</span>\n                        </button>\n                    }\n                </div>\n\n                @* Global layouts *@\n                @if (Context.GlobalLayouts is not null && Context.GlobalLayouts.Count > 0)\n                {\n                    <div class=\"px-3 py-2 text-xs font-semibold text-muted-foreground uppercase tracking-wider border-t border-b border-border/40 bg-muted/30\">\n                        @Context.Localizer[\"DataGrid.Global\"]\n                    </div>\n                    <div class=\"p-1\">\n                        @foreach (var layout in Context.GlobalLayouts.Where(l => l.Scope == \"Global\"))\n                        {\n                            var l = layout;\n                            <div class=\"flex items-center gap-1 rounded px-2 py-1 hover:bg-accent group\">\n                                <span class=\"flex-1 text-sm truncate\">@l.Name</span>\n                                <button type=\"button\"\n                                        class=\"inline-flex items-center justify-center h-6 w-6 rounded text-muted-foreground hover:text-foreground hover:bg-accent-foreground/10 opacity-0 group-hover:opacity-100 transition-opacity\"\n                                        title=\"@Context.Localizer[\"DataGrid.ApplyLayout\"]\"\n                                        @onclick=\"@(async () => await ApplyLayout(l))\">\n                                    <Blazicon Svg=\"Lucide.Check\" class=\"h-3.5 w-3.5\" />\n                                </button>\n                                @if (Context.OnDeleteNamedLayout.HasDelegate)\n                                {\n                                    <button type=\"button\"\n                                            class=\"inline-flex items-center justify-center h-6 w-6 rounded text-muted-foreground hover:text-destructive hover:bg-destructive/10 opacity-0 group-hover:opacity-100 transition-opacity\"\n                                            title=\"@Context.Localizer[\"DataGrid.Delete\"]\"\n                                            @onclick=\"@(async () => { _layoutsPanelOpen = false; await Context.OnDeleteNamedLayout.InvokeAsync(l.Id); })\">\n                                        <Blazicon Svg=\"Lucide.X\" class=\"h-3.5 w-3.5\" />\n                                    </button>\n                                }\n                            </div>\n                        }\n                    </div>\n                }\n\n                @* System Default *@\n                <div class=\"px-3 py-2 text-xs font-semibold text-muted-foreground uppercase tracking-wider border-t border-b border-border/40 bg-muted/30\">\n                    @Context.Localizer[\"DataGrid.SystemDefault\"]\n                </div>\n                <div class=\"p-1\">\n                    <div class=\"flex items-center gap-1 rounded px-2 py-1 hover:bg-accent group\">\n                        <span class=\"flex-1 text-sm truncate\">@Context.Localizer[\"DataGrid.Default\"]</span>\n                        <button type=\"button\"\n                                class=\"inline-flex items-center justify-center h-6 w-6 rounded text-muted-foreground hover:text-foreground hover:bg-accent-foreground/10 opacity-0 group-hover:opacity-100 transition-opacity\"\n                                title=\"@Context.Localizer[\"DataGrid.ResetLayout\"]\"\n                                @onclick=\"ResetLayout\">\n                            <Blazicon Svg=\"Lucide.Check\" class=\"h-3.5 w-3.5\" />\n                        </button>\n                    </div>\n                </div>\n\n            </div>\n        }\n    </div>\n}\n\n@code {\n    [CascadingParameter] public DataGridToolbarContext<TItem>? Context { get; set; }\n\n    private bool _layoutsPanelOpen;\n    private bool _showSaveInput;\n    private string _newLayoutName = \"\";\n    private List<DataGridNamedLayout> _personalLayouts = new();\n\n    protected override async Task OnParametersSetAsync()\n    {\n        if (Context is not null && Context.EnableLayoutPersistence && _layoutsPanelOpen)\n        {\n            await LoadPersonalLayouts();\n        }\n    }\n\n    private async Task ToggleLayoutsPanel()\n    {\n        _layoutsPanelOpen = !_layoutsPanelOpen;\n        if (_layoutsPanelOpen)\n        {\n            await LoadPersonalLayouts();\n        }\n        else\n        {\n            _showSaveInput = false;\n            _newLayoutName = \"\";\n        }\n    }\n\n    private async Task LoadPersonalLayouts()\n    {\n        if (Context is null || !Context.EnableLayoutPersistence) return;\n        var key = (Context.LayoutStorageKey ?? \"datagrid\") + \"-named\";\n        try\n        {\n            var json = await Context.Interop.LoadFromLocalStorage(key);\n            if (!string.IsNullOrEmpty(json))\n                _personalLayouts = System.Text.Json.JsonSerializer.Deserialize<List<DataGridNamedLayout>>(json) ?? new();\n            else\n                _personalLayouts = new();\n        }\n        catch (Exception ex)\n        {\n            Console.Error.WriteLine($\"[Lumeo DataGrid] Failed to load personal layouts: {ex.Message}\");\n            _personalLayouts = new();\n            if (Context.OnError.HasDelegate) await Context.OnError.InvokeAsync(ex);\n        }\n    }\n\n    private async Task CommitSavePersonal()\n    {\n        if (Context is null) return;\n        var name = _newLayoutName.Trim();\n        if (string.IsNullOrEmpty(name)) return;\n\n        _showSaveInput = false;\n        _newLayoutName = \"\";\n\n        await Context.OnSaveNamedLayout.InvokeAsync(new DataGridNamedLayout(Guid.NewGuid().ToString(\"N\"), name, \"Personal\", new DataGridLayout()));\n        await LoadPersonalLayouts();\n        StateHasChanged();\n    }\n\n    private async Task HandleSaveKeyDown(KeyboardEventArgs e)\n    {\n        if (e.Key == \"Enter\") await CommitSavePersonal();\n        else if (e.Key == \"Escape\") { _showSaveInput = false; _newLayoutName = \"\"; }\n    }\n\n    private async Task ApplyLayout(DataGridNamedLayout layout)\n    {\n        if (Context is null) return;\n        _layoutsPanelOpen = false;\n        _showSaveInput = false;\n        await Context.OnApplyNamedLayout.InvokeAsync(layout);\n    }\n\n    private async Task DeletePersonalLayout(string id)\n    {\n        if (Context is null) return;\n        _personalLayouts.RemoveAll(l => l.Id == id);\n        var key = (Context.LayoutStorageKey ?? \"datagrid\") + \"-named\";\n        try\n        {\n            var json = System.Text.Json.JsonSerializer.Serialize(_personalLayouts);\n            await Context.Interop.SaveToLocalStorage(key, json);\n        }\n        catch (Exception ex)\n        {\n            Console.Error.WriteLine($\"[Lumeo DataGrid] Failed to save personal layouts: {ex.Message}\");\n            if (Context.OnError.HasDelegate) await Context.OnError.InvokeAsync(ex);\n        }\n\n        if (Context.OnDeleteNamedLayout.HasDelegate)\n            await Context.OnDeleteNamedLayout.InvokeAsync(id);\n\n        StateHasChanged();\n    }\n\n    private async Task SaveLayout()\n    {\n        if (Context is null) return;\n        _layoutsPanelOpen = false;\n        await Context.OnSaveLayout.InvokeAsync();\n    }\n\n    private async Task ResetLayout()\n    {\n        if (Context is null) return;\n        _layoutsPanelOpen = false;\n        await Context.OnResetLayout.InvokeAsync();\n    }\n}\n"
    },
    {
      "path": "UI/DataGrid/ToolbarContent.razor",
      "content": "@namespace Lumeo\n@typeparam TItem\n@implements IDisposable\n\n@* Renders nothing directly — the parent DataGrid reads Class + ChildContent\n   through the registration below and renders them in the toolbar slot. *@\n\n@code {\n    [CascadingParameter] public DataGrid<TItem>? Parent { get; set; }\n\n    /// <summary>Custom CSS classes applied to the wrapper around the toolbar content\n    /// (the flex row holding your custom buttons + the filter chips).</summary>\n    [Parameter] public string? Class { get; set; }\n\n    [Parameter] public RenderFragment? ChildContent { get; set; }\n\n    protected override void OnInitialized()\n    {\n        Parent?.RegisterToolbarContent(this);\n    }\n\n    protected override void OnParametersSet()\n    {\n        // Re-register on each render in case Class or ChildContent changed — the\n        // parent treats re-registration as an update.\n        Parent?.RegisterToolbarContent(this);\n    }\n\n    public void Dispose() => Parent?.UnregisterToolbarContent(this);\n}\n"
    }
  ],
  "slots": [
    {
      "name": "ChildContent",
      "owner": "DataGrid",
      "type": "RenderFragment?",
      "description": null
    },
    {
      "name": "DetailTemplate",
      "owner": "DataGrid",
      "type": "RenderFragment<TItem>?",
      "description": null
    },
    {
      "name": "EmptyContent",
      "owner": "DataGrid",
      "type": "RenderFragment?",
      "description": null
    },
    {
      "name": "RowContextMenu",
      "owner": "DataGrid",
      "type": "RenderFragment<TItem>?",
      "description": null
    },
    {
      "name": "DetailTemplate",
      "owner": "DataGridBody",
      "type": "RenderFragment<TItem>?",
      "description": null
    },
    {
      "name": "EmptyContent",
      "owner": "DataGridBody",
      "type": "RenderFragment?",
      "description": null
    },
    {
      "name": "CellTemplate",
      "owner": "DataGridColumnDef",
      "type": "RenderFragment<TItem>?",
      "description": null
    },
    {
      "name": "HeaderTemplate",
      "owner": "DataGridColumnDef",
      "type": "RenderFragment?",
      "description": null
    },
    {
      "name": "EditTemplate",
      "owner": "DataGridColumnDef",
      "type": "RenderFragment<CellEditContext<TItem>>?",
      "description": null
    },
    {
      "name": "FilterTemplate",
      "owner": "DataGridColumnDef",
      "type": "RenderFragment<DataGridFilterTemplateContext>?",
      "description": null
    },
    {
      "name": "DetailTemplate",
      "owner": "DataGridDetailRow",
      "type": "RenderFragment<TItem>?",
      "description": null
    },
    {
      "name": "DetailTemplate",
      "owner": "DataGridRow",
      "type": "RenderFragment<TItem>?",
      "description": null
    },
    {
      "name": "ChildContent",
      "owner": "ToolbarContent",
      "type": "RenderFragment?",
      "description": null
    }
  ],
  "serviceDependencies": [
    {
      "service": "ILumeoLocalizer",
      "namespace": "Lumeo.Services.Localization"
    },
    {
      "service": "ToastService",
      "namespace": "Lumeo.Services"
    }
  ],
  "cascadingDependencies": [
    {
      "name": "Context",
      "type": "DataGridContext<TItem>?",
      "required": false
    },
    {
      "name": "ParentGrid",
      "type": "DataGrid<TItem>?",
      "required": false
    },
    {
      "name": "DragState",
      "type": "DataGridDragState?",
      "required": false
    },
    {
      "name": "DragStateCtx",
      "type": "DataGridDragState?",
      "required": false
    },
    {
      "name": "Parent",
      "type": "DataGrid<TItem>?",
      "required": false
    }
  ],
  "relatedComponents": [
    {
      "name": "DataGridBody",
      "reason": "sub-component"
    },
    {
      "name": "DataGridCell",
      "reason": "sub-component"
    },
    {
      "name": "DataGridColumnDef",
      "reason": "sub-component"
    },
    {
      "name": "DataGridColumnFilter",
      "reason": "sub-component"
    },
    {
      "name": "DataGridColumnVisibility",
      "reason": "sub-component"
    },
    {
      "name": "DataGridDetailRow",
      "reason": "sub-component"
    },
    {
      "name": "DataGridFooter",
      "reason": "sub-component"
    },
    {
      "name": "DataGridGroupRow",
      "reason": "sub-component"
    },
    {
      "name": "DataGridHeader",
      "reason": "sub-component"
    },
    {
      "name": "DataGridHeaderCell",
      "reason": "sub-component"
    },
    {
      "name": "DataGridPagination",
      "reason": "sub-component"
    },
    {
      "name": "DataGridRow",
      "reason": "sub-component"
    },
    {
      "name": "DataGridToolbar",
      "reason": "sub-component"
    },
    {
      "name": "DataGridToolbarColumns",
      "reason": "sub-component"
    },
    {
      "name": "DataGridToolbarCopySelected",
      "reason": "sub-component"
    },
    {
      "name": "DataGridToolbarExport",
      "reason": "sub-component"
    },
    {
      "name": "DataGridToolbarFullscreen",
      "reason": "sub-component"
    },
    {
      "name": "DataGridToolbarLayouts",
      "reason": "sub-component"
    },
    {
      "name": "ToolbarContent",
      "reason": "sub-component"
    },
    {
      "name": "Heading",
      "reason": "used internally"
    },
    {
      "name": "Skeleton",
      "reason": "used internally"
    },
    {
      "name": "Checkbox",
      "reason": "used internally"
    },
    {
      "name": "Select",
      "reason": "used internally"
    },
    {
      "name": "Pagination",
      "reason": "used internally"
    },
    {
      "name": "Button",
      "reason": "used internally"
    },
    {
      "name": "List",
      "reason": "used internally"
    }
  ],
  "keyboardInteractions": [
    {
      "key": "Escape",
      "action": "HandleFullscreenKeyDown handles Escape in DataGrid.razor"
    },
    {
      "key": "Enter",
      "action": "HandleCellKeyDown handles Enter in DataGridCell.razor"
    },
    {
      "key": "F2",
      "action": "HandleCellKeyDown handles F2 in DataGridCell.razor"
    },
    {
      "key": "Enter",
      "action": "OnEditKeyDown handles Enter in DataGridCell.razor"
    },
    {
      "key": "Escape",
      "action": "OnEditKeyDown handles Escape in DataGridCell.razor"
    },
    {
      "key": "Tab",
      "action": "OnEditKeyDown handles Tab in DataGridCell.razor"
    },
    {
      "key": "Enter",
      "action": "HandleKeyDown handles Enter in DataGridHeaderCell.razor"
    },
    {
      "key": " ",
      "action": "HandleKeyDown handles   in DataGridHeaderCell.razor"
    },
    {
      "key": "Spacebar",
      "action": "HandleKeyDown handles Spacebar in DataGridHeaderCell.razor"
    },
    {
      "key": "Enter",
      "action": "HandleSaveKeyDown handles Enter in DataGridToolbarLayouts.razor"
    },
    {
      "key": "Escape",
      "action": "HandleSaveKeyDown handles Escape in DataGridToolbarLayouts.razor"
    }
  ],
  "tests": [
    "tests/Lumeo.Docs.Tests/Helpers/DocsTestContext.cs",
    "tests/Lumeo.Tests/Components/DataGrid/DataGridAutoSaveStaleTests.cs",
    "tests/Lumeo.Tests/Components/DataGrid/DataGridBatchEditTests.cs",
    "tests/Lumeo.Tests/Components/DataGrid/DataGridColumnCultureTests.cs",
    "tests/Lumeo.Tests/Components/DataGrid/DataGridColumnRegistrationTests.cs",
    "tests/Lumeo.Tests/Components/DataGrid/DataGridCrossTalkTests.cs",
    "tests/Lumeo.Tests/Components/DataGrid/DataGridCultureTests.cs",
    "tests/Lumeo.Tests/Components/DataGrid/DataGridExpandableTests.cs",
    "tests/Lumeo.Tests/Components/DataGrid/DataGridFilterExtensibilityTests.cs",
    "tests/Lumeo.Tests/Components/DataGrid/DataGridGroupPanelTests.cs",
    "tests/Lumeo.Tests/Components/DataGrid/DataGridKeyboardNavTests.cs",
    "tests/Lumeo.Tests/Components/DataGrid/DataGridLayoutJsonTests.cs",
    "tests/Lumeo.Tests/Components/DataGrid/DataGridRenderTreeTests.cs",
    "tests/Lumeo.Tests/Components/DataGrid/DataGridTests.cs",
    "tests/Lumeo.Tests/Components/DataGrid/DataGridToolbarTogglesTests.cs",
    "tests/Lumeo.Tests/Components/DataGrid/DataGridTreeBatchGroupTests.cs",
    "tests/Lumeo.Tests/Components/DataGrid/DataGridVirtualizedServerModeTests.cs",
    "tests/Lumeo.Tests/Contract/ComponentContractTests.cs"
  ],
  "mdSummary": "# DataGrid\n\nEnterprise grid: sort, filter, inline edit, multi-level group (client + server), pin, virtualize, export.\n\n**Package:** `Lumeo.DataGrid` · **Category:** Data Display/Tables\n**Docs:** https://lumeo.nativ.sh/components/data-grid\n\n## Parameters\n| Owner | Name | Type | Default | Description |\n|---|---|---|---|---|\n| DataGrid | Items | `IEnumerable<TItem>?` | `` |  |\n| DataGrid | Columns | `List<DataGridColumn<TItem>>?` | `` |  |\n| DataGrid | Striped | `bool` | `` |  |\n| DataGrid | Bordered | `bool` | `` |  |\n| DataGrid | Compact | `bool` | `` |  |\n| DataGrid | Hoverable | `bool` | `true` |  |\n| DataGrid | Height | `string?` | `` |  |\n| DataGrid | SelectionMode | `DataGridSelectionMode` | `DataGridSelectionMode.None` |  |\n| DataGrid | EditMode | `DataGridEditMode` | `DataGridEditMode.None` |  |\n| DataGrid | PageSize | `int` | `10` |  |\n| DataGrid | PageSizeOptions | `int[]` | `new[] { 10, 25, 50, 100 }` | The list of page-size options shown in the rows-per-page selector. Defaults to { 10, 25, 50, 100 }. Set to e.g. { 10, 50, 100, 200, 300 } for larger datasets, or to an empty array to hide the selector entirely. |\n| DataGrid | ShowPagination | `bool` | `true` |  |\n| DataGrid | ShowToolbar | `bool` | `` |  |\n| DataGrid | ShowSearch | `bool` | `true` | Show the global search box in the toolbar. Default true. |\n| DataGrid | ShowColumnChooser | `bool` | `true` | Show the Columns button (visibility/reorder menu). Default true. |\n| DataGrid | ShowExport | `bool` | `true` | Show the Export button and its format dropdown. Default true. Set false to hide export entirely. For fine-grained per-format control see ExportFormats. |\n| DataGrid | ExportFormats | `DataGridExportFormat` | `DataGridExportFormat.All` | Which export formats appear in the Export dropdown. Default All. Set to e.g. Csv \\| Excel to hide PDF in WebAssembly where QuestPDF isn't supported. When no flags are set (or ShowExport is false), the Export button is hidden. |\n| DataGrid | VirtualizeThreshold | `int` | `500` | Item count above which Blazor's &lt;Virtualize&gt; kicks in. Only relevant when ShowPagination is false (or PageSize &gt; this threshold) — paged grids show at most PageSize rows at once and never trigger virtualization. Default 500. |\n| DataGrid | VirtualItemSize | `float` | `41f` | Estimated row height in CSS pixels — used by &lt;Virtualize&gt; to size the scroll spacers. Tune if your rows are taller (dense badges, two-line cells). Default 41px. |\n| DataGrid | VirtualOverscanCount | `int` | `3` | Extra rows rendered above and below the viewport to hide pop-in during fast scrolling. Default 3. |\n| DataGrid | Reorderable | `bool` | `` |  |\n| DataGrid | RowReorderable | `bool` | `` |  |\n| DataGrid | GroupBy | `string?` | `` |  |\n| DataGrid | GroupsExpandedByDefault | `bool` | `true` |  |\n| DataGrid | GroupByFields | `IReadOnlyList<string>?` | `` | Ordered list of fields for multi-level grouping. When set (non-empty) this takes precedence over the single GroupBy parameter. Each entry must be a column Field. The grid renders nested group rows with increasing indentation per level; per-level aggregates are shown on each group header row. |\n| DataGrid | ShowGroupPanel | `bool` | `` | Shows a slim bar above the grid that lists the current grouping levels as removable chips, plus a dropdown to add a new level from any Groupable column. Default false. |\n| DataGrid | GroupPanelText | `string?` | `` | Placeholder/hint text shown in the group panel when no fields are grouped. |\n| DataGrid | ChildItemsSelector | `Func<TItem, IEnumerable<TItem>?>?` | `` | When set, the grid renders a hierarchical tree-grid: this selector returns a row's child rows (null/empty = leaf). Each parent row gets an expand chevron in the tree column (see TreeColumnField). Distinct from DetailTemplate, which is a flat expand-for-detail fragment. Tree-grid mode and grouping are mutually exclusive — when both are configured, tree-grid mode wins. |\n| DataGrid | TreeGridDefaultExpanded | `bool` | `` | Whether tree nodes start expanded. Default false. |\n| DataGrid | TreeColumnField | `string?` | `` | Field of the column that carries the tree chevron + indentation. Null (default) uses the first visible column. |\n| DataGrid | OnTreeNodeExpand | `EventCallback<TItem>` | `` | Raised when a tree node is expanded or collapsed (the item, plus its new expanded state). |\n| DataGrid | Expandable | `bool` | `` | When true, adds a small Expand icon to the toolbar. Clicking it renders the grid in a fullscreen modal overlay. Default false. |\n| DataGrid | IsExpanded | `bool` | `` | When true, the grid is currently rendered in fullscreen. Bind this for programmatic control, or leave unbound to let the grid manage its own state via the toolbar button. |\n| DataGrid | IsExpandedChanged | `EventCallback<bool>` | `` |  |\n| DataGrid | FullscreenTitle | `string?` | `` | Optional title shown in the fullscreen header bar. Defaults to \"Data Grid\". |\n| DataGrid | RowClass | `Func<TItem, string>?` | `` |  |\n| DataGrid | RowStyle | `Func<TItem, string>?` | `` |  |\n| DataGrid | EnableLayoutPersistence | `bool` | `` |  |\n| DataGrid | LayoutStorageKey | `string?` | `` |  |\n| DataGrid | OnLayoutSave | `EventCallback<DataGridLayout>` | `` |  |\n| DataGrid | SavedLayout | `DataGridLayout?` | `` |  |\n| DataGrid | GlobalLayouts | `List<DataGridNamedLayout>?` | `` |  |\n| DataGrid | OnSaveNamedLayout | `EventCallback<DataGridNamedLayout>` | `` |  |\n| DataGrid | OnDeleteNamedLayout | `EventCallback<string>` | `` |  |\n| DataGrid | ServerMode | `bool` | `` |  |\n| DataGrid | OnServerRequest | `EventCallback<DataGridServerRequest>` | `` |  |\n| DataGrid | TotalCount | `int` | `` |  |\n| DataGrid | IsLoading | `bool` | `` |  |\n| DataGrid | Virtualized | `bool` | `` | Enables Microsoft.AspNetCore.Components.Web.Virtualization.Virtualize{TItem} with an ItemsProvider — rows are fetched from the backend in scroll-driven windows instead of paged. Requires OnRangeRequest. When active, pagination is hidden automatically and the grid renders a single tall scrollable body. |\n| DataGrid | OnRangeRequest | `Func<DataGridRangeRequest, ValueTask<DataGridRangeResponse<TItem>>>?` | `` | Range-fetch callback used in virtualised server mode. The grid invokes this whenever the viewport scrolls into an unloaded window. The callback must return the requested slice plus the current total row count so the scroll spacers stay accurate. |\n| DataGrid | ChildContent | `RenderFragment?` | `` |  |\n| DataGrid | DetailTemplate | `RenderFragment<TItem>?` | `` |  |\n| DataGrid | EmptyContent | `RenderFragment?` | `` |  |\n| DataGrid | RowContextMenu | `RenderFragment<TItem>?` | `` |  |\n| DataGrid | OnRowClick | `EventCallback<TItem>` | `` |  |\n| DataGrid | OnRowDoubleClick | `EventCallback<TItem>` | `` |  |\n| DataGrid | OnCellEdit | `EventCallback<CellEditEventArgs<TItem>>` | `` |  |\n| DataGrid | OnRowEdit | `EventCallback<RowEditEventArgs<TItem>>` | `` |  |\n| DataGrid | OnBatchSave | `EventCallback<DataGridBatchSaveEventArgs<TItem>>` | `` | Fired when the user clicks \"Save all\" while EditMode is Batch. Args carry the buffered modifications + added rows. On a clean callback return (no exception), the grid's pending-changes buffer is cleared automatically. |\n| DataGrid | ShowAddRow | `bool` | `` | When true and NewItemFactory is set, renders a thin \"+ Add row\" trigger below the body. Each click creates a TItem via the factory, pushes it into the added-rows buffer, and immediately shows it inline so cells can be filled in. Only meaningful in Batch. |\n| DataGrid | NewItemFactory | `Func<TItem>?` | `` | Factory used by the \"+ Add row\" trigger to produce a fresh TItem. Required when ShowAddRow is true. The returned item is stored in the added-rows buffer; the user fills its cells inline and the row is delivered via OnBatchSave. |\n| DataGrid | ColumnVirtualize | `bool` | `` | Simplified column virtualization: caps the number of horizontally- rendered data columns at MaxVisibleColumns. Pinned columns always make the cut. Default false. |\n| DataGrid | MaxVisibleColumns | `int` | `30` | Maximum columns rendered when ColumnVirtualize is true. Default 30. |\n| DataGrid | BatchSaveAllText | `string?` | `\"Save all\"` | Label for the batch-mode \"Save all\" button. Override for localization. |\n| DataGrid | BatchDiscardText | `string?` | `\"Discard\"` | Label for the batch-mode \"Discard\" button. Override for localization. |\n| DataGrid | BatchAddRowText | `string?` | `\"Add row\"` | Label for the batch-mode \"+ Add row\" trigger. Override for localization. |\n| DataGrid | OnColumnReorder | `EventCallback<ColumnReorderEventArgs>` | `` |  |\n| DataGrid | OnRowReorder | `EventCallback<RowReorderEventArgs<TItem>>` | `` |  |\n| DataGrid | OnError | `EventCallback<Exception>` | `` |  |\n| DataGrid | SelectedItems | `IReadOnlyList<TItem>?` | `` |  |\n| DataGrid | SelectedItemsChanged | `EventCallback<IReadOnlyList<TItem>>` | `` |  |\n| DataGrid | Class | `string?` | `` |  |\n| DataGrid | AdditionalAttributes | `Dictionary<string, object>?` | `` |  |\n| DataGrid | Culture | `System.Globalization.CultureInfo?` | `` | CultureInfo used for cell value formatting (dates/numbers) and exports. When null (default), falls back to CurrentCulture. Useful when your app sets a different culture than the UI locale — e.g. force de-DE formatting in an English UI. |\n| DataGridBody | Context | `DataGridContext<TItem>?` | `` |  |\n| DataGridBody | DetailTemplate | `RenderFragment<TItem>?` | `` |  |\n| DataGridBody | EmptyContent | `RenderFragment?` | `` |  |\n| DataGridBody | UseVirtualization | `bool` | `` |  |\n| DataGridBody | VirtualItemSize | `float` | `41f` |  |\n| DataGridBody | VirtualOverscanCount | `int` | `3` |  |\n| DataGridBody | ServerVirtualizationProvider | `Microsoft.AspNetCore.Components.Web.Virtualization.ItemsProviderDelegate<IndexedItem>?` | `` | When set, switches the body to ItemsProvider-mode virtualisation (window-fetched server scrolling). Null disables. |\n| DataGridBody | HasDetailTemplate | `bool` | `` |  |\n| DataGridBody | SkeletonRowCount | `int` | `5` |  |\n| DataGridBody | OnContextMenu | `EventCallback<(TItem, double, double)>` | `` |  |\n| DataGridBody | Class | `string?` | `` |  |\n| DataGridBody | AdditionalAttributes | `Dictionary<string, object>?` | `` |  |\n| DataGridCell | Context | `DataGridContext<TItem>?` | `` |  |\n| DataGridCell | Item | `TItem` | `default!` |  |\n| DataGridCell | Column | `DataGridColumn<TItem>` | `default!` |  |\n| DataGridCell | RowIndex | `int` | `` | 0-based index of the owning row within DisplayedItems (non-grouped). |\n| DataGridCell | ColumnIndex | `int` | `` | 0-based index of this column within the visible columns. |\n| DataGridCell | AriaColIndex | `int` | `` | 1-based ARIA column index (includes any leading non-data columns). |\n| DataGridCell | Navigable | `bool` | `true` | Whether this cell participates in roving-tabindex keyboard navigation. False in grouped mode where row indices are not globally unique. |\n| DataGridCell | Class | `string?` | `` |  |\n| DataGridCell | AdditionalAttributes | `Dictionary<string, object>?` | `` |  |\n| DataGridColumnDef | ParentGrid | `DataGrid<TItem>?` | `` |  |\n| DataGridColumnDef | Title | `string?` | `` |  |\n| DataGridColumnDef | Field | `string?` | `` |  |\n| DataGridColumnDef | FieldSelector | `Func<TItem, object?>?` | `` |  |\n| DataGridColumnDef | Width | `double?` | `` |  |\n| DataGridColumnDef | Sortable | `bool` | `` |  |\n| DataGridColumnDef | Filterable | `bool` | `` |  |\n| DataGridColumnDef | Resizable | `bool` | `true` |  |\n| DataGridColumnDef | FilterType | `DataGridFilterType` | `DataGridFilterType.Text` |  |\n| DataGridColumnDef | Aggregate | `AggregateType` | `AggregateType.None` |  |\n| DataGridColumnDef | Format | `string?` | `` |  |\n| DataGridColumnDef | Pin | `PinDirection` | `PinDirection.None` |  |\n| DataGridColumnDef | CellTemplate | `RenderFragment<TItem>?` | `` |  |\n| DataGridColumnDef | HeaderTemplate | `RenderFragment?` | `` |  |\n| DataGridColumnDef | EditTemplate | `RenderFragment<CellEditContext<TItem>>?` | `` |  |\n| DataGridColumnDef | MinWidth | `double?` | `` |  |\n| DataGridColumnDef | MaxWidth | `double?` | `` |  |\n| DataGridColumnDef | Pinnable | `bool` | `` |  |\n| DataGridColumnDef | Groupable | `bool` | `` |  |\n| DataGridColumnDef | Reorderable | `bool` | `true` |  |\n| DataGridColumnDef | CellClass | `Func<TItem, string>?` | `` |  |\n| DataGridColumnDef | HeaderCssClass | `string?` | `` |  |\n| DataGridColumnDef | CustomSort | `Comparison<object?>?` | `` |  |\n| DataGridColumnDef | FilterOptions | `List<FilterOption>?` | `` |  |\n| DataGridColumnDef | Operators | `List<FilterOperator>?` | `` |  |\n| DataGridColumnDef | FilterTemplate | `RenderFragment<DataGridFilterTemplateContext>?` | `` |  |\n| DataGridColumnDef | Class | `string?` | `` |  |\n| DataGridColumnDef | AdditionalAttributes | `Dictionary<string, object>?` | `` |  |\n| DataGridColumnFilter | Column | `DataGridColumn<TItem>?` | `` |  |\n| DataGridColumnFilter | FilterOptions | `List<FilterOption>?` | `` |  |\n| DataGridColumnFilter | CurrentFilter | `FilterDescriptor?` | `` |  |\n| DataGridColumnFilter | OnApply | `EventCallback<FilterDescriptor?>` | `` |  |\n| DataGridColumnFilter | Class | `string?` | `` |  |\n| DataGridColumnFilter | AdditionalAttributes | `Dictionary<string, object>?` | `` |  |\n| DataGridColumnVisibility | Columns | `IReadOnlyList<DataGridColumn<TItem>>` | `Array.Empty<DataGridColumn<TItem>>()` |  |\n| DataGridColumnVisibility | OnColumnToggle | `EventCallback<DataGridColumn<TItem>>` | `` |  |\n| DataGridColumnVisibility | OnColumnReorder | `EventCallback<ColumnReorderEventArgs>` | `` |  |\n| DataGridColumnVisibility | Class | `string?` | `` |  |\n| DataGridColumnVisibility | AdditionalAttributes | `Dictionary<string, object>?` | `` |  |\n| DataGridDetailRow | Item | `TItem` | `default!` |  |\n| DataGridDetailRow | DetailTemplate | `RenderFragment<TItem>?` | `` |  |\n| DataGridDetailRow | ColSpan | `int` | `1` |  |\n| DataGridDetailRow | Class | `string?` | `` |  |\n| DataGridDetailRow | AdditionalAttributes | `Dictionary<string, object>?` | `` |  |\n| DataGridFooter | Context | `DataGridContext<TItem>?` | `` |  |\n| DataGridFooter | AllItems | `IReadOnlyList<TItem>?` | `` |  |\n| DataGridFooter | HasDetailTemplate | `bool` | `` |  |\n| DataGridFooter | Class | `string?` | `` |  |\n| DataGridFooter | AdditionalAttributes | `Dictionary<string, object>?` | `` |  |\n| DataGridGroupRow | Context | `DataGridContext<TItem>?` | `` |  |\n| DataGridGroupRow | GroupKey | `string?` | `` |  |\n| DataGridGroupRow | Count | `int` | `` |  |\n| DataGridGroupRow | IsExpanded | `bool` | `` |  |\n| DataGridGroupRow | OnToggle | `EventCallback` | `` |  |\n| DataGridGroupRow | ColSpan | `int` | `1` |  |\n| DataGridGroupRow | Level | `int` | `` | Nesting depth for multi-level grouping (0 = top level). Controls indentation. |\n| DataGridGroupRow | Aggregates | `IReadOnlyList<DataGridGroupAggregate>?` | `` | Per-group aggregate strip rendered next to the group label. |\n| DataGridGroupRow | Class | `string?` | `` |  |\n| DataGridGroupRow | AdditionalAttributes | `Dictionary<string, object>?` | `` |  |\n| DataGridHeader | Context | `DataGridContext<TItem>?` | `` |  |\n| DataGridHeader | HasDetailTemplate | `bool` | `` |  |\n| DataGridHeader | ActiveFilterColumn | `string?` | `` |  |\n| DataGridHeader | Reorderable | `bool` | `` |  |\n| DataGridHeader | OnFilterToggle | `EventCallback<string?>` | `` |  |\n| DataGridHeader | OnFilterApply | `EventCallback<FilterApplyEventArgs>` | `` |  |\n| DataGridHeader | OnColumnReorder | `EventCallback<ColumnReorderEventArgs>` | `` |  |\n| DataGridHeader | Class | `string?` | `` |  |\n| DataGridHeader | AdditionalAttributes | `Dictionary<string, object>?` | `` |  |\n| DataGridHeaderCell | Context | `DataGridContext<TItem>?` | `` |  |\n| DataGridHeaderCell | ParentGrid | `DataGrid<TItem>?` | `` |  |\n| DataGridHeaderCell | DragState | `DataGridDragState?` | `` |  |\n| DataGridHeaderCell | Column | `DataGridColumn<TItem>` | `default!` |  |\n| DataGridHeaderCell | ColumnIndex | `int` | `` |  |\n| DataGridHeaderCell | AriaColIndex | `int` | `` | 1-based ARIA column index (includes any leading non-data columns). |\n| DataGridHeaderCell | SortDirection | `SortDirection` | `SortDirection.None` |  |\n| DataGridHeaderCell | HasActiveFilter | `bool` | `` |  |\n| DataGridHeaderCell | IsFilterOpen | `bool` | `` |  |\n| DataGridHeaderCell | Reorderable | `bool` | `` |  |\n| DataGridHeaderCell | OnFilterToggle | `EventCallback` | `` |  |\n| DataGridHeaderCell | OnFilterApply | `EventCallback<FilterApplyEventArgs>` | `` |  |\n| DataGridHeaderCell | OnColumnReorder | `EventCallback<ColumnReorderEventArgs>` | `` |  |\n| DataGridHeaderCell | SortPriority | `int` | `` |  |\n| DataGridHeaderCell | TotalSortCount | `int` | `` |  |\n| DataGridHeaderCell | Class | `string?` | `` |  |\n| DataGridHeaderCell | AdditionalAttributes | `Dictionary<string, object>?` | `` |  |\n| DataGridPagination | Context | `DataGridContext<TItem>?` | `` |  |\n| DataGridPagination | PageChanged | `EventCallback<int>` | `` |  |\n| DataGridPagination | PageSizeChanged | `EventCallback<int>` | `` |  |\n| DataGridPagination | PageSizeOptions | `int[]` | `new[] { 10, 25, 50, 100 }` | Page-size options shown in the rows-per-page selector. Empty array hides the selector entirely. |\n| DataGridPagination | Class | `string?` | `` |  |\n| DataGridPagination | AdditionalAttributes | `Dictionary<string, object>?` | `` |  |\n| DataGridRow | Context | `DataGridContext<TItem>?` | `` |  |\n| DataGridRow | ParentGrid | `DataGrid<TItem>?` | `` |  |\n| DataGridRow | DragStateCtx | `DataGridDragState?` | `` |  |\n| DataGridRow | Item | `TItem` | `default!` |  |\n| DataGridRow | RowIndex | `int` | `` |  |\n| DataGridRow | DetailTemplate | `RenderFragment<TItem>?` | `` |  |\n| DataGridRow | OnContextMenu | `EventCallback<(TItem, double, double)>` | `` |  |\n| DataGridRow | Class | `string?` | `` |  |\n| DataGridRow | AdditionalAttributes | `Dictionary<string, object>?` | `` |  |\n| DataGridToolbar | Context | `DataGridContext<TItem>?` | `` |  |\n| DataGridToolbar | SearchText | `string?` | `` |  |\n| DataGridToolbar | SearchTextChanged | `EventCallback<string>` | `` |  |\n| DataGridToolbar | RegisteredContent | `ToolbarContent<TItem>?` | `` | The ToolbarContent{TItem} component the consumer placed inside the DataGrid, forwarded by the parent grid. When null, the toolbar's left slot renders just the search + filter chips; when non-null, its Class styles the wrapper and its ChildContent renders alongside the chips. |\n| DataGridToolbar | OnColumnVisibilityToggle | `EventCallback` | `` |  |\n| DataGridToolbar | OnColumnToggle | `EventCallback<DataGridColumn<TItem>>` | `` |  |\n| DataGridToolbar | OnColumnReorder | `EventCallback<ColumnReorderEventArgs>` | `` |  |\n| DataGridToolbar | OnExport | `EventCallback<string>` | `` |  |\n| DataGridToolbar | OnRemoveFilter | `EventCallback<FilterDescriptor>` | `` |  |\n| DataGridToolbar | SelectedItems | `List<TItem>?` | `` |  |\n| DataGridToolbar | AllItems | `List<TItem>?` | `` |  |\n| DataGridToolbar | EffectiveColumns | `IReadOnlyList<DataGridColumn<TItem>>?` | `` |  |\n| DataGridToolbar | EnableLayoutPersistence | `bool` | `` |  |\n| DataGridToolbar | OnSaveLayout | `EventCallback` | `` |  |\n| DataGridToolbar | OnResetLayout | `EventCallback` | `` |  |\n| DataGridToolbar | GlobalLayouts | `List<DataGridNamedLayout>?` | `` |  |\n| DataGridToolbar | OnSaveNamedLayout | `EventCallback<DataGridNamedLayout>` | `` |  |\n| DataGridToolbar | OnDeleteNamedLayout | `EventCallback<string>` | `` |  |\n| DataGridToolbar | OnApplyNamedLayout | `EventCallback<DataGridNamedLayout>` | `` |  |\n| DataGridToolbar | LayoutStorageKey | `string?` | `` |  |\n| DataGridToolbar | OnError | `EventCallback<Exception>` | `` |  |\n| DataGridToolbar | Expandable | `bool` | `` |  |\n| DataGridToolbar | IsExpanded | `bool` | `` |  |\n| DataGridToolbar | OnToggleExpanded | `EventCallback` | `` |  |\n| DataGridToolbar | ShowSearch | `bool` | `true` | Show the global search box. Default true. |\n| DataGridToolbar | ShowColumnChooser | `bool` | `true` | Show the Columns (visibility/reorder) button. Default true. |\n| DataGridToolbar | ShowExport | `bool` | `true` | Show the Export dropdown. Default true. Combined with ExportFormats. |\n| DataGridToolbar | ExportFormats | `DataGridExportFormat` | `DataGridExportFormat.All` | Which export formats to include in the dropdown. Default All. |\n| DataGridToolbar | Class | `string?` | `` |  |\n| DataGridToolbar | AdditionalAttributes | `Dictionary<string, object>?` | `` |  |\n| DataGridToolbarColumns | Context | `DataGridToolbarContext<TItem>?` | `` |  |\n| DataGridToolbarCopySelected | Context | `DataGridToolbarContext<TItem>?` | `` |  |\n| DataGridToolbarExport | Context | `DataGridToolbarContext<TItem>?` | `` |  |\n| DataGridToolbarFullscreen | Context | `DataGridToolbarContext<TItem>?` | `` |  |\n| DataGridToolbarLayouts | Context | `DataGridToolbarContext<TItem>?` | `` |  |\n| ToolbarContent | Parent | `DataGrid<TItem>?` | `` |  |\n| ToolbarContent | Class | `string?` | `` | Custom CSS classes applied to the wrapper around the toolbar content (the flex row holding your custom buttons + the filter chips). |\n| ToolbarContent | ChildContent | `RenderFragment?` | `` |  |\n\n## Slots\n- `ChildContent` (DataGrid) — RenderFragment slot\n- `DetailTemplate` (DataGrid) — RenderFragment slot\n- `EmptyContent` (DataGrid) — RenderFragment slot\n- `RowContextMenu` (DataGrid) — RenderFragment slot\n- `DetailTemplate` (DataGridBody) — RenderFragment slot\n- `EmptyContent` (DataGridBody) — RenderFragment slot\n- `CellTemplate` (DataGridColumnDef) — RenderFragment slot\n- `HeaderTemplate` (DataGridColumnDef) — RenderFragment slot\n- `EditTemplate` (DataGridColumnDef) — RenderFragment slot\n- `FilterTemplate` (DataGridColumnDef) — RenderFragment slot\n- `DetailTemplate` (DataGridDetailRow) — RenderFragment slot\n- `DetailTemplate` (DataGridRow) — RenderFragment slot\n- `ChildContent` (ToolbarContent) — RenderFragment slot\n\n## Events\n- `OnTreeNodeExpand` (`EventCallback<TItem>`) — Raised when a tree node is expanded or collapsed (the item, plus its new expanded state).\n- `IsExpandedChanged` (`EventCallback<bool>`)\n- `OnLayoutSave` (`EventCallback<DataGridLayout>`)\n- `OnSaveNamedLayout` (`EventCallback<DataGridNamedLayout>`)\n- `OnDeleteNamedLayout` (`EventCallback<string>`)\n- `OnServerRequest` (`EventCallback<DataGridServerRequest>`)\n- `OnRowClick` (`EventCallback<TItem>`)\n- `OnRowDoubleClick` (`EventCallback<TItem>`)\n- `OnCellEdit` (`EventCallback<CellEditEventArgs<TItem>>`)\n- `OnRowEdit` (`EventCallback<RowEditEventArgs<TItem>>`)\n- `OnBatchSave` (`EventCallback<DataGridBatchSaveEventArgs<TItem>>`) — Fired when the user clicks \"Save all\" while EditMode is Batch. Args carry the buffered modifications + added rows. On a clean callback return (no exception), the grid's pending-changes buffer is cleared automatically.\n- `OnColumnReorder` (`EventCallback<ColumnReorderEventArgs>`)\n- `OnRowReorder` (`EventCallback<RowReorderEventArgs<TItem>>`)\n- `OnError` (`EventCallback<Exception>`)\n- `SelectedItemsChanged` (`EventCallback<IReadOnlyList<TItem>>`)\n\n## Sub-components\n- `DataGridBody`, `DataGridCell`, `DataGridColumnDef`, `DataGridColumnFilter`, `DataGridColumnVisibility`, `DataGridDetailRow`, `DataGridFooter`, `DataGridGroupRow`, `DataGridHeader`, `DataGridHeaderCell`, `DataGridPagination`, `DataGridRow`, `DataGridToolbar`, `DataGridToolbarColumns`, `DataGridToolbarCopySelected`, `DataGridToolbarExport`, `DataGridToolbarFullscreen`, `DataGridToolbarLayouts`, `ToolbarContent`\n\n## Cascading values it expects\n- `Context` (`DataGridContext<TItem>?`)\n- `ParentGrid` (`DataGrid<TItem>?`)\n- `DragState` (`DataGridDragState?`)\n- `DragStateCtx` (`DataGridDragState?`)\n- `Parent` (`DataGrid<TItem>?`)\n\n## Services it injects\n- `ILumeoLocalizer` (Lumeo.Services.Localization)\n- `ToastService` (Lumeo.Services)\n\n## Keyboard\n- `Escape` — HandleFullscreenKeyDown handles Escape in DataGrid.razor\n- `Enter` — HandleCellKeyDown handles Enter in DataGridCell.razor\n- `F2` — HandleCellKeyDown handles F2 in DataGridCell.razor\n- `Enter` — OnEditKeyDown handles Enter in DataGridCell.razor\n- `Escape` — OnEditKeyDown handles Escape in DataGridCell.razor\n- `Tab` — OnEditKeyDown handles Tab in DataGridCell.razor\n- `Enter` — HandleKeyDown handles Enter in DataGridHeaderCell.razor\n- ` ` — HandleKeyDown handles   in DataGridHeaderCell.razor\n- `Spacebar` — HandleKeyDown handles Spacebar in DataGridHeaderCell.razor\n- `Enter` — HandleSaveKeyDown handles Enter in DataGridToolbarLayouts.razor\n- `Escape` — HandleSaveKeyDown handles Escape in DataGridToolbarLayouts.razor\n\n## Example\n```razor\n<DataGrid />\n```\n\n## CSS variables\n`--color-accent`, `--color-accent-foreground`, `--color-background`, `--color-border`, `--color-card`, `--color-card-foreground`, `--color-destructive`, `--color-foreground`, `--color-input`, `--color-muted`, `--color-muted-foreground`, `--color-popover`, `--color-popover-foreground`, `--color-primary`, `--color-primary-foreground`, `--color-ring`, `--radius`\n\n## Files\n- `UI/DataGrid/DataGrid.razor`\n- `UI/DataGrid/DataGridBody.razor`\n- `UI/DataGrid/DataGridCell.razor`\n- `UI/DataGrid/DataGridColumn.cs`\n- `UI/DataGrid/DataGridColumnDef.razor`\n- `UI/DataGrid/DataGridColumnFilter.razor`\n- `UI/DataGrid/DataGridColumnVisibility.razor`\n- `UI/DataGrid/DataGridContext.cs`\n- `UI/DataGrid/DataGridDetailRow.razor`\n- `UI/DataGrid/DataGridDragState.cs`\n- `UI/DataGrid/DataGridEnums.cs`\n- `UI/DataGrid/DataGridFilterOperator.cs`\n- `UI/DataGrid/DataGridFooter.razor`\n- `UI/DataGrid/DataGridGroupRow.razor`\n- `UI/DataGrid/DataGridHeader.razor`\n- `UI/DataGrid/DataGridHeaderCell.razor`\n- `UI/DataGrid/DataGridLayoutService.cs`\n- `UI/DataGrid/DataGridPagination.razor`\n- `UI/DataGrid/DataGridRow.razor`\n- `UI/DataGrid/DataGridServerService.cs`\n- `UI/DataGrid/DataGridState.cs`\n- `UI/DataGrid/DataGridToolbar.razor`\n- `UI/DataGrid/DataGridToolbarColumns.razor`\n- `UI/DataGrid/DataGridToolbarContext.cs`\n- `UI/DataGrid/DataGridToolbarCopySelected.razor`\n- `UI/DataGrid/DataGridToolbarExport.razor`\n- `UI/DataGrid/DataGridToolbarFullscreen.razor`\n- `UI/DataGrid/DataGridToolbarLayouts.razor`\n- `UI/DataGrid/ToolbarContent.razor`\n"
}