Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
<style>
.row-details-cell {
padding: 0.5rem 1rem 1rem 2.5rem;
background: var(--colorNeutralBackground2);
}
</style>

<FluentDataGrid Items="@customers" ShowHover="true">
<ChildContent>
<PropertyColumn Title="Customer" Property="@(c => c.Name)" Sortable="true" />
<PropertyColumn Title="City" Property="@(c => c.City)" Sortable="true" />
<PropertyColumn Title="Orders" Property="@(c => OrdersOf(c.Id).Count())" Align="DataGridCellAlignment.End" />
<PropertyColumn Title="Total amount" Property="@(c => OrdersOf(c.Id).Sum(o => o.Amount))" Format="c" Align="DataGridCellAlignment.End" />
</ChildContent>
<RowDetails Context="customer">
<h5 style="margin: 0 0 0.5rem 0;">Orders of @customer.Name</h5>
<FluentDataGrid Items="@OrdersOf(customer.Id)" ShowHover="true">
<PropertyColumn Title="Order #" Property="@(o => o.OrderNumber)" />
<PropertyColumn Title="Date" Property="@(o => o.OrderDate)" Format="yyyy-MM-dd" Sortable="true" InitialSortDirection="DataGridSortDirection.Descending" IsDefaultSortColumn="true" />
<PropertyColumn Title="Amount" Property="@(o => o.Amount)" Format="c" Align="DataGridCellAlignment.End" Sortable="true" />
<TemplateColumn Title="Status">
<FluentBadge Color="@(context.Status == "Delivered" ? BadgeColor.Success : BadgeColor.Subtle)">
@context.Status
</FluentBadge>
</TemplateColumn>
</FluentDataGrid>
</RowDetails>
</FluentDataGrid>

@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<Customer> customers = new List<Customer>
{
new(1, "Contoso Ltd.", "Seattle"),
new(2, "Fabrikam, Inc.", "Paris"),
new(3, "Northwind Traders", "London"),
new(4, "Adventure Works", "Brussels"),
}.AsQueryable();

private List<Order> 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<Order> OrdersOf(int customerId)
=> orders.Where(o => o.CustomerId == customerId).AsQueryable();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
ο»Ώ<style>
.custom-detail .row-details-cell {
background: var(--colorNeutralBackground2);
}

.custom-detail .detail-card {
max-width: 32rem;
margin: 1rem 0 1rem 1.5rem;
padding: 1.25rem 1.5rem;
}

.custom-detail .detail-label {
color: var(--colorNeutralForeground3);
min-width: 5rem;
}

.custom-detail .detail-email {
color: var(--colorBrandForegroundLink);
}
</style>

<div class="custom-detail">
<FluentDataGrid Items="@customers" ShowHover="true">
<ChildContent>
<PropertyColumn Title="Customer" Property="@(c => c.Name)" Sortable="true" />
<PropertyColumn Title="City" Property="@(c => c.City)" Sortable="true" />
<TemplateColumn Title="Status">
<FluentBadge Color="@(context.IsActive ? BadgeColor.Success : BadgeColor.Subtle)">
@(context.IsActive ? "Active" : "Inactive")
</FluentBadge>
</TemplateColumn>
</ChildContent>
<RowDetails Context="customer">
<FluentCard Class="detail-card">
<FluentStack Orientation="Orientation.Vertical" VerticalGap="12">
<h5 style="margin: 0;">@customer.Name</h5>

<FluentStack Orientation="Orientation.Horizontal" VerticalAlignment="VerticalAlignment.Center" HorizontalGap="8">
<FluentIcon Value="@(new Icons.Regular.Size20.Location())" Color="Color.Default" />
<span class="detail-label">City</span>
<span>@customer.City</span>
</FluentStack>

<FluentStack Orientation="Orientation.Horizontal" VerticalAlignment="VerticalAlignment.Center" HorizontalGap="8">
<FluentIcon Value="@(new Icons.Regular.Size20.Mail())" Color="Color.Default" />
<span class="detail-label">Email</span>
<a class="detail-email" href="@($"mailto:{customer.Email}")">@customer.Email</a>
</FluentStack>

<FluentStack Orientation="Orientation.Horizontal" VerticalAlignment="VerticalAlignment.Center" HorizontalGap="8">
<FluentIcon Value="@(new Icons.Regular.Size20.Phone())" Color="Color.Default" />
<span class="detail-label">Phone</span>
<span>@customer.Phone</span>
</FluentStack>

<FluentStack Orientation="Orientation.Vertical" VerticalGap="4">
<span class="detail-label">Satisfaction</span>
<FluentProgressBar Min="0" Max="100" Value="@customer.Satisfaction" Width="200px" />
</FluentStack>
</FluentStack>
</FluentCard>
</RowDetails>
</FluentDataGrid>
</div>

