From 62aff69f4bbe1c604a3f3adf7e02fb054f4a1cb4 Mon Sep 17 00:00:00 2001 From: sebguischr <57598238+sebguischr@users.noreply.github.com> Date: Fri, 10 Jul 2026 09:58:15 +0200 Subject: [PATCH 1/5] [DataGrid] Add master/detail row expansion Adds a RowDetails template parameter that lets any row be expanded via a chevron toggle to reveal detail content spanning all columns directly below it, independent of the existing hierarchical view (which requires parent/child rows of the same item type and columns). The RowDetails template's context is the row's item, so it can host a child FluentDataGrid filtered by that item, nested to any depth. Also fixes a CSS isolation issue where the ::deep tr selector used for CSS Grid display mode could match rows of a nested/child FluentDataGrid, and introduces DataGridCellType.RowDetails so the detail cell isn't constrained by the fixed row height/grid-column styling applied to normal cells. --- .../Examples/DataGridMasterDetail.razor | 58 +++++++ .../DataGridMasterDetailTwoLevels.razor | 76 ++++++++++ .../Pages/DataGridMasterDetailPage.md | 37 +++++ .../Components/DataGrid/FluentDataGrid.razor | 31 ++++ .../DataGrid/FluentDataGrid.razor.cs | 100 ++++++++++++ .../DataGrid/FluentDataGrid.razor.css | 5 +- .../DataGrid/FluentDataGridCell.razor | 2 +- .../DataGrid/FluentDataGridCell.razor.cs | 4 +- .../DataGrid/FluentDataGridCell.razor.css | 6 + .../DataGrid/FluentDataGridRow.razor.cs | 8 +- src/Core/Enums/DataGridCellType.cs | 6 + .../FluentDataGridRowDetailsTests.razor | 143 ++++++++++++++++++ 12 files changed, 471 insertions(+), 5 deletions(-) create mode 100644 examples/Demo/FluentUI.Demo.Client/Documentation/Components/DataGrid/Examples/DataGridMasterDetail.razor create mode 100644 examples/Demo/FluentUI.Demo.Client/Documentation/Components/DataGrid/Examples/DataGridMasterDetailTwoLevels.razor create mode 100644 examples/Demo/FluentUI.Demo.Client/Documentation/Components/DataGrid/Pages/DataGridMasterDetailPage.md create mode 100644 tests/Core/Components/DataGrid/FluentDataGridRowDetailsTests.razor diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/DataGrid/Examples/DataGridMasterDetail.razor b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/DataGrid/Examples/DataGridMasterDetail.razor new file mode 100644 index 0000000000..aedb035731 --- /dev/null +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/DataGrid/Examples/DataGridMasterDetail.razor @@ -0,0 +1,58 @@ + + + + + + + + + + +
Orders of @customer.Name
+ + + + + + + @context.Status + + + +
+
+ +@code { + private record Customer(int Id, string Name, string City); + private record Order(string OrderNumber, int CustomerId, DateTime OrderDate, decimal Amount, string Status); + + private IQueryable customers = new List + { + new(1, "Contoso Ltd.", "Seattle"), + new(2, "Fabrikam, Inc.", "Paris"), + new(3, "Northwind Traders", "London"), + new(4, "Adventure Works", "Brussels"), + }.AsQueryable(); + + private List orders = new() + { + new("ORD-1001", 1, new DateTime(2026, 5, 12), 1250.00m, "Delivered"), + new("ORD-1002", 1, new DateTime(2026, 6, 3), 480.50m, "Shipped"), + new("ORD-1003", 1, new DateTime(2026, 6, 28), 90.00m, "Pending"), + new("ORD-1004", 2, new DateTime(2026, 4, 21), 3200.00m, "Delivered"), + new("ORD-1005", 2, new DateTime(2026, 6, 15), 150.75m, "Cancelled"), + new("ORD-1006", 3, new DateTime(2026, 5, 30), 780.25m, "Delivered"), + new("ORD-1007", 3, new DateTime(2026, 6, 10), 2100.00m, "Shipped"), + new("ORD-1008", 3, new DateTime(2026, 7, 1), 55.99m, "Pending"), + new("ORD-1009", 4, new DateTime(2026, 6, 22), 640.00m, "Shipped"), + }; + + // The child grid is filtered with the master row's item (the customer) + private IQueryable OrdersOf(int customerId) + => orders.Where(o => o.CustomerId == customerId).AsQueryable(); +} diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/DataGrid/Examples/DataGridMasterDetailTwoLevels.razor b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/DataGrid/Examples/DataGridMasterDetailTwoLevels.razor new file mode 100644 index 0000000000..1301b84b63 --- /dev/null +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/DataGrid/Examples/DataGridMasterDetailTwoLevels.razor @@ -0,0 +1,76 @@ + + +
+ + + + + + + +
Orders of @customer.Name
+ + + + + + + +
Lines of order @order.OrderNumber
+ + + + + + +
+
+
+
+
+ +@code { + private record Customer(int Id, string Name, string City); + private record Order(string OrderNumber, int CustomerId, DateTime OrderDate); + private record OrderLine(string OrderNumber, string Product, int Quantity, decimal UnitPrice); + + private IQueryable customers = new List + { + new(1, "Contoso Ltd.", "Seattle"), + new(2, "Fabrikam, Inc.", "Paris"), + }.AsQueryable(); + + private List orders = new() + { + new("ORD-1001", 1, new DateTime(2026, 5, 12)), + new("ORD-1002", 1, new DateTime(2026, 6, 3)), + new("ORD-1004", 2, new DateTime(2026, 4, 21)), + }; + + private List lines = new() + { + new("ORD-1001", "Standing desk", 2, 450.00m), + new("ORD-1001", "Office chair", 2, 175.00m), + new("ORD-1002", "Monitor 27\"", 1, 320.50m), + new("ORD-1002", "HDMI cable", 4, 40.00m), + new("ORD-1004", "Laptop docking station", 10, 220.00m), + new("ORD-1004", "Wireless keyboard", 10, 100.00m), + }; + + // Each level filters its child grid with its own row item + private IQueryable OrdersOf(int customerId) + => orders.Where(o => o.CustomerId == customerId).AsQueryable(); + + private IQueryable LinesOf(string orderNumber) + => lines.Where(l => l.OrderNumber == orderNumber).AsQueryable(); +} diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/DataGrid/Pages/DataGridMasterDetailPage.md b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/DataGrid/Pages/DataGridMasterDetailPage.md new file mode 100644 index 0000000000..025c8f1601 --- /dev/null +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/DataGrid/Pages/DataGridMasterDetailPage.md @@ -0,0 +1,37 @@ +--- +title: Master / Detail +route: /DataGrid/MasterDetail +--- + +# Master / Detail + +The `FluentDataGrid` supports a master/detail view. Set the `RowDetails` template parameter to make each row +expandable through a chevron button shown in the first column. The template content is rendered in an extra row, +spanning all columns, directly below the expanded row. + +The template's `context` is the master row's item, so you can place a child `FluentDataGrid` inside it and filter +its items based on the master row. In this example, expanding a customer shows a child grid with that customer's +orders. + +Unlike the [hierarchical view](/DataGrid/Hierarchical) — where parent and child rows share the same item type and +the same columns within a single grid — the master/detail view displays data of a completely different structure: +the child grid defines its own columns, sorting and content, and is simply filtered by the master row's item. + +Rows can also be expanded and collapsed programmatically with the `ToggleRowDetailsAsync`, `ExpandRowDetailsAsync`, +`CollapseRowDetailsAsync`, `ExpandAllRowDetailsAsync` and `CollapseAllRowDetailsAsync` methods. The +`OnRowDetailsToggle` event callback is raised each time a row is expanded or collapsed. + +*Note: `RowDetails` cannot be combined with `Virtualize`.* + +{{ DataGridMasterDetail Files=Code:DataGridMasterDetail.razor }} + +## Two levels of detail + +Since the detail template can contain any content, a child `FluentDataGrid` can itself define a `RowDetails` +template, giving as many nested levels as needed. In this example, expanding a customer shows its orders, and +expanding an order shows its order lines. + +Each level filters its own child grid with its own row item (the `Context` of each template is named explicitly — +`customer`, then `order` — to avoid ambiguity between the nested templates). + +{{ DataGridMasterDetailTwoLevels Files=Code:DataGridMasterDetailTwoLevels.razor }} diff --git a/src/Core/Components/DataGrid/FluentDataGrid.razor b/src/Core/Components/DataGrid/FluentDataGrid.razor index ebcd012f45..e8afb2ecb9 100644 --- a/src/Core/Components/DataGrid/FluentDataGrid.razor +++ b/src/Core/Components/DataGrid/FluentDataGrid.razor @@ -108,15 +108,27 @@ { var rowClass = RowClass?.Invoke(item) ?? null; var rowStyle = RowStyle?.Invoke(item) ?? null; + var rowDetailsExpanded = RowDetails is not null && IsRowDetailsExpanded(item); @for (var colIndex = 0; colIndex < _columns.Count; colIndex++) { var col = _columns[colIndex]; + var isFirstColumn = colIndex == 0; string? tooltip = col.Tooltip ? @col.RawCellContent(item) : null; + @if (isFirstColumn && RowDetails is not null) + { + + + + + + + + } @if (col.HierarchicalToggle && item is IHierarchicalGridItem hierarchicalItem) { @@ -138,6 +150,25 @@ } + @if (rowDetailsExpanded) + { + string? detailsStyle = null; + string? detailsColspan = null; + if (DisplayMode == DataGridDisplayMode.Grid) + { + detailsStyle = $"grid-column: 1 / {_columns.Count + 1}"; + } + else + { + detailsColspan = _columns.Count.ToString(CultureInfo.InvariantCulture); + } + + + + @RowDetails!(item) + + + } } [ExcludeFromCodeCoverage(Justification = "This method is used virtualized mode and cannot be tested with bUnit currently.")] diff --git a/src/Core/Components/DataGrid/FluentDataGrid.razor.cs b/src/Core/Components/DataGrid/FluentDataGrid.razor.cs index fd4b4ce2c6..f4c54890f9 100644 --- a/src/Core/Components/DataGrid/FluentDataGrid.razor.cs +++ b/src/Core/Components/DataGrid/FluentDataGrid.razor.cs @@ -39,6 +39,7 @@ private enum ColumnHeaderUiKind internal const string EMPTY_CONTENT_ROW_CLASS = "empty-content-row"; internal const string LOADING_CONTENT_ROW_CLASS = "loading-content-row"; internal const string ERROR_CONTENT_ROW_CLASS = "error-content-row"; + internal const string ROW_DETAILS_ROW_CLASS = "row-details-row"; private ElementReference? _gridReference; private Virtualize<(int, TGridItem)>? _virtualizeComponent; @@ -58,6 +59,9 @@ private enum ColumnHeaderUiKind private bool _checkColumnResizing; private bool _checkColumnReordering; private bool _manualGrid; + + // Keys (as returned by ItemKey) of the rows whose RowDetails content is currently expanded + private readonly HashSet _expandedRowDetails = []; private readonly RenderFragment _renderColumnHeaders; private readonly RenderFragment _renderNonVirtualizedRows; private readonly RenderFragment _renderEmptyContent; @@ -347,6 +351,25 @@ public FluentDataGrid(LibraryConfiguration configuration) : base(configuration) [Parameter] public EventCallback OnCollapseAll { get; set; } + /// + /// Gets or sets the template that defines the detail content shown when a row is expanded (master/detail view). + /// + /// When set, each row displays an expand/collapse button in its first column. The expanded content is rendered + /// in an extra row spanning all columns, directly below the master row. The template's context is the row's + /// , so it can, for example, contain a child + /// whose items are filtered by the master row. + /// + /// Cannot be used together with . + /// + [Parameter] + public RenderFragment? RowDetails { get; set; } + + /// + /// Event callback for when a row's content is expanded or collapsed. + /// + [Parameter] + public EventCallback OnRowDetailsToggle { get; set; } + /// /// Event callback for when the grid's sort order changes. /// @@ -518,6 +541,11 @@ protected override async Task OnParametersSetAsync() throw new InvalidOperationException($"FluentDataGrid cannot use both {nameof(Virtualize)} and {nameof(MultiLine)} at the same time."); } + if (Virtualize && RowDetails is not null) + { + throw new InvalidOperationException($"FluentDataGrid cannot use both {nameof(Virtualize)} and {nameof(RowDetails)} at the same time."); + } + // Perform a re-query only if the data source or something else has changed var dataSourceHasChanged = !Equals(ItemsProvider, _lastAssignedItemsProvider) || !ReferenceEquals(Items, _lastAssignedItems); if (dataSourceHasChanged) @@ -1867,4 +1895,76 @@ private async Task ToggleExpandedAsync(TGridItem item) await RefreshDataAsync(); } } + + /// + /// Gets a value indicating whether the content of the specified is currently expanded. + /// + /// The item that holds the row's values. + public bool IsRowDetailsExpanded(TGridItem item) => _expandedRowDetails.Contains(ItemKey(item)); + + /// + /// Expands or collapses the content of the specified . + /// + /// The item that holds the row's values. + /// A representing the completion of the operation. + public async Task ToggleRowDetailsAsync(TGridItem item) + { + var key = ItemKey(item); + if (!_expandedRowDetails.Remove(key)) + { + _expandedRowDetails.Add(key); + } + + if (OnRowDetailsToggle.HasDelegate) + { + await OnRowDetailsToggle.InvokeAsync(item); + } + + StateHasChanged(); + } + + /// + /// Expands the content of the specified . + /// + /// The item that holds the row's values. + /// A representing the completion of the operation. + public Task ExpandRowDetailsAsync(TGridItem item) + => IsRowDetailsExpanded(item) ? Task.CompletedTask : ToggleRowDetailsAsync(item); + + /// + /// Collapses the content of the specified . + /// + /// The item that holds the row's values. + /// A representing the completion of the operation. + public Task CollapseRowDetailsAsync(TGridItem item) + => IsRowDetailsExpanded(item) ? ToggleRowDetailsAsync(item) : Task.CompletedTask; + + /// + /// Expands the content of all currently loaded rows. + /// + /// A representing the completion of the operation. + public Task ExpandAllRowDetailsAsync() + { + foreach (var item in _internalGridContext.Items) + { + _expandedRowDetails.Add(ItemKey(item)); + } + + StateHasChanged(); + return Task.CompletedTask; + } + + /// + /// Collapses the content of all rows. + /// + /// A representing the completion of the operation. + public Task CollapseAllRowDetailsAsync() + { + _expandedRowDetails.Clear(); + StateHasChanged(); + return Task.CompletedTask; + } + + // Distinct @key for the extra details row rendered below the master row + private object RowDetailsKey(TGridItem item) => (ItemKey(item), nameof(RowDetails)); } diff --git a/src/Core/Components/DataGrid/FluentDataGrid.razor.css b/src/Core/Components/DataGrid/FluentDataGrid.razor.css index 268d4e553a..618e05d503 100644 --- a/src/Core/Components/DataGrid/FluentDataGrid.razor.css +++ b/src/Core/Components/DataGrid/FluentDataGrid.razor.css @@ -21,7 +21,10 @@ display: contents; } - .fluent-data-grid.grid tr { + /* Scoped to direct thead/tbody children so a nested FluentDataGrid (e.g. in RowDetails) + doesn't have its own rows absorbed into this grid's CSS Grid layout. */ + .fluent-data-grid.grid > thead > ::deep tr, + .fluent-data-grid.grid > tbody > ::deep tr { display: contents; } diff --git a/src/Core/Components/DataGrid/FluentDataGridCell.razor b/src/Core/Components/DataGrid/FluentDataGridCell.razor index 85b0859883..5800fb6868 100644 --- a/src/Core/Components/DataGrid/FluentDataGridCell.razor +++ b/src/Core/Components/DataGrid/FluentDataGridCell.razor @@ -2,7 +2,7 @@ @inherits FluentComponentBase @typeparam TGridItem -@if (CellType == DataGridCellType.Default) +@if (CellType is DataGridCellType.Default or DataGridCellType.RowDetails) { protected string? StyleValue => DefaultStyleBuilder - .AddStyle("grid-column", GridColumn.ToString(CultureInfo.InvariantCulture), () => !Grid.EffectiveLoadingValue && (Grid.Items is not null || Grid.ItemsProvider is not null) && InternalGridContext.TotalItemCount > 0 && Grid.DisplayMode == DataGridDisplayMode.Grid) + .AddStyle("grid-column", GridColumn.ToString(CultureInfo.InvariantCulture), () => CellType != DataGridCellType.RowDetails && !Grid.EffectiveLoadingValue && (Grid.Items is not null || Grid.ItemsProvider is not null) && InternalGridContext.TotalItemCount > 0 && Grid.DisplayMode == DataGridDisplayMode.Grid) .AddStyle("text-align", "center", Column is SelectColumn) .AddStyle("align-content", "center", Column is SelectColumn) .AddStyle("min-width", Column?.MinWidth, Owner.RowType is DataGridRowType.Header or DataGridRowType.StickyHeader && (Grid.HeaderCellAsButtonWithMenu || Column?.Pin != DataGridColumnPin.None)) @@ -46,7 +46,7 @@ public FluentDataGridCell(LibraryConfiguration configuration) : base(configurati .AddStyle("padding-top", "6px", Column is SelectColumn && Grid.RowSize == DataGridRowSize.Small && Owner.RowType == DataGridRowType.Default) .AddStyle("width", Column?.Width, !string.IsNullOrEmpty(Column?.Width) && Grid.DisplayMode == DataGridDisplayMode.Table) .AddStyle("height", $"{Grid.ItemSize.ToString(CultureInfo.InvariantCulture):0}px", () => !Grid.EffectiveLoadingValue && Grid.Virtualize) - .AddStyle("height", $"{((int)Grid.RowSize).ToString(CultureInfo.InvariantCulture)}px", () => !Grid.EffectiveLoadingValue && !Grid.Virtualize && !Grid.MultiLine && (Grid.Items is not null || Grid.ItemsProvider is not null) && InternalGridContext.TotalItemCount > 0) + .AddStyle("height", $"{((int)Grid.RowSize).ToString(CultureInfo.InvariantCulture)}px", () => CellType != DataGridCellType.RowDetails && !Grid.EffectiveLoadingValue && !Grid.Virtualize && !Grid.MultiLine && (Grid.Items is not null || Grid.ItemsProvider is not null) && InternalGridContext.TotalItemCount > 0) .AddStyle("height", "100%", Grid.MultiLine) .AddStyle("min-height", "40px", Owner.RowType != DataGridRowType.Default) .AddStyle("position", "sticky", Column != null && Column.Pin != DataGridColumnPin.None) diff --git a/src/Core/Components/DataGrid/FluentDataGridCell.razor.css b/src/Core/Components/DataGrid/FluentDataGridCell.razor.css index 02ab96466f..4a2c402453 100644 --- a/src/Core/Components/DataGrid/FluentDataGridCell.razor.css +++ b/src/Core/Components/DataGrid/FluentDataGridCell.razor.css @@ -171,3 +171,9 @@ min-height: 20px; vertical-align: middle; } + +.fluent-data-grid td.row-details-cell { + white-space: normal; + overflow: visible; + text-overflow: unset; +} diff --git a/src/Core/Components/DataGrid/FluentDataGridRow.razor.cs b/src/Core/Components/DataGrid/FluentDataGridRow.razor.cs index 1a08cb9966..4f0d3248ea 100644 --- a/src/Core/Components/DataGrid/FluentDataGridRow.razor.cs +++ b/src/Core/Components/DataGrid/FluentDataGridRow.razor.cs @@ -149,7 +149,8 @@ internal async Task HandleOnRowClickAsync(string rowId) if (!string.IsNullOrWhiteSpace(row.Class) && (row.Class.Contains(FluentDataGrid.EMPTY_CONTENT_ROW_CLASS, StringComparison.Ordinal) || - row.Class.Contains(FluentDataGrid.LOADING_CONTENT_ROW_CLASS, StringComparison.Ordinal))) + row.Class.Contains(FluentDataGrid.LOADING_CONTENT_ROW_CLASS, StringComparison.Ordinal) || + row.Class.Contains(FluentDataGrid.ROW_DETAILS_ROW_CLASS, StringComparison.Ordinal))) { return; } @@ -177,6 +178,11 @@ internal async Task HandleOnRowDoubleClickAsync(string rowId) return; } + if (!string.IsNullOrWhiteSpace(row.Class) && row.Class.Contains(FluentDataGrid.ROW_DETAILS_ROW_CLASS, StringComparison.Ordinal)) + { + return; + } + if (Grid.OnRowDoubleClick.HasDelegate) { await Grid.OnRowDoubleClick.InvokeAsync(row); diff --git a/src/Core/Enums/DataGridCellType.cs b/src/Core/Enums/DataGridCellType.cs index 05bb5deb86..d9c2263e0f 100644 --- a/src/Core/Enums/DataGridCellType.cs +++ b/src/Core/Enums/DataGridCellType.cs @@ -27,4 +27,10 @@ public enum DataGridCellType /// [Description("rowheader")] RowHeader, + + /// + /// A cell that spans all columns and holds the expanded content of a row. + /// + [Description("rowdetails")] + RowDetails, } diff --git a/tests/Core/Components/DataGrid/FluentDataGridRowDetailsTests.razor b/tests/Core/Components/DataGrid/FluentDataGridRowDetailsTests.razor new file mode 100644 index 0000000000..02a3ca9948 --- /dev/null +++ b/tests/Core/Components/DataGrid/FluentDataGridRowDetailsTests.razor @@ -0,0 +1,143 @@ +@using Bunit.TestDoubles +@using Xunit +@inherits FluentUITestContext + +@code { + public FluentDataGridRowDetailsTests() + { + JSInterop.Mode = JSRuntimeMode.Loose; + + // Register services + Services.AddFluentUIComponents(); + Services.AddScoped(factory => new KeyCodeService()); + } + + [Fact] + public void FluentDataGrid_RowDetails_Renders_Toggle_And_Starts_Collapsed() + { + // Arrange && Act + var cut = RenderGrid(); + + // Assert + Assert.Equal(3, cut.FindAll(".row-details-toggle-container").Count); + Assert.Empty(cut.FindAll("tr.row-details-row")); + } + + [Fact] + public void FluentDataGrid_RowDetails_Toggle_Expands_And_Collapses() + { + // Arrange + var cut = RenderGrid(); + + // Act: expand the first row + cut.FindAll(".hierarchical-expand-button")[0].Click(); + + // Assert + var detailsRow = cut.Find("tr.row-details-row"); + Assert.Contains("details of Denis Voituron", detailsRow.TextContent); + + // Act: collapse it again + cut.FindAll(".hierarchical-expand-button")[0].Click(); + + // Assert + Assert.Empty(cut.FindAll("tr.row-details-row")); + } + + [Fact] + public async Task FluentDataGrid_RowDetails_ExpandAll_And_CollapseAllAsync() + { + // Arrange + var cut = RenderGrid(); + + // Act + await cut.InvokeAsync(() => cut.Instance.ExpandAllRowDetailsAsync()); + + // Assert + Assert.Equal(3, cut.FindAll("tr.row-details-row").Count); + + // Act + await cut.InvokeAsync(() => cut.Instance.CollapseAllRowDetailsAsync()); + + // Assert + Assert.Empty(cut.FindAll("tr.row-details-row")); + } + + [Fact] + public async Task FluentDataGrid_RowDetails_Expand_And_Collapse_Single_ItemAsync() + { + // Arrange + var cut = RenderGrid(); + var item = GetCustomers().First(); + + // Act && Assert + await cut.InvokeAsync(() => cut.Instance.ExpandRowDetailsAsync(item)); + Assert.True(cut.Instance.IsRowDetailsExpanded(item)); + + // Expanding twice is a no-op + await cut.InvokeAsync(() => cut.Instance.ExpandRowDetailsAsync(item)); + Assert.True(cut.Instance.IsRowDetailsExpanded(item)); + + await cut.InvokeAsync(() => cut.Instance.CollapseRowDetailsAsync(item)); + Assert.False(cut.Instance.IsRowDetailsExpanded(item)); + } + + [Fact] + public void FluentDataGrid_RowDetails_Toggle_Raises_OnRowDetailsToggle() + { + // Arrange + Customer? toggledItem = null; + var cut = Render>( + @ + + + + +

details of @context.Name

+
+
); + + // Act + cut.FindAll(".hierarchical-expand-button")[1].Click(); + + // Assert + Assert.Equal("Vincent Baaij", toggledItem?.Name); + } + + [Fact] + public void FluentDataGrid_RowDetails_With_Virtualize_Throws() + { + // Arrange && Act && Assert + Assert.Throws(() => Render>( + @ + + + + +

details of @context.Name

+
+
)); + } + + private IRenderedComponent> RenderGrid() + { + return Render>( + @ + + + + +

details of @context.Name

+
+
); + } + + // Sample data... + private IEnumerable GetCustomers() + { + yield return new Customer(1, "Denis Voituron"); + yield return new Customer(2, "Vincent Baaij"); + yield return new Customer(3, "Bill Gates"); + } + + private record Customer(int Id, string Name); +} From f8249d57e7697a384c5a119e7121aacc1f91e545 Mon Sep 17 00:00:00 2001 From: sebguischr <57598238+sebguischr@users.noreply.github.com> Date: Fri, 10 Jul 2026 10:24:15 +0200 Subject: [PATCH 2/5] [DataGrid] Fix RowDetails CSS for dev-v5's unscoped stylesheet model dev-v5 doesn't use Blazor's per-component CSS isolation (no [b-xxxx] scope attributes are emitted anywhere in the bundled stylesheet); DataGrid styles are plain global CSS instead. The ::deep combinator I had carried over from the dev branch fix is meaningless in that model and was left unprocessed by the build, silently invalidating the whole selector and breaking `display: contents` for every grid (not just nested ones). Replaced it with plain child combinators, which is all that's needed since there's no scope boundary to cross. Also fixes the RowDetails toggle button starting its own line instead of staying beside the first column's value: unlike the HierarchicalToggle container (which wraps the entire cell content), the RowDetails toggle is a standalone element rendered before the cell's own content, so it needs display: inline-flex rather than the block-level flex used for hierarchical-toggle-container. --- src/Core/Components/DataGrid/FluentDataGrid.razor.css | 6 +++--- src/Core/Components/DataGrid/FluentDataGridCell.razor.css | 7 +++++++ 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/Core/Components/DataGrid/FluentDataGrid.razor.css b/src/Core/Components/DataGrid/FluentDataGrid.razor.css index 618e05d503..61d7a76990 100644 --- a/src/Core/Components/DataGrid/FluentDataGrid.razor.css +++ b/src/Core/Components/DataGrid/FluentDataGrid.razor.css @@ -21,10 +21,10 @@ display: contents; } - /* Scoped to direct thead/tbody children so a nested FluentDataGrid (e.g. in RowDetails) + /* Restricted to direct thead/tbody children so a nested FluentDataGrid (e.g. in RowDetails) doesn't have its own rows absorbed into this grid's CSS Grid layout. */ - .fluent-data-grid.grid > thead > ::deep tr, - .fluent-data-grid.grid > tbody > ::deep tr { + .fluent-data-grid.grid > thead > tr, + .fluent-data-grid.grid > tbody > tr { display: contents; } diff --git a/src/Core/Components/DataGrid/FluentDataGridCell.razor.css b/src/Core/Components/DataGrid/FluentDataGridCell.razor.css index 4a2c402453..ed4fa6a220 100644 --- a/src/Core/Components/DataGrid/FluentDataGridCell.razor.css +++ b/src/Core/Components/DataGrid/FluentDataGridCell.razor.css @@ -177,3 +177,10 @@ overflow: visible; text-overflow: unset; } + +/* Unlike the HierarchicalToggle container (which wraps the whole cell content), + this toggle is a standalone element placed before the cell's own content, + so it must stay inline instead of starting its own block. */ +.fluent-data-grid .row-details-toggle-container { + display: inline-flex; +} From af9aaec7767d367c794a2d70d061c580b85b8d94 Mon Sep 17 00:00:00 2001 From: sebguischr <57598238+sebguischr@users.noreply.github.com> Date: Fri, 10 Jul 2026 10:51:01 +0200 Subject: [PATCH 3/5] [DataGrid] Address review feedback on RowDetails - Give the RowDetails toggle button its own aria-label (DataGrid_ToggleRowDetails) instead of reusing "Toggle nesting", which described the unrelated hierarchical-view expander and was misleading for screen reader users. - Make ExpandAllRowDetailsAsync/CollapseAllRowDetailsAsync raise OnRowDetailsToggle for each row whose expansion state actually changes, matching the callback's documented "raised when a row is expanded or collapsed" contract (previously only the single-row toggle methods raised it). - Add tests for both, plus a regression test locking in the dedicated aria-label. --- .../Pages/DataGridMasterDetailPage.md | 4 +- .../Components/DataGrid/FluentDataGrid.razor | 2 +- .../DataGrid/FluentDataGrid.razor.cs | 24 +++++-- src/Core/Localization/LanguageResource.resx | 3 + .../FluentDataGridRowDetailsTests.razor | 66 +++++++++++++++++++ 5 files changed, 92 insertions(+), 7 deletions(-) diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/DataGrid/Pages/DataGridMasterDetailPage.md b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/DataGrid/Pages/DataGridMasterDetailPage.md index 025c8f1601..c51b01881c 100644 --- a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/DataGrid/Pages/DataGridMasterDetailPage.md +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/DataGrid/Pages/DataGridMasterDetailPage.md @@ -19,7 +19,9 @@ the child grid defines its own columns, sorting and content, and is simply filte Rows can also be expanded and collapsed programmatically with the `ToggleRowDetailsAsync`, `ExpandRowDetailsAsync`, `CollapseRowDetailsAsync`, `ExpandAllRowDetailsAsync` and `CollapseAllRowDetailsAsync` methods. The -`OnRowDetailsToggle` event callback is raised each time a row is expanded or collapsed. +`OnRowDetailsToggle` event callback is raised for every row whose expansion state actually changes — including once +per affected row when using `ExpandAllRowDetailsAsync`/`CollapseAllRowDetailsAsync` — but not for rows that were +already in the requested state. *Note: `RowDetails` cannot be combined with `Virtualize`.* diff --git a/src/Core/Components/DataGrid/FluentDataGrid.razor b/src/Core/Components/DataGrid/FluentDataGrid.razor index e8afb2ecb9..be2e2440de 100644 --- a/src/Core/Components/DataGrid/FluentDataGrid.razor +++ b/src/Core/Components/DataGrid/FluentDataGrid.razor @@ -123,7 +123,7 @@ { - + diff --git a/src/Core/Components/DataGrid/FluentDataGrid.razor.cs b/src/Core/Components/DataGrid/FluentDataGrid.razor.cs index f4c54890f9..c4213c030e 100644 --- a/src/Core/Components/DataGrid/FluentDataGrid.razor.cs +++ b/src/Core/Components/DataGrid/FluentDataGrid.razor.cs @@ -1941,28 +1941,42 @@ public Task CollapseRowDetailsAsync(TGridItem item) /// /// Expands the content of all currently loaded rows. + /// Raises for each row that was not already expanded. /// /// A representing the completion of the operation. - public Task ExpandAllRowDetailsAsync() + public async Task ExpandAllRowDetailsAsync() { foreach (var item in _internalGridContext.Items) { - _expandedRowDetails.Add(ItemKey(item)); + if (_expandedRowDetails.Add(ItemKey(item)) && OnRowDetailsToggle.HasDelegate) + { + await OnRowDetailsToggle.InvokeAsync(item); + } } StateHasChanged(); - return Task.CompletedTask; } /// /// Collapses the content of all rows. + /// Raises for each currently loaded row that was expanded. /// /// A representing the completion of the operation. - public Task CollapseAllRowDetailsAsync() + public async Task CollapseAllRowDetailsAsync() { + if (OnRowDetailsToggle.HasDelegate) + { + foreach (var item in _internalGridContext.Items) + { + if (_expandedRowDetails.Contains(ItemKey(item))) + { + await OnRowDetailsToggle.InvokeAsync(item); + } + } + } + _expandedRowDetails.Clear(); StateHasChanged(); - return Task.CompletedTask; } // Distinct @key for the extra details row rendered below the master row diff --git a/src/Core/Localization/LanguageResource.resx b/src/Core/Localization/LanguageResource.resx index 848ee1fe40..e206790120 100644 --- a/src/Core/Localization/LanguageResource.resx +++ b/src/Core/Localization/LanguageResource.resx @@ -363,6 +363,9 @@ Toggle nesting + + Toggle row details + {0} item diff --git a/tests/Core/Components/DataGrid/FluentDataGridRowDetailsTests.razor b/tests/Core/Components/DataGrid/FluentDataGridRowDetailsTests.razor index 02a3ca9948..afc469ba3d 100644 --- a/tests/Core/Components/DataGrid/FluentDataGridRowDetailsTests.razor +++ b/tests/Core/Components/DataGrid/FluentDataGridRowDetailsTests.razor @@ -103,6 +103,72 @@ Assert.Equal("Vincent Baaij", toggledItem?.Name); } + [Fact] + public async Task FluentDataGrid_RowDetails_ExpandAll_Raises_OnRowDetailsToggle_For_Each_Changed_RowAsync() + { + // Arrange + var toggled = new List(); + var cut = Render>( + @ + + + + +

