Skip to content

feat(DataGrid): add multi-column sorting - #5312

Open
Steven Rasmussen (StevenRasmussen) wants to merge 20 commits into
microsoft:devfrom
StevenRasmussen:multi-sort-grid
Open

Steven Rasmussen (StevenRasmussen) wants to merge 20 commits into
microsoft:devfrom
StevenRasmussen:multi-sort-grid

Conversation

@StevenRasmussen

@StevenRasmussen Steven Rasmussen (StevenRasmussen) commented Sep 20, 2026

Copy link
Copy Markdown
Contributor

Fixes #3461
Set SortMode="DataGridSortMode.Multiple" to sort by several columns at once, each with its own direction. The first sorted column is the primary sort, the ones after it break its ties. Single sort mode is unchanged and renders identical markup.

Users add a column to the sort with Shift+click or Shift+Enter, or from the column header: the header menu when HeaderCellAsButtonWithMenu is set, a column header popup otherwise. Both are built from the same menu items, so the actions and their order match. Each header offers only the direction the column is not already sorted in, and choosing it changes that column's direction while it keeps its place in the sort. ShowMultiSortActions="false" keeps the headers as they are in single sort mode for applications that supply their own sorting UI.

Accessibility:

  • aria-sort marks the primary sort column only, per WAI-ARIA's guidance to apply it to one header at a time. Further levels are conveyed by the header's accessible description ("Sorted descending, sort level 2 of 3").
  • The sort priority is also shown next to the direction icon, hidden from assistive technology so it is not announced twice.
  • A polite status message announces the resulting order, since changing the sort updates neither the focused element nor its name.
  • Closing the header menu or popup returns focus to the button that opened it, and sortable headers advertise aria-keyshortcuts="Shift+Enter".

Sorting internals:

  • IGridSort gains CanApplyThen and ApplyThen, which append a sort to an existing ordering with ThenBy. Both have default implementations, so existing implementations keep compiling.
  • GridSort supports then-by from its stored expressions and comparers; ColumnKeyGridSort takes an optional thenSortFunction.
  • Hierarchical grids restore parent/child order once, after the last sort level, rather than per level, which would have made every level after the first a no-op.
  • Sort state in the query string holds one entry per level, and is now applied once the columns have been collected.

Adds ClearSortAsync (leaves the grid unsorted) alongside ResetSortAsync (returns to the sort the columns declare), plus AddSortByColumnAsync and SetSortAsync. Includes a demo page, documentation, and 54 tests.

BREAKING CHANGE: the sort is exposed as an ordered list of DataGridSortColumn everywhere, and the properties that reported only the first sorted column are removed:

  • GridItemsProviderRequest.SortByColumn and .SortByAscending — use SortColumns, or SortColumns.FirstOrDefault() for the primary sort. ApplySorting() and GetSortByProperties() are unchanged and now cover every sort level.
  • DataGridSortEventArgs.Column and .SortByAscending — use SortColumns. An empty list now means the grid is not sorted, where Column used to be null.
  • FluentDataGrid.SortByAscending — use SortColumns.

See the DataGrid migration guide for before/after examples.

✅ Checklist

General

  • I have added tests for my changes.
  • I have tested my changes.
  • I have updated the project documentation to reflect my changes.
  • I have read the CONTRIBUTING documentation and followed the standards for this project.

Component-specific

component

  • I have modified an existing component
  • I have validated the Unit Tests for an existing component

MCP Server

  • I have added or updated MCP Server tools/prompts/resources
  • I have added Unit Tests for my MCP Server changes

Set SortMode="DataGridSortMode.Multiple" to sort by several columns at
once, each with its own direction. The first sorted column is the primary
sort, the ones after it break its ties. Single sort mode is unchanged and
renders identical markup.