@code {
private record Customer(int Id, string Name, string City, string Email, string Phone, int Satisfaction, bool IsActive);

private IQueryable<Customer> customers = new List<Customer>
{
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();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
<style>
.two-levels .row-details-cell {
padding: 0.5rem 1rem 1rem 2.5rem;
background: var(--colorNeutralBackground2);
}

/* Second level of details, nested inside the first one */
.two-levels .row-details-cell .row-details-cell {
background: var(--colorNeutralBackground3);
}
</style>

<div class="two-levels">
<FluentDataGrid Items="@customers" ShowHover="true">
<ChildContent>
<PropertyColumn Title="Customer" Property="@(c => c.Name)" Sortable="true" />
<PropertyColumn Title="City" Property="@(c => c.City)" Sortable="true" />
<PropertyColumn Title="Orders" Property="@(c => OrdersOf(c.Id).Count())" Align="DataGridCellAlignment.End" />
</ChildContent>
<RowDetails Context="customer">
<h5 style="margin: 0 0 0.5rem 0;">Orders of @customer.Name</h5>
<FluentDataGrid Items="@OrdersOf(customer.Id)" ShowHover="true">
<ChildContent>
<PropertyColumn Title="Order #" Property="@(o => o.OrderNumber)" />
<PropertyColumn Title="Date" Property="@(o => o.OrderDate)" Format="yyyy-MM-dd" Sortable="true" InitialSortDirection="DataGridSortDirection.Descending" IsDefaultSortColumn="true" />
<PropertyColumn Title="Amount" Property="@(o => LinesOf(o.OrderNumber).Sum(l => l.Quantity * l.UnitPrice))" Format="c" Align="DataGridCellAlignment.End" />
</ChildContent>
<RowDetails Context="order">
<h5 style="margin: 0 0 0.5rem 0;">Lines of order @order.OrderNumber</h5>
<FluentDataGrid Items="@LinesOf(order.OrderNumber)" ShowHover="true">
<PropertyColumn Title="Product" Property="@(l => l.Product)" />
<PropertyColumn Title="Quantity" Property="@(l => l.Quantity)" Align="DataGridCellAlignment.End" />
<PropertyColumn Title="Unit price" Property="@(l => l.UnitPrice)" Format="c" Align="DataGridCellAlignment.End" />
<PropertyColumn Title="Line total" Property="@(l => l.Quantity * l.UnitPrice)" Format="c" Align="DataGridCellAlignment.End" />
</FluentDataGrid>
</RowDetails>
</FluentDataGrid>
</RowDetails>
</FluentDataGrid>
</div>

@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<Customer> customers = new List<Customer>
{
new(1, "Contoso Ltd.", "Seattle"),
new(2, "Fabrikam, Inc.", "Paris"),
}.AsQueryable();

private List<Order> 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<OrderLine> 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<Order> OrdersOf(int customerId)
=> orders.Where(o => o.CustomerId == customerId).AsQueryable();

private IQueryable<OrderLine> LinesOf(string orderNumber)
=> lines.Where(l => l.OrderNumber == orderNumber).AsQueryable();
}
Original file line number Diff line number Diff line change
@@ -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 }}
31 changes: 31 additions & 0 deletions src/Core/Components/DataGrid/FluentDataGrid.razor
Original file line number Diff line number Diff line change
Expand Up @@ -108,15 +108,27 @@
{
var rowClass = RowClass?.Invoke(item) ?? null;
var rowStyle = RowStyle?.Invoke(item) ?? null;
var rowDetailsExpanded = RowDetails is not null && IsRowDetailsExpanded(item);

<FluentDataGridRow @key="@(ItemKey(item))" aria-rowindex="@rowIndex" Class="@rowClass" Style="@rowStyle" TGridItem="TGridItem" Item="@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;

<FluentDataGridCell GridColumn=@(colIndex + 1) Class="@ColumnJustifyClass(col)" Style="@col.Style" @key="@col" TGridItem="TGridItem" Item="@item" title="@tooltip" aria-label="@tooltip" tabindex="@(col.DisableCellFocus ? null : 0)">
@if (isFirstColumn && RowDetails is not null)
{
<span class="hierarchical-toggle-container row-details-toggle-container">
<span @onclick:stopPropagation="true" class="hierarchical-expander @(rowDetailsExpanded ? "hierarchical-expanded" : "hierarchical-collapsed")">
<FluentButton aria-label="@Localizer[Localization.LanguageResource.DataGrid_ToggleRowDetails]" aria-expanded="@(rowDetailsExpanded ? "true" : "false")" Appearance="ButtonAppearance.Subtle" Size="ButtonSize.Small" Class="hierarchical-expand-button" OnClick="@(() => ToggleRowDetailsAsync(item))">
<FluentIcon Width="12" Value="@(new CoreIcons.Regular.Size20.ChevronRight())" Color="Color.Default" />
</FluentButton>
Comment thread
sebguischr marked this conversation as resolved.
</span>
</span>
}
@if (col.HierarchicalToggle && item is IHierarchicalGridItem hierarchicalItem)
{
<span class="hierarchical-toggle-container" style="margin-inline-start: calc(var(--spacingHorizontalL) * @hierarchicalItem.Depth);">
Expand All @@ -138,6 +150,25 @@
</FluentDataGridCell>
}
</FluentDataGridRow>
@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);
}

<FluentDataGridRow @key="@RowDetailsKey(item)" Class="@ROW_DETAILS_ROW_CLASS" TGridItem="TGridItem" Item="@item">
<FluentDataGridCell CellType="@DataGridCellType.RowDetails" Class="row-details-cell" Style="@detailsStyle" colspan="@detailsColspan" TGridItem="TGridItem" Item="@item">
@RowDetails!(item)
</FluentDataGridCell>
</FluentDataGridRow>
}
}

[ExcludeFromCodeCoverage(Justification = "This method is used virtualized mode and cannot be tested with bUnit currently.")]
Expand Down
Loading