feat(DataGrid): add multi-column sorting - #5312
Steven Rasmussen (StevenRasmussen) wants to merge 20 commits into
Conversation
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.
There was a problem hiding this comment.
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
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
ThenBysupport. - 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.
…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.
|
Copilot review |
…eflected on a page refresh.
_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.
…fluentui-blazor into multi-sort-grid
| - 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 |
There was a problem hiding this comment.
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. | | ||
|
|
There was a problem hiding this comment.
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
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.
…fluentui-blazor into multi-sort-grid
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.

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:
Sorting internals:
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:
See the DataGrid migration guide for before/after examples.
✅ Checklist
General
Component-specific
component
MCP Server