Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -424,3 +424,4 @@ all-icons.json
all-emojis.json
/global.json
/src/Core.Scripts/src/BuildConstants.ts
/src/Core.Scripts/obj/
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,13 @@
StripedRows="true"
ResizableColumns="true"
DisplayMode="DataGridDisplayMode.Grid">
<PropertyColumn Title="ID" Property="@(e => e.Id)" Width="100px" Pin="DataGridColumnPin.Left" Sortable="true" />
<PropertyColumn Title="Full Name" Property="@(e => e.FullName)" Width="160px" Pin="DataGridColumnPin.Left" Sortable="true" />
<PropertyColumn Title="ID" Property="@(e => e.Id)" Width="100px" Pin="DataGridColumnPin.Start" Sortable="true" />
<PropertyColumn Title="Full Name" Property="@(e => e.FullName)" Width="160px" Pin="DataGridColumnPin.Start" Sortable="true" />
<PropertyColumn Title="Department" Property="@(e => e.Department)" Sortable="true" />
<PropertyColumn Title="Location" Property="@(e => e.Location)" Sortable="true" />
<PropertyColumn Title="Start Date" Property="@(e => e.StartDate)" Sortable="true" />
<PropertyColumn Title="Salary" Property="@(e => e.Salary)" Width="120px" Sortable="true" Align="DataGridCellAlignment.End" />
<TemplateColumn Title="Actions" Width="120px" Pin="DataGridColumnPin.Right">
<TemplateColumn Title="Actions" Width="120px" Pin="DataGridColumnPin.End">
<FluentButton IconStart="@(new Icons.Regular.Size16.Edit())" Appearance="ButtonAppearance.Subtle" Title="Edit" @onclick="@(() => selectedName = context.FullName + " (edit)")" />
<FluentButton IconStart="@(new Icons.Regular.Size16.Delete())" Appearance="ButtonAppearance.Subtle" Title="Delete" @onclick="@(() => selectedName = context.FullName + " (delete)")" />
</TemplateColumn>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,8 @@
---
title: Pinned columns
order: 0095
route: /DataGrid/PinnedColumns
---
Comment on lines -1 to -5
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This needs to stay in place. Is used for the demo site nav menu

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in a0f46eb by restoring the page front matter (title and route) so the demo nav/menu registration stays intact.


# Pinned columns

Columns can be pinned (frozen) to the left or right edge of the grid so that they remain visible
while the user scrolls horizontally through wider datasets.
Columns can be pinned (frozen) to the start or end edge of the grid so that they remain visible
while the user scrolls horizontally through wider datasets. Using `Start`/`End` instead of
`Left`/`Right` means pinned columns automatically work correctly in both LTR and RTL layouts.

## Parameters

Expand All @@ -16,18 +11,18 @@ Set the `Pin` parameter on any `PropertyColumn` or `TemplateColumn`:
| Value | Behavior |
|---|---|
| `DataGridColumnPin.None` | Default — column scrolls normally |
| `DataGridColumnPin.Left` | Column stays anchored to the left edge |
| `DataGridColumnPin.Right` | Column stays anchored to the right edge |
| `DataGridColumnPin.Start` | Column stays anchored to the start edge (left in LTR, right in RTL) |
| `DataGridColumnPin.End` | Column stays anchored to the end edge (right in LTR, left in RTL) |

## Rules

* **Explicit pixel width required.** Every pinned column must declare a `Width` in pixels
(e.g. `Width="150px"`). Relative units (`fr`, `%`) are not supported because the browser cannot
determine a fixed sticky offset from them at render time.
* **Left-pinned columns must be contiguous at the start.** Each left-pinned column must
immediately follow another left-pinned column, or be the very first column.
* **Right-pinned columns must be contiguous at the end.** Each right-pinned column must
immediately precede another right-pinned column, or be the very last column.
* **Start-pinned columns must be contiguous at the start.** Each start-pinned column must
immediately follow another start-pinned column, or be the very first column.
* **End-pinned columns must be contiguous at the end.** Each end-pinned column must
immediately precede another end-pinned column, or be the very last column.
* Violating any of these rules throws an `ArgumentException` with a descriptive message.

## Scrollable container
Expand All @@ -39,10 +34,10 @@ bar appears when columns overflow the container:
```razor
<div style="overflow-x: auto;">
<FluentDataGrid Items="@employees" Style="min-width: max-content;">
<PropertyColumn Title="ID" Property="@(e => e.Id)" Width="60px" Pin="DataGridColumnPin.Left" />
<PropertyColumn Title="Name" Property="@(e => e.Name)" Width="160px" Pin="DataGridColumnPin.Left" />
<PropertyColumn Title="ID" Property="@(e => e.Id)" Width="60px" Pin="DataGridColumnPin.Start" />
<PropertyColumn Title="Name" Property="@(e => e.Name)" Width="160px" Pin="DataGridColumnPin.Start" />
<PropertyColumn Title="City" Property="@(e => e.City)" />
<TemplateColumn Title="Actions" Width="120px" Pin="DataGridColumnPin.Right">
<TemplateColumn Title="Actions" Width="120px" Pin="DataGridColumnPin.End">
...
</TemplateColumn>
</FluentDataGrid>
Expand All @@ -64,16 +59,16 @@ property `--fluent-data-grid-pinned-background`:
## Notes

* Column resizing interacts correctly with sticky offsets — the JavaScript in
`FluentDataGrid.razor.ts` recalculates `left` / `right` values after every resize step via
`FluentDataGrid.razor.ts` recalculates start and end offset values after every resize step via
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is too much detail. It is not necessary for the developer using the component to know this

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated in a0f46eb to remove the implementation detail and keep the note user-focused.

`UpdatePinnedColumnOffsets`.
* Virtualization and paging are fully compatible because each rendered row's cells carry the
same `position: sticky` styling regardless of which page or scroll position is active.
* In RTL layouts the browser interprets `left` / `right` according to the document direction, so
pinned columns behave correctly without additional configuration.
* RTL layouts are fully supported: start and end automatically map to the correct physical
direction based on the document's writing mode.

## Example

Demonstrates pinned (frozen) columns using `Pin="DataGridColumnPin.Left"` and `Pin="DataGridColumnPin.Right"`.
Demonstrates pinned (frozen) columns using `Pin="DataGridColumnPin.Start"` and `Pin="DataGridColumnPin.End"`.
The two leftmost columns and the Actions column remain visible while the rest scroll horizontally.

Wrap the grid in a `<div style="overflow-x: auto;">` container and give the grid a `Style="min-width: max-content;"`
Expand Down
10 changes: 5 additions & 5 deletions src/Core/Components/DataGrid/Columns/ColumnBase.razor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -188,21 +188,21 @@ public abstract partial class ColumnBase<TGridItem>
public string? Width { get; set; }

/// <summary>
/// Gets or sets whether this column is pinned (frozen) to the left or right edge of the grid,
/// Gets or sets whether this column is pinned (frozen) to the start or end edge of the grid,
/// so it remains visible when the user scrolls horizontally.
/// Pinned columns require an explicit <see cref="Width"/> in pixels (e.g., <c>"150px"</c>).
/// Left-pinned columns must be contiguous at the start of the column list;
/// right-pinned columns must be contiguous at the end.
/// Start-pinned columns must be contiguous at the start of the column list;
/// end-pinned columns must be contiguous at the end.
/// </summary>
[Parameter]
public DataGridColumnPin Pin { get; set; } = DataGridColumnPin.None;

/// <summary>
/// The sticky <c>left</c> or <c>right</c> CSS offset (in pixels) computed by
/// The sticky start or end CSS offset computed by
/// <see cref="FluentDataGrid{TGridItem}"/> when columns are collected.
/// Not intended for direct use by consumers.
/// </summary>
internal double PinOffsetPx { get; set; }
internal string PinOffset { get; set; } = "0px";

/// <summary>
/// Gets or sets the minimal width of the column.
Expand Down
47 changes: 23 additions & 24 deletions src/Core/Components/DataGrid/FluentDataGrid.razor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -624,12 +624,12 @@ private void FinishCollectingColumns()
}

/// <summary>
/// Validates the pinned-column configuration and computes the sticky pixel offsets for each
/// Validates the pinned-column configuration and computes the sticky offsets for each
/// pinned column. Rules enforced:
/// <list type="bullet">
/// <item>Pinned columns must specify an explicit pixel <c>Width</c> (e.g., <c>"150px"</c>).</item>
/// <item>Left-pinned columns must be contiguous at the beginning of the column list.</item>
/// <item>Right-pinned columns must be contiguous at the end of the column list.</item>
/// <item>Start-pinned columns must be contiguous at the beginning of the column list.</item>
/// <item>End-pinned columns must be contiguous at the end of the column list.</item>
/// </list>
/// </summary>
private void ValidateAndComputePinnedColumns()
Expand All @@ -642,20 +642,20 @@ private void ValidateAndComputePinnedColumns()

ValidatePinnedColumnConstraints();

// Compute left-pin sticky offsets (cumulative left-to-right).
var leftOffset = 0.0;
foreach (var col in _columns.Where(c => c.Pin == DataGridColumnPin.Left))
// Compute start-pin sticky offsets in display order.
var startOffset = 0.0;
foreach (var col in _columns.Where(c => c.Pin == DataGridColumnPin.Start))
{
col.PinOffsetPx = leftOffset;
leftOffset += ParsePixelWidth(col.Width);
col.PinOffset = $"{startOffset.ToString(CultureInfo.InvariantCulture)}px";
startOffset += ParsePixelWidth(col.Width);
}

// Compute right-pin sticky offsets (cumulative right-to-left).
var rightOffset = 0.0;
foreach (var col in _columns.Where(c => c.Pin == DataGridColumnPin.Right).Reverse())
// Compute end-pin sticky offsets in reverse display order.
var endOffset = 0.0;
foreach (var col in _columns.Where(c => c.Pin == DataGridColumnPin.End).Reverse())
{
col.PinOffsetPx = rightOffset;
rightOffset += ParsePixelWidth(col.Width);
col.PinOffset = $"{endOffset.ToString(CultureInfo.InvariantCulture)}px";
endOffset += ParsePixelWidth(col.Width);
}
}

Expand Down Expand Up @@ -683,27 +683,27 @@ private void ValidatePinnedColumnConstraints()
}
}

// Left-pinned columns must be contiguous at the start: each one must be preceded by
// another left-pinned column (or be the very first column).
// Start-pinned columns must be contiguous at the start: each one must be preceded by
// another start-pinned column (or be the very first column).
for (var i = 0; i < _columns.Count; i++)
{
if (_columns[i].Pin == DataGridColumnPin.Left && i > 0 && _columns[i - 1].Pin != DataGridColumnPin.Left)
if (_columns[i].Pin == DataGridColumnPin.Start && i > 0 && _columns[i - 1].Pin != DataGridColumnPin.Start)
{
throw new ArgumentException(
$"Column '{_columns[i].Title ?? _columns[i].Index.ToString(CultureInfo.InvariantCulture)}' is left-pinned but the preceding column is not. " +
"Left-pinned columns must be contiguous at the start of the column list.");
$"Column '{_columns[i].Title ?? _columns[i].Index.ToString(CultureInfo.InvariantCulture)}' is start-pinned but the preceding column is not. " +
"Start-pinned columns must be contiguous at the start of the column list.");
}
}

// Right-pinned columns must be contiguous at the end: each one must be followed by
// another right-pinned column (or be the very last column).
// End-pinned columns must be contiguous at the end: each one must be followed by
// another end-pinned column (or be the very last column).
for (var i = 0; i < _columns.Count; i++)
{
if (_columns[i].Pin == DataGridColumnPin.Right && i < _columns.Count - 1 && _columns[i + 1].Pin != DataGridColumnPin.Right)
if (_columns[i].Pin == DataGridColumnPin.End && i < _columns.Count - 1 && _columns[i + 1].Pin != DataGridColumnPin.End)
{
throw new ArgumentException(
$"Column '{_columns[i].Title ?? _columns[i].Index.ToString(CultureInfo.InvariantCulture)}' is right-pinned but the following column is not. " +
"Right-pinned columns must be contiguous at the end of the column list.");
$"Column '{_columns[i].Title ?? _columns[i].Index.ToString(CultureInfo.InvariantCulture)}' is end-pinned but the following column is not. " +
"End-pinned columns must be contiguous at the end of the column list.");
}
}
}
Expand Down Expand Up @@ -1445,4 +1445,3 @@ private async Task ToggleExpandedAsync(TGridItem item)
}
}
}

24 changes: 12 additions & 12 deletions src/Core/Components/DataGrid/FluentDataGrid.razor.css
Original file line number Diff line number Diff line change
Expand Up @@ -100,37 +100,37 @@
/* ---- Pinned (sticky) columns ---- */

/* Background: keeps content from showing through when scrolling. Override --fluent-data-grid-pinned-background to theme. */
.fluent-data-grid th.col-pinned-left,
.fluent-data-grid th.col-pinned-right {
.fluent-data-grid th.col-pinned-start,
.fluent-data-grid th.col-pinned-end {
background-color: var(--colorNeutralBackground1);
}

.fluent-data-grid td.col-pinned-left,
.fluent-data-grid td.col-pinned-right {
.fluent-data-grid td.col-pinned-start,
.fluent-data-grid td.col-pinned-end {
background-color: var(--fluent-data-grid-pinned-background);
}

/* Visual separator on the trailing edge of the last left-pinned column */
.fluent-data-grid td:nth-last-child(1 of .col-pinned-left) {
/* Visual separator on the trailing edge of the last start-pinned column */
.fluent-data-grid td:nth-last-child(1 of .col-pinned-start) {
border-inline-end: var(--strokeWidthThin) solid var(--colorNeutralStroke1);
}

/* Visual separator on the leading edge of the first right-pinned column */
.fluent-data-grid td:nth-child(1 of .col-pinned-right) {
/* Visual separator on the leading edge of the first end-pinned column */
.fluent-data-grid td:nth-child(1 of .col-pinned-end) {
border-inline-start: var(--strokeWidthThin) solid var(--colorNeutralStroke1);
}

/* Pinned header cells must stack above pinned data cells (z-index: 1 applied inline by C#).
For UseMenuService=false the inline z-index: 5 is absent, so we provide a floor here.
The sticky-header row needs the highest value to beat both the sticky-header row z-index (2)
and the pinned data cell z-index (1). */
.fluent-data-grid th.col-pinned-left,
.fluent-data-grid th.col-pinned-right {
.fluent-data-grid th.col-pinned-start,
.fluent-data-grid th.col-pinned-end {
z-index: 2;
}

.fluent-data-grid tr[row-type='sticky-header'] > th.col-pinned-left,
.fluent-data-grid tr[row-type='sticky-header'] > th.col-pinned-right {
.fluent-data-grid tr[row-type='sticky-header'] > th.col-pinned-start,
.fluent-data-grid tr[row-type='sticky-header'] > th.col-pinned-end {
background-color: var(--colorNeutralBackground4);
z-index: 4;
}
Expand Down
34 changes: 17 additions & 17 deletions src/Core/Components/DataGrid/FluentDataGrid.razor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -564,10 +564,10 @@ export namespace Microsoft.FluentUI.Blazor.DataGrid {
* pinned column so that columns stack correctly against the grid edge after the initial
* render or after a column is resized.
*
Comment on lines 562 to 566
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The JSDoc for UpdatePinnedColumnOffsets still says it recalculates left / right offsets, but the implementation now uses logical properties (insetInlineStart / insetInlineEnd). Update the comment to match the new behavior to avoid confusion for maintainers.

Copilot uses AI. Check for mistakes.
* Left-pinned columns are processed left-to-right; each column's offset is the sum of
* the widths of all left-pinned columns to its left.
* Right-pinned columns are processed right-to-left; each column's offset is the sum of
* the widths of all right-pinned columns to its right.
* Start-pinned columns are processed in DOM order; each column's offset is the sum of
* the widths of all start-pinned columns before it.
* End-pinned columns are processed in reverse DOM order; each column's offset is the sum of
* the widths of all end-pinned columns after it.
*
* The function reads the actual rendered header-cell width so it handles both Grid mode
* (CSS grid layout) and Table mode (standard table layout). Grid mode uses `offsetWidth`
Expand All @@ -592,7 +592,7 @@ export namespace Microsoft.FluentUI.Blazor.DataGrid {
* Applies a cumulative sticky offset to all cells in a column and returns the new
* running total to be used by the next column in the sequence.
*/
function applyOffset(header: HTMLElement, offset: number, side: 'left' | 'right'): number {
function applyOffset(header: HTMLElement, offset: number, side: 'insetInlineStart' | 'insetInlineEnd'): number {
const colIndex = header.getAttribute('col-index');
if (!colIndex) { return offset; }

Expand All @@ -602,24 +602,24 @@ export namespace Microsoft.FluentUI.Blazor.DataGrid {
return offset + headerWidth(header);
}

// Left-pinned columns: process in DOM (left-to-right) order.
const leftPinnedHeaders = Array.from(
gridElement.querySelectorAll('th.col-pinned-left')
// Start-pinned columns: process in DOM order.
const startPinnedHeaders = Array.from(
gridElement.querySelectorAll('th.col-pinned-start')
) as HTMLElement[];

let leftOffset = 0;
for (const header of leftPinnedHeaders) {
leftOffset = applyOffset(header, leftOffset, 'left');
let startOffset = 0;
for (const header of startPinnedHeaders) {
startOffset = applyOffset(header, startOffset, 'insetInlineStart');
}

// Right-pinned columns: process in reverse DOM (right-to-left) order.
const rightPinnedHeaders = Array.from(
gridElement.querySelectorAll('th.col-pinned-right')
// End-pinned columns: process in reverse DOM order.
const endPinnedHeaders = Array.from(
gridElement.querySelectorAll('th.col-pinned-end')
) as HTMLElement[];

let rightOffset = 0;
for (let i = rightPinnedHeaders.length - 1; i >= 0; i--) {
rightOffset = applyOffset(rightPinnedHeaders[i], rightOffset, 'right');
let endOffset = 0;
for (let i = endPinnedHeaders.length - 1; i >= 0; i--) {
endOffset = applyOffset(endPinnedHeaders[i], endOffset, 'insetInlineEnd');
}
}
}
8 changes: 4 additions & 4 deletions src/Core/Components/DataGrid/FluentDataGridCell.razor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,8 @@ public FluentDataGridCell(LibraryConfiguration configuration) : base(configurati
.AddClass("column-header", when: CellType == DataGridCellType.ColumnHeader)
.AddClass("select-all", when: CellType == DataGridCellType.ColumnHeader && Column is SelectColumn<TGridItem>)
.AddClass("multiline-text", when: Grid.MultiLine && (Grid.Items is not null || Grid.ItemsProvider is not null) && CellType != DataGridCellType.ColumnHeader)
.AddClass("col-pinned-left", Column?.Pin == DataGridColumnPin.Left)
.AddClass("col-pinned-right", Column?.Pin == DataGridColumnPin.Right)
.AddClass("col-pinned-start", Column?.Pin == DataGridColumnPin.Start)
.AddClass("col-pinned-end", Column?.Pin == DataGridColumnPin.End)
Comment on lines +34 to +35
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

col-pinned-left/right class names were replaced with col-pinned-start/end. This is a breaking change for consumers who target the old class names in custom CSS/tests. If backward compatibility is required, consider emitting both the old and new class names for a transition period (or clearly documenting this as a breaking change/migration step).

Suggested change
.AddClass("col-pinned-start", Column?.Pin == DataGridColumnPin.Start)
.AddClass("col-pinned-end", Column?.Pin == DataGridColumnPin.End)
.AddClass("col-pinned-start", Column?.Pin == DataGridColumnPin.Start)
.AddClass("col-pinned-left", Column?.Pin == DataGridColumnPin.Start)
.AddClass("col-pinned-end", Column?.Pin == DataGridColumnPin.End)
.AddClass("col-pinned-right", Column?.Pin == DataGridColumnPin.End)

Copilot uses AI. Check for mistakes.
.AddClass(Owner.Class)
.Build();

Expand All @@ -50,8 +50,8 @@ public FluentDataGridCell(LibraryConfiguration configuration) : base(configurati
.AddStyle("height", "100%", Grid.MultiLine)
.AddStyle("min-height", "40px", Owner.RowType != DataGridRowType.Default)
.AddStyle("position", "sticky", Column != null && Column.Pin != DataGridColumnPin.None)
.AddStyle("left", $"{(Column?.PinOffsetPx ?? 0).ToString(CultureInfo.InvariantCulture)}px", Column?.Pin == DataGridColumnPin.Left)
.AddStyle("right", $"{(Column?.PinOffsetPx ?? 0).ToString(CultureInfo.InvariantCulture)}px", Column?.Pin == DataGridColumnPin.Right)
.AddStyle("inset-inline-start", Column?.PinOffset ?? "0px", Column?.Pin == DataGridColumnPin.Start)
.AddStyle("inset-inline-end", Column?.PinOffset ?? "0px", Column?.Pin == DataGridColumnPin.End)
.AddStyle("z-index", "1", Column != null && Column.Pin != DataGridColumnPin.None && CellType == DataGridCellType.Default)
.AddStyle(Owner.Style)
.Build();
Expand Down
Loading