diff --git a/src/MudBlazor.UnitTests/Components/DataGridTests.cs b/src/MudBlazor.UnitTests/Components/DataGridTests.cs index 60a7bfcaab8e..9ccb08b2a552 100644 --- a/src/MudBlazor.UnitTests/Components/DataGridTests.cs +++ b/src/MudBlazor.UnitTests/Components/DataGridTests.cs @@ -5164,6 +5164,158 @@ public async Task DataGridSelectColumn() selectAllCheckboxes[1].Instance.ReadValue.Should().BeFalse(); } + [Test] + [SetCulture("")] + [SetUICulture("")] + public void SelectColumn_DefaultAriaLabels_AreApplied() + { + var items = new List { 1, 2 }; + + var comp = Context.Render>(parameters => parameters + .Add(p => p.Items, items) + .Add(p => p.MultiSelection, true) + .Add(p => p.Columns, builder => + { + builder.OpenComponent>(0); + builder.CloseComponent(); + builder.OpenComponent>(1); + builder.AddAttribute(2, nameof(PropertyColumn.Property), (Expression>)(x => x)); + builder.CloseComponent(); + })); + + var headerCheckbox = comp.Find("thead .mud-checkbox input"); + headerCheckbox.GetAttribute("aria-label").Should().Be("Select all rows"); + + var rowCheckboxes = comp.FindAll("tbody .mud-checkbox input"); + rowCheckboxes[0].GetAttribute("aria-label").Should().Be("Select row 1"); + rowCheckboxes[1].GetAttribute("aria-label").Should().Be("Select row 2"); + } + + [Test] + public void DataGrid_WithoutSelectColumn_DoesNotRenderAriaSelected() + { + var items = new List { 1, 2 }; + + var comp = Context.Render>(parameters => parameters + .Add(p => p.Items, items) + .Add(p => p.MultiSelection, true) + .Add(p => p.Columns, builder => + { + builder.OpenComponent>(0); + builder.AddAttribute(1, nameof(PropertyColumn.Property), (Expression>)(x => x)); + builder.CloseComponent(); + })); + + var rows = comp.FindAll("tbody tr"); + rows[0].HasAttribute("aria-selected").Should().BeFalse(); + rows[1].HasAttribute("aria-selected").Should().BeFalse(); + } + + [Test] + public void SelectColumn_CustomAriaLabels_OverrideDefaults() + { + var items = new List + { + new() { Id = 1, Name = "First" }, + new() { Id = 2, Name = "Second" } + }; + + var comp = Context.Render>(parameters => parameters + .Add(p => p.Items, items) + .Add(p => p.MultiSelection, true) + .Add(p => p.Columns, builder => + { + builder.OpenComponent>(0); + builder.AddAttribute(1, nameof(SelectColumn.RowCheckboxAriaLabelFunc), (Func)((item, index) => $"{item.Name} row {index + 1}")); + builder.AddAttribute(2, nameof(SelectColumn.SelectAllAriaLabel), "Pick every row"); + builder.CloseComponent(); + builder.OpenComponent>(3); + builder.AddAttribute(4, nameof(PropertyColumn.Property), (Expression>)(x => x.Name)); + builder.CloseComponent(); + })); + + var headerCheckbox = comp.Find("thead .mud-checkbox input"); + headerCheckbox.GetAttribute("aria-label").Should().Be("Pick every row"); + + var rowCheckboxes = comp.FindAll("tbody .mud-checkbox input"); + rowCheckboxes[0].GetAttribute("aria-label").Should().Be("First row 1"); + rowCheckboxes[1].GetAttribute("aria-label").Should().Be("Second row 2"); + } + + [Test] + public void SelectColumn_CustomAriaLabelledBy_ReplacesDefaultLabels() + { + var items = new List + { + new() { Id = 1, Name = "First" }, + new() { Id = 2, Name = "Second" } + }; + + var comp = Context.Render>(parameters => parameters + .Add(p => p.Items, items) + .Add(p => p.MultiSelection, true) + .Add(p => p.Columns, builder => + { + builder.OpenComponent>(0); + builder.AddAttribute(1, nameof(SelectColumn.RowCheckboxAriaLabelledByFunc), (Func)((item, index) => $"row-label-{index}-{item.Id}")); + builder.AddAttribute(2, nameof(SelectColumn.SelectAllAriaLabelledBy), "header-label"); + builder.CloseComponent(); + builder.OpenComponent>(3); + builder.AddAttribute(4, nameof(PropertyColumn.Property), (Expression>)(x => x.Name)); + builder.CloseComponent(); + })); + + var headerCheckbox = comp.Find("thead .mud-checkbox input"); + headerCheckbox.GetAttribute("aria-labelledby").Should().Be("header-label"); + headerCheckbox.HasAttribute("aria-label").Should().BeFalse(); + + var rowCheckboxes = comp.FindAll("tbody .mud-checkbox input"); + rowCheckboxes[0].GetAttribute("aria-labelledby").Should().Be("row-label-0-1"); + rowCheckboxes[0].HasAttribute("aria-label").Should().BeFalse(); + rowCheckboxes[1].GetAttribute("aria-labelledby").Should().Be("row-label-1-2"); + rowCheckboxes[1].HasAttribute("aria-label").Should().BeFalse(); + } + + [Test] + public async Task SelectColumn_SetsAriaSelectedOnRows() + { + var items = new List { 1, 2 }; + + var comp = Context.Render>(parameters => parameters + .Add(p => p.Items, items) + .Add(p => p.MultiSelection, true) + .Add(p => p.Columns, builder => + { + builder.OpenComponent>(0); + builder.CloseComponent(); + builder.OpenComponent>(1); + builder.AddAttribute(2, nameof(PropertyColumn.Property), (Expression>)(x => x)); + builder.CloseComponent(); + })); + + List Rows() => comp.FindAll("tbody tr").ToList(); + List RowCheckboxes() => comp.FindAll("tbody .mud-checkbox input").ToList(); + IElement HeaderCheckbox() => comp.Find("thead .mud-checkbox input"); + + Rows()[0].GetAttribute("aria-selected").Should().Be("false"); + Rows()[1].GetAttribute("aria-selected").Should().Be("false"); + + await RowCheckboxes()[0].ChangeAsync(true); + + Rows()[0].GetAttribute("aria-selected").Should().Be("true"); + Rows()[1].GetAttribute("aria-selected").Should().Be("false"); + + await HeaderCheckbox().ChangeAsync(true); + + Rows()[0].GetAttribute("aria-selected").Should().Be("true"); + Rows()[1].GetAttribute("aria-selected").Should().Be("true"); + + await HeaderCheckbox().ChangeAsync(false); + + Rows()[0].GetAttribute("aria-selected").Should().Be("false"); + Rows()[1].GetAttribute("aria-selected").Should().Be("false"); + } + [Test] public async Task FilterDefinitionTestHasFilterProperty() { diff --git a/src/MudBlazor/Components/DataGrid/Cell.cs b/src/MudBlazor/Components/DataGrid/Cell.cs index 48b0efee1028..2db83b0c117b 100644 --- a/src/MudBlazor/Components/DataGrid/Cell.cs +++ b/src/MudBlazor/Components/DataGrid/Cell.cs @@ -47,6 +47,11 @@ internal object? ComputedValue #endregion public Cell(MudDataGrid dataGrid, Column column, T item) + : this(dataGrid, column, item, -1) + { + } + + public Cell(MudDataGrid dataGrid, Column column, T item, int rowIndex) { _dataGrid = dataGrid; _column = column; @@ -55,7 +60,7 @@ public Cell(MudDataGrid dataGrid, Column column, T item) OnStartedEditingItem(); // Create the CellContext - _cellContext = new CellContext(_dataGrid, _item); + _cellContext = rowIndex >= 0 ? new CellContext(_dataGrid, _item, rowIndex) : new CellContext(_dataGrid, _item); } public async Task StringValueChangedAsync(string? value) diff --git a/src/MudBlazor/Components/DataGrid/CellContext.cs b/src/MudBlazor/Components/DataGrid/CellContext.cs index 41918e34499c..32ad70e35a2b 100644 --- a/src/MudBlazor/Components/DataGrid/CellContext.cs +++ b/src/MudBlazor/Components/DataGrid/CellContext.cs @@ -36,6 +36,11 @@ public class CellContext<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTy /// public bool Open => OpenHierarchies.Contains(Item); + /// + /// The zero-based index of the row displayed in the grid, or -1 when unavailable. + /// + public int RowIndex { get; private set; } = -1; + /// /// Creates a new instance. /// @@ -56,6 +61,18 @@ public CellContext(MudDataGrid dataGrid, T item) }; } + /// + /// Creates a new instance with an explicit row index. + /// + /// The data grid which owns this context. + /// The item displayed in the cell. + /// The zero-based row index. + public CellContext(MudDataGrid dataGrid, T item, int rowIndex) + : this(dataGrid, item) + { + RowIndex = rowIndex; + } + /// /// Represents behaviors which can be performed on a cell. /// diff --git a/src/MudBlazor/Components/DataGrid/DataGridVirtualizeRow.razor b/src/MudBlazor/Components/DataGrid/DataGridVirtualizeRow.razor index 35c26741fe5d..715049d51c2b 100644 --- a/src/MudBlazor/Components/DataGrid/DataGridVirtualizeRow.razor +++ b/src/MudBlazor/Components/DataGrid/DataGridVirtualizeRow.razor @@ -57,6 +57,7 @@ var rowStyle = new StyleBuilder().AddStyle(DataGrid.RowStyle).AddStyle(DataGrid.RowStyleFunc?.Invoke(itemBag.Item, itemBag.Index)).Build(); } @@ -65,7 +66,7 @@ { if (!column.HiddenState.Value) { - @DataGrid.Cell(column, itemBag.Item) + @DataGrid.Cell(column, itemBag.Item, itemBag.Index) } } @@ -74,13 +75,13 @@ { @if (DataGrid.ChildRowRenderer != null) { - @DataGrid.ChildRowRenderer(new CellContext(DataGrid, itemBag.Item)) + @DataGrid.ChildRowRenderer(new CellContext(DataGrid, itemBag.Item, itemBag.Index)) } @if (DataGrid.ChildRowContent != null) { - @DataGrid.ChildRowContent(new CellContext(DataGrid, itemBag.Item)) + @DataGrid.ChildRowContent(new CellContext(DataGrid, itemBag.Item, itemBag.Index)) } @@ -96,3 +97,18 @@ + +@code { + private Dictionary? GetRowAttributes(T item) + { + if (!DataGrid.HasSelectColumn) + { + return null; + } + + return new Dictionary(1) + { + ["aria-selected"] = DataGrid.Selection.Contains(item).ToString().ToLowerInvariant() + }; + } +} diff --git a/src/MudBlazor/Components/DataGrid/MudDataGrid.razor b/src/MudBlazor/Components/DataGrid/MudDataGrid.razor index 3928de84ffde..1cb8982dcd16 100644 --- a/src/MudBlazor/Components/DataGrid/MudDataGrid.razor +++ b/src/MudBlazor/Components/DataGrid/MudDataGrid.razor @@ -359,10 +359,10 @@ ; - internal RenderFragment Cell(Column column, T item) => + internal RenderFragment Cell(Column column, T item, int rowIndex) => @ @{ - var cell = new Cell(this, column, item); + var cell = new Cell(this, column, item, rowIndex); } @if (column.Editable && !ReadOnly && EditMode == DataGridEditMode.Cell) diff --git a/src/MudBlazor/Components/DataGrid/MudDataGrid.razor.cs b/src/MudBlazor/Components/DataGrid/MudDataGrid.razor.cs index 70af0f5b9027..d0c1109850fa 100644 --- a/src/MudBlazor/Components/DataGrid/MudDataGrid.razor.cs +++ b/src/MudBlazor/Components/DataGrid/MudDataGrid.razor.cs @@ -255,6 +255,8 @@ private Task ItemUpdatedAsync(MudItemDropInfo> dropItem) /// public readonly List> RenderedColumns = new List>(); + internal bool HasSelectColumn => RenderedColumns.OfType>().Any(); + internal T? _editingItem; internal T? _editingSourceItem; diff --git a/src/MudBlazor/Components/DataGrid/SelectColumn.razor b/src/MudBlazor/Components/DataGrid/SelectColumn.razor index 888ddf13c599..7e63d910dd03 100644 --- a/src/MudBlazor/Components/DataGrid/SelectColumn.razor +++ b/src/MudBlazor/Components/DataGrid/SelectColumn.razor @@ -10,7 +10,8 @@ return @; + ValueChanged="@context.Actions.SetSelectAllAsync" + UserAttributes="@GetSelectAllAttributes()" />; } return null!; @@ -21,7 +22,8 @@ Size="@Size" Value="@context.Selected" ValueChanged="@context.Actions.SetSelectedItemAsync" - Disabled="@(DisabledFunc?.Invoke(context.Item) ?? false)" />; + Disabled="@(DisabledFunc?.Invoke(context.Item) ?? false)" + UserAttributes="@GetRowCheckboxAttributes(context)" />; private RenderFragment>? GetSelectFooterTemplate() => context => { @@ -30,7 +32,8 @@ return @; + ValueChanged="@context.Actions.SetSelectAllAsync" + UserAttributes="@GetSelectAllAttributes()" />; } return null!; diff --git a/src/MudBlazor/Components/DataGrid/SelectColumn.razor.cs b/src/MudBlazor/Components/DataGrid/SelectColumn.razor.cs index b02db3d3832d..95f635f52236 100644 --- a/src/MudBlazor/Components/DataGrid/SelectColumn.razor.cs +++ b/src/MudBlazor/Components/DataGrid/SelectColumn.razor.cs @@ -2,8 +2,10 @@ // MudBlazor licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using Microsoft.AspNetCore.Components; +using MudBlazor.Resources; namespace MudBlazor; @@ -14,6 +16,9 @@ namespace MudBlazor; /// public partial class SelectColumn<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] T> : TemplateColumn { + [Inject] + private InternalMudLocalizer Localizer { get; set; } = null!; + /// /// Shows a checkbox in the header. /// @@ -50,6 +55,39 @@ public partial class SelectColumn<[DynamicallyAccessedMembers(DynamicallyAccesse [Parameter] public Func? DisabledFunc { get; set; } + /// + /// Provides a custom aria-label for each row selection checkbox. + /// + /// + /// When not set, a default localized label is used. + /// + [Parameter] + public Func? RowCheckboxAriaLabelFunc { get; set; } + + /// + /// Provides a custom aria-labelledby value for each row selection checkbox. + /// + /// + /// When set, this value is applied in addition to any aria-label. + /// + [Parameter] + public Func? RowCheckboxAriaLabelledByFunc { get; set; } + + /// + /// The aria-label applied to the header and footer select-all checkboxes. + /// + /// + /// Defaults to a localized label when not provided. + /// + [Parameter] + public string? SelectAllAriaLabel { get; set; } + + /// + /// The aria-labelledby value applied to the header and footer select-all checkboxes. + /// + [Parameter] + public string? SelectAllAriaLabelledBy { get; set; } + public override RenderFragment>? GetHeaderTemplate() => ShowInHeader ? GetSelectHeaderTemplate() : null; public override RenderFragment> GetCellTemplate() => GetSelectCellTemplate(); public override RenderFragment>? GetFooterTemplate() => ShowInFooter ? GetSelectFooterTemplate() : null; @@ -64,4 +102,52 @@ public SelectColumn() ShowColumnOptions = false; HeaderStyle = "width:0%"; } + + private Dictionary GetSelectAllAttributes() + { + var label = SelectAllAriaLabel; + if (string.IsNullOrWhiteSpace(label) && string.IsNullOrWhiteSpace(SelectAllAriaLabelledBy)) + { + label = Localizer[LanguageResource.MudDataGrid_SelectAllRows].Value; + } + + return BuildAriaAttributes(label, SelectAllAriaLabelledBy); + } + + private Dictionary GetRowCheckboxAttributes(CellContext context) + { + var label = RowCheckboxAriaLabelFunc?.Invoke(context.Item, context.RowIndex); + var labelledBy = RowCheckboxAriaLabelledByFunc?.Invoke(context.Item, context.RowIndex); + + if (string.IsNullOrWhiteSpace(label) && string.IsNullOrWhiteSpace(labelledBy)) + { + label = GetDefaultRowAriaLabel(context.RowIndex); + } + + return BuildAriaAttributes(label, labelledBy); + } + + private string GetDefaultRowAriaLabel(int rowIndex) + { + return rowIndex >= 0 + ? Localizer[LanguageResource.MudDataGrid_SelectRowWithIndex, rowIndex + 1].Value + : Localizer[LanguageResource.MudDataGrid_SelectRow].Value; + } + + private static Dictionary BuildAriaAttributes(string? label, string? labelledBy) + { + var attributes = new Dictionary(2); + + if (!string.IsNullOrWhiteSpace(label)) + { + attributes["aria-label"] = label; + } + + if (!string.IsNullOrWhiteSpace(labelledBy)) + { + attributes["aria-labelledby"] = labelledBy; + } + + return attributes; + } } diff --git a/src/MudBlazor/Resources/LanguageResource.resx b/src/MudBlazor/Resources/LanguageResource.resx index 05415d4cab93..b6dc85e9e613 100644 --- a/src/MudBlazor/Resources/LanguageResource.resx +++ b/src/MudBlazor/Resources/LanguageResource.resx @@ -423,6 +423,15 @@ Remove filter + + Select row + + + Select row {0} + + + Select all rows + First page