Users add a column to the sort with Shift+click or Shift+Enter, or from
the column header: the header menu when HeaderCellAsButtonWithMenu is
set, a column header popup otherwise. Both are built from the same menu
items, so the actions and their order match. Each header offers only the
direction the column is not already sorted in, and choosing it changes
that column's direction while it keeps its place in the sort.
ShowMultiSortActions="false" keeps the headers as they are in single sort
mode for applications that supply their own sorting UI.

Accessibility:
- aria-sort marks the primary sort column only, per WAI-ARIA's guidance
  to apply it to one header at a time. Further levels are conveyed by the
  header's accessible description ("Sorted descending, sort level 2 of 3").
- The sort priority is also shown next to the direction icon, hidden from
  assistive technology so it is not announced twice.
- A polite status message announces the resulting order, since changing
  the sort updates neither the focused element nor its name.
- Closing the header menu or popup returns focus to the button that opened
  it, and sortable headers advertise aria-keyshortcuts="Shift+Enter".

Sorting internals:
- IGridSort gains CanApplyThen and ApplyThen, which append a sort to an
  existing ordering with ThenBy. Both have default implementations, so
  existing implementations keep compiling.
- GridSort supports then-by from its stored expressions and comparers;
  ColumnKeyGridSort takes an optional thenSortFunction.
- Hierarchical grids restore parent/child order once, after the last sort
  level, rather than per level, which would have made every level after
  the first a no-op.
- Sort state in the query string holds one entry per level, and is now
  applied once the columns have been collected.

Adds ClearSortAsync (leaves the grid unsorted) alongside ResetSortAsync
(returns to the sort the columns declare), plus AddSortByColumnAsync and
SetSortAsync. Includes a demo page, documentation, and 54 tests.

BREAKING CHANGE: the sort is exposed as an ordered list of
DataGridSortColumn<TGridItem> everywhere, and the properties that
reported only the first sorted column are removed:

- GridItemsProviderRequest.SortByColumn and .SortByAscending — use
  SortColumns, or SortColumns.FirstOrDefault() for the primary sort.
  ApplySorting() and GetSortByProperties() are unchanged and now cover
  every sort level.
- DataGridSortEventArgs.Column and .SortByAscending — use SortColumns.
  An empty list now means the grid is not sorted, where Column used to
  be null.
- FluentDataGrid.SortByAscending — use SortColumns.

See the DataGrid migration guide for before/after examples.
The MCP server's knowledge of multi-column sorting is markdown embedded
from the demo's documentation, so nothing in C# fails if that content is
moved, renamed, or dropped from the embedding globs. These tests pin the
parts an assistant needs:

- The DataGrid migration guide names SortByColumn, SortByAscending,
  SortColumns and DataGridSortMode.Multiple. Those are the identifiers a
  migration hits compile errors on, so the guide has to keep naming them.
- The multi-column sorting page is embedded and findable, and its content
  covers SortMode, AddSortByColumnAsync and ShowMultiSortActions.

Follow-up to b1edaa2.
Copilot AI lite review requested due to automatic review settings September 20, 2026 02:25
@StevenRasmussen Steven Rasmussen (StevenRasmussen) changed the title feat(DataGrid): add multi-column sorting #3461 feat(DataGrid): add multi-column sorting Sep 20, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot review overview

🟡 Changes recommended

Unresolved issues remain in sort compatibility, persistence, default sorting, and focus behavior.

Get a fresh assessment by requesting another Copilot review.

Review effort: Lite
Findings: 4 Medium severity

Open (4)
What changed in this PR

This PR adds opt-in multi-column sorting to FluentDataGrid, including ordered sort state, accessible header actions, persistence, documentation, and tests.

Changes:

  • Adds multiple sort levels, APIs, and ThenBy support.
  • Adds accessible menus, announcements, and focus handling.
  • Updates hierarchy handling, examples, localization, and migration guidance.
