Skip to content
Draft
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,50 @@
ο»Ώ<p>
Demonstrates pinned (frozen) columns using <code>Pin="DataGridColumnPin.Left"</code> and
<code>Pin="DataGridColumnPin.Right"</code>.
The two leftmost columns and the Actions column remain visible while the rest scroll horizontally.
</p>
<p>
Wrap the grid in a <code>&lt;div style="overflow-x: auto;"&gt;</code> container and give the grid a
<code>Style="min-width: max-content;"</code> so that the horizontal scroll bar appears.
Pinned columns require an explicit pixel <code>Width</code>.
</p>

<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" Sortable="true" />
<PropertyColumn Title="Full Name" Property="@(e => e.FullName)" Width="160px" Pin="DataGridColumnPin.Left" 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)" Sortable="true" Align="DataGridCellAlignment.End" />
<TemplateColumn Title="Actions" Width="120px" Pin="DataGridColumnPin.Right">
<FluentButton IconStart="@(new Icons.Regular.Size16.Edit())" Appearance="ButtonAppearance.Stealth" Title="Edit" @onclick="@(() => selectedName = context.FullName + " (edit)")" />
<FluentButton IconStart="@(new Icons.Regular.Size16.Delete())" Appearance="ButtonAppearance.Stealth" Title="Delete" @onclick="@(() => selectedName = context.FullName + " (delete)")" />
</TemplateColumn>
</FluentDataGrid>
</div>

@if (!string.IsNullOrEmpty(selectedName))
{
<p style="margin-top: 1rem;">Last action: <strong>@selectedName</strong></p>
}

