diff --git a/examples/Demo/Shared/Microsoft.FluentUI.AspNetCore.Components.xml b/examples/Demo/Shared/Microsoft.FluentUI.AspNetCore.Components.xml index 0210b697d4..648d2f4e79 100644 --- a/examples/Demo/Shared/Microsoft.FluentUI.AspNetCore.Components.xml +++ b/examples/Demo/Shared/Microsoft.FluentUI.AspNetCore.Components.xml @@ -1332,6 +1332,56 @@ Constructs an instance of . + + + Gets or sets the text shown in the column menu + + + + + Gets the default labels for the options UI. + + + + + Gets or sets the text shown in the column menu + + + + + Gets or sets the label in the discrete mode resize UI + + + + + Gets or sets the label in the exact mode resize UI + + + + + Gets or sets the aria label for the grow button in the discrete resize UI + + + + + Gets or sets the aria label for the shrink button in the discrete resize UI + + + + + Gets or sets the aria label for the reset button in the resize UI + + + + + Gets or sets the aria label for the submit button in the resize UI + + + + + Gets the default labels for the resize UI. + + Gets a reference to the enclosing . @@ -1350,6 +1400,26 @@ The display of this component is dependant on a ResizeType being set + + + Gets or sets the text shown in the column menu + + + + + Gets or sets the text shown in the column menu when in ascending order + + + + + Gets or sets the text shown in the column menu when in descending order + + + + + Gets the default labels for the sort UI. + + Represents a sort order specification used within . @@ -1794,6 +1864,21 @@ (Aria) Labels used in the column resize UI. + + + Labels used in the column sort UI. + + + + + Labels used in the column options UI. + + + + + If true, enables the new style of header cell that includes a button to display all column options through a menu. + + Optionally defines a value for @key on each rendered row. Typically this should be used to specify a @@ -1948,6 +2033,13 @@ The column whose options are to be displayed, if any are available. + + + Displays the column resize UI for the specified column, closing any other column + resize UI that was previously displayed. + + The column whose resize UI is to be displayed. + Instructs the grid to re-fetch and render the current data from the supplied data source diff --git a/examples/Demo/Shared/Pages/DataGrid/Examples/DataGridTypical.razor b/examples/Demo/Shared/Pages/DataGrid/Examples/DataGridTypical.razor index 1d7cb02c02..c3a7fd6db0 100644 --- a/examples/Demo/Shared/Pages/DataGrid/Examples/DataGridTypical.razor +++ b/examples/Demo/Shared/Pages/DataGrid/Examples/DataGridTypical.razor @@ -11,6 +11,7 @@ GridTemplateColumns="0.2fr 1fr 0.2fr 0.2fr 0.2fr 0.2fr" RowClass="@rowClass" RowStyle="@rowStyle" + HeaderCellAsButtonWithMenu="true" Style="height: 405px;overflow:auto;" ColumnResizeLabels="@customLabels"> @@ -25,8 +26,8 @@ - - + +
@@ -64,7 +65,7 @@ int minMedals; int maxMedals = 130; - ColumnResizeLabels customLabels = new ColumnResizeLabels(DiscreteLabel: "Width (+/- 10px)", ResetAriaLabel: "restore"); + ColumnResizeLabels customLabels = ColumnResizeLabels.Default with { DiscreteLabel = "Width (+/- 10px)", ResetAriaLabel = "Restore" }; GridSort rankSort = GridSort .ByDescending(x => x.Medals.Gold) diff --git a/examples/Demo/Shared/Pages/Lab/IssueTester.razor b/examples/Demo/Shared/Pages/Lab/IssueTester.razor index 0e8d03c85c..bcdacb427a 100644 --- a/examples/Demo/Shared/Pages/Lab/IssueTester.razor +++ b/examples/Demo/Shared/Pages/Lab/IssueTester.razor @@ -1 +1,2 @@ @page "/issue-tester" + diff --git a/src/Core/Components/DataGrid/Columns/ColumnBase.razor b/src/Core/Components/DataGrid/Columns/ColumnBase.razor index fc30d388cc..7e450895fd 100644 --- a/src/Core/Components/DataGrid/Columns/ColumnBase.razor +++ b/src/Core/Components/DataGrid/Columns/ColumnBase.razor @@ -13,6 +13,53 @@ { @HeaderCellItemTemplate(this) } + else if (Grid.HeaderCellAsButtonWithMenu) + { + string? tooltip = Tooltip ? Title : null; + + + + +
@Title
+ + @if (Grid.SortByAscending.HasValue && ShowSortIcon) + { + if (Grid.SortByAscending == true) + { + + } + else + { + + } + } + @if (Grid.ResizeType is not null && ColumnOptions is not null) + { + @if (Filtered.GetValueOrDefault()) + { + + } + } +
+ + @if (Sortable.HasValue ? Sortable.Value : IsSortableByDefault()) + { + + @GetSortOptionText() + + } + @if (Grid.ResizeType is not null && Grid.ResizableColumns) + { + @Grid.ColumnResizeLabels.ResizeMenu + } + @if (ColumnOptions is not null) + { + @Grid.ColumnOptionsLabels.OptionsMenu + } + +
+
+ } else { string? tooltip = Tooltip ? Title : null; diff --git a/src/Core/Components/DataGrid/Columns/ColumnBase.razor.cs b/src/Core/Components/DataGrid/Columns/ColumnBase.razor.cs index 97e6f0f254..f186b6ac45 100644 --- a/src/Core/Components/DataGrid/Columns/ColumnBase.razor.cs +++ b/src/Core/Components/DataGrid/Columns/ColumnBase.razor.cs @@ -12,6 +12,10 @@ namespace Microsoft.FluentUI.AspNetCore.Components; /// The type of data represented by each row in the grid. public abstract partial class ColumnBase { + private bool _isMenuOpen; + private static readonly string[] KEYBOARD_MENU_SELECT_KEYS = ["Enter", "NumpadEnter"]; + private readonly string _columnId = $"column-header{Identifier.NewId()}"; + [CascadingParameter] internal InternalGridContext InternalGridContext { get; set; } = default!; @@ -128,6 +132,8 @@ public abstract partial class ColumnBase ///
protected FluentDataGrid Grid => InternalGridContext.Grid; + protected bool AnyColumnActionEnabled => Sortable is true || IsDefaultSortColumn || ColumnOptions != null || Grid.ResizableColumns; + /// /// Event callback for when the row is clicked. /// @@ -218,4 +224,65 @@ public ColumnBase() { HeaderContent = RenderDefaultHeaderContent; } + + private async Task HandleColumnHeaderClickedAsync() + { + if ((Sortable is true || IsDefaultSortColumn) && (Grid.ResizableColumns || ColumnOptions is not null)) + { + _isMenuOpen = !_isMenuOpen; + } + else if ((Sortable is true || IsDefaultSortColumn) && !Grid.ResizableColumns && ColumnOptions is null) + { + await Grid.SortByColumnAsync(this); + } + else if (Sortable is not true && !IsDefaultSortColumn && ColumnOptions is null && Grid.ResizableColumns) + { + await Grid.ShowColumnResizeAsync(this); + } + } + + private async Task HandleSortMenuKeyDownAsync(KeyboardEventArgs args) + { + if (KEYBOARD_MENU_SELECT_KEYS.Contains(args.Key)) + { + await Grid.SortByColumnAsync(this); + StateHasChanged(); + _isMenuOpen = false; + } + } + + private async Task HandleResizeMenuKeyDownAsync(KeyboardEventArgs args) + { + if (KEYBOARD_MENU_SELECT_KEYS.Contains(args.Key)) + { + await Grid.ShowColumnResizeAsync(this); + _isMenuOpen = false; + } + } + + private async Task HandleOptionsMenuKeyDownAsync(KeyboardEventArgs args) + { + if (KEYBOARD_MENU_SELECT_KEYS.Contains(args.Key)) + { + await Grid.ShowColumnOptionsAsync(this); + _isMenuOpen = false; + } + } + + private string GetSortOptionText() + { + if (Grid.SortByAscending.HasValue && ShowSortIcon) + { + if (Grid.SortByAscending is true) + { + return Grid.ColumnSortLabels.SortMenuAscendingLabel; + } + else + { + return Grid.ColumnSortLabels.SortMenuDescendingLabel; + } + } + + return Grid.ColumnSortLabels.SortMenu; + } } diff --git a/src/Core/Components/DataGrid/Columns/ColumnOptionsLabels.cs b/src/Core/Components/DataGrid/Columns/ColumnOptionsLabels.cs new file mode 100644 index 0000000000..6779270951 --- /dev/null +++ b/src/Core/Components/DataGrid/Columns/ColumnOptionsLabels.cs @@ -0,0 +1,19 @@ +// ------------------------------------------------------------------------ +// MIT License - Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------------------ +namespace Microsoft.FluentUI.AspNetCore.Components; + +public record ColumnOptionsLabels +{ + /// + /// Gets or sets the text shown in the column menu + /// + public string OptionsMenu { get; set; } = "Filter"; + + /// + /// Gets the default labels for the options UI. + /// + public static ColumnOptionsLabels Default => new(); + +} + diff --git a/src/Core/Components/DataGrid/Columns/ColumnResizeLabels.cs b/src/Core/Components/DataGrid/Columns/ColumnResizeLabels.cs new file mode 100644 index 0000000000..638dec6983 --- /dev/null +++ b/src/Core/Components/DataGrid/Columns/ColumnResizeLabels.cs @@ -0,0 +1,48 @@ +// ------------------------------------------------------------------------ +// MIT License - Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------------------ + +namespace Microsoft.FluentUI.AspNetCore.Components; + +public record ColumnResizeLabels +{ + /// + /// Gets or sets the text shown in the column menu + /// + public string ResizeMenu { get; set; } = "Resize"; + + /// + /// Gets or sets the label in the discrete mode resize UI + /// + public string DiscreteLabel { get; set; } = "Column width"; + + /// + /// Gets or sets the label in the exact mode resize UI + /// + public string ExactLabel { get; set; } = "Column width (in pixels)"; + + /// + /// Gets or sets the aria label for the grow button in the discrete resize UI + /// + public string? GrowAriaLabel { get; set; } = "Grow column width"; + + /// + /// Gets or sets the aria label for the shrink button in the discrete resize UI + /// + public string? ShrinkAriaLabel { get; set; } = "Shrink column width"; + + /// + /// Gets or sets the aria label for the reset button in the resize UI + /// + public string? ResetAriaLabel { get; set; } = "Reset column widths"; + + /// + /// Gets or sets the aria label for the submit button in the resize UI + /// + public string? SubmitAriaLabel { get; set; } = "Set column widths"; + + /// + /// Gets the default labels for the resize UI. + /// + public static ColumnResizeLabels Default => new(); +} diff --git a/src/Core/Components/DataGrid/Columns/ColumnSortLabels.cs b/src/Core/Components/DataGrid/Columns/ColumnSortLabels.cs new file mode 100644 index 0000000000..706541f3a8 --- /dev/null +++ b/src/Core/Components/DataGrid/Columns/ColumnSortLabels.cs @@ -0,0 +1,29 @@ +// ------------------------------------------------------------------------ +// MIT License - Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------------------ +namespace Microsoft.FluentUI.AspNetCore.Components; + +public record ColumnSortLabels +{ + /// + /// Gets or sets the text shown in the column menu + /// + public string SortMenu { get; set; } = "Sort"; + + /// + /// Gets or sets the text shown in the column menu when in ascending order + /// + public string SortMenuAscendingLabel { get; set; } = "Sort (ascending)"; + + /// + /// Gets or sets the text shown in the column menu when in descending order + /// + public string SortMenuDescendingLabel { get; set; } = "Sort (descending)"; + + /// + /// Gets the default labels for the sort UI. + /// + public static ColumnSortLabels Default => new(); + +} + diff --git a/src/Core/Components/DataGrid/FluentDataGrid.razor b/src/Core/Components/DataGrid/FluentDataGrid.razor index b204808cc6..733199fe8f 100644 --- a/src/Core/Components/DataGrid/FluentDataGrid.razor +++ b/src/Core/Components/DataGrid/FluentDataGrid.razor @@ -27,6 +27,7 @@ aria-rowcount="@(_internalGridContext.TotalItemCount + 1)" @onrowfocus=HandleOnRowFocusAsync @onclosecolumnoptions="CloseColumnOptions" + @onclosecolumnresize="CloseColumnResize" @attributes="AdditionalAttributes"> @if (GenerateHeader != GenerateHeaderOption.None) { @@ -150,21 +151,21 @@ @if (col == _displayOptionsForColumn) {
- @col.ColumnOptions - @if (ResizeType is not null ) - { - @if (@col.ColumnOptions is not null) - { - - } +
+ } + @if (ResizableColumns && col == _displayResizeForColumn) + { +
- - } + @if (ResizeType is not null) + { + + } -
} + @if (ResizableColumns) { diff --git a/src/Core/Components/DataGrid/FluentDataGrid.razor.cs b/src/Core/Components/DataGrid/FluentDataGrid.razor.cs index e1abd62601..9aa3dfffc5 100644 --- a/src/Core/Components/DataGrid/FluentDataGrid.razor.cs +++ b/src/Core/Components/DataGrid/FluentDataGrid.razor.cs @@ -104,7 +104,25 @@ public partial class FluentDataGrid : FluentComponentBase, IHandleEve /// (Aria) Labels used in the column resize UI. /// [Parameter] - public ColumnResizeLabels ColumnResizeLabels { get; set; } = new ColumnResizeLabels(); + public ColumnResizeLabels ColumnResizeLabels { get; set; } = ColumnResizeLabels.Default; + + /// + /// Labels used in the column sort UI. + /// + [Parameter] + public ColumnSortLabels ColumnSortLabels { get; set; } = ColumnSortLabels.Default; + + /// + /// Labels used in the column options UI. + /// + [Parameter] + public ColumnOptionsLabels ColumnOptionsLabels { get; set; } = ColumnOptionsLabels.Default; + + /// + /// If true, enables the new style of header cell that includes a button to display all column options through a menu. + /// + [Parameter] + public bool HeaderCellAsButtonWithMenu { get; set; } /// /// Optionally defines a value for @key on each rendered row. Typically this should be used to specify a @@ -245,9 +263,11 @@ public partial class FluentDataGrid : FluentComponentBase, IHandleEve // Tracking state for options and sorting private ColumnBase? _displayOptionsForColumn; + private ColumnBase? _displayResizeForColumn; private ColumnBase? _sortByColumn; private bool _sortByAscending; private bool _checkColumnOptionsPosition; + private bool _checkColumnResizePosition; private bool _manualGrid; // The associated ES6 module, which uses document-level event listeners @@ -370,7 +390,13 @@ protected override async Task OnAfterRenderAsync(bool firstRender) if (_checkColumnOptionsPosition && _displayOptionsForColumn is not null) { _checkColumnOptionsPosition = false; - _ = Module?.InvokeVoidAsync("checkColumnOptionsPosition", _gridReference).AsTask(); + _ = Module?.InvokeVoidAsync("checkColumnPopupPosition", _gridReference, ".col-options").AsTask(); + } + + if (_checkColumnResizePosition && _displayResizeForColumn is not null) + { + _checkColumnResizePosition = false; + _ = Module?.InvokeVoidAsync("checkColumnPopupPosition", _gridReference, ".col-resize").AsTask(); } } @@ -491,6 +517,19 @@ public Task ShowColumnOptionsAsync(ColumnBase column) return Task.CompletedTask; } + /// + /// Displays the column resize UI for the specified column, closing any other column + /// resize UI that was previously displayed. + /// + /// The column whose resize UI is to be displayed. + public Task ShowColumnResizeAsync(ColumnBase column) + { + _displayResizeForColumn = column; + _checkColumnResizePosition = true; // Triggers a call to JSRuntime to position the options element, apply autofocus, and any other setup + StateHasChanged(); + return Task.CompletedTask; + } + public void SetLoadingState(bool loading) { Loading = loading; @@ -689,6 +728,12 @@ private void CloseColumnOptions() StateHasChanged(); } + private void CloseColumnResize() + { + _displayResizeForColumn = null; + StateHasChanged(); + } + private async Task HandleOnRowFocusAsync(DataGridRowFocusEventArgs args) { var rowId = args.RowId; diff --git a/src/Core/Components/DataGrid/FluentDataGrid.razor.css b/src/Core/Components/DataGrid/FluentDataGrid.razor.css index 28c17768ab..d371ef5f67 100644 --- a/src/Core/Components/DataGrid/FluentDataGrid.razor.css +++ b/src/Core/Components/DataGrid/FluentDataGrid.razor.css @@ -16,7 +16,7 @@ fluent-data-grid { font-weight: 600; } -.col-options { +.col-options, .col-resize { position: absolute; min-width: 250px; top: 2.2rem; diff --git a/src/Core/Components/DataGrid/FluentDataGrid.razor.js b/src/Core/Components/DataGrid/FluentDataGrid.razor.js index 1606a5f195..d6c3c6eadc 100644 --- a/src/Core/Components/DataGrid/FluentDataGrid.razor.js +++ b/src/Core/Components/DataGrid/FluentDataGrid.razor.js @@ -16,6 +16,10 @@ export function init(gridElement) { if (columnOptionsElement && event.composedPath().indexOf(columnOptionsElement) < 0) { gridElement.dispatchEvent(new CustomEvent('closecolumnoptions', { bubbles: true })); } + const columnResizeElement = gridElement?.querySelector('.col-resize'); + if (columnResizeElement && event.composedPath().indexOf(columnResizeElement) < 0) { + gridElement.dispatchEvent(new CustomEvent('closecolumnresize', { bubbles: true })); + } }; const keyDownHandler = event => { const columnOptionsElement = gridElement?.querySelector('.col-options'); @@ -33,6 +37,21 @@ export function init(gridElement) { } ); } + const columnResizeElement = gridElement?.querySelector('.col-resize'); + if (columnResizeElement) { + if (event.key === "Escape") { + gridElement.dispatchEvent(new CustomEvent('closecolumnresize', { bubbles: true })); + gridElement.focus(); + } + columnResizeElement.addEventListener( + "keydown", + (event) => { + if (event.key === "ArrowRight" || event.key === "ArrowLeft" || event.key === "ArrowDown" || event.key === "ArrowUp") { + event.stopPropagation(); + } + } + ); + } }; const cells = gridElement.querySelectorAll('[role="gridcell"]'); @@ -92,6 +111,53 @@ export function checkColumnOptionsPosition(gridElement) { } } +export function checkColumnResizePosition(gridElement) { + const colOptions = gridElement?._rowElements[0] && gridElement?.querySelector('.col-resize'); // Only match within *our* thead, not nested tables + if (colResize) { + // We want the options popup to be positioned over the grid, not overflowing on either side, because it's possible that + // beyond either side is off-screen or outside the scroll range of an ancestor + const gridRect = gridElement.getBoundingClientRect(); + const resizeRect = colResize.getBoundingClientRect(); + const leftOverhang = Math.max(0, gridRect.left - resizeRect.left); + const rightOverhang = Math.max(0, resizeRect.right - gridRect.right); + if (leftOverhang || rightOverhang) { + // In the unlikely event that it overhangs both sides, we'll center it + const applyOffset = leftOverhang && rightOverhang ? (leftOverhang - rightOverhang) / 2 : (leftOverhang - rightOverhang); + colResize.style.transform = `translateX(${applyOffset}px)`; + } + + colResize.scrollIntoViewIfNeeded(); + + const autoFocusElem = colResize.querySelector('[autofocus]'); + if (autoFocusElem) { + autoFocusElem.focus(); + } + } +} + +export function checkColumnPopupPosition(gridElement, selector) { + const colPopup = gridElement?._rowElements[0] && gridElement?.querySelector(selector); // Only match within *our* thead, not nested tables + if (colPopup) { + // We want the options popup to be positioned over the grid, not overflowing on either side, because it's possible that + // beyond either side is off-screen or outside the scroll range of an ancestor + const gridRect = gridElement.getBoundingClientRect(); + const popupRect = colPopup.getBoundingClientRect(); + const leftOverhang = Math.max(0, gridRect.left - popupRect.left); + const rightOverhang = Math.max(0, popupRect.right - gridRect.right); + if (leftOverhang || rightOverhang) { + // In the unlikely event that it overhangs both sides, we'll center it + const applyOffset = leftOverhang && rightOverhang ? (leftOverhang - rightOverhang) / 2 : (leftOverhang - rightOverhang); + colPopup.style.transform = `translateX(${applyOffset}px)`; + } + + colPopup.scrollIntoViewIfNeeded(); + + const autoFocusElem = colPopup.querySelector('[autofocus]'); + if (autoFocusElem) { + autoFocusElem.focus(); + } + } +} export function enableColumnResizing(gridElement) { if (gridElement === latestGridElement) return; @@ -276,5 +342,6 @@ export function resizeColumnExact(gridElement, column, width) { .join(' '); gridElement.dispatchEvent(new CustomEvent('closecolumnoptions', { bubbles: true })); + gridElement.dispatchEvent(new CustomEvent('closecolumnresize', { bubbles: true })); gridElement.focus(); } diff --git a/src/Core/Components/DataGrid/FluentDataGridCell.razor.css b/src/Core/Components/DataGrid/FluentDataGridCell.razor.css index 3bb056b485..e866d970d3 100644 --- a/src/Core/Components/DataGrid/FluentDataGridCell.razor.css +++ b/src/Core/Components/DataGrid/FluentDataGridCell.razor.css @@ -80,6 +80,10 @@ fluent-data-grid-cell { text-overflow: ellipsis; } +::deep .col-sort-button.disabled::part(control) { + opacity: 1 !important; +} + .col-justify-center ::deep .col-sort-button::part(control) { justify-content: center; } @@ -88,6 +92,10 @@ fluent-data-grid-cell { justify-content: end; } +.col-justify-end ::deep .col-sort-button::part(start), .col-justify-right ::deep .col-sort-button::part(start) { + margin-inline-end: 0 !important; +} + .col-justify-center { justify-self: center; } diff --git a/src/Core/Components/DataGrid/Infrastructure/ColumnResizeLabels.cs b/src/Core/Components/DataGrid/Infrastructure/ColumnResizeLabels.cs deleted file mode 100644 index bd700d3461..0000000000 --- a/src/Core/Components/DataGrid/Infrastructure/ColumnResizeLabels.cs +++ /dev/null @@ -1,12 +0,0 @@ -// ------------------------------------------------------------------------ -// MIT License - Copyright (c) Microsoft Corporation. All rights reserved. -// ------------------------------------------------------------------------ - -namespace Microsoft.FluentUI.AspNetCore.Components; - -public record ColumnResizeLabels(string DiscreteLabel = "Column width", - string ExactLabel = "Column width (in pixels)", - string GrowAriaLabel = "Grow column width", - string ShrinkAriaLabel = "Shrink column width", - string ResetAriaLabel = "Reset column widths", - string SubmitAriaLabel = "Set column widths"); diff --git a/src/Core/Events/EventHandlers.cs b/src/Core/Events/EventHandlers.cs index 675da23599..07a5936d81 100644 --- a/src/Core/Events/EventHandlers.cs +++ b/src/Core/Events/EventHandlers.cs @@ -17,6 +17,7 @@ namespace Microsoft.FluentUI.AspNetCore.Components; [EventHandler("oncellfocus", typeof(DataGridCellFocusEventArgs), enableStopPropagation: true, enablePreventDefault: true)] [EventHandler("onrowfocus", typeof(DataGridRowFocusEventArgs), enableStopPropagation: true, enablePreventDefault: true)] [EventHandler("onclosecolumnoptions", typeof(EventArgs), enableStopPropagation: true, enablePreventDefault: true)] +[EventHandler("onclosecolumnresize", typeof(EventArgs), enableStopPropagation: true, enablePreventDefault: true)] [EventHandler("ontooltipdismiss", typeof(EventArgs), enableStopPropagation: true, enablePreventDefault: true)] [EventHandler("onsplitterresized", typeof(SplitterResizedEventArgs), enableStopPropagation: true, enablePreventDefault: true)] [EventHandler("onsplittercollapsed", typeof(SplitterCollapsedEventArgs), enableStopPropagation: true, enablePreventDefault: true)]