File Summary
tests/​McpServer/​Services/​MigrationServiceTests.cs Tests migration documentation.
tests/​McpServer/​Services/​ComponentDocumentationServiceTests.cs Tests documentation indexing.
tests/​Core/​Components/​DataGrid/​GridSortTests.cs Tests chained and hierarchical sorting.
tests/​Core/​Components/​DataGrid/​FluentDataGridTests.razor Updates single-sort assertions.
tests/​Core/​Components/​DataGrid/​FluentDataGridMultiSortTests.razor Tests multi-sort behavior and accessibility.
tests/​Core/​Components/​DataGrid/​ColumnKeyGridSortTests.cs Tests secondary sorting.
src/​Core/​Localization/​LanguageResource.resx Adds sorting and accessibility strings.
src/​Core/​Events/​DataGridSortEventArgs.cs Exposes ordered sort columns.
src/​Core/​Enums/​DataGridSortMode.cs Defines sort modes.
src/​Core/​Components/​DataGrid/​Infrastructure/​HierarchicalSortHelper.cs Restores hierarchy after sorting.
src/​Core/​Components/​DataGrid/​GridItemsProviderRequest.cs Applies and reports multiple sort levels.
src/​Core/​Components/​DataGrid/​FluentDataGrid.razor.css Styles sort indicators and popup content.
src/​Core/​Components/​DataGrid/​FluentDataGrid.razor.cs Implements sort state, APIs, persistence, and focus behavior.
src/​Core/​Components/​DataGrid/​FluentDataGrid.razor Adds status regions and popup actions.
src/​Core/​Components/​DataGrid/​Columns/​IGridSort.cs Adds secondary-sort contracts.
src/​Core/​Components/​DataGrid/​Columns/​GridSort.cs Implements ThenBy sorting.
src/​Core/​Components/​DataGrid/​Columns/​DataGridSortColumn.cs Represents a sort level.
src/​Core/​Components/​DataGrid/​Columns/​ColumnSortOptions.razor.cs Supports popup sort actions.
src/​Core/​Components/​DataGrid/​Columns/​ColumnSortOptions.razor Renders popup sort actions.
src/​Core/​Components/​DataGrid/​Columns/​ColumnSortMenuItems.razor.cs Implements shared menu behavior.
src/​Core/​Components/​DataGrid/​Columns/​ColumnSortMenuItems.razor Renders shared sort menu items.
src/​Core/​Components/​DataGrid/​Columns/​ColumnKeyGridSort.cs Supports custom secondary sorting.
src/​Core/​Components/​DataGrid/​Columns/​ColumnBase.razor.cs Handles sort gestures and focus.
src/​Core/​Components/​DataGrid/​Columns/​ColumnBase.razor Updates header markup and indicators.
src/​Core/​Components/​DataGrid/​ColumnHeaderCapabilities.cs Tracks sort-menu capabilities.
src/​Core/​Components/​Button/​FluentButton.razor.cs Exposes the element reference.
src/​Core/​Components/​Button/​FluentButton.razor Captures the button element.
examples/​Demo/​FluentUI.Demo.Client/​wwwroot/​skills/​fluentui-blazor-usage/​references/​DATAGRID.md Documents multi-sort usage.
examples/​Demo/​FluentUI.Demo.Client/​Documentation/​GetStarted/​Migration/​MigrationFluentDataGrid.md Documents migration changes.
examples/​Demo/​FluentUI.Demo.Client/​Documentation/​Components/​DataGrid/​Pages/​DataGridMultiSortPage.md Adds multi-sort documentation.
examples/​Demo/​FluentUI.Demo.Client/​Documentation/​Components/​DataGrid/​FluentDataGrid.md Links multi-sort guidance.
examples/​Demo/​FluentUI.Demo.Client/​Documentation/​Components/​DataGrid/​Examples/​DataGridRemoteData2.razor Updates remote sorting usage.
examples/​Demo/​FluentUI.Demo.Client/​Documentation/​Components/​DataGrid/​Examples/​DataGridMultiSort.razor Adds a multi-sort demo.
examples/​Demo/​FluentUI.Demo.Client/​Documentation/​Components/​DataGrid/​Examples/​DataGridCustomComparer.razor Updates sort event usage.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread src/Core/Components/DataGrid/Columns/ColumnBase.razor Outdated
Comment thread src/Core/Components/DataGrid/Columns/ColumnKeyGridSort.cs Outdated
Comment thread src/Core/Components/DataGrid/FluentDataGrid.razor.cs
Comment thread src/Core/Components/DataGrid/FluentDataGrid.razor.cs Outdated
…ved state

