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