From 4c85be1827e5f1c0d441de1055a2b09b63b8080f Mon Sep 17 00:00:00 2001 From: Vincent Baaij Date: Sun, 14 Jun 2026 00:21:05 +0200 Subject: [PATCH 1/8] Just make the branch available --- .../Dialog/Examples/Dialog/SimpleDialog.razor | 27 ++++ .../DataGrid/FluentDataGrid.razor.ts | 150 ++++++++++++++---- 2 files changed, 149 insertions(+), 28 deletions(-) diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Dialog/Examples/Dialog/SimpleDialog.razor b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Dialog/Examples/Dialog/SimpleDialog.razor index 98b288d12b..dc84e83369 100644 --- a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Dialog/Examples/Dialog/SimpleDialog.razor +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Dialog/Examples/Dialog/SimpleDialog.razor @@ -3,8 +3,35 @@ + + + + + + + + + + + +@code { + PaginationState pagination = new PaginationState() { ItemsPerPage = 2 }; + record Person2(int PersonId, string Name, DateOnly BirthDate); + + IQueryable people = new[] + { + new Person2(10895, "Jean Martin", new DateOnly(1985, 3, 16)), + new Person2(10944, "António Langa", new DateOnly(1991, 12, 1)), + new Person2(11203, "Julie Smith", new DateOnly(1958, 10, 10)), + new Person2(11205, "Nur Sari", new DateOnly(1922, 4, 27)), + new Person2(11898, "Jose Hernandez", new DateOnly(2011, 5, 3)), + new Person2(12130, "Kenji Sato", new DateOnly(2004, 1, 9)), + }.AsQueryable(); + + private RenderFragment template = @; +} @code { // A simple type is not updatable [Parameter] diff --git a/src/Core/Components/DataGrid/FluentDataGrid.razor.ts b/src/Core/Components/DataGrid/FluentDataGrid.razor.ts index b8fda6af1a..47f8e4c479 100644 --- a/src/Core/Components/DataGrid/FluentDataGrid.razor.ts +++ b/src/Core/Components/DataGrid/FluentDataGrid.razor.ts @@ -12,6 +12,52 @@ export namespace Microsoft.FluentUI.Blazor.DataGrid { element.style.visibility = 'hidden'; } }; + + const getDeepActiveElement = (): HTMLElement | null => { + let activeElement: Element | null = document.activeElement; + while (activeElement instanceof HTMLElement && activeElement.shadowRoot?.activeElement) { + activeElement = activeElement.shadowRoot.activeElement; + } + + return activeElement instanceof HTMLElement ? activeElement : null; + }; + + const getFocusedGridElement = (gridElement: HTMLElement, event: KeyboardEvent): HTMLElement | null => { + const composedPath = event.composedPath(); + + for (const entry of composedPath) { + if (!(entry instanceof HTMLElement)) { + continue; + } + + const tableCell = entry.closest('td,th'); + if (tableCell instanceof HTMLElement && gridElement.contains(tableCell)) { + return tableCell; + } + + if (entry === gridElement) { + return gridElement; + } + } + + const activeElement = getDeepActiveElement(); + if (activeElement) { + const tableCell = activeElement.closest('td,th'); + if (tableCell instanceof HTMLElement && gridElement.contains(tableCell)) { + return tableCell; + } + + const table = activeElement.closest('table'); + if (table instanceof HTMLElement && table === gridElement) { + return table; + } + } + + return null; + }; + + const handledArrowNavigationEventFlag = '__fluentDataGridArrowNavigationHandled'; + interface Grid { id: string; columns: Column[]; // or a more specific type if you have one @@ -77,49 +123,98 @@ export namespace Microsoft.FluentUI.Blazor.DataGrid { }; const keyboardNavigation = (sibling: HTMLElement | null) => { if (sibling !== null) { - if (start) start.focus(); - sibling.focus(); - start = sibling; + const focusSibling = () => { + sibling.focus({ preventScroll: true }); + start = sibling; + }; + + // Defer focusing until after the current key event completes so focus traps + // or inner component key handlers cannot immediately override DataGrid focus. + setTimeout(() => { + focusSibling(); + + // Some host components can move focus again on the next frame. + // Re-apply focus once when focus escaped the current grid. + requestAnimationFrame(() => { + const activeElement = getDeepActiveElement(); + if (!activeElement || !gridElement.contains(activeElement)) { + focusSibling(); + } + }); + }, 0); } } + const getAdjacentRowCell = (cell: HTMLTableCellElement, direction: 'up' | 'down') => { + const row = cell.parentElement as HTMLTableRowElement | null; + if (!row) { + return null; + } + + const rowGroupName = row.parentElement?.tagName.toLowerCase(); + let targetRow = direction === 'up' + ? row.previousElementSibling as HTMLTableRowElement | null + : row.nextElementSibling as HTMLTableRowElement | null; + + if (!targetRow) { + const table = row.closest('table'); + if (direction === 'down' && rowGroupName === 'thead') { + targetRow = table?.querySelector('tbody tr') as HTMLTableRowElement | null; + } else if (direction === 'up' && rowGroupName === 'tbody') { + targetRow = table?.querySelector('thead tr:last-child') as HTMLTableRowElement | null; + } + } + + if (!targetRow) { + return null; + } + + return targetRow.cells[cell.cellIndex] as HTMLTableCellElement | null; + }; const keyDownHandler = (event: KeyboardEvent) => { + if ((event as any)[handledArrowNavigationEventFlag]) { + return; + } + + const isArrowKey = event.key === "ArrowRight" || event.key === "ArrowLeft" || event.key === "ArrowDown" || event.key === "ArrowUp"; + const headerUiElement = gridElement?.querySelector(headerUiSelector); if (headerUiElement && headerUiElement.contains(event.target as HTMLElement)) { - if (event.key === "ArrowRight" || event.key === "ArrowLeft" || event.key === "ArrowDown" || event.key === "ArrowUp") { + if (isArrowKey) { event.stopPropagation(); return; } } - if (document.activeElement?.tagName.toLowerCase() != 'table' && document.activeElement?.tagName.toLowerCase() != 'td' && document.activeElement?.tagName.toLowerCase() != 'th') { + if (!isArrowKey) { return; } - if ((event.target as HTMLElement).getAttribute('role') !== "gridcell" && (event.key === "ArrowRight" || event.key === "ArrowLeft" || event.key === "ArrowDown" || event.key === "ArrowUp")) { + const focusedGridElement = getFocusedGridElement(gridElement, event); + if (!(focusedGridElement instanceof HTMLTableCellElement)) { return; } - // check if start is a child of gridElement - if (start !== null && (gridElement.contains(start) || gridElement === start) && document.activeElement === start && document.activeElement.tagName.toLowerCase() !== 'fluent-text-field' && document.activeElement.tagName.toLowerCase() !== 'fluent-menu-item') { - const idx = (start as HTMLTableCellElement).cellIndex; + const targetElement = event.target instanceof HTMLElement ? event.target : null; + if (targetElement && targetElement !== focusedGridElement && targetElement.closest('[role="gridcell"]') === focusedGridElement) { + return; + } + + if (start !== focusedGridElement) { + start = focusedGridElement; + } + + if (start !== null && (gridElement.contains(start) || gridElement === start)) { + (event as any)[handledArrowNavigationEventFlag] = true; const isRTL = getComputedStyle(gridElement).direction === 'rtl'; if (event.key === "ArrowUp") { - // up arrow - const previousRow = start.parentElement?.previousElementSibling as HTMLTableRowElement | null; - if (previousRow !== null) { - event.preventDefault(); - const previousSibling = previousRow.cells[idx]; - keyboardNavigation(previousSibling); - } + event.preventDefault(); + const previousSibling = getAdjacentRowCell(start as HTMLTableCellElement, 'up'); + keyboardNavigation(previousSibling); } else if (event.key === "ArrowDown") { - // down arrow - const nextRow = start.parentElement?.nextElementSibling as HTMLTableRowElement | null; - if (nextRow !== null) { - event.preventDefault(); - const nextSibling = nextRow.cells[idx]; - keyboardNavigation(nextSibling); - } + event.preventDefault(); + const nextSibling = getAdjacentRowCell(start as HTMLTableCellElement, 'down'); + keyboardNavigation(nextSibling); } else if (event.key === "ArrowLeft") { // left arrow event.preventDefault(); @@ -134,9 +229,6 @@ export namespace Microsoft.FluentUI.Blazor.DataGrid { event.stopPropagation(); } } - else { - start = document.activeElement as HTMLElement; - } }; @@ -151,7 +243,7 @@ export namespace Microsoft.FluentUI.Blazor.DataGrid { } cell.addEventListener( "keydown", - (event: KeyboardEvent ) => { + (event: KeyboardEvent) => { if ((event.target as HTMLElement).role !== "gridcell" && (event.key === "ArrowRight" || event.key === "ArrowLeft")) { event.stopPropagation(); } @@ -163,7 +255,9 @@ export namespace Microsoft.FluentUI.Blazor.DataGrid { document.body.addEventListener('click', bodyClickHandler, { signal }); document.body.addEventListener('mousedown', bodyClickHandler, { signal }); document.body.addEventListener('keydown', bodyKeyDownHandler, { signal }); - gridElement.addEventListener('keydown', keyDownHandler, { signal }); + // Listen at document capture phase so keyboard navigation still works when focus is + // inside inner elements that stop propagation before table-level handlers run. + document.addEventListener('keydown', keyDownHandler, { signal, capture: true }); return { stop: () => { From 350d5601d2df2ffbc4c77718e71ff62b2f9856e3 Mon Sep 17 00:00:00 2001 From: Vincent Baaij Date: Sun, 14 Jun 2026 11:08:48 +0200 Subject: [PATCH 2/8] Store --- .../Dialog/Examples/Dialog/SimpleDialog.razor | 2 +- .../src/Components/Dialog/FluentDialog.ts | 39 ++++++++ .../src/Components/Menu/FluentMenu.ts | 43 ++++---- .../DataGrid/Columns/ColumnBase.razor | 7 +- .../DataGrid/Columns/ColumnBase.razor.cs | 61 +++++++++++- .../DataGrid/FluentDataGrid.razor.ts | 98 +++++++++++++++++-- .../Components/Dialog/FluentDialog.razor.cs | 9 ++ .../DataGrid/FluentDataGridTests.razor | 27 +++++ 8 files changed, 250 insertions(+), 36 deletions(-) diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Dialog/Examples/Dialog/SimpleDialog.razor b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Dialog/Examples/Dialog/SimpleDialog.razor index dc84e83369..993d2cf5de 100644 --- a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Dialog/Examples/Dialog/SimpleDialog.razor +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Dialog/Examples/Dialog/SimpleDialog.razor @@ -6,7 +6,7 @@ - + diff --git a/src/Core.Scripts/src/Components/Dialog/FluentDialog.ts b/src/Core.Scripts/src/Components/Dialog/FluentDialog.ts index 56c232ce97..d32a358f6a 100644 --- a/src/Core.Scripts/src/Components/Dialog/FluentDialog.ts +++ b/src/Core.Scripts/src/Components/Dialog/FluentDialog.ts @@ -1,5 +1,14 @@ export namespace Microsoft.FluentUI.Blazor.Components.Dialog { + const getDeepActiveElement = (): HTMLElement | null => { + let activeElement: Element | null = document.activeElement; + while (activeElement instanceof HTMLElement && activeElement.shadowRoot?.activeElement) { + activeElement = activeElement.shadowRoot.activeElement; + } + + return activeElement instanceof HTMLElement ? activeElement : null; + }; + /** * Display the fluent-dialog with the given id * @param id The id of the fluent-dialog to display @@ -78,4 +87,34 @@ export namespace Microsoft.FluentUI.Blazor.Components.Dialog { dialog.addEventListener('keydown', handler, true); } } + + /** + * Returns whether dialog keyboard shortcuts should be handled for the current focused element. + * Shortcuts are limited to dialog action areas so interactive content inside drawers/dialogs + * can use Enter/Space/Arrow keys without being intercepted. + * @param id The id of the fluent-dialog/fluent-drawer element + */ + export function ShouldHandleShortcut(id: string): boolean { + const dialog = document.getElementById(id) as HTMLElement | null; + if (!dialog) { + return false; + } + + const activeElement = getDeepActiveElement(); + if (!activeElement || !dialog.contains(activeElement)) { + return false; + } + + // Never steal keyboard handling from editable/menu-like controls in dialog content. + console.log(activeElement); + if ( + activeElement.matches('input, textarea, select, fluent-data-grid, [contenteditable=""], [contenteditable="true"], [role="textbox"], [role="combobox"], [role="spinbutton"], [role="listbox"], [role="menu"], [role="menuitem"]') || + !!activeElement.closest('fluent-menu, fluent-menu-list, fluent-menu-item, .col-header-ui') + ) { + return false; + } + + // Keep shortcuts active for explicit dialog action surfaces. + return !!activeElement.closest('[slot="action"], [slot="footer"], [slot="close"], [slot="title-action"]'); + } } diff --git a/src/Core.Scripts/src/Components/Menu/FluentMenu.ts b/src/Core.Scripts/src/Components/Menu/FluentMenu.ts index fa39308977..9b1ef8d908 100644 --- a/src/Core.Scripts/src/Components/Menu/FluentMenu.ts +++ b/src/Core.Scripts/src/Components/Menu/FluentMenu.ts @@ -8,31 +8,34 @@ export namespace Microsoft.FluentUI.Blazor.Components.Menu { * @param triggerId The id of the trigger element that will open the menu when clicked. */ export function Initialize(id: string, triggerId: string) { - const trigger = document.getElementById(triggerId) as HTMLElement | null; - if (!trigger) return; + const initWithRetry = (attempt: number = 0) => { + const trigger = document.getElementById(triggerId) as HTMLElement | null; + const menu = document.getElementById(id) as Menu | null; + if (!trigger || !menu) { + if (attempt < 10) { + requestAnimationFrame(() => initWithRetry(attempt + 1)); + } + return; + } - trigger.style["anchor-name" as any] = `--anchor-${triggerId}`; - const menuElement = document.getElementById(id) as Menu | null; - if (!menuElement) return; + trigger.style["anchor-name" as any] = `--anchor-${triggerId}`; - // Set the anchor name for the menu list to position it relative to the trigger - const doInit = () => { - const menu = document.getElementById(id) as Menu | null; - if (menu && menu.slottedMenuList?.length) { - menu.slottedMenuList[0].style["position-anchor" as any] = `--anchor-${triggerId}`; - menu.slottedTriggersChanged(menu.slottedTriggers, [trigger]); + // Keep trigger wiring explicit for hosted surfaces (drawer/dialog/shadow-heavy layouts). + menu.setAttribute("trigger", triggerId); + + const menuList = menu.slottedMenuList?.[0] as MenuList | undefined; + if (!menuList) { + if (attempt < 10) { + requestAnimationFrame(() => initWithRetry(attempt + 1)); + } + return; } - }; - // already populated (e.g. hot-reload / re-render) - if (menuElement.slottedMenuList?.length) { - doInit(); - } + menuList.style["position-anchor" as any] = `--anchor-${triggerId}`; + menu.slottedTriggersChanged(menu.slottedTriggers ?? [], [trigger]); + }; - // wait for slotchange macrotask - else { - requestAnimationFrame(doInit); - } + initWithRetry(); } /** diff --git a/src/Core/Components/DataGrid/Columns/ColumnBase.razor b/src/Core/Components/DataGrid/Columns/ColumnBase.razor index fac6ae1fac..4faa302268 100644 --- a/src/Core/Components/DataGrid/Columns/ColumnBase.razor +++ b/src/Core/Components/DataGrid/Columns/ColumnBase.razor @@ -1,5 +1,4 @@ @using Microsoft.AspNetCore.Components.Rendering -@using Microsoft.FluentUI.AspNetCore.Components.DataGrid.Infrastructure @using Microsoft.FluentUI.AspNetCore.Components.Extensions @namespace Microsoft.FluentUI.AspNetCore.Components @typeparam TGridItem @@ -35,7 +34,7 @@ @if (headerCapabilities.HasAnyAction) { - +
@HeaderTitleContent
@@ -65,7 +64,7 @@ } - + @if (headerCapabilities.CanSort) { @@ -188,7 +187,7 @@ private RenderFragment OptionsButton() { return - @ + @ ; } diff --git a/src/Core/Components/DataGrid/Columns/ColumnBase.razor.cs b/src/Core/Components/DataGrid/Columns/ColumnBase.razor.cs index dff48abeb4..3276a1dd31 100644 --- a/src/Core/Components/DataGrid/Columns/ColumnBase.razor.cs +++ b/src/Core/Components/DataGrid/Columns/ColumnBase.razor.cs @@ -8,7 +8,6 @@ using Microsoft.AspNetCore.Components.Web; using Microsoft.AspNetCore.Components.Web.Virtualization; using Microsoft.FluentUI.AspNetCore.Components.DataGrid.Infrastructure; -using Microsoft.FluentUI.AspNetCore.Components.Utilities; namespace Microsoft.FluentUI.AspNetCore.Components; @@ -19,8 +18,13 @@ namespace Microsoft.FluentUI.AspNetCore.Components; public abstract partial class ColumnBase { private static readonly string[] KEYBOARD_MENU_SELECT_KEYS = ["Enter", "NumpadEnter"]; - private readonly string _columnId = Identifier.NewId(); private FluentMenu? _menu; + private bool _suppressNextHeaderSyntheticClick; + private bool _openHeaderMenuAfterRender; + + private string HeaderButtonId => $"{Grid.Id}-col-{Index}"; + + private string HeaderMenuId => $"{HeaderButtonId}-menu"; /// [Inject] @@ -345,7 +349,29 @@ protected internal virtual Task OnCellKeyDownAsync(FluentDataGridCell internal bool CanSortFromHeader() => Sortable ?? IsSortableByDefault(); - private async Task HandleColumnHeaderClickedAsync() + /// + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (_openHeaderMenuAfterRender && _menu is not null) + { + _openHeaderMenuAfterRender = false; + await _menu.OpenMenuAsync(); + } + } + + private async Task HandleColumnHeaderClickedAsync(MouseEventArgs args) + { + if (_suppressNextHeaderSyntheticClick && args.Detail == 0) + { + _suppressNextHeaderSyntheticClick = false; + return; + } + + _suppressNextHeaderSyntheticClick = false; + await HandleColumnHeaderActivatedAsync(); + } + + private async Task HandleColumnHeaderActivatedAsync() { var headerCapabilities = HeaderCapabilities; var hasSorting = headerCapabilities.CanSort; @@ -362,8 +388,8 @@ private async Task HandleColumnHeaderClickedAsync() if (hasMultiple) { + _openHeaderMenuAfterRender = true; return; - //StateHasChanged(); } if (hasSorting) @@ -384,6 +410,33 @@ private async Task HandleColumnHeaderClickedAsync() } } + private async Task HandleHeaderButtonKeyDownAsync(KeyboardEventArgs args) + { + if (!string.Equals(args.Code, "Enter", StringComparison.OrdinalIgnoreCase) && + !string.Equals(args.Code, "Space", StringComparison.OrdinalIgnoreCase)) + { + return; + } + + if (HeaderCapabilities.HasAnyAction && Grid.HeaderCellAsButtonWithMenu) + { + _suppressNextHeaderSyntheticClick = true; + _openHeaderMenuAfterRender = true; + return; + } + + await HandleColumnHeaderActivatedAsync(); + } + + private async Task HandleOptionsButtonKeyDownAsync(KeyboardEventArgs args) + { + if (string.Equals(args.Code, "Enter", StringComparison.OrdinalIgnoreCase) || + string.Equals(args.Code, "Space", StringComparison.OrdinalIgnoreCase)) + { + await Grid.ShowAllHeaderUIAsync(this); + } + } + private async Task HandleSortMenuKeyDownAsync(KeyboardEventArgs args) { if (KEYBOARD_MENU_SELECT_KEYS.Contains(args.Key, StringComparer.OrdinalIgnoreCase)) diff --git a/src/Core/Components/DataGrid/FluentDataGrid.razor.ts b/src/Core/Components/DataGrid/FluentDataGrid.razor.ts index 47f8e4c479..2dcf32a3fb 100644 --- a/src/Core/Components/DataGrid/FluentDataGrid.razor.ts +++ b/src/Core/Components/DataGrid/FluentDataGrid.razor.ts @@ -123,8 +123,16 @@ export namespace Microsoft.FluentUI.Blazor.DataGrid { }; const keyboardNavigation = (sibling: HTMLElement | null) => { if (sibling !== null) { + // th elements are not focusable; transfer focus to the inner header button instead. + if (sibling.matches('th')) { + const headerButton = sibling.querySelector('.col-sort-button, .col-options-button'); + if (headerButton) { + sibling = headerButton; + } + } + const focusSibling = () => { - sibling.focus({ preventScroll: true }); + sibling?.focus({ preventScroll: true }); start = sibling; }; @@ -144,6 +152,36 @@ export namespace Microsoft.FluentUI.Blazor.DataGrid { }, 0); } } + const getHeaderButtons = () => { + return Array.from( + gridElement.querySelectorAll('.column-header .col-sort-button, .column-header .col-options-button') + ).filter(button => button.tabIndex >= 0 && !button.hasAttribute('disabled')); + }; + const moveHeaderFocus = (current: HTMLElement, shiftKey: boolean) => { + const headerButtons = getHeaderButtons(); + const currentIndex = headerButtons.indexOf(current); + if (currentIndex < 0) { + return false; + } + + const nextIndex = shiftKey ? currentIndex - 1 : currentIndex + 1; + if (nextIndex < 0 || nextIndex >= headerButtons.length) { + return false; + } + + headerButtons[nextIndex].focus({ preventScroll: true }); + return true; + }; + const moveFocusIntoHeader = () => { + const headerButtons = getHeaderButtons(); + const firstHeaderButton = headerButtons[0]; + if (!firstHeaderButton) { + return false; + } + + firstHeaderButton.focus({ preventScroll: true }); + return true; + }; const getAdjacentRowCell = (cell: HTMLTableCellElement, direction: 'up' | 'down') => { const row = cell.parentElement as HTMLTableRowElement | null; if (!row) { @@ -176,6 +214,42 @@ export namespace Microsoft.FluentUI.Blazor.DataGrid { } const isArrowKey = event.key === "ArrowRight" || event.key === "ArrowLeft" || event.key === "ArrowDown" || event.key === "ArrowUp"; + const targetElement = event.target instanceof HTMLElement ? event.target : null; + const isMenuInteraction = event.composedPath().some((entry) => + entry instanceof HTMLElement && + (entry.matches('fluent-menu, fluent-menu-list, fluent-menu-item, [role="menu"], [role="menuitem"]') || + !!entry.closest('fluent-menu, fluent-menu-list, fluent-menu-item, [role="menu"], [role="menuitem"]')) + ); + + if (isArrowKey && isMenuInteraction) { + return; + } + + if (event.key === "Tab" && !event.shiftKey) { + const activeElement = getDeepActiveElement(); + if (activeElement && !activeElement.matches('.col-sort-button, .col-options-button')) { + const focusableElements = Array.from( + document.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex=\"-1\"])') + ).filter(element => element.tabIndex >= 0 && !element.hasAttribute('disabled') && element.getClientRects().length > 0); + const currentIndex = focusableElements.indexOf(activeElement); + if (currentIndex >= 0) { + const nextFocusable = focusableElements[currentIndex + 1] ?? null; + if (nextFocusable && getHeaderButtons().includes(nextFocusable) && moveFocusIntoHeader()) { + event.preventDefault(); + event.stopPropagation(); + return; + } + } + } + } + + if (event.key === "Tab" && targetElement && (targetElement.matches('.col-sort-button') || targetElement.matches('.col-options-button'))) { + if (moveHeaderFocus(targetElement, event.shiftKey)) { + event.preventDefault(); + event.stopPropagation(); + return; + } + } const headerUiElement = gridElement?.querySelector(headerUiSelector); if (headerUiElement && headerUiElement.contains(event.target as HTMLElement)) { @@ -194,7 +268,6 @@ export namespace Microsoft.FluentUI.Blazor.DataGrid { return; } - const targetElement = event.target instanceof HTMLElement ? event.target : null; if (targetElement && targetElement !== focusedGridElement && targetElement.closest('[role="gridcell"]') === focusedGridElement) { return; } @@ -211,10 +284,12 @@ export namespace Microsoft.FluentUI.Blazor.DataGrid { event.preventDefault(); const previousSibling = getAdjacentRowCell(start as HTMLTableCellElement, 'up'); keyboardNavigation(previousSibling); + event.stopPropagation(); } else if (event.key === "ArrowDown") { event.preventDefault(); const nextSibling = getAdjacentRowCell(start as HTMLTableCellElement, 'down'); keyboardNavigation(nextSibling); + event.stopPropagation(); } else if (event.key === "ArrowLeft") { // left arrow event.preventDefault(); @@ -229,7 +304,6 @@ export namespace Microsoft.FluentUI.Blazor.DataGrid { event.stopPropagation(); } } - }; const cells = gridElement.querySelectorAll('[role="gridcell"]'); @@ -289,10 +363,20 @@ export namespace Microsoft.FluentUI.Blazor.DataGrid { colPopup.style.visibility = 'visible'; (colPopup as any).scrollIntoViewIfNeeded?.(); - const autoFocusElem = colPopup.querySelector('[autofocus]') as HTMLElement | null; - if (autoFocusElem) { - autoFocusElem.focus(); - } + requestAnimationFrame(() => { + const autoFocusElem = colPopup.querySelector('[autofocus]'); + if (autoFocusElem) { + autoFocusElem.focus({ preventScroll: true }); + return; + } + + const firstFocusable = colPopup.querySelector( + 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])' + ); + if (firstFocusable && firstFocusable.getClientRects().length > 0) { + firstFocusable.focus({ preventScroll: true }); + } + }); } } diff --git a/src/Core/Components/Dialog/FluentDialog.razor.cs b/src/Core/Components/Dialog/FluentDialog.razor.cs index ca64bdadaf..81e703496f 100644 --- a/src/Core/Components/Dialog/FluentDialog.razor.cs +++ b/src/Core/Components/Dialog/FluentDialog.razor.cs @@ -185,6 +185,15 @@ private async Task OnKeyDownHandlerAsync(Microsoft.AspNetCore.Components.Web.Key return; } + if (IsDrawer()) + { + var shouldHandleShortcut = await JSRuntime.InvokeAsync("Microsoft.FluentUI.Blazor.Components.Dialog.ShouldHandleShortcut", Id); + if (!shouldHandleShortcut) + { + return; + } + } + var shortCut = $"{(e.CtrlKey ? "Ctrl+" : string.Empty)}{(e.AltKey ? "Alt+" : string.Empty)}{(e.ShiftKey ? "Shift+" : string.Empty)}{e.Key}"; // OK button diff --git a/tests/Core/Components/DataGrid/FluentDataGridTests.razor b/tests/Core/Components/DataGrid/FluentDataGridTests.razor index f67665c707..e283143251 100644 --- a/tests/Core/Components/DataGrid/FluentDataGridTests.razor +++ b/tests/Core/Components/DataGrid/FluentDataGridTests.razor @@ -1695,6 +1695,33 @@ Assert.Equal(3, items.Count); // Only one item in the menu } + [Fact] + public void FluentDataGrid_HeaderCellAsButtonWithMenu_MenuKeyDown_KeyboardOpensMenu() + { + // Arrange && Act + FluentDataGrid? grid = default!; + var cut = Render>( + @ + + +
Hello!
+
+
+
+ ); + + var row = cut.FindComponent>(); + + row.Find(".col-sort-button").KeyDown(new KeyboardEventArgs() { Code = "Enter", Key = "Enter" }); + + var items = cut.FindAll("fluent-menu-item"); + + // Assert + Assert.NotEmpty(items); + Assert.Equal(3, items.Count); + Assert.Empty(cut.FindAll(".col-header-ui")); + } + [Fact] public void FluentDataGrid_HeaderCellAsButtonWithMenu_OnlySort() { From a56da3b224a5703569c4b9efe509c53857ef9d99 Mon Sep 17 00:00:00 2001 From: Vincent Baaij Date: Tue, 30 Jun 2026 23:24:09 +0200 Subject: [PATCH 3/8] Fix #4562 --- .../src/Components/Dialog/FluentDialog.ts | 1 - .../Components/Dialog/FluentDialog.razor.cs | 33 +++++++++++++++++-- 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/src/Core.Scripts/src/Components/Dialog/FluentDialog.ts b/src/Core.Scripts/src/Components/Dialog/FluentDialog.ts index fcde87244b..610039cfc7 100644 --- a/src/Core.Scripts/src/Components/Dialog/FluentDialog.ts +++ b/src/Core.Scripts/src/Components/Dialog/FluentDialog.ts @@ -118,7 +118,6 @@ export namespace Microsoft.FluentUI.Blazor.Components.Dialog { } // Never steal keyboard handling from editable/menu-like controls in dialog content. - console.log(activeElement); if ( activeElement.matches('input, textarea, select, fluent-data-grid, [contenteditable=""], [contenteditable="true"], [role="textbox"], [role="combobox"], [role="spinbutton"], [role="listbox"], [role="menu"], [role="menuitem"]') || !!activeElement.closest('fluent-menu, fluent-menu-list, fluent-menu-item, .col-header-ui') diff --git a/src/Core/Components/Dialog/FluentDialog.razor.cs b/src/Core/Components/Dialog/FluentDialog.razor.cs index 81e703496f..0e16f3a2fa 100644 --- a/src/Core/Components/Dialog/FluentDialog.razor.cs +++ b/src/Core/Components/Dialog/FluentDialog.razor.cs @@ -14,7 +14,7 @@ namespace Microsoft.FluentUI.AspNetCore.Components; /// The dialog component is a window overlaid on either the primary window or another dialog window. /// Windows under a modal dialog are inert. ///
-public partial class FluentDialog : FluentComponentBase +public partial class FluentDialog : FluentComponentBase, IHandleEvent { private string? _shownInstanceId; @@ -109,7 +109,13 @@ protected override Task OnAfterRenderAsync(bool firstRender) /// internal async Task OnToggleAsync(DialogToggleEventArgs args) { - if (string.CompareOrdinal(args.Id, Instance?.Id) != 0) + // The 'beforetoggle'/'toggle' DOM events are shared by the native element and the + // Popover API. Any popover rendered inside the dialog/drawer content (e.g. fluent-menu-list, + // select listbox, tooltip) also raises these events. Blazor's event delegation attributes + // them to this dialog's @ondialogtoggle handler. We must ignore events that don't target + // this dialog instance; otherwise the IHandleEvent implementation below would re-render the + // whole dialog subtree and detach any open popover content. + if (args is null || string.CompareOrdinal(args.Id, Instance?.Id) != 0) { return; } @@ -134,6 +140,29 @@ internal async Task OnToggleAsync(DialogToggleEventArgs args) } } + /// + /// Handles UI events for this component. + /// + /// + /// The dialog's content is supplied by the consumer (declaratively or through the + /// ) and is re-rendered on its own. The dialog's own event handlers + /// ( and ) only forward to dialog + /// actions/state callbacks that already request their own renders, so they don't need the + /// automatic StateHasChanged that the default implementation + /// performs after every callback. + /// + /// Suppressing that automatic render is important because the 'beforetoggle'/'toggle' and + /// 'keydown' DOM events also bubble from content rendered inside the dialog/drawer (for example a + /// fluent-menu-list popover, a select listbox or a DataGrid header). Blazor's event + /// delegation attributes those to this dialog's handlers, and an unnecessary re-render of the + /// dialog subtree would recreate keyed child content (e.g. DataGrid header cells) and detach any + /// open popover. + /// + /// + [ExcludeFromCodeCoverage(Justification = "Tested in aspnetcore code")] + Task IHandleEvent.HandleEventAsync(EventCallbackWorkItem callback, object? arg) + => callback.InvokeAsync(arg); + /// private async Task RaiseOnStateChangeAsync(DialogEventArgs args) { From 9d30de194c017d6dccee2c9a23bf6f5017a98876 Mon Sep 17 00:00:00 2001 From: Vincent Baaij Date: Tue, 30 Jun 2026 23:50:47 +0200 Subject: [PATCH 4/8] Clean-up --- .../Dialog/Examples/Dialog/SimpleDialog.razor | 27 ------------------- .../Components/Dialog/FluentDialog.razor.cs | 2 +- 2 files changed, 1 insertion(+), 28 deletions(-) diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Dialog/Examples/Dialog/SimpleDialog.razor b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Dialog/Examples/Dialog/SimpleDialog.razor index 993d2cf5de..98b288d12b 100644 --- a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Dialog/Examples/Dialog/SimpleDialog.razor +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Dialog/Examples/Dialog/SimpleDialog.razor @@ -3,35 +3,8 @@ - - - - - - - - - - - -@code { - PaginationState pagination = new PaginationState() { ItemsPerPage = 2 }; - record Person2(int PersonId, string Name, DateOnly BirthDate); - - IQueryable people = new[] - { - new Person2(10895, "Jean Martin", new DateOnly(1985, 3, 16)), - new Person2(10944, "António Langa", new DateOnly(1991, 12, 1)), - new Person2(11203, "Julie Smith", new DateOnly(1958, 10, 10)), - new Person2(11205, "Nur Sari", new DateOnly(1922, 4, 27)), - new Person2(11898, "Jose Hernandez", new DateOnly(2011, 5, 3)), - new Person2(12130, "Kenji Sato", new DateOnly(2004, 1, 9)), - }.AsQueryable(); - - private RenderFragment template = @; -} @code { // A simple type is not updatable [Parameter] diff --git a/src/Core/Components/Dialog/FluentDialog.razor.cs b/src/Core/Components/Dialog/FluentDialog.razor.cs index 0e16f3a2fa..352e8e1320 100644 --- a/src/Core/Components/Dialog/FluentDialog.razor.cs +++ b/src/Core/Components/Dialog/FluentDialog.razor.cs @@ -115,7 +115,7 @@ internal async Task OnToggleAsync(DialogToggleEventArgs args) // them to this dialog's @ondialogtoggle handler. We must ignore events that don't target // this dialog instance; otherwise the IHandleEvent implementation below would re-render the // whole dialog subtree and detach any open popover content. - if (args is null || string.CompareOrdinal(args.Id, Instance?.Id) != 0) + if (string.CompareOrdinal(args.Id, Instance?.Id) != 0) { return; } From 593f324fc119352214aabafbf89dbdbc80522b3d Mon Sep 17 00:00:00 2001 From: Vincent Baaij Date: Thu, 2 Jul 2026 23:16:34 +0200 Subject: [PATCH 5/8] - Removed global listener - Removed unnecessary IsDrawer check - Removed march/closest test for specific elements --- src/Core.Scripts/src/Components/Dialog/FluentDialog.ts | 8 -------- src/Core/Components/DataGrid/FluentDataGrid.razor.ts | 7 +++---- src/Core/Components/Dialog/FluentDialog.razor.cs | 9 +++------ 3 files changed, 6 insertions(+), 18 deletions(-) diff --git a/src/Core.Scripts/src/Components/Dialog/FluentDialog.ts b/src/Core.Scripts/src/Components/Dialog/FluentDialog.ts index 610039cfc7..56175ff427 100644 --- a/src/Core.Scripts/src/Components/Dialog/FluentDialog.ts +++ b/src/Core.Scripts/src/Components/Dialog/FluentDialog.ts @@ -117,14 +117,6 @@ export namespace Microsoft.FluentUI.Blazor.Components.Dialog { return false; } - // Never steal keyboard handling from editable/menu-like controls in dialog content. - if ( - activeElement.matches('input, textarea, select, fluent-data-grid, [contenteditable=""], [contenteditable="true"], [role="textbox"], [role="combobox"], [role="spinbutton"], [role="listbox"], [role="menu"], [role="menuitem"]') || - !!activeElement.closest('fluent-menu, fluent-menu-list, fluent-menu-item, .col-header-ui') - ) { - return false; - } - // Keep shortcuts active for explicit dialog action surfaces. return !!activeElement.closest('[slot="action"], [slot="footer"], [slot="close"], [slot="title-action"]'); } diff --git a/src/Core/Components/DataGrid/FluentDataGrid.razor.ts b/src/Core/Components/DataGrid/FluentDataGrid.razor.ts index 2dcf32a3fb..375c7fec69 100644 --- a/src/Core/Components/DataGrid/FluentDataGrid.razor.ts +++ b/src/Core/Components/DataGrid/FluentDataGrid.razor.ts @@ -329,9 +329,8 @@ export namespace Microsoft.FluentUI.Blazor.DataGrid { document.body.addEventListener('click', bodyClickHandler, { signal }); document.body.addEventListener('mousedown', bodyClickHandler, { signal }); document.body.addEventListener('keydown', bodyKeyDownHandler, { signal }); - // Listen at document capture phase so keyboard navigation still works when focus is - // inside inner elements that stop propagation before table-level handlers run. - document.addEventListener('keydown', keyDownHandler, { signal, capture: true }); + + gridElement.addEventListener('keydown', keyDownHandler, { signal, capture: true }); return { stop: () => { @@ -766,7 +765,7 @@ export namespace Microsoft.FluentUI.Blazor.DataGrid { let headerBeingResized: HTMLElement | null | undefined; if (!column) { - const targetElement = (document.activeElement as HTMLElement)?.parentElement?.parentElement?.parentElement?.parentElement; + const targetElement = (document.activeElement as HTMLElement)?.parentElement; if (!(targetElement && targetElement.classList.contains('column-header') && targetElement.classList.contains('resizable'))) { return; } diff --git a/src/Core/Components/Dialog/FluentDialog.razor.cs b/src/Core/Components/Dialog/FluentDialog.razor.cs index 352e8e1320..ada4482581 100644 --- a/src/Core/Components/Dialog/FluentDialog.razor.cs +++ b/src/Core/Components/Dialog/FluentDialog.razor.cs @@ -214,13 +214,10 @@ private async Task OnKeyDownHandlerAsync(Microsoft.AspNetCore.Components.Web.Key return; } - if (IsDrawer()) + var shouldHandleShortcut = await JSRuntime.InvokeAsync("Microsoft.FluentUI.Blazor.Components.Dialog.ShouldHandleShortcut", Id); + if (!shouldHandleShortcut) { - var shouldHandleShortcut = await JSRuntime.InvokeAsync("Microsoft.FluentUI.Blazor.Components.Dialog.ShouldHandleShortcut", Id); - if (!shouldHandleShortcut) - { - return; - } + return; } var shortCut = $"{(e.CtrlKey ? "Ctrl+" : string.Empty)}{(e.AltKey ? "Alt+" : string.Empty)}{(e.ShiftKey ? "Shift+" : string.Empty)}{e.Key}"; From 17c7c245d3d87449cc7ad5dfc314779d2ce6421a Mon Sep 17 00:00:00 2001 From: Vincent Baaij Date: Fri, 3 Jul 2026 08:25:51 +0200 Subject: [PATCH 6/8] Raise test timout for Dialog tests --- tests/Core/Components/Dialog/FluentDialogTests.razor | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Core/Components/Dialog/FluentDialogTests.razor b/tests/Core/Components/Dialog/FluentDialogTests.razor index 60c7edf48f..e9e8964a6a 100644 --- a/tests/Core/Components/Dialog/FluentDialogTests.razor +++ b/tests/Core/Components/Dialog/FluentDialogTests.razor @@ -3,7 +3,7 @@ @code { // A timeout can be set when you open a dialog box and do not close it. - private const int TEST_TIMEOUT = 3000; + private const int TEST_TIMEOUT = 13000; public FluentDialogTests() { From 9bf75c4b233e7d68b427032fa0aa35a08c1fabda Mon Sep 17 00:00:00 2001 From: Vincent Baaij Date: Fri, 3 Jul 2026 10:05:56 +0200 Subject: [PATCH 7/8] Trying to fix test timeout --- tests/Core/Components/Dialog/FluentDialogTests.razor | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/Core/Components/Dialog/FluentDialogTests.razor b/tests/Core/Components/Dialog/FluentDialogTests.razor index e9e8964a6a..a9878cfcb0 100644 --- a/tests/Core/Components/Dialog/FluentDialogTests.razor +++ b/tests/Core/Components/Dialog/FluentDialogTests.razor @@ -228,6 +228,13 @@ } }); + // Ensure dialog is rendered before sending keyboard shortcut. + // This avoids CI race conditions where the key event is dispatched too early. + DialogProvider.WaitForAssertion(() => + { + var _ = DialogProvider.Find("fluent-dialog"); + }); + // Send a shortcut to close the dialog if (item.Pressed is not null) { From 82f24de749bbf4ff8c83fba796d30b167ead5fe7 Mon Sep 17 00:00:00 2001 From: Vincent Baaij Date: Fri, 3 Jul 2026 10:43:56 +0200 Subject: [PATCH 8/8] Fix Dialog test (becuse of changed shortcuthandling). - Added JS interop setup in the test constructor: -Microsoft.FluentUI.Blazor.Components.Dialog.ShouldHandleShortcut now returns true for test invocations. - Kept shortcut key dispatch on fluent-dialog (reliable for @onkeydown in bUnit). - Updated invalid shortcut path consistently. --- tests/Core/Components/Dialog/FluentDialogTests.razor | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/tests/Core/Components/Dialog/FluentDialogTests.razor b/tests/Core/Components/Dialog/FluentDialogTests.razor index a9878cfcb0..a91e1341f1 100644 --- a/tests/Core/Components/Dialog/FluentDialogTests.razor +++ b/tests/Core/Components/Dialog/FluentDialogTests.razor @@ -8,6 +8,7 @@ public FluentDialogTests() { JSInterop.Mode = JSRuntimeMode.Loose; + JSInterop.Setup("Microsoft.FluentUI.Blazor.Components.Dialog.ShouldHandleShortcut", _ => true).SetResult(true); Services.AddFluentUIComponents(options => options.UseGlobalOverlay = false); DialogService = Services.GetRequiredService(); @@ -228,13 +229,6 @@ } }); - // Ensure dialog is rendered before sending keyboard shortcut. - // This avoids CI race conditions where the key event is dispatched too early. - DialogProvider.WaitForAssertion(() => - { - var _ = DialogProvider.Find("fluent-dialog"); - }); - // Send a shortcut to close the dialog if (item.Pressed is not null) {