Addresses the review feedback on microsoft#5312:

- Restore the alignment-specific placement of the column header's popup
  button. It moved to a fixed position after the sort button, which changed
  the markup and keyboard order of start and center aligned headers even in
  single sort mode.
- Keep ColumnKeyGridSort's (columnKey, sortFunction) constructor instead of
  adding thenSortFunction as an optional argument to it. The optional
  argument changed the compiled signature, so callers built against an
  earlier version would have failed with a MissingMethodException.
- Collapse the sort to a single level when SortMode is switched back to
  Single. The grid kept sorting by, and reporting, every level, and
  ResetSortAsync could restore several of them.
- Escape the column titles in the sort state saved to the query string, and
  read the direction as each entry's last token, so a title holding a comma
  or a space round-trips. Levels now resolve to the first column with that
  title not already used, so columns sharing a title restore one level each.
@vnbaaij

Copy link
Copy Markdown
Collaborator

Copilot review

Comment thread src/Core/Components/Button/FluentButton.razor.cs Outdated
Comment thread src/Core/Components/DataGrid/FluentDataGrid.razor.cs Outdated
Comment thread src/Core/Components/DataGrid/FluentDataGrid.razor.cs Outdated
Comment thread src/Core/Components/DataGrid/GridItemsProviderRequest.cs
Comment thread src/Core/Components/DataGrid/FluentDataGrid.razor.cs Outdated
Comment thread src/Core/Components/DataGrid/FluentDataGrid.razor.cs
Comment thread src/Core/Components/DataGrid/FluentDataGrid.razor.cs Outdated
Comment thread src/Core/Components/DataGrid/GridItemsProviderRequest.cs Outdated
Comment thread src/Core/Components/DataGrid/Columns/ColumnBase.razor.cs Outdated
_defaultSortColumns was only ever appended to, so a column that was
removed, or recreated as a new component instance, stayed in it. The
dedupe guard compares by reference, so a recreated column was added
again while the stale entry remained. ResetSortAsync then restored a
detached column, which reached OnSortChanged and
GridItemsProviderRequest as a column the grid no longer shows.

Columns are now collected into a staging list that replaces
_defaultSortColumns once the collection completes, so it only ever names
columns the grid currently holds. Staging rather than clearing up front
keeps HasDeclaredSort stable during the render that collects, which the
column header menu reads.

InternalGridContext.DefaultSortColumn had the same leak: it was only
assigned while null, so it pinned the first column instance for the
lifetime of the grid. It is now derived from the rebuilt list.

Adds tests for a declared sort column being removed and added.
…lumn

The property recorded the single column that declared a sort, and was
read to restore that sort before the grid supported more than one sort
level. Multi-column sorting replaced it with _defaultSortColumns, the
ordered list of every declared level, which left the property written
but never read.

Keeping it would mean a second source of truth for the declared sort,
alongside the list that already holds it. InternalGridContext is
internal, so nothing outside the assembly can observe the removal.
GetSortableLevels drops a level whose column has no SortBy, and a
secondary level whose sort cannot be appended to an existing ordering.
Those levels stayed in SortColumns, so the header showed a direction and
a sort priority, the change was announced, and OnSortChanged and
GridItemsProviderRequest reported a column that never reached the query.
The displayed sort could therefore disagree with the data order.

The same rule now decides what enters SortColumns in the first place,
through CanSortDataAtLevel, applied to the two paths that did not check:
the sort declared through IsDefaultSortColumn, and the sort restored from
the query string, which only tested CanApplyThen and let a column with no
SortBy through.