details of @context.Name

+
+
); + + // Act: expand one row manually first, then expand all + await cut.InvokeAsync(() => cut.Instance.ExpandRowDetailsAsync(GetCustomers().First())); + toggled.Clear(); + await cut.InvokeAsync(() => cut.Instance.ExpandAllRowDetailsAsync()); + + // Assert: only the two rows that actually changed state raised the callback + Assert.Equal(2, toggled.Count); + Assert.DoesNotContain(toggled, c => c.Name == "Denis Voituron"); + + // Act: expanding all again is a no-op, nothing should be raised + toggled.Clear(); + await cut.InvokeAsync(() => cut.Instance.ExpandAllRowDetailsAsync()); + Assert.Empty(toggled); + } + + [Fact] + public async Task FluentDataGrid_RowDetails_CollapseAll_Raises_OnRowDetailsToggle_For_Each_Expanded_RowAsync() + { + // Arrange + var toggled = new List(); + var cut = Render>( + @ + + + + +

details of @context.Name

+
+
); + await cut.InvokeAsync(() => cut.Instance.ExpandRowDetailsAsync(GetCustomers().First())); + toggled.Clear(); + + // Act + await cut.InvokeAsync(() => cut.Instance.CollapseAllRowDetailsAsync()); + + // Assert: only the one row that was actually expanded raised the callback + Assert.Single(toggled); + Assert.Equal("Denis Voituron", toggled[0].Name); + } + + [Fact] + public void FluentDataGrid_RowDetails_Toggle_Has_Dedicated_AriaLabel() + { + // Arrange && Act + var cut = RenderGrid(); + + // Assert: the toggle button must not reuse the hierarchical-view's "Toggle nesting" label + var button = cut.FindAll(".row-details-toggle-container button, .row-details-toggle-container fluent-button")[0]; + Assert.Equal("Toggle row details", button.GetAttribute("aria-label")); + } + [Fact] public void FluentDataGrid_RowDetails_With_Virtualize_Throws() { From cb67cfa1588c55e8e6b2a94e2e6f3c4e556013f8 Mon Sep 17 00:00:00 2001 From: sebguischr <57598238+sebguischr@users.noreply.github.com> Date: Fri, 10 Jul 2026 16:21:46 +0200 Subject: [PATCH 4/5] [DataGrid] Exclude row-details-row from the ShowHover highlight With ShowHover enabled, hovering anywhere inside a RowDetails block (which can contain arbitrary, potentially large content such as a nested grid) highlighted the entire details cell, since the hover background rule only excluded header/sticky-header/loading rows. Excluded row-details-row the same way. --- src/Core/Components/DataGrid/FluentDataGridRow.razor.css | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Core/Components/DataGrid/FluentDataGridRow.razor.css b/src/Core/Components/DataGrid/FluentDataGridRow.razor.css index 3534d166d5..082888cf2b 100644 --- a/src/Core/Components/DataGrid/FluentDataGridRow.razor.css +++ b/src/Core/Components/DataGrid/FluentDataGridRow.razor.css @@ -4,7 +4,8 @@ .fluent-data-grid .hover:not([row-type='header'], .fluent-data-grid [row-type='sticky-header'], -.fluent-data-grid .loading-content-row):hover td:not(.empty-content-cell) { +.fluent-data-grid .loading-content-row, +.fluent-data-grid .row-details-row):hover td:not(.empty-content-cell) { cursor: pointer; background-color: var(--datagrid-hover-color, var(--colorNeutralStroke2)); } From 0a419eb46f12584bf77b2622b233c9e31a3da5b3 Mon Sep 17 00:00:00 2001 From: sebguischr <57598238+sebguischr@users.noreply.github.com> Date: Fri, 10 Jul 2026 17:05:59 +0200 Subject: [PATCH 5/5] [DataGrid] Add custom-content RowDetails example Adds an "Any content as detail" example showing that the RowDetails template isn't limited to a nested FluentDataGrid: it can host any content bound to the master row's item. Here a customer row expands into a contact card (FluentCard/FluentStack/FluentProgressBar) instead of a child grid. --- .../Examples/DataGridMasterDetailCustom.razor | 76 +++++++++++++++++++ .../Pages/DataGridMasterDetailPage.md | 9 +++ 2 files changed, 85 insertions(+) create mode 100644 examples/Demo/FluentUI.Demo.Client/Documentation/Components/DataGrid/Examples/DataGridMasterDetailCustom.razor diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/DataGrid/Examples/DataGridMasterDetailCustom.razor b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/DataGrid/Examples/DataGridMasterDetailCustom.razor new file mode 100644 index 0000000000..7e2781a124 --- /dev/null +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/DataGrid/Examples/DataGridMasterDetailCustom.razor @@ -0,0 +1,76 @@ + + +
+ + + + + + + @(context.IsActive ? "Active" : "Inactive") + + + + + + +
@customer.Name
+ + + + City + @customer.City + + + + + Email + + + + + + Phone + @customer.Phone + + + + Satisfaction + + +
+
+
+
+
+ +@code { + private record Customer(int Id, string Name, string City, string Email, string Phone, int Satisfaction, bool IsActive); + + private IQueryable customers = new List + { + new(1, "Contoso Ltd.", "Seattle", "contact@contoso.com", "+1 206 555 0100", 92, true), + new(2, "Fabrikam, Inc.", "Paris", "hello@fabrikam.com", "+33 1 55 00 12 00", 78, true), + new(3, "Northwind Traders", "London", "sales@northwind.com", "+44 20 7946 0000", 64, false), + new(4, "Adventure Works", "Brussels", "info@adventure-works.com", "+32 2 555 01 23", 85, true), + }.AsQueryable(); +} diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/DataGrid/Pages/DataGridMasterDetailPage.md b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/DataGrid/Pages/DataGridMasterDetailPage.md index c51b01881c..4a7947d082 100644 --- a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/DataGrid/Pages/DataGridMasterDetailPage.md +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/DataGrid/Pages/DataGridMasterDetailPage.md @@ -27,6 +27,15 @@ already in the requested state. {{ DataGridMasterDetail Files=Code:DataGridMasterDetail.razor }} +## Any content as detail + +The detail template is not limited to a child `FluentDataGrid`: it can host any content. Because the template's +`context` is the master row's item, you can build a fully custom detail panel — cards, stacks, icons, links, +progress bars, buttons or any other component — bound to that item. In this example, expanding a customer shows a +contact card built from standard Fluent components instead of a nested grid. + +{{ DataGridMasterDetailCustom Files=Code:DataGridMasterDetailCustom.razor }} + ## Two levels of detail Since the detail template can contain any content, a child `FluentDataGrid` can itself define a `RowDetails`