Skip to content

Commit 4933136

Browse files
adamintvnbaaijdvoituron
authored
[MenuButton] Accessibility fixes and allow custom button content (#4093)
* Make FluentMenuButton fully accessible * Allow providing a render fragment for the button content * fix bug * apply pr feedback * address pr comments * add a data grid example using HeaderCellAsButtonWithMenu * move logic up to fluent menu * undo the menu button focus changes * undo redundant changes in menu button * close menus on esc * fix test * fix comment, test modules, remove redundant initializer * remove unused method, add open parameter directly to initialize method * reinitialize after render --------- Co-authored-by: Vincent Baaij <[email protected]> Co-authored-by: Denis Voituron <[email protected]>
1 parent 09e3633 commit 4933136

File tree

12 files changed

+287
-26
lines changed

12 files changed

+287
-26
lines changed

examples/Demo/Shared/Pages/DataGrid/DataGridPage.razor

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,7 @@
175175
<FluentAnchor Href="/datagrid-virtualize" Appearance="Appearance.Hypertext">Virtualized grid</FluentAnchor>
176176
<FluentAnchor Href="/datagrid-remote-data" Appearance="Appearance.Hypertext">Remote data</FluentAnchor>
177177
<FluentAnchor Href="/datagrid-manual" Appearance="Appearance.Hypertext">Manual grid</FluentAnchor>
178+
<FluentAnchor Href="/datagrid-menu-header" Appearance="Appearance.Hypertext">Menu header</FluentAnchor>
178179
</FluentStack>
179180

180181
<h2 id="documentation">Documentation</h2>
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
<FluentDataGrid Items="@people" ResizableColumns="true" ResizeType="DataGridResizeType.Exact" HeaderCellAsButtonWithMenu="true">
2+
<PropertyColumn Property="@(p => p.PersonId)" Sortable="true" />
3+
<PropertyColumn Property="@(p => p.Name)" Sortable="true" />
4+
<PropertyColumn Property="@(p => p.BirthDate)" Format="yyyy-MM-dd" Sortable="true" />
5+
</FluentDataGrid>
6+
7+
@code {
8+
record Person(int PersonId, string Name, DateOnly BirthDate);
9+
10+
IQueryable<Person> people = new[]
11+
{
12+
new Person(10895, "Jean Martin", new DateOnly(1985, 3, 16)),
13+
new Person(10944, "António Langa", new DateOnly(1991, 12, 1)),
14+
new Person(11203, "Julie Smith", new DateOnly(1958, 10, 10)),
15+
new Person(11205, "Nur Sari", new DateOnly(1922, 4, 27)),
16+
new Person(11898, "Jose Hernandez", new DateOnly(2011, 5, 3)),
17+
new Person(12130, "Kenji Sato", new DateOnly(2004, 1, 9)),
18+
}.AsQueryable();
19+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
@page "/datagrid-menu-header"
2+
@using FluentUI.Demo.Shared.Pages.DataGrid.Examples
3+
4+
<PageTitle>Menu header</PageTitle>
5+
<DemoSection Title="Menu header" Component="@typeof(DataGridMenuHeader)">
6+
<Description>
7+
You can make column headers appear as a menu by setting the <code>HeaderCellAsButtonWithMenu</code> property to true.
8+
</Description>
9+
</DemoSection>

examples/Demo/Shared/Shared/DemoNavProvider.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -480,6 +480,11 @@ public DemoNavProvider()
480480
icon: new Icons.Regular.Size20.Grid(),
481481
title: "Manual grid"
482482
),
483+
new NavLink (
484+
href:"/datagrid-menu-header",
485+
icon: new Icons.Regular.Size20.Grid(),
486+
title: "Menu header"
487+
),
483488
]
484489
),
485490
new NavLink(

src/Core/Components/AnchoredRegion/FluentAnchoredRegion.razor.js

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,7 @@ export function goToNextFocusableElement(forContainer, toOriginal, delay) {
3636
/**
3737
* Focusable Element
3838
*/
39-
class FocusableElement {
40-
39+
export class FocusableElement {
4140
FOCUSABLE_SELECTORS = "input, select, textarea, button, object, a[href], area[href], [tabindex]";
4241
_originalActiveElement;
4342
_container;
@@ -64,11 +63,32 @@ class FocusableElement {
6463
* @returns
6564
*/
6665
findNextFocusableElement(currentElement) {
67-
// Get all focusable elements
68-
const focusableElements = this._container.querySelectorAll(this.FOCUSABLE_SELECTORS);
66+
// Fluent web components may have children that are focusable, but they are not
67+
// focusable themselves. Thus, we unfortunately need to query every element or provide
68+
// a list of all fluent elements that have focusable children.
69+
const queriedElements = Array.from(this._container.querySelectorAll("*")).filter(el => {
70+
return el.matches(this.FOCUSABLE_SELECTORS) || el.tagName.toLowerCase().startsWith("fluent-");
71+
});
72+
73+
const focusableElements = [];
74+
75+
// If an element is a fluent web component and is not focusable, replace with its inner focusable element
76+
// if one exists.
77+
queriedElements.forEach((el, index) => {
78+
if (el.tagName.toLowerCase().startsWith("fluent-") && el.tabIndex === -1 && !!el.shadowRoot) {
79+
Array.from(el.shadowRoot.children).forEach(child => {
80+
if (child.tabIndex !== -1 && child.checkVisibility()) {
81+
focusableElements.push(child);
82+
}
83+
});
84+
}
85+
else {
86+
focusableElements.push(el);
87+
}
88+
});
6989

70-
// Filter out elements with tabindex="-1"
71-
const filteredElements = Array.from(focusableElements).filter(el => el?.tabIndex !== -1);
90+
// Filter out elements with tabindex="-1" and elements that are not visible
91+
const filteredElements = focusableElements.filter(el => !!el && el.tabIndex !== -1 && el.checkVisibility());
7292

7393
// Find the index of the current element
7494
const current = currentElement ?? document.activeElement;

src/Core/Components/Menu/FluentMenu.razor.cs

Lines changed: 37 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,17 +11,20 @@
1111

1212
namespace Microsoft.FluentUI.AspNetCore.Components;
1313

14-
public partial class FluentMenu : FluentComponentBase, IDisposable
14+
public partial class FluentMenu : FluentComponentBase, IAsyncDisposable
1515
{
1616
private const string JAVASCRIPT_FILE = "./_content/Microsoft.FluentUI.AspNetCore.Components/Components/Menu/FluentMenu.razor.js";
17+
private const string ANCHORED_REGION_JAVASCRIPT_FILE = "./_content/Microsoft.FluentUI.AspNetCore.Components/Components/AnchoredRegion/FluentAnchoredRegion.razor.js";
1718

19+
private bool _reinitializeEventListeners = false;
1820
private bool _opened = false;
1921
private DotNetObjectReference<FluentMenu>? _dotNetHelper = null;
2022

2123
private bool _contextMenu = false;
2224
private readonly Dictionary<string, FluentMenuItem> items = [];
2325
private IMenuService? _menuService = null;
2426
private IJSObjectReference _jsModule = default!;
27+
private IJSObjectReference _anchoredRegionModule = default!;
2528

2629
private (int top, int right, int bottom, int left) _stylePositions;
2730

@@ -99,6 +102,7 @@ public bool Open
99102
if (_opened != value)
100103
{
101104
_opened = value;
105+
_reinitializeEventListeners = true;
102106
if (DrawMenuWithService)
103107
{
104108
UpdateMenuProviderAsync().ConfigureAwait(true);
@@ -214,12 +218,11 @@ protected override async Task OnAfterRenderAsync(bool firstRender)
214218
if (firstRender)
215219
{
216220
_jsModule = await JSRuntime.InvokeAsync<IJSObjectReference>("import", JAVASCRIPT_FILE.FormatCollocatedUrl(LibraryConfiguration));
221+
_anchoredRegionModule = await JSRuntime.InvokeAsync<IJSObjectReference>("import", ANCHORED_REGION_JAVASCRIPT_FILE.FormatCollocatedUrl(LibraryConfiguration));
222+
_dotNetHelper = DotNetObjectReference.Create(this);
217223

218224
if (Trigger != MouseButton.None)
219225
{
220-
221-
_dotNetHelper = DotNetObjectReference.Create(this);
222-
223226
if (Anchor is not null)
224227
{
225228
// Add LeftClick event
@@ -236,6 +239,21 @@ protected override async Task OnAfterRenderAsync(bool firstRender)
236239
}
237240
}
238241
}
242+
243+
if (_jsModule is not null)
244+
{
245+
await _jsModule.InvokeVoidAsync("initialize", Anchor, Id, Open, _anchoredRegionModule, _dotNetHelper);
246+
}
247+
}
248+
else
249+
{
250+
if (_jsModule is not null && _reinitializeEventListeners)
251+
{
252+
// If the menu was closed, remove its set event listeners. If it opened (ie if the menu starts out closed),
253+
// we should set them now.
254+
_reinitializeEventListeners = false;
255+
await _jsModule.InvokeVoidAsync("initialize", Anchor, Id, Open, _anchoredRegionModule, _dotNetHelper);
256+
}
239257
}
240258

241259
await base.OnAfterRenderAsync(firstRender);
@@ -251,6 +269,7 @@ public FluentMenu()
251269
/// Close the menu.
252270
/// </summary>
253271
/// <returns></returns>
272+
[JSInvokable]
254273
public async Task CloseAsync()
255274
{
256275
Open = false;
@@ -372,8 +391,21 @@ internal async Task<bool> IsCheckedAsync(FluentMenuItem item)
372391
/// <summary>
373392
/// Dispose this menu.
374393
/// </summary>
375-
public void Dispose()
394+
public async ValueTask DisposeAsync()
376395
{
377396
_dotNetHelper?.Dispose();
397+
398+
try
399+
{
400+
await _jsModule.InvokeVoidAsync("dispose", Anchor);
401+
await _jsModule.DisposeAsync();
402+
await _anchoredRegionModule.DisposeAsync();
403+
}
404+
catch (Exception ex) when (ex is JSDisconnectedException ||
405+
ex is OperationCanceledException)
406+
{
407+
// The JSRuntime side may routinely be gone already if the reason we're disposing is that
408+
// the client disconnected. This is not an error.
409+
}
378410
}
379411
}

src/Core/Components/Menu/FluentMenu.razor.js

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,3 +31,117 @@ export function isChecked(menuItemId) {
3131
});
3232
});
3333
}
34+
35+
const menuState = new Map();
36+
37+
export async function initialize(anchorId, menuId, menuOpen, anchoredRegionModule, dotNetHelper) {
38+
// Dispose existing listeners if any
39+
dispose(anchorId);
40+
41+
if (!menuId || !menuOpen) {
42+
return;
43+
}
44+
45+
const FocusableElement = anchoredRegionModule
46+
? anchoredRegionModule.FocusableElement
47+
: null;
48+
if (!FocusableElement) {
49+
throw new Error("FocusableElement not available from AnchoredRegion module");
50+
}
51+
52+
const menuElement = document.getElementById(menuId);
53+
const anchorElement = document.getElementById(anchorId);
54+
55+
// Return if either the menu or anchor element have not rendered.
56+
if (!menuElement || !anchorElement) {
57+
return;
58+
}
59+
60+
// We need to handle four cases to be fully accessible:
61+
// 1. When Tab is pressed on the anchor, focus must be moved to the first focusable element in the menu
62+
// 2. When Shift+Tab is pressed on any focusable element in the menu, focus must be moved back to the anchor. This will also close the menu.
63+
// 3. When Tab is pressed on any focusable element in the menu, focus should continue to the next focusable element in the element's root. This will also close the menu.
64+
// 4. When Escape is pressed on any focusable element in the menu, focus must be moved back to the anchor. This will also close the menu.
65+
const menuItemKeydownListener = function (ev) {
66+
if (ev.key === "Tab" || ev.key === "Escape") {
67+
try {
68+
ev.preventDefault && ev.preventDefault();
69+
ev.stopPropagation && ev.stopPropagation();
70+
71+
if (!ev.shiftKey && ev.key === "Tab") {
72+
// When Tab is pressed on a focusable element, we should continue to the next focusable element
73+
// If this element is a fluent element, we should try to find the next focusable element within the shadow DOM of the fluent element if one exists,
74+
// as that is the focusable element.
75+
let element;
76+
if (anchorElement.tagName.startsWith("FLUENT-") && anchorElement.shadowRoot && anchorElement.shadowRoot.children.length > 0) {
77+
element = anchorElement.shadowRoot.children[0];
78+
}
79+
else {
80+
element = anchorElement;
81+
}
82+
83+
new FocusableElement(anchorElement.getRootNode()).findNextFocusableElement(element)?.focus();
84+
}
85+
else {
86+
// When Shift+Tab is pressed on a focusable element, move focus back to the anchor
87+
anchorElement.focus();
88+
}
89+
90+
dotNetHelper.invokeMethodAsync('CloseAsync');
91+
} catch (ex) {
92+
console.error("Failed to focus anchor:", ex);
93+
}
94+
}
95+
};
96+
97+
menuElement.addEventListener("keydown", menuItemKeydownListener);
98+
99+
// Add keydown listener to the anchor for Tab (no shift) to focus first element
100+
const anchorKeyDownListener = function (ev) {
101+
if (ev.key === "Tab") {
102+
if (ev.shiftKey) {
103+
dotNetHelper.invokeMethodAsync('CloseAsync');
104+
}
105+
else {
106+
const focusableHelper = new FocusableElement(menuElement);
107+
const firstFocusable = focusableHelper.findNextFocusableElement();
108+
109+
if (!firstFocusable) {
110+
return; // no element to attach listener to
111+
}
112+
113+
// When Tab is pressed on the anchor, move focus to the first focusable element
114+
try {
115+
firstFocusable.focus();
116+
ev.preventDefault && ev.preventDefault();
117+
ev.stopPropagation && ev.stopPropagation();
118+
} catch (ex) {
119+
console.error("Failed to focus first focusable element:", ex);
120+
}
121+
}
122+
}
123+
};
124+
125+
anchorElement.addEventListener("keydown", anchorKeyDownListener);
126+
127+
menuState.set(anchorId, {
128+
menuItemKeydownListener,
129+
anchorKeyDownListener,
130+
menuElement,
131+
anchorElement
132+
});
133+
}
134+
135+
// Called to cleanup listeners when component is disposed
136+
export function dispose(anchorId) {
137+
const state = menuState.get(anchorId);
138+
if (!state) {
139+
return;
140+
}
141+
142+
const { menuItemKeydownListener, anchorKeyDownListener, menuElement, anchorElement } = state;
143+
menuElement.removeEventListener("keydown", menuItemKeydownListener);
144+
anchorElement.removeEventListener("keydown", anchorKeyDownListener);
145+
146+
menuState.delete(anchorId);
147+
}

src/Core/Components/MenuButton/FluentMenuButton.razor

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,17 @@
11
@namespace Microsoft.FluentUI.AspNetCore.Components
2-
@using Microsoft.FluentUI.AspNetCore.Components.DesignTokens
32
@inherits FluentComponentBase
43

54
<div class="fluent-menubutton-container" style="@Style" @attributes="@AdditionalAttributes">
6-
<FluentButton Id="@_buttonId" @ref="Button" Appearance="@ButtonAppearance" Class="@Class" Style="@ButtonStyle" IconStart="@IconStart" aria-haspopup="true" aria-expanded="@_visible" @onclick=ToggleMenu @onkeydown=OnKeyDown>
7-
@Text
8-
<FluentIcon Value="@(new CoreIcons.Regular.Size12.ChevronDown())" Slot="end" Color="@_iconColor" />
5+
<FluentButton Id="@_buttonId" @ref="Button" Appearance="@ButtonAppearance" Class="@Class" Style="@ButtonStyle" IconStart="@IconStart" aria-haspopup="true" aria-expanded="@_visible" @onclick=ToggleMenu>
6+
@if (ButtonContent is not null)
7+
{
8+
@ButtonContent
9+
}
10+
else
11+
{
12+
@Text
13+
<FluentIcon Value="@(new CoreIcons.Regular.Size12.ChevronDown())" Slot="end" Color="@_iconColor" />
14+
}
915
</FluentButton>
1016
<FluentOverlay @bind-Visible="@_visible" Transparent="true" FullScreen="true" />
1117
<FluentMenu @ref="Menu" Anchor="@_buttonId" aria-labelledby="button" Style="@MenuStyleValue" @bind-Open=@_visible @onmenuchange=OnMenuChangeAsync UseMenuService="@UseMenuService">

src/Core/Components/MenuButton/FluentMenuButton.razor.cs

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
// ------------------------------------------------------------------------
44

55
using Microsoft.AspNetCore.Components;
6-
using Microsoft.AspNetCore.Components.Web;
76
using Microsoft.FluentUI.AspNetCore.Components.Utilities;
87

98
namespace Microsoft.FluentUI.AspNetCore.Components;
@@ -45,7 +44,7 @@ public partial class FluentMenuButton : FluentComponentBase
4544
public bool UseMenuService { get; set; } = true;
4645

4746
/// <summary>
48-
/// Gets or sets the texts shown on th button.
47+
/// Gets or sets the texts shown on the button. This property will be ignored if <see cref="ButtonContent"/> is provided.
4948
/// </summary>
5049
[Parameter]
5150
public string? Text { get; set; }
@@ -88,13 +87,25 @@ public partial class FluentMenuButton : FluentComponentBase
8887
[Parameter]
8988
public EventCallback<MenuChangeEventArgs> OnMenuChanged { get; set; }
9089

90+
/// <summary>
91+
/// The content to be rendered inside the button. This parameter should be supplied if you do not want to render a chevron
92+
/// on the menu button. Only one of <see cref="Text"/> or <see cref="ButtonContent"/> may be provided.
93+
/// </summary>
94+
[Parameter]
95+
public RenderFragment? ButtonContent { get; set; }
96+
9197
protected override void OnInitialized()
9298
{
9399
_buttonId = Identifier.NewId();
94100
}
95101

96102
protected override void OnParametersSet()
97103
{
104+
if (Text is not null && ButtonContent is not null)
105+
{
106+
throw new ArgumentException($"Only one of the parameters {nameof(Text)} or {nameof(ButtonContent)} can be provided.");
107+
}
108+
98109
_iconColor = ButtonAppearance == Appearance.Accent ? Color.Fill : Color.FillInverse;
99110
}
100111

@@ -117,12 +128,4 @@ private async Task OnMenuChangeAsync(MenuChangeEventArgs args)
117128

118129
_visible = false;
119130
}
120-
121-
private void OnKeyDown(KeyboardEventArgs args)
122-
{
123-
if (args is not null && args.Key == "Escape")
124-
{
125-
_visible = false;
126-
}
127-
}
128131
}

0 commit comments

Comments
 (0)