This changes rendered output where a column declared a sort it could not
carry out. A PropertyColumn only derives a SortBy when Sortable is set,
so <PropertyColumn Property="..." IsDefaultSortColumn="true" /> declared
a sort that never ordered anything; its header advertised col-sort="asc"
and aria-sort="ascending" over unsorted rows. Seven snapshots recorded
that markup and are updated to aria-sort="none". The row order in them is
unchanged, which is what shows the sort was never applied.

Also makes the multi-sort demo's Location column Sortable="false", so
there is a non-sortable column between two sortable ones to try the
boundary against. Its title says so, because a header with no sort
actions otherwise looks the same as one that is simply not sorted on yet.
Levels were saved by column title and restored by matching titles in
declaration order. A grid holding two columns with the same title could
therefore restore the levels onto the wrong ones: sorting the second
column first saved "Sortable asc,Sortable desc", which came back with the
directions and priorities swapped onto the first column and the second.

Levels are now saved by ColumnKey, which stays distinct when titles
collide, so they return to the columns they were saved from whatever
order those were sorted in. A level whose key matches no column falls
back to matching a title, so links saved before this keep working.

AssignColumnKeys moves ahead of the restore in FinishCollectingColumns,
since the keys were still empty at the point the saved levels were
resolved.

A column key derived from a bound property now uses the declaring type's
short name instead of its full name, so a saved sort reads
"Employee.Department asc" rather than carrying a whole namespace through
the query string. Collisions this creates are resolved by the suffix
AssignColumnKeys already adds.

Documents that ColumnId is worth setting on a grid that saves its state:
it is the only key the author chooses, so it survives columns being added
or removed around it, where a title shared by two columns separates them
only by position.

BREAKING CHANGE: column keys derived from a bound property no longer
include the namespace of the declaring type. A column order persisted by
GetColumnOrder or ColumnOrderChanged from an earlier version no longer
matches those columns, and they fall back to declaration order. Set
ColumnId to pin the keys an application stores.
Single sort state used to be written as an unescaped "<title> <direction>"
and read by splitting on the first space. The current grammar treats a
comma as a level separator and a backslash as an escape, so a saved link
whose title holds either one is split into pieces that resolve to no
column and the sort is lost.

The value is kept as it was read, and when the current grammar resolves
no column at all it is re-read as the single unescaped level those
versions wrote. The retry is narrow on purpose: it only runs when nothing
resolved, so a link that resolves in part is left alone, and it is
guarded against the empty value that means the grid was explicitly left
unsorted.

A link restored this way is rewritten in the current format the next time
the grid saves its state, so it stops depending on the retry.

Resolving levels and reading one entry move into their own methods, since
both the current and the legacy path need them.

Covers the old format with tests for a comma, a backslash, a comma with
spaces, both directions, a missing and an unreadable direction, a title
holding spaces, single sort mode, a column that no longer exists, and the
rewrite to the current format. Four of them fail without the retry.
_defaultSortColumns held what SortMode could apply rather than what the
columns declare: AddColumn collected only the first level unless the mode
was Multiple, and switching to Single trimmed the rest away. Two things
therefore decided its contents, and neither of them owned it.

It now holds every declared level whatever the mode, and the mode is
applied where the sort is used instead: the declared sort is applied to
the grid one level at a time only in Multiple mode, and RestoreDefaultSort
takes the first level alone otherwise. CollapseSortLevelsForSingleSortMode
collapses the current sort and leaves the declared sort alone.

No behaviour changes, and the Single mode tests that were already there
cover that. Adds two tests for switching between the modes, which nothing
covered: a grid that starts in Multiple, drops to Single and returns, and
one that starts in Single and moves to Multiple. Both restore the sort
the columns declare in full once Multiple can apply it.
IsSameRequest decides whether the items provider is asked for data again.
It compared each sort level by Column.Index, which AddColumn assigns from
the column's position every time the columns are collected. A column that
was removed and the one that took its place therefore carry the same
index, so two requests naming different columns compared equal and the
grid kept serving the order it already had.

