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/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
+ @customer.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/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..4a7947d082
--- /dev/null
+++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/DataGrid/Pages/DataGridMasterDetailPage.md
@@ -0,0 +1,48 @@
+---
+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 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`.*
+
+{{ 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`
+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..be2e2440de 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..c4213c030e 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,90 @@ 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.
+ /// Raises for each row that was not already expanded.
+ ///
+ /// A representing the completion of the operation.
+ public async Task ExpandAllRowDetailsAsync()
+ {
+ foreach (var item in _internalGridContext.Items)
+ {
+ if (_expandedRowDetails.Add(ItemKey(item)) && OnRowDetailsToggle.HasDelegate)
+ {
+ await OnRowDetailsToggle.InvokeAsync(item);
+ }
+ }
+
+ StateHasChanged();
+ }
+
+ ///
+ /// Collapses the content of all rows.
+ /// Raises for each currently loaded row that was expanded.
+ ///
+ /// A representing the completion of the operation.
+ 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();
+ }
+
+ // 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..61d7a76990 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 {
+ /* 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 > tr,
+ .fluent-data-grid.grid > tbody > 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..ed4fa6a220 100644
--- a/src/Core/Components/DataGrid/FluentDataGridCell.razor.css
+++ b/src/Core/Components/DataGrid/FluentDataGridCell.razor.css
@@ -171,3 +171,16 @@
min-height: 20px;
vertical-align: middle;
}
+
+.fluent-data-grid td.row-details-cell {
+ white-space: normal;
+ 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;
+}
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/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));
}
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/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
new file mode 100644
index 0000000000..afc469ba3d
--- /dev/null
+++ b/tests/Core/Components/DataGrid/FluentDataGridRowDetailsTests.razor
@@ -0,0 +1,209 @@
+@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 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()
+ {
+ // 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);
+}