@code {
string selectedName = string.Empty;

record Employee(int Id, string FullName, string Department, string Location, string StartDate, string Salary);

IQueryable<Employee> employees = new[]
{
new Employee(1, "Denis Voituron", "Engineering", "Brussels", "2019-03-01", "$120,000"),
new Employee(2, "Vincent Baaij", "Engineering", "Amsterdam", "2018-07-15", "$130,000"),
new Employee(3, "Bill Gates", "Executive", "Medina", "1975-04-04", "$1,000,000"),
new Employee(4, "Satya Nadella", "Executive", "Bellevue", "1992-02-17", "$950,000"),
new Employee(5, "Scott Hanselman", "Developer Relations", "Portland", "2007-01-22", "$200,000"),
new Employee(6, "Mads Torgersen", "Languages", "Seattle", "2005-08-01", "$180,000"),
new Employee(7, "David Fowler", "Framework", "Seattle", "2010-06-14", "$190,000"),
new Employee(8, "Damian Edwards", "Framework", "Cambridge", "2009-03-30", "$185,000"),
new Employee(9, "Jon Galloway", "Community", "San Diego", "2011-11-01", "$160,000"),
new Employee(10, "Maria Naggaga", "Engineering", "New York", "2016-05-20", "$155,000"),
}.AsQueryable();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
---
title: Pinned columns
order: 0095
route: /DataGrid/PinnedColumns
---

# 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.

## Parameters

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 |

## 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.
* Violating any of these rules throws an `ArgumentException` with a descriptive message.

## Scrollable container

Sticky positioning only activates inside a scrollable ancestor. Wrap the grid in a container with
`overflow-x: auto` and give the grid `Style="min-width: max-content;"` so that a horizontal scroll
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="City" Property="@(e => e.City)" />
<TemplateColumn Title="Actions" Width="120px" Pin="DataGridColumnPin.Right">
...
</TemplateColumn>
</FluentDataGrid>
</div>
```

## Theming the pinned background

Pinned cells receive a solid background to prevent scrolling content from showing through. The
color defaults to `--colorNeutralBackground1` and can be overridden per-grid with the CSS custom
property `--fluent-data-grid-pinned-background`:

```css
.my-grid {
--fluent-data-grid-pinned-background: var(--colorNeutralBackground3);
}
```

## Notes

* Column resizing interacts correctly with sticky offsets β€” the JavaScript in
`FluentDataGrid.razor.ts` recalculates `left` / `right` values after every resize step via
`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.

{{ DataGridPinnedColumns }}
1 change: 1 addition & 0 deletions src/Core.Scripts/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
obj/
29 changes: 29 additions & 0 deletions src/Core.Scripts/obj\Debug/\package.g.props
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
ο»Ώ<?xml version="1.0" encoding="utf-8"?>
<Project>
<PropertyGroup>
<PackageJsonPrivate Condition="$(PackageJsonPrivate) == ''">true</PackageJsonPrivate>
<PackageJsonName Condition="$(PackageJsonName) == ''">microsoft.fluentui.aspnetcore.components.assets</PackageJsonName>
<PackageJsonSource Condition="$(PackageJsonSource) == ''">src/index.ts</PackageJsonSource>
<PackageJsonMain Condition="$(PackageJsonMain) == ''">dist/Microsoft.FluentUI.AspNetCore.Components.lib.module.js</PackageJsonMain>
<PackageJsonCssfiles Condition="$(PackageJsonCssfiles) == ''">../Core/Components/**/*.css</PackageJsonCssfiles>
<PackageJsonCssbundle Condition="$(PackageJsonCssbundle) == ''">dist/Microsoft.FluentUI.AspNetCore.Components.bundle.scp.css</PackageJsonCssbundle>
<PackageJsonScriptsBuild Condition="$(PackageJsonScriptsBuild) == ''">node ./esbuild.config.mjs</PackageJsonScriptsBuild>
<PackageJsonScriptsClean Condition="$(PackageJsonScriptsClean) == ''">rimraf ./dist</PackageJsonScriptsClean>
<PackageJsonKeywords Condition="$(PackageJsonKeywords) == ''">[]</PackageJsonKeywords>
<PackageJsonAuthor Condition="$(PackageJsonAuthor) == ''"></PackageJsonAuthor>
<PackageJsonLicense Condition="$(PackageJsonLicense) == ''">ISC</PackageJsonLicense>
<PackageJsonType Condition="$(PackageJsonType) == ''">module</PackageJsonType>
<PackageJsonDevdependenciesTypesSortablejs Condition="$(PackageJsonDevdependenciesTypesSortablejs) == ''">^1.15.9</PackageJsonDevdependenciesTypesSortablejs>
<PackageJsonDevdependenciesTypescriptEslintEslintPlugin Condition="$(PackageJsonDevdependenciesTypescriptEslintEslintPlugin) == ''">8.57.1</PackageJsonDevdependenciesTypescriptEslintEslintPlugin>
<PackageJsonDevdependenciesTypescriptEslintParser Condition="$(PackageJsonDevdependenciesTypescriptEslintParser) == ''">8.57.1</PackageJsonDevdependenciesTypescriptEslintParser>
<PackageJsonDevdependenciesEsbuild Condition="$(PackageJsonDevdependenciesEsbuild) == ''">0.27.4</PackageJsonDevdependenciesEsbuild>
<PackageJsonDevdependenciesEsbuildPluginInlineCss Condition="$(PackageJsonDevdependenciesEsbuildPluginInlineCss) == ''">0.0.1</PackageJsonDevdependenciesEsbuildPluginInlineCss>
<PackageJsonDevdependenciesEslint Condition="$(PackageJsonDevdependenciesEslint) == ''">10.0.3</PackageJsonDevdependenciesEslint>
<PackageJsonDevdependenciesGlob Condition="$(PackageJsonDevdependenciesGlob) == ''">^13.0.6</PackageJsonDevdependenciesGlob>
<PackageJsonDevdependenciesImask Condition="$(PackageJsonDevdependenciesImask) == ''">^7.6.1</PackageJsonDevdependenciesImask>
<PackageJsonDevdependenciesRimraf Condition="$(PackageJsonDevdependenciesRimraf) == ''">6.1.3</PackageJsonDevdependenciesRimraf>
<PackageJsonDevdependenciesTabbable Condition="$(PackageJsonDevdependenciesTabbable) == ''">6.4.0</PackageJsonDevdependenciesTabbable>
<PackageJsonDevdependenciesTypescript Condition="$(PackageJsonDevdependenciesTypescript) == ''">5.9.3</PackageJsonDevdependenciesTypescript>
<PackageJsonDependenciesFluentuiWebComponents Condition="$(PackageJsonDependenciesFluentuiWebComponents) == ''">^3.0.0-rc.9</PackageJsonDependenciesFluentuiWebComponents>
</PropertyGroup>
</Project>
17 changes: 17 additions & 0 deletions src/Core/Components/DataGrid/Columns/ColumnBase.razor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,23 @@ public abstract partial class ColumnBase<TGridItem>
[Parameter]
public string? Width { get; set; }

/// <summary>
/// Gets or sets whether this column is pinned (frozen) to the left or right 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.
/// </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
/// <see cref="FluentDataGrid{TGridItem}"/> when columns are collected.
/// Not intended for direct use by consumers.
/// </summary>
internal double PinOffsetPx { get; set; }

/// <summary>
/// Gets or sets the minimal width of the column.
/// Defaults to 100px for a regular column and 50px for a select column.
Expand Down
109 changes: 109 additions & 0 deletions src/Core/Components/DataGrid/FluentDataGrid.razor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -600,6 +600,9 @@ private void FinishCollectingColumns()
throw new ArgumentException("The 'HierarchicalToggle' parameter can only be set on the first column of the grid.");
}

// Validate and compute offsets for pinned columns.
ValidateAndComputePinnedColumns();

// Always re-evaluate after collecting columns when using displaymode grid. A column might be added or hidden and the _internalGridTemplateColumns needs to reflect that.
if (DisplayMode == DataGridDisplayMode.Grid)
{
Expand All @@ -620,6 +623,112 @@ private void FinishCollectingColumns()
}
}

/// <summary>
/// Validates the pinned-column configuration and computes the sticky pixel 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>
/// </list>
/// </summary>
private void ValidateAndComputePinnedColumns()
{
var hasPinned = _columns.Exists(c => c.Pin != DataGridColumnPin.None);
if (!hasPinned)
{
return;
}

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))
{
col.PinOffsetPx = leftOffset;
leftOffset += 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())
{
col.PinOffsetPx = rightOffset;
rightOffset += ParsePixelWidth(col.Width);
}
}

/// <summary>
/// Enforces width and ordering constraints for pinned columns. Called only when at least one
/// pinned column exists.
/// </summary>
private void ValidatePinnedColumnConstraints()
{
// Width must be an explicit pixel value.
foreach (var col in _columns.Where(c => c.Pin != DataGridColumnPin.None))
{
if (string.IsNullOrWhiteSpace(col.Width))
{
throw new ArgumentException(
$"Column '{col.Title ?? col.Index.ToString(CultureInfo.InvariantCulture)}' has Pin set but no Width. " +
"Pinned columns require an explicit Width in pixels (e.g., '150px').");
}

if (!col.Width!.Trim().EndsWith("px", StringComparison.OrdinalIgnoreCase))
{
throw new ArgumentException(
$"Column '{col.Title ?? col.Index.ToString(CultureInfo.InvariantCulture)}' has Pin set but Width '{col.Width}' is not in pixels. " +
"Pinned columns require an explicit Width in pixels (e.g., '150px').");
}
}

// 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).
for (var i = 0; i < _columns.Count; i++)
{
if (_columns[i].Pin == DataGridColumnPin.Left && i > 0 && _columns[i - 1].Pin != DataGridColumnPin.Left)
{
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.");
}
}

// 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).
for (var i = 0; i < _columns.Count; i++)
{
if (_columns[i].Pin == DataGridColumnPin.Right && i < _columns.Count - 1 && _columns[i + 1].Pin != DataGridColumnPin.Right)
{
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.");
}
}
}

/// <summary>
/// Parses a CSS pixel value string such as <c>"150px"</c> and returns the numeric value.
/// Returns <c>0</c> if the string is null, empty, or not a valid pixel value.
/// </summary>
private static double ParsePixelWidth(string? width)
{
if (string.IsNullOrWhiteSpace(width))
{
return 0;
}

var trimmed = width.Trim();
if (trimmed.EndsWith("px", StringComparison.OrdinalIgnoreCase) &&
double.TryParse(trimmed[..^2], NumberStyles.Number, CultureInfo.InvariantCulture, out var px))
{
return px;
}

return 0;
}

/// <summary>
/// Sets the grid's current sort column to the specified <paramref name="column"/>.
/// </summary>
Expand Down
37 changes: 37 additions & 0 deletions src/Core/Components/DataGrid/FluentDataGrid.razor.css
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,43 @@
z-index: 2;
}

/* ---- Pinned (sticky) columns ---- */

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

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

/* Visual separator on the leading edge of the first right-pinned column */
.fluent-data-grid td.col-pinned-right,
.fluent-data-grid th.col-pinned-right {
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 {
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 {
background-color: var(--fluent-data-grid-pinned-background, var(--colorNeutralBackground4));
z-index: 4;
}

.fluent-data-grid[striped] tbody tr:nth-child(even) td {
background-color: var(--fluent-data-grid-stripe-background-color);
}
Loading