Levels are now compared by ColumnKey, the identity the grid already uses
for column order and for saved sort state. It also keeps the comparison
right the other way round: a column recreated across renders keeps its
key, so the provider is not asked again for an order that has not
changed, which comparing instances would have done.

Adds tests for the four cases the comparison has to tell apart: different
columns that share an index, the same column recreated, the same column
in the other direction, and the same columns at swapped priorities. Only
the first one failed before.

IsSameRequest is no longer marked as unreachable from unit tests.
Shift+S was described as clearing every sort level. It calls
RemoveSortByColumnAsync, which returns to the sort the columns declare,
and only leaves the grid unsorted when no column declares one. That is
also what separates it from Clear all sorts, which always unsorts.

The column header action table stopped at Clear sort and Clear all sorts,
leaving out Reset all sorts, which the menu offers whenever more than one
column is sorted on and some column declares a sort.
The header buttons tested KeyboardEventArgs.Code against "Enter" and
"Space". Numpad Enter reports its own code, so the numeric keypad could
not activate a header at all: Shift and Numpad Enter did not add a sort
level, and the key did not open the header menu either.

The three handlers that shared those comparisons now share one list of
codes, which Numpad Enter joins. That covers the column options button
as well as the two sort ones, since the same key would otherwise reach
the sort actions but not the options.

Adds a theory for each of the two sort buttons over Enter, Numpad Enter
and Space. Only the Numpad Enter cases fail without the change.
@vnbaaij Vincent Baaij (vnbaaij) added the v5 For the next major version label Sep 21, 2026
@vnbaaij Vincent Baaij (vnbaaij) added this to the v5.0 milestone Sep 21, 2026
- Changing the sort updates neither the focused element nor its name, so the new order is announced through a status
message ("Sorted by Department ascending, then by Location descending").

## Example

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.

Move this to before the 'Saving the sort in the URL' section

| Clear sort | The column is sorted on | Stops sorting by this column, keeping the other levels. |
| Reset all sorts | More than one column is sorted on, and some column declares a sort | Returns to the sort the columns declare through `IsDefaultSortColumn`. |
| Clear all sorts | More than one column is sorted on | Removes every sort level, leaving the grid unsorted. |

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.

It multi sort enabled and using HeaderCellAsButtonWithMenu then below the sort items a menu divider should be shown so they appear grouped. (there can be more non-sort releated menu items) Then, because they are grouped, the 'sort'/'sorts' doesn't need to be repeated in every item. So change the menu to:

  • Sort descending/ascending
  • Add
  • Clear
  • Clear all
  • Reset
Image

As can be seen in the image, the is an issue with the text alignment of the menu items. This needs to be solved in FluentDataGridCell.razor.css. After the .fluent-data-grid th[cell-type='columnheader']:hover block the following needs to be added:

.fluent-data-grid th[cell-type='columnheader'] fluent-menu-item {
    text-align: start;
  }

This is not because of your changes but a leftover from a previous PR that needs to be fixed anyway

b1edaa2 added a byte order mark to the first line of every file it
touched that did not already have one, which showed up in the diff as a
change to line 1 of each. None of them is a .razor file, where a BOM is
the convention here; they are .cs, .md, .razor.css and .resx files, which
carry none.

Every file is back to the first bytes it had before the branch, or has no
BOM where the branch created it.
The multi-sort example had a checkbox that turned SaveStateInUrl on and
off, which is not how anyone writes a grid: the parameter is set once,
and the checkbox only got in the way of copying the example.

The main example loses the checkbox and goes back to being about sorting
by several columns. Saving the sort in the URL gets its own example under
the section that describes it, showing the same grid with the parameter
set to true and nothing to configure, so it can be copied as it stands.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

v5 For the next major version

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: add [multi-column sorting] to [FluentDataGrid]

4 participants