From a7ad2cfa3e776e6a0e47b0d194ec4fb3f1e993b2 Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Wed, 22 Jul 2026 12:26:07 -0400 Subject: [PATCH 1/6] Fix anchored region focus across shadow DOM --- .github/workflows/build-core-lib.yml | 19 ++ eng/pipelines/build-all-lib.yml | 24 +- eng/pipelines/build-core-lib.yml | 24 +- src/Core.Assets/.npmrc | 3 +- src/Core.Assets/package-lock.json | 64 ++++ src/Core.Assets/package.json | 5 +- src/Core.Assets/playwright.config.js | 7 + .../tests/FluentAnchoredRegion.spec.js | 323 ++++++++++++++++++ .../FluentAnchoredRegion.razor.js | 140 ++++++-- 9 files changed, 570 insertions(+), 39 deletions(-) create mode 100644 src/Core.Assets/playwright.config.js create mode 100644 src/Core.Assets/tests/FluentAnchoredRegion.spec.js diff --git a/.github/workflows/build-core-lib.yml b/.github/workflows/build-core-lib.yml index 4744f7cf6c..05d0af1380 100644 --- a/.github/workflows/build-core-lib.yml +++ b/.github/workflows/build-core-lib.yml @@ -54,6 +54,25 @@ jobs: dotnet-version: 10.0.x dotnet-quality: ga + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22.x + cache: npm + cache-dependency-path: src/Core.Assets/package-lock.json + + - name: Install Core.Assets dependencies + run: npm ci + working-directory: ${{ github.workspace }}/src/Core.Assets + + - name: Install Chromium for browser tests + run: npm run test:install + working-directory: ${{ github.workspace }}/src/Core.Assets + + - name: Browser tests + run: npm test + working-directory: ${{ github.workspace }}/src/Core.Assets + # Build - name: Restore diff --git a/eng/pipelines/build-all-lib.yml b/eng/pipelines/build-all-lib.yml index 404c85543a..7e4a78f13c 100644 --- a/eng/pipelines/build-all-lib.yml +++ b/eng/pipelines/build-all-lib.yml @@ -165,13 +165,35 @@ extends: nugetConfigPath: nuget.config includeNuGetOrg: false + - task: NodeTool@0 + displayName: 'Install Node.js' + inputs: + versionSpec: '22.x' + # NPM install Core.Assets - task: Npm@1 displayName: "NPM install Core.Assets" inputs: - command: "install" + command: "custom" + customCommand: "ci" workingDir: "src/Core.Assets" + - task: Npm@1 + condition: eq(variables['ShouldTest'], 'true') + displayName: 'Install Chromium for browser tests' + inputs: + command: 'custom' + customCommand: 'run test:install' + workingDir: 'src/Core.Assets' + + - task: Npm@1 + condition: eq(variables['ShouldTest'], 'true') + displayName: 'Browser tests' + inputs: + command: 'custom' + customCommand: 'test' + workingDir: 'src/Core.Assets' + # Build the projects - task: DotNetCoreCLI@2 displayName: 'Build $(Build.BuildNumber)' diff --git a/eng/pipelines/build-core-lib.yml b/eng/pipelines/build-core-lib.yml index f56a7fe6b3..39bdb983cf 100644 --- a/eng/pipelines/build-core-lib.yml +++ b/eng/pipelines/build-core-lib.yml @@ -195,13 +195,35 @@ extends: nugetConfigPath: nuget.config includeNuGetOrg: false + - task: NodeTool@0 + displayName: 'Install Node.js' + inputs: + versionSpec: '22.x' + # NPM install Core.Assets - task: Npm@1 displayName: "NPM install Core.Assets" inputs: - command: "install" + command: "custom" + customCommand: "ci" workingDir: "src/Core.Assets" + - task: Npm@1 + condition: eq(variables['ShouldTest'], 'true') + displayName: 'Install Chromium for browser tests' + inputs: + command: 'custom' + customCommand: 'run test:install' + workingDir: 'src/Core.Assets' + + - task: Npm@1 + condition: eq(variables['ShouldTest'], 'true') + displayName: 'Browser tests' + inputs: + command: 'custom' + customCommand: 'test' + workingDir: 'src/Core.Assets' + # Build the projects - task: DotNetCoreCLI@2 displayName: 'Build $(Build.BuildNumber)' diff --git a/src/Core.Assets/.npmrc b/src/Core.Assets/.npmrc index 8c8d2b239b..8b8c4cb63d 100644 --- a/src/Core.Assets/.npmrc +++ b/src/Core.Assets/.npmrc @@ -1,2 +1,3 @@ registry=https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public-npm/npm/registry/ -always-auth=true \ No newline at end of file +always-auth=true +replace-registry-host=never \ No newline at end of file diff --git a/src/Core.Assets/package-lock.json b/src/Core.Assets/package-lock.json index a813bae8d0..07758fd491 100644 --- a/src/Core.Assets/package-lock.json +++ b/src/Core.Assets/package-lock.json @@ -12,6 +12,7 @@ "devDependencies": { "@microsoft/fast-element": "1.14.0", "@microsoft/fast-foundation": "2.50.0", + "@playwright/test": "1.54.1", "@typescript-eslint/eslint-plugin": "^7.6.0", "@typescript-eslint/parser": "^7.6.0", "esbuild": "0.28.1", @@ -758,6 +759,22 @@ "node": ">=14" } }, + "node_modules/@playwright/test": { + "version": "1.54.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.54.1.tgz", + "integrity": "sha512-FS8hQ12acieG2dYSksmLOF7BNxnVf2afRJdCuM1eMSxj6QTSE6G4InGF7oApGgDb65MX7AwMVlIkpru0yZA4Xw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.54.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "7.18.0", "resolved": "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public-npm/npm/registry/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.18.0.tgz", @@ -1690,6 +1707,21 @@ "dev": true, "license": "ISC" }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/glob": { "version": "10.5.0", "resolved": "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public-npm/npm/registry/glob/-/glob-10.5.0.tgz", @@ -2252,6 +2284,38 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.54.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.54.1.tgz", + "integrity": "sha512-peWpSwIBmSLi6aW2auvrUtf2DqY16YYcCMO8rTVx486jKmDTJg7UAhyrraP98GB8BoPURZP8+nxO7TSd4cPr5g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.54.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.54.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.54.1.tgz", + "integrity": "sha512-Nbjs2zjj0htNhzgiy5wu+3w09YetDx5pkrpI/kZotDlDUaYk0HVA5xrBVPdow4SAUIlhgKcJeJg4GRKW6xHusA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public-npm/npm/registry/prelude-ls/-/prelude-ls-1.2.1.tgz", diff --git a/src/Core.Assets/package.json b/src/Core.Assets/package.json index 91365e7dab..62389ac477 100644 --- a/src/Core.Assets/package.json +++ b/src/Core.Assets/package.json @@ -5,13 +5,16 @@ "main": "dist/Microsoft.FluentUI.AspNetCore.Components.lib.module.js", "scripts": { "build": "node ./esbuild.config.mjs", - "clean": "rimraf ./dist" + "clean": "rimraf ./dist", + "test": "playwright test", + "test:install": "playwright install chromium" }, "keywords": [], "author": "", "license": "ISC", "type": "module", "devDependencies": { + "@playwright/test": "1.54.1", "@microsoft/fast-element": "1.14.0", "@microsoft/fast-foundation": "2.50.0", "@typescript-eslint/eslint-plugin": "^7.6.0", diff --git a/src/Core.Assets/playwright.config.js b/src/Core.Assets/playwright.config.js new file mode 100644 index 0000000000..ce3c36f08d --- /dev/null +++ b/src/Core.Assets/playwright.config.js @@ -0,0 +1,7 @@ +import { defineConfig } from "@playwright/test"; + +export default defineConfig({ + outputDir: "obj/playwright", + reporter: "line", + testDir: "tests" +}); \ No newline at end of file diff --git a/src/Core.Assets/tests/FluentAnchoredRegion.spec.js b/src/Core.Assets/tests/FluentAnchoredRegion.spec.js new file mode 100644 index 0000000000..3a0a6cfda9 --- /dev/null +++ b/src/Core.Assets/tests/FluentAnchoredRegion.spec.js @@ -0,0 +1,323 @@ +import { expect, test } from "@playwright/test"; +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const testDirectory = path.dirname(fileURLToPath(import.meta.url)); +const anchoredRegionPath = path.resolve(testDirectory, "../../Core/Components/AnchoredRegion/FluentAnchoredRegion.razor.js"); +const fluentWebComponentsPath = path.resolve(testDirectory, "../node_modules/@fluentui/web-components/dist/web-components.min.js"); + +async function loadAnchoredRegionModule(page) { + const moduleSource = await readFile(anchoredRegionPath, "utf8"); + + await page.goto("about:blank"); + await page.addScriptTag({ path: fluentWebComponentsPath, type: "module" }); + await page.evaluate(async source => { + const moduleUrl = URL.createObjectURL(new Blob([source], { type: "text/javascript" })); + globalThis.anchoredRegionModule = await import(moduleUrl); + URL.revokeObjectURL(moduleUrl); + }, moduleSource); +} + +test.beforeEach(async ({ page }) => { + await loadAnchoredRegionModule(page); +}); + +test("Tab from a menu anchored to a Fluent element moves focus after the anchor", async ({ page }) => { + const result = await page.evaluate(async () => { + await customElements.whenDefined("fluent-button"); + document.body.innerHTML = ` + + Anchor + + `; + + await document.getElementById("anchor").updateComplete; + + let closeCalls = 0; + anchoredRegionModule.initializeKeyboardNavigation("anchor", "popup", { + invokeMethodAsync() { + closeCalls++; + } + }, undefined, true); + + const menuItem = document.getElementById("menu-item"); + menuItem.focus(); + menuItem.dispatchEvent(new KeyboardEvent("keydown", { + bubbles: true, + composed: true, + key: "Tab", + keyCode: 9 + })); + + return { activeElementId: document.activeElement.id, closeCalls }; + }); + + expect(result).toEqual({ activeElementId: "next", closeCalls: 1 }); +}); + +test("Tab recognizes the last popup control focused through a Fluent shadow root", async ({ page }) => { + const result = await page.evaluate(() => { + class FluentControlElement extends HTMLElement { + constructor() { + super(); + const shadowRoot = this.attachShadow({ mode: "open" }); + shadowRoot.innerHTML = ""; + } + } + + customElements.define("fluent-control-test", FluentControlElement); + document.body.innerHTML = ` + + + + `; + + let closeCalls = 0; + anchoredRegionModule.initializeKeyboardNavigation("anchor", "popup", { + invokeMethodAsync() { + closeCalls++; + } + }); + + const control = document.getElementById("control"); + const shadowControl = control.shadowRoot.getElementById("shadow-control"); + shadowControl.focus(); + shadowControl.dispatchEvent(new KeyboardEvent("keydown", { + bubbles: true, + composed: true, + key: "Tab", + keyCode: 9 + })); + + return { + activeElementId: document.activeElement.id, + closeCalls + }; + }); + + expect(result).toEqual({ activeElementId: "next", closeCalls: 1 }); +}); + +test("Tab recognizes a focused shadow control represented by its focusable Fluent host", async ({ page }) => { + const result = await page.evaluate(() => { + class FluentFocusableControlElement extends HTMLElement { + constructor() { + super(); + const shadowRoot = this.attachShadow({ mode: "open" }); + shadowRoot.innerHTML = ""; + } + } + + customElements.define("fluent-focusable-control-test", FluentFocusableControlElement); + document.body.innerHTML = ` + + + + `; + + let closeCalls = 0; + anchoredRegionModule.initializeKeyboardNavigation("anchor", "popup", { + invokeMethodAsync() { + closeCalls++; + } + }); + + const control = document.getElementById("control"); + const shadowControl = control.shadowRoot.getElementById("shadow-control"); + shadowControl.focus(); + shadowControl.dispatchEvent(new KeyboardEvent("keydown", { + bubbles: true, + composed: true, + key: "Tab", + keyCode: 9 + })); + + return { activeElementId: document.activeElement.id, closeCalls }; + }); + + expect(result).toEqual({ activeElementId: "next", closeCalls: 1 }); +}); + +test("Tab enters a popup control nested in open shadow roots", async ({ page }) => { + const activeElementId = await page.evaluate(() => { + class FluentNestedControlElement extends HTMLElement { + constructor() { + super(); + const shadowRoot = this.attachShadow({ mode: "open" }); + shadowRoot.innerHTML = ""; + } + } + + class FluentOuterControlElement extends HTMLElement { + constructor() { + super(); + const shadowRoot = this.attachShadow({ mode: "open" }); + shadowRoot.innerHTML = ""; + } + } + + customElements.define("fluent-nested-control-test", FluentNestedControlElement); + customElements.define("fluent-outer-control-test", FluentOuterControlElement); + document.body.innerHTML = ` + + `; + + anchoredRegionModule.initializeKeyboardNavigation("anchor", "popup", { + invokeMethodAsync() { } + }); + + const anchor = document.getElementById("anchor"); + anchor.focus(); + anchor.dispatchEvent(new KeyboardEvent("keydown", { + bubbles: true, + key: "Tab", + keyCode: 9 + })); + + let activeElement = document.activeElement; + while (activeElement?.shadowRoot?.activeElement) { + activeElement = activeElement.shadowRoot.activeElement; + } + + return activeElement?.id; + }); + + expect(activeElementId).toBe("nested-control"); +}); + +test("Tab exits after the focusable descendant of a composite anchor", async ({ page }) => { + const result = await page.evaluate(async () => { + await customElements.whenDefined("fluent-button"); + document.body.innerHTML = ` + +
Anchor
+ + `; + + await document.getElementById("trigger").updateComplete; + + let closeCalls = 0; + anchoredRegionModule.initializeKeyboardNavigation("anchor", "popup", { + invokeMethodAsync() { + closeCalls++; + } + }, undefined, true); + + const menuItem = document.getElementById("menu-item"); + menuItem.focus(); + menuItem.dispatchEvent(new KeyboardEvent("keydown", { + bubbles: true, + composed: true, + key: "Tab", + keyCode: 9 + })); + + return { activeElementId: document.activeElement.id, closeCalls }; + }); + + expect(result).toEqual({ activeElementId: "next", closeCalls: 1 }); +}); + +test("Shift+Tab returns focus to the control inside a focusable composite anchor", async ({ page }) => { + const result = await page.evaluate(() => { + class FocusableAnchorElement extends HTMLElement { + constructor() { + super(); + const shadowRoot = this.attachShadow({ mode: "open" }); + shadowRoot.innerHTML = ""; + } + } + + customElements.define("focusable-anchor-test", FocusableAnchorElement); + document.body.innerHTML = ` + + `; + + let closeCalls = 0; + anchoredRegionModule.initializeKeyboardNavigation("anchor", "popup", { + invokeMethodAsync() { + closeCalls++; + } + }, undefined, true); + + const menuItem = document.getElementById("menu-item"); + menuItem.focus(); + menuItem.dispatchEvent(new KeyboardEvent("keydown", { + bubbles: true, + composed: true, + key: "Tab", + keyCode: 9, + shiftKey: true + })); + + let activeElement = document.activeElement; + while (activeElement?.shadowRoot?.activeElement) { + activeElement = activeElement.shadowRoot.activeElement; + } + + return { activeElementId: activeElement?.id, closeCalls }; + }); + + expect(result).toEqual({ activeElementId: "anchor-control", closeCalls: 1 }); +}); + +test("Shift+Tab falls back to the anchor control when host focus does not move focus", async ({ page }) => { + const result = await page.evaluate(() => { + class UnfocusableAnchorElement extends HTMLElement { + constructor() { + super(); + const shadowRoot = this.attachShadow({ mode: "open" }); + shadowRoot.innerHTML = ""; + } + + focus() { + } + } + + customElements.define("unfocusable-anchor-test", UnfocusableAnchorElement); + document.body.innerHTML = ` + + `; + + let closeCalls = 0; + anchoredRegionModule.initializeKeyboardNavigation("anchor", "popup", { + invokeMethodAsync() { + closeCalls++; + } + }, undefined, true); + + const menuItem = document.getElementById("menu-item"); + menuItem.focus(); + menuItem.dispatchEvent(new KeyboardEvent("keydown", { + bubbles: true, + composed: true, + key: "Tab", + keyCode: 9, + shiftKey: true + })); + + let activeElement = document.activeElement; + while (activeElement?.shadowRoot?.activeElement) { + activeElement = activeElement.shadowRoot.activeElement; + } + + return { activeElementId: activeElement?.id, closeCalls }; + }); + + expect(result).toEqual({ activeElementId: "anchor-control", closeCalls: 1 }); +}); + +test("FocusableElement does not wrap when the current element is outside its focus list", async ({ page }) => { + const nextElementId = await page.evaluate(() => { + document.body.innerHTML = ` + + `; + + const elementOutsideDocument = document.createElement("button"); + return new anchoredRegionModule.FocusableElement(document) + .findNextFocusableElement(elementOutsideDocument)?.id ?? null; + }); + + expect(nextElementId).toBeNull(); +}); \ No newline at end of file diff --git a/src/Core/Components/AnchoredRegion/FluentAnchoredRegion.razor.js b/src/Core/Components/AnchoredRegion/FluentAnchoredRegion.razor.js index 39184bd29f..8938678587 100644 --- a/src/Core/Components/AnchoredRegion/FluentAnchoredRegion.razor.js +++ b/src/Core/Components/AnchoredRegion/FluentAnchoredRegion.razor.js @@ -45,6 +45,45 @@ export function goToNextFocusableElement(forContainer, toOriginal, delay) { const keyboardNavigationState = new Map(); +function getDeepActiveElement(root = document) { + let activeElement = root.activeElement; + + while (activeElement?.shadowRoot?.activeElement) { + activeElement = activeElement.shadowRoot.activeElement; + } + + return activeElement; +} + +function containsComposedElement(container, element) { + let current = element; + + while (current) { + if (container.contains?.(current)) { + return true; + } + + const root = current.getRootNode(); + current = root instanceof ShadowRoot ? root.host : null; + } + + return false; +} + +function focusElementOrDescendant(element) { + if (element.tabIndex !== -1) { + element.focus(); + + const activeElement = getDeepActiveElement(); + if (activeElement !== element && containsComposedElement(element, activeElement)) { + return; + } + } + + const focusTarget = new FocusableElement(element).findNextFocusableElement(); + (focusTarget ?? element).focus(); +} + /** * Attaches keyboard navigation listeners to an anchor+popup pair. * @param {string} anchorId - Id of the anchor element. @@ -73,7 +112,7 @@ export function initializeKeyboardNavigation(anchorId, popupId, dotNetHelper, cl // Case 4: close key → return focus to anchor, close ev.preventDefault(); ev.stopPropagation(); - anchorElement.focus(); + focusElementOrDescendant(anchorElement); dotNetHelper.invokeMethodAsync('CloseAsync'); return; } @@ -85,41 +124,30 @@ export function initializeKeyboardNavigation(anchorId, popupId, dotNetHelper, cl ev.stopPropagation(); if (!ev.shiftKey) { // Case 3: move to element after anchor in page - let startFrom; - if (anchorElement.tagName.startsWith("FLUENT-") && anchorElement.shadowRoot?.children.length > 0) { - startFrom = anchorElement.shadowRoot.children[0]; - } else { - startFrom = anchorElement; - } - new FocusableElement(anchorElement.getRootNode()).findNextFocusableElement(startFrom)?.focus(); + new FocusableElement(anchorElement.getRootNode()).findNextFocusableElement(anchorElement)?.focus(); } else { // Case 2: Shift+Tab → focus anchor - anchorElement.focus(); + focusElementOrDescendant(anchorElement); } dotNetHelper.invokeMethodAsync('CloseAsync'); } else { // Popover pattern: only intercept Tab at the first/last boundary; // let the browser handle Tab naturally for elements in between. - const focusables = new FocusableElement(popupElement).getFocusableElements(); - const activeIndex = focusables.indexOf(document.activeElement); + const popupFocus = new FocusableElement(popupElement); + const focusables = popupFocus.getFocusableElements(); + const activeIndex = popupFocus.getFocusableElementIndex(getDeepActiveElement(), focusables); if (!ev.shiftKey && (focusables.length === 0 || activeIndex === focusables.length - 1)) { // Case 3: Tab on last element → next page element after anchor, close ev.preventDefault(); ev.stopPropagation(); - let startFrom; - if (anchorElement.tagName.startsWith("FLUENT-") && anchorElement.shadowRoot?.children.length > 0) { - startFrom = anchorElement.shadowRoot.children[0]; - } else { - startFrom = anchorElement; - } - new FocusableElement(anchorElement.getRootNode()).findNextFocusableElement(startFrom)?.focus(); + new FocusableElement(anchorElement.getRootNode()).findNextFocusableElement(anchorElement)?.focus(); dotNetHelper.invokeMethodAsync('CloseAsync'); } else if (ev.shiftKey && (focusables.length === 0 || activeIndex === 0)) { // Case 2: Shift+Tab on first element → focus anchor, close ev.preventDefault(); ev.stopPropagation(); - anchorElement.focus(); + focusElementOrDescendant(anchorElement); dotNetHelper.invokeMethodAsync('CloseAsync'); } // Otherwise: middle element — let browser handle Tab/Shift+Tab naturally @@ -198,24 +226,62 @@ export class FocusableElement { * @returns {Element[]} */ getFocusableElements() { - const queriedElements = Array.from(this._container.querySelectorAll("*")).filter(el => { - return el.matches(this.FOCUSABLE_SELECTORS) || el.tagName.toLowerCase().startsWith("fluent-"); - }); - const focusableElements = []; - queriedElements.forEach(el => { - if (el.tagName.toLowerCase().startsWith("fluent-") && el.tabIndex === -1 && !!el.shadowRoot) { - Array.from(el.shadowRoot.children).forEach(child => { - if (child.tabIndex !== -1 && child.checkVisibility()) { - focusableElements.push(child); - } - }); - } else { - focusableElements.push(el); + + const collectFocusableElements = container => { + Array.from(container.children).forEach(element => { + const isFocusable = element.matches(this.FOCUSABLE_SELECTORS) + && element.tabIndex !== -1 + && element.checkVisibility(); + + if (isFocusable) { + focusableElements.push(element); + } else if (element.shadowRoot) { + collectFocusableElements(element.shadowRoot); + } + + collectFocusableElements(element); + }); + }; + + if (this._container.shadowRoot) { + collectFocusableElements(this._container.shadowRoot); + } + + collectFocusableElements(this._container); + return focusableElements; + } + + /** + * Gets the position of an element or its shadow/composite representation in a focusable element list. + * @param currentElement + * @param focusableElements + * @param reverse - If true, use the first focusable descendant instead of the last. + * @returns + */ + getFocusableElementIndex(currentElement, focusableElements = this.getFocusableElements(), reverse = false) { + let current = currentElement; + + while (current) { + const currentIndex = focusableElements.indexOf(current); + if (currentIndex !== -1) { + return currentIndex; } - }); - return focusableElements.filter(el => !!el && el.tabIndex !== -1 && el.checkVisibility()); + const root = current.getRootNode(); + current = root instanceof ShadowRoot ? root.host : null; + } + + const descendantIndexes = focusableElements + .map((element, index) => ({ element, index })) + .filter(({ element }) => containsComposedElement(currentElement, element)) + .map(({ index }) => index); + + if (descendantIndexes.length === 0) { + return -1; + } + + return reverse ? descendantIndexes[0] : descendantIndexes[descendantIndexes.length - 1]; } /** @@ -234,7 +300,11 @@ export class FocusableElement { // Find the index of the current element const current = currentElement ?? document.activeElement; if (current != null) { - const currentIndex = filteredElements.indexOf(current); + const currentIndex = this.getFocusableElementIndex(current, filteredElements, reverse); + + if (currentIndex === -1) { + return currentElement === undefined && !reverse ? filteredElements[0] : null; + } // Calculate the index of the next (or previous) element const nextIndex = reverse From 7ca0a5dea6da0f12d8358771f8193cbee7953742 Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Wed, 22 Jul 2026 12:29:46 -0400 Subject: [PATCH 2/6] Remove Playwright test infrastructure --- .github/workflows/build-core-lib.yml | 19 -- eng/pipelines/build-all-lib.yml | 24 +- eng/pipelines/build-core-lib.yml | 24 +- src/Core.Assets/.npmrc | 3 +- src/Core.Assets/package-lock.json | 64 ---- src/Core.Assets/package.json | 5 +- src/Core.Assets/playwright.config.js | 7 - .../tests/FluentAnchoredRegion.spec.js | 323 ------------------ 8 files changed, 4 insertions(+), 465 deletions(-) delete mode 100644 src/Core.Assets/playwright.config.js delete mode 100644 src/Core.Assets/tests/FluentAnchoredRegion.spec.js diff --git a/.github/workflows/build-core-lib.yml b/.github/workflows/build-core-lib.yml index 05d0af1380..4744f7cf6c 100644 --- a/.github/workflows/build-core-lib.yml +++ b/.github/workflows/build-core-lib.yml @@ -54,25 +54,6 @@ jobs: dotnet-version: 10.0.x dotnet-quality: ga - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: 22.x - cache: npm - cache-dependency-path: src/Core.Assets/package-lock.json - - - name: Install Core.Assets dependencies - run: npm ci - working-directory: ${{ github.workspace }}/src/Core.Assets - - - name: Install Chromium for browser tests - run: npm run test:install - working-directory: ${{ github.workspace }}/src/Core.Assets - - - name: Browser tests - run: npm test - working-directory: ${{ github.workspace }}/src/Core.Assets - # Build - name: Restore diff --git a/eng/pipelines/build-all-lib.yml b/eng/pipelines/build-all-lib.yml index 7e4a78f13c..404c85543a 100644 --- a/eng/pipelines/build-all-lib.yml +++ b/eng/pipelines/build-all-lib.yml @@ -165,35 +165,13 @@ extends: nugetConfigPath: nuget.config includeNuGetOrg: false - - task: NodeTool@0 - displayName: 'Install Node.js' - inputs: - versionSpec: '22.x' - # NPM install Core.Assets - task: Npm@1 displayName: "NPM install Core.Assets" inputs: - command: "custom" - customCommand: "ci" + command: "install" workingDir: "src/Core.Assets" - - task: Npm@1 - condition: eq(variables['ShouldTest'], 'true') - displayName: 'Install Chromium for browser tests' - inputs: - command: 'custom' - customCommand: 'run test:install' - workingDir: 'src/Core.Assets' - - - task: Npm@1 - condition: eq(variables['ShouldTest'], 'true') - displayName: 'Browser tests' - inputs: - command: 'custom' - customCommand: 'test' - workingDir: 'src/Core.Assets' - # Build the projects - task: DotNetCoreCLI@2 displayName: 'Build $(Build.BuildNumber)' diff --git a/eng/pipelines/build-core-lib.yml b/eng/pipelines/build-core-lib.yml index 39bdb983cf..f56a7fe6b3 100644 --- a/eng/pipelines/build-core-lib.yml +++ b/eng/pipelines/build-core-lib.yml @@ -195,35 +195,13 @@ extends: nugetConfigPath: nuget.config includeNuGetOrg: false - - task: NodeTool@0 - displayName: 'Install Node.js' - inputs: - versionSpec: '22.x' - # NPM install Core.Assets - task: Npm@1 displayName: "NPM install Core.Assets" inputs: - command: "custom" - customCommand: "ci" + command: "install" workingDir: "src/Core.Assets" - - task: Npm@1 - condition: eq(variables['ShouldTest'], 'true') - displayName: 'Install Chromium for browser tests' - inputs: - command: 'custom' - customCommand: 'run test:install' - workingDir: 'src/Core.Assets' - - - task: Npm@1 - condition: eq(variables['ShouldTest'], 'true') - displayName: 'Browser tests' - inputs: - command: 'custom' - customCommand: 'test' - workingDir: 'src/Core.Assets' - # Build the projects - task: DotNetCoreCLI@2 displayName: 'Build $(Build.BuildNumber)' diff --git a/src/Core.Assets/.npmrc b/src/Core.Assets/.npmrc index 8b8c4cb63d..8c8d2b239b 100644 --- a/src/Core.Assets/.npmrc +++ b/src/Core.Assets/.npmrc @@ -1,3 +1,2 @@ registry=https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public-npm/npm/registry/ -always-auth=true -replace-registry-host=never \ No newline at end of file +always-auth=true \ No newline at end of file diff --git a/src/Core.Assets/package-lock.json b/src/Core.Assets/package-lock.json index 07758fd491..a813bae8d0 100644 --- a/src/Core.Assets/package-lock.json +++ b/src/Core.Assets/package-lock.json @@ -12,7 +12,6 @@ "devDependencies": { "@microsoft/fast-element": "1.14.0", "@microsoft/fast-foundation": "2.50.0", - "@playwright/test": "1.54.1", "@typescript-eslint/eslint-plugin": "^7.6.0", "@typescript-eslint/parser": "^7.6.0", "esbuild": "0.28.1", @@ -759,22 +758,6 @@ "node": ">=14" } }, - "node_modules/@playwright/test": { - "version": "1.54.1", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.54.1.tgz", - "integrity": "sha512-FS8hQ12acieG2dYSksmLOF7BNxnVf2afRJdCuM1eMSxj6QTSE6G4InGF7oApGgDb65MX7AwMVlIkpru0yZA4Xw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "playwright": "1.54.1" - }, - "bin": { - "playwright": "cli.js" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "7.18.0", "resolved": "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public-npm/npm/registry/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.18.0.tgz", @@ -1707,21 +1690,6 @@ "dev": true, "license": "ISC" }, - "node_modules/fsevents": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", - "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, "node_modules/glob": { "version": "10.5.0", "resolved": "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public-npm/npm/registry/glob/-/glob-10.5.0.tgz", @@ -2284,38 +2252,6 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/playwright": { - "version": "1.54.1", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.54.1.tgz", - "integrity": "sha512-peWpSwIBmSLi6aW2auvrUtf2DqY16YYcCMO8rTVx486jKmDTJg7UAhyrraP98GB8BoPURZP8+nxO7TSd4cPr5g==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "playwright-core": "1.54.1" - }, - "bin": { - "playwright": "cli.js" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "fsevents": "2.3.2" - } - }, - "node_modules/playwright-core": { - "version": "1.54.1", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.54.1.tgz", - "integrity": "sha512-Nbjs2zjj0htNhzgiy5wu+3w09YetDx5pkrpI/kZotDlDUaYk0HVA5xrBVPdow4SAUIlhgKcJeJg4GRKW6xHusA==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "playwright-core": "cli.js" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public-npm/npm/registry/prelude-ls/-/prelude-ls-1.2.1.tgz", diff --git a/src/Core.Assets/package.json b/src/Core.Assets/package.json index 62389ac477..91365e7dab 100644 --- a/src/Core.Assets/package.json +++ b/src/Core.Assets/package.json @@ -5,16 +5,13 @@ "main": "dist/Microsoft.FluentUI.AspNetCore.Components.lib.module.js", "scripts": { "build": "node ./esbuild.config.mjs", - "clean": "rimraf ./dist", - "test": "playwright test", - "test:install": "playwright install chromium" + "clean": "rimraf ./dist" }, "keywords": [], "author": "", "license": "ISC", "type": "module", "devDependencies": { - "@playwright/test": "1.54.1", "@microsoft/fast-element": "1.14.0", "@microsoft/fast-foundation": "2.50.0", "@typescript-eslint/eslint-plugin": "^7.6.0", diff --git a/src/Core.Assets/playwright.config.js b/src/Core.Assets/playwright.config.js deleted file mode 100644 index ce3c36f08d..0000000000 --- a/src/Core.Assets/playwright.config.js +++ /dev/null @@ -1,7 +0,0 @@ -import { defineConfig } from "@playwright/test"; - -export default defineConfig({ - outputDir: "obj/playwright", - reporter: "line", - testDir: "tests" -}); \ No newline at end of file diff --git a/src/Core.Assets/tests/FluentAnchoredRegion.spec.js b/src/Core.Assets/tests/FluentAnchoredRegion.spec.js deleted file mode 100644 index 3a0a6cfda9..0000000000 --- a/src/Core.Assets/tests/FluentAnchoredRegion.spec.js +++ /dev/null @@ -1,323 +0,0 @@ -import { expect, test } from "@playwright/test"; -import { readFile } from "node:fs/promises"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; - -const testDirectory = path.dirname(fileURLToPath(import.meta.url)); -const anchoredRegionPath = path.resolve(testDirectory, "../../Core/Components/AnchoredRegion/FluentAnchoredRegion.razor.js"); -const fluentWebComponentsPath = path.resolve(testDirectory, "../node_modules/@fluentui/web-components/dist/web-components.min.js"); - -async function loadAnchoredRegionModule(page) { - const moduleSource = await readFile(anchoredRegionPath, "utf8"); - - await page.goto("about:blank"); - await page.addScriptTag({ path: fluentWebComponentsPath, type: "module" }); - await page.evaluate(async source => { - const moduleUrl = URL.createObjectURL(new Blob([source], { type: "text/javascript" })); - globalThis.anchoredRegionModule = await import(moduleUrl); - URL.revokeObjectURL(moduleUrl); - }, moduleSource); -} - -test.beforeEach(async ({ page }) => { - await loadAnchoredRegionModule(page); -}); - -test("Tab from a menu anchored to a Fluent element moves focus after the anchor", async ({ page }) => { - const result = await page.evaluate(async () => { - await customElements.whenDefined("fluent-button"); - document.body.innerHTML = ` - - Anchor - - `; - - await document.getElementById("anchor").updateComplete; - - let closeCalls = 0; - anchoredRegionModule.initializeKeyboardNavigation("anchor", "popup", { - invokeMethodAsync() { - closeCalls++; - } - }, undefined, true); - - const menuItem = document.getElementById("menu-item"); - menuItem.focus(); - menuItem.dispatchEvent(new KeyboardEvent("keydown", { - bubbles: true, - composed: true, - key: "Tab", - keyCode: 9 - })); - - return { activeElementId: document.activeElement.id, closeCalls }; - }); - - expect(result).toEqual({ activeElementId: "next", closeCalls: 1 }); -}); - -test("Tab recognizes the last popup control focused through a Fluent shadow root", async ({ page }) => { - const result = await page.evaluate(() => { - class FluentControlElement extends HTMLElement { - constructor() { - super(); - const shadowRoot = this.attachShadow({ mode: "open" }); - shadowRoot.innerHTML = ""; - } - } - - customElements.define("fluent-control-test", FluentControlElement); - document.body.innerHTML = ` - - - - `; - - let closeCalls = 0; - anchoredRegionModule.initializeKeyboardNavigation("anchor", "popup", { - invokeMethodAsync() { - closeCalls++; - } - }); - - const control = document.getElementById("control"); - const shadowControl = control.shadowRoot.getElementById("shadow-control"); - shadowControl.focus(); - shadowControl.dispatchEvent(new KeyboardEvent("keydown", { - bubbles: true, - composed: true, - key: "Tab", - keyCode: 9 - })); - - return { - activeElementId: document.activeElement.id, - closeCalls - }; - }); - - expect(result).toEqual({ activeElementId: "next", closeCalls: 1 }); -}); - -test("Tab recognizes a focused shadow control represented by its focusable Fluent host", async ({ page }) => { - const result = await page.evaluate(() => { - class FluentFocusableControlElement extends HTMLElement { - constructor() { - super(); - const shadowRoot = this.attachShadow({ mode: "open" }); - shadowRoot.innerHTML = ""; - } - } - - customElements.define("fluent-focusable-control-test", FluentFocusableControlElement); - document.body.innerHTML = ` - - - - `; - - let closeCalls = 0; - anchoredRegionModule.initializeKeyboardNavigation("anchor", "popup", { - invokeMethodAsync() { - closeCalls++; - } - }); - - const control = document.getElementById("control"); - const shadowControl = control.shadowRoot.getElementById("shadow-control"); - shadowControl.focus(); - shadowControl.dispatchEvent(new KeyboardEvent("keydown", { - bubbles: true, - composed: true, - key: "Tab", - keyCode: 9 - })); - - return { activeElementId: document.activeElement.id, closeCalls }; - }); - - expect(result).toEqual({ activeElementId: "next", closeCalls: 1 }); -}); - -test("Tab enters a popup control nested in open shadow roots", async ({ page }) => { - const activeElementId = await page.evaluate(() => { - class FluentNestedControlElement extends HTMLElement { - constructor() { - super(); - const shadowRoot = this.attachShadow({ mode: "open" }); - shadowRoot.innerHTML = ""; - } - } - - class FluentOuterControlElement extends HTMLElement { - constructor() { - super(); - const shadowRoot = this.attachShadow({ mode: "open" }); - shadowRoot.innerHTML = ""; - } - } - - customElements.define("fluent-nested-control-test", FluentNestedControlElement); - customElements.define("fluent-outer-control-test", FluentOuterControlElement); - document.body.innerHTML = ` - - `; - - anchoredRegionModule.initializeKeyboardNavigation("anchor", "popup", { - invokeMethodAsync() { } - }); - - const anchor = document.getElementById("anchor"); - anchor.focus(); - anchor.dispatchEvent(new KeyboardEvent("keydown", { - bubbles: true, - key: "Tab", - keyCode: 9 - })); - - let activeElement = document.activeElement; - while (activeElement?.shadowRoot?.activeElement) { - activeElement = activeElement.shadowRoot.activeElement; - } - - return activeElement?.id; - }); - - expect(activeElementId).toBe("nested-control"); -}); - -test("Tab exits after the focusable descendant of a composite anchor", async ({ page }) => { - const result = await page.evaluate(async () => { - await customElements.whenDefined("fluent-button"); - document.body.innerHTML = ` - -
Anchor
- - `; - - await document.getElementById("trigger").updateComplete; - - let closeCalls = 0; - anchoredRegionModule.initializeKeyboardNavigation("anchor", "popup", { - invokeMethodAsync() { - closeCalls++; - } - }, undefined, true); - - const menuItem = document.getElementById("menu-item"); - menuItem.focus(); - menuItem.dispatchEvent(new KeyboardEvent("keydown", { - bubbles: true, - composed: true, - key: "Tab", - keyCode: 9 - })); - - return { activeElementId: document.activeElement.id, closeCalls }; - }); - - expect(result).toEqual({ activeElementId: "next", closeCalls: 1 }); -}); - -test("Shift+Tab returns focus to the control inside a focusable composite anchor", async ({ page }) => { - const result = await page.evaluate(() => { - class FocusableAnchorElement extends HTMLElement { - constructor() { - super(); - const shadowRoot = this.attachShadow({ mode: "open" }); - shadowRoot.innerHTML = ""; - } - } - - customElements.define("focusable-anchor-test", FocusableAnchorElement); - document.body.innerHTML = ` - - `; - - let closeCalls = 0; - anchoredRegionModule.initializeKeyboardNavigation("anchor", "popup", { - invokeMethodAsync() { - closeCalls++; - } - }, undefined, true); - - const menuItem = document.getElementById("menu-item"); - menuItem.focus(); - menuItem.dispatchEvent(new KeyboardEvent("keydown", { - bubbles: true, - composed: true, - key: "Tab", - keyCode: 9, - shiftKey: true - })); - - let activeElement = document.activeElement; - while (activeElement?.shadowRoot?.activeElement) { - activeElement = activeElement.shadowRoot.activeElement; - } - - return { activeElementId: activeElement?.id, closeCalls }; - }); - - expect(result).toEqual({ activeElementId: "anchor-control", closeCalls: 1 }); -}); - -test("Shift+Tab falls back to the anchor control when host focus does not move focus", async ({ page }) => { - const result = await page.evaluate(() => { - class UnfocusableAnchorElement extends HTMLElement { - constructor() { - super(); - const shadowRoot = this.attachShadow({ mode: "open" }); - shadowRoot.innerHTML = ""; - } - - focus() { - } - } - - customElements.define("unfocusable-anchor-test", UnfocusableAnchorElement); - document.body.innerHTML = ` - - `; - - let closeCalls = 0; - anchoredRegionModule.initializeKeyboardNavigation("anchor", "popup", { - invokeMethodAsync() { - closeCalls++; - } - }, undefined, true); - - const menuItem = document.getElementById("menu-item"); - menuItem.focus(); - menuItem.dispatchEvent(new KeyboardEvent("keydown", { - bubbles: true, - composed: true, - key: "Tab", - keyCode: 9, - shiftKey: true - })); - - let activeElement = document.activeElement; - while (activeElement?.shadowRoot?.activeElement) { - activeElement = activeElement.shadowRoot.activeElement; - } - - return { activeElementId: activeElement?.id, closeCalls }; - }); - - expect(result).toEqual({ activeElementId: "anchor-control", closeCalls: 1 }); -}); - -test("FocusableElement does not wrap when the current element is outside its focus list", async ({ page }) => { - const nextElementId = await page.evaluate(() => { - document.body.innerHTML = ` - - `; - - const elementOutsideDocument = document.createElement("button"); - return new anchoredRegionModule.FocusableElement(document) - .findNextFocusableElement(elementOutsideDocument)?.id ?? null; - }); - - expect(nextElementId).toBeNull(); -}); \ No newline at end of file From baf7efa5eb57fbbe3d5170fca1da04b9f2de563a Mon Sep 17 00:00:00 2001 From: Vincent Baaij Date: Wed, 22 Jul 2026 22:29:33 +0200 Subject: [PATCH 3/6] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../Components/AnchoredRegion/FluentAnchoredRegion.razor.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Core/Components/AnchoredRegion/FluentAnchoredRegion.razor.js b/src/Core/Components/AnchoredRegion/FluentAnchoredRegion.razor.js index 8938678587..5905e6a5f7 100644 --- a/src/Core/Components/AnchoredRegion/FluentAnchoredRegion.razor.js +++ b/src/Core/Components/AnchoredRegion/FluentAnchoredRegion.razor.js @@ -59,7 +59,7 @@ function containsComposedElement(container, element) { let current = element; while (current) { - if (container.contains?.(current)) { + if (container?.contains?.(current)) { return true; } From c1fe91296c0f2215d46b4dc5c85d2681909abdd3 Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Mon, 27 Jul 2026 15:04:46 -0400 Subject: [PATCH 4/6] Exclude the open popup when finding the next page element after the anchor CloseAsync is invoked after focus() and resolves asynchronously, so the popup is still in the DOM during traversal. For popups rendered inline next to their anchor (FluentPopover, and FluentMenu with DrawMenuWithoutService), the next focusable element in document order after the anchor is the popup's own content, so Tab from the last item looped back into the popup instead of leaving it. Filter the popup's composed subtree out of the candidate list before locating the anchor, and take the following candidate without wrapping. --- .../AnchoredRegion/FluentAnchoredRegion.razor.js | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/src/Core/Components/AnchoredRegion/FluentAnchoredRegion.razor.js b/src/Core/Components/AnchoredRegion/FluentAnchoredRegion.razor.js index 5905e6a5f7..110479857d 100644 --- a/src/Core/Components/AnchoredRegion/FluentAnchoredRegion.razor.js +++ b/src/Core/Components/AnchoredRegion/FluentAnchoredRegion.razor.js @@ -84,6 +84,18 @@ function focusElementOrDescendant(element) { (focusTarget ?? element).focus(); } +function findNextPageElementAfterAnchor(anchorElement, popupElement) { + const focusable = new FocusableElement(anchorElement.getRootNode()); + + // The popup stays in the DOM until CloseAsync completes, so skip it when looking for the next page control. + const candidates = focusable.getFocusableElements() + .filter(element => !containsComposedElement(popupElement, element)); + + const anchorIndex = focusable.getFocusableElementIndex(anchorElement, candidates); + + return anchorIndex === -1 ? null : candidates[anchorIndex + 1] ?? null; +} + /** * Attaches keyboard navigation listeners to an anchor+popup pair. * @param {string} anchorId - Id of the anchor element. @@ -124,7 +136,7 @@ export function initializeKeyboardNavigation(anchorId, popupId, dotNetHelper, cl ev.stopPropagation(); if (!ev.shiftKey) { // Case 3: move to element after anchor in page - new FocusableElement(anchorElement.getRootNode()).findNextFocusableElement(anchorElement)?.focus(); + findNextPageElementAfterAnchor(anchorElement, popupElement)?.focus(); } else { // Case 2: Shift+Tab → focus anchor focusElementOrDescendant(anchorElement); @@ -141,7 +153,7 @@ export function initializeKeyboardNavigation(anchorId, popupId, dotNetHelper, cl // Case 3: Tab on last element → next page element after anchor, close ev.preventDefault(); ev.stopPropagation(); - new FocusableElement(anchorElement.getRootNode()).findNextFocusableElement(anchorElement)?.focus(); + findNextPageElementAfterAnchor(anchorElement, popupElement)?.focus(); dotNetHelper.invokeMethodAsync('CloseAsync'); } else if (ev.shiftKey && (focusables.length === 0 || activeIndex === 0)) { // Case 2: Shift+Tab on first element → focus anchor, close From ccd09c08c74ff3c17d0425776372fe6371eed196 Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Mon, 27 Jul 2026 15:21:29 -0400 Subject: [PATCH 5/6] Add Popover keyboard navigation demo example Adds an example where the popover is declared between its anchor and the next control, which is the arrangement the existing Popover examples do not cover and the one the anchored region focus fix addresses. --- .../Examples/PopoverKeyboardNavigation.razor | 25 +++++++++++++++++++ .../Shared/Pages/Popover/PopoverPage.razor | 10 ++++++++ 2 files changed, 35 insertions(+) create mode 100644 examples/Demo/Shared/Pages/Popover/Examples/PopoverKeyboardNavigation.razor diff --git a/examples/Demo/Shared/Pages/Popover/Examples/PopoverKeyboardNavigation.razor b/examples/Demo/Shared/Pages/Popover/Examples/PopoverKeyboardNavigation.razor new file mode 100644 index 0000000000..bf31365340 --- /dev/null +++ b/examples/Demo/Shared/Pages/Popover/Examples/PopoverKeyboardNavigation.razor @@ -0,0 +1,25 @@ +
+ + Open Callout + + + @* Declared between its anchor and the next control so Tab has to skip the open callout. *@ + +
Keyboard navigation
+ + + +
+ Save +
+
+ + + + Next control +
+ +@code { + private bool _visible; + private string? _name; +} diff --git a/examples/Demo/Shared/Pages/Popover/PopoverPage.razor b/examples/Demo/Shared/Pages/Popover/PopoverPage.razor index d9b5501aae..e9c2848f9f 100644 --- a/examples/Demo/Shared/Pages/Popover/PopoverPage.razor +++ b/examples/Demo/Shared/Pages/Popover/PopoverPage.razor @@ -15,6 +15,16 @@ + + +

+ Tab and Shift+Tab move through the open callout. Tabbing past its last element closes the callout and + moves focus to the next control after the anchor, and Shift+Tab from its first element returns focus to + the anchor. Escape closes the callout and returns focus to the anchor. +

+
+
+

Documentation

From 76f5ede4cead6206aa36c536086e127a17a035c1 Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Mon, 27 Jul 2026 18:29:06 -0400 Subject: [PATCH 6/6] update popover example --- ...crosoft.FluentUI.AspNetCore.Components.xml | 722 +++++++++--------- 1 file changed, 361 insertions(+), 361 deletions(-) diff --git a/examples/Demo/Shared/Microsoft.FluentUI.AspNetCore.Components.xml b/examples/Demo/Shared/Microsoft.FluentUI.AspNetCore.Components.xml index 21341ace91..085c0da078 100644 --- a/examples/Demo/Shared/Microsoft.FluentUI.AspNetCore.Components.xml +++ b/examples/Demo/Shared/Microsoft.FluentUI.AspNetCore.Components.xml @@ -109,6 +109,78 @@ Gets or sets the content to be rendered inside the component. + + + + + + + + + Prompts the user to save the linked URL. See a element for more information. + + + + + Gets or sets the URL the hyperlink references. See a element for more information. + Use Target parameter to specify where. + + + + + Hints at the language of the referenced resource. See a element for more information. + + + + + See a element for more information. + + + + + See a element for more information. + + + + + See a element for more information. + + + + + Gets or sets the target attribute that specifies where to open the link, if Href is specified. + Possible values: _blank | _self | _parent | _top. + + + + + See a element for more information. + + + + + Gets or sets the visual appearance. See + Defaults to + + + + + Gets or sets the displayed at the start of anchor content. + + + + + Gets or sets the displayed at the end of anchor content. + + + + + Gets or sets the content to be rendered inside the component. + + + + + @@ -244,78 +316,6 @@ - - - - - - - - - Prompts the user to save the linked URL. See a element for more information. - - - - - Gets or sets the URL the hyperlink references. See a element for more information. - Use Target parameter to specify where. - - - - - Hints at the language of the referenced resource. See a element for more information. - - - - - See a element for more information. - - - - - See a element for more information. - - - - - See a element for more information. - - - - - Gets or sets the target attribute that specifies where to open the link, if Href is specified. - Possible values: _blank | _self | _parent | _top. - - - - - See a element for more information. - - - - - Gets or sets the visual appearance. See - Defaults to - - - - - Gets or sets the displayed at the start of anchor content. - - - - - Gets or sets the displayed at the end of anchor content. - - - - - Gets or sets the content to be rendered inside the component. - - - - - @@ -6799,6 +6799,16 @@ Gets or sets the list of items to display. + + + Gets or sets the height of the header (in pixels). + + + + + Gets or sets the content to be rendered inside the component. + + Gets or sets the header content. @@ -6834,83 +6844,6 @@ Gets or sets the content of the body. - - - Gets or sets the height of the header (in pixels). - - - - - Gets or sets the content to be rendered inside the component. - - - - - Gets or sets a reference to the button. - - - - - Gets or sets the button appearance. - - - - - The content to be rendered inside the button. This parameter should be supplied if you do not want to render a chevron - on the menu button. - If both and ButtonContent are provided, ButtonContent will be used. - - - - - Gets or sets a reference to the menu. - - - - - Use IMenuService to create the menu, if this service was injected. - This value must be defined before the component is rendered (you can't change it during the component lifecycle). - Default, true. - - - - - Gets or sets the texts shown on the button. - If both Text and are provided, ButtonContent will be used. - - - - - Gets or sets the displayed at the start of button content. - - - - - Gets or sets the button style. - - - - - Gets or sets the menu style. - - - - - Gets or sets the items to show in the menu. - - - - - Gets or sets the content to be shown in the menu. - Should consist of components. - - - - - The callback to invoke when a menu item is chosen. - Using this event prevents the execution of any OnClick event on an included FluentMenuItem. - - @@ -7215,32 +7148,99 @@ - - - - - - - - - - + - Gets or sets the type of message bar. - Default is MessageType.MessageBar. See for more details. + Gets or sets a reference to the button. - + - Gets or sets the actual message instance shown in the message bar. + Gets or sets the button appearance. - + - Gets or sets the message to be shown when not using the MessageService methods. + The content to be rendered inside the button. This parameter should be supplied if you do not want to render a chevron + on the menu button. + If both and ButtonContent are provided, ButtonContent will be used. - + + + Gets or sets a reference to the menu. + + + + + Use IMenuService to create the menu, if this service was injected. + This value must be defined before the component is rendered (you can't change it during the component lifecycle). + Default, true. + + + + + Gets or sets the texts shown on the button. + If both Text and are provided, ButtonContent will be used. + + + + + Gets or sets the displayed at the start of button content. + + + + + Gets or sets the button style. + + + + + Gets or sets the menu style. + + + + + Gets or sets the items to show in the menu. + + + + + Gets or sets the content to be shown in the menu. + Should consist of components. + + + + + The callback to invoke when a menu item is chosen. + Using this event prevents the execution of any OnClick event on an included FluentMenuItem. + + + + + + + + + + + + + + Gets or sets the type of message bar. + Default is MessageType.MessageBar. See for more details. + + + + + Gets or sets the actual message instance shown in the message bar. + + + + + Gets or sets the message to be shown when not using the MessageService methods. + + + Gets or sets the intent of the message bar. Default is MessageIntent.Info. See for more details. @@ -7702,408 +7702,408 @@ Optional section int - + - Returns if the group is expanded, - and if collapsed. + Base class for and . - + - Gets or sets a callback that is triggered whenever changes. + Gets or sets the URL for the group. - + - If set to then the tree will - expand when it is created. + Gets or sets the target attribute that specifies where to open the group, if Href is specified. + Possible values: _blank | _self | _parent | _top. - + - Returns if the group is collapsed, - and if expanded. + Gets or sets the Icon to use if set. - + - Base class for and . + Gets or sets the color of the icon. + It supports the theme colors, default value uses the themes drawer icon color. - + - Gets or sets the content to be rendered inside the component. + Gets or sets the icon drawing and fill color to a custom value. + Needs to be formatted as an HTML hex color string (#rrggbb or #rgb) or CSS variable. + ⚠️ Only available when Color is set to Color.Custom. - + - Gets or sets whether the link is disabled. + If true, the button will be disabled. - + - Gets or sets the destination of the link. + Gets or sets the content to be shown. - + - Gets or sets the icon to display with the link - Use a constant value from the class + Gets or sets the class names to use to indicate the item is active, separated by space. - + - Called when the user attempts to execute the default action of a menu item. + Gets or sets how the link should be matched. + Defaults to . - + - Gets or sets a value indicating whether the item is selected. + Gets or sets the tooltip to display when the mouse is placed over the item. + For the Title is used as fallback. - + - Event callback for when changes. + The callback to invoke when the item is clicked. - + - Gets or sets the text of the link. + If true, force browser to redirect outside component router-space. - + - Gets or sets the width of the link (in pixels). + Gets or sets the id of the custom toggle element + Defaults to navmenu-toggle - + - Returns if the item has an set. + Returns if the item has an set. - + - Gets or sets the tree item associated with this menu item. + If a custom toggle is being used to hide/show the menu, this defines the 'onclick' code + Uses the as the id of the element that will be clicked - + - Called when the user attempts to action a menu item + Gets or sets the text to display for the group. - Information about the menu item - - + - Gets or sets the content to be rendered inside the component. + If true, expands the nav group, otherwise collapse it. + Two-way bindable - + - Gets or sets the content to be rendered for the expander icon when the menu is collapsible. - The default icon will be used if this is not specified. + If true, hides expand button at the end of the NavGroup. - + - Gets or sets the title of the navigation menu. - Default to "Navigation menu". + Explicitly sets the height for the Collapse element to override the css default. - + - Gets or sets the width of the menu (in pixels). + Defines the vertical spacing between the NavGroup and adjacent items. + Needs to be a valid CSS value. - + - Gets or sets whether or not the menu can be collapsed. + If set, overrides the default expand icon. - - - - + - Event callback for when the property changes. + Allows for specific markup and styling to be applied for the group title + When using this, the contained s and s need to be placed in a ChildContent tag. + When specifying both Title and TitleTemplate, both will be rendered. - + - Called when the user attempts to execute the default action of a menu item. + Gets or sets a callback that is triggered whenever changes. - - - If set to then the tree will expand when it is created. - + + - + - If true, the menu will re-navigate to the current page when the user clicks on the currently selected menu item. + Determines whether the current URI should match the link. + The absolute URI of the current location. + True if the link should be highlighted as active; otherwise, false. - - + + - + - Navigation manager + Gets or sets the content to be rendered for the collapse icon when the menu is collapsible. + The default icon will be used if this is not specified. - - - - - - - - - - + - An interface for supporting the ownership of . + Gets or sets the title of the navigation menu using the aria-label attribute. + Defaults to "Navigation menu". - + - . + Gets or sets the width of the menu (in pixels). - + - Returns if the implementing component - is collapsed, and if expanded. + Gets or sets whether or not the menu can be collapsed. - + - Returns if the implementing component - is expanded, and if collapsed. + Gets or sets whether a menu with all child links is shown for s when the navigation menu is collapsed. - + + + + - Returns if the result of - contains any items, otherwise returns . + Event callback for when the property changes. - + - Gets all items directly - parented by the implementing object. + Adjust the vertical spacing between navlinks. - - + - Called by a direct child to register itself with its owner - when it is created. + Gets or sets if a custom toggle for showing/hiding the menu is used. + This is primarily intended to be used in a mobile view - - + - Called by a direct child to unregister itself from its owner - when it is being disposed. + Navigation manager - - + - Base class for and . + Returns if the group is expanded, + and if collapsed. - + - Gets or sets the URL for the group. + Gets or sets a callback that is triggered whenever changes. - + - Gets or sets the target attribute that specifies where to open the group, if Href is specified. - Possible values: _blank | _self | _parent | _top. + If set to then the tree will + expand when it is created. - + - Gets or sets the Icon to use if set. + Returns if the group is collapsed, + and if expanded. - + - Gets or sets the color of the icon. - It supports the theme colors, default value uses the themes drawer icon color. + Base class for and . - + - Gets or sets the icon drawing and fill color to a custom value. - Needs to be formatted as an HTML hex color string (#rrggbb or #rgb) or CSS variable. - ⚠️ Only available when Color is set to Color.Custom. + Gets or sets the content to be rendered inside the component. - + - If true, the button will be disabled. + Gets or sets whether the link is disabled. - + - Gets or sets the content to be shown. + Gets or sets the destination of the link. - + - Gets or sets the class names to use to indicate the item is active, separated by space. + Gets or sets the icon to display with the link + Use a constant value from the class - + - Gets or sets how the link should be matched. - Defaults to . + Called when the user attempts to execute the default action of a menu item. - + - Gets or sets the tooltip to display when the mouse is placed over the item. - For the Title is used as fallback. + Gets or sets a value indicating whether the item is selected. - + - The callback to invoke when the item is clicked. + Event callback for when changes. - + - If true, force browser to redirect outside component router-space. + Gets or sets the text of the link. - + - Gets or sets the id of the custom toggle element - Defaults to navmenu-toggle + Gets or sets the width of the link (in pixels). - + - Returns if the item has an set. + Returns if the item has an set. - + - If a custom toggle is being used to hide/show the menu, this defines the 'onclick' code - Uses the as the id of the element that will be clicked + Gets or sets the tree item associated with this menu item. - + - Gets or sets the text to display for the group. + Called when the user attempts to action a menu item + Information about the menu item + - + - If true, expands the nav group, otherwise collapse it. - Two-way bindable + Gets or sets the content to be rendered inside the component. - + - If true, hides expand button at the end of the NavGroup. + Gets or sets the content to be rendered for the expander icon when the menu is collapsible. + The default icon will be used if this is not specified. - + - Explicitly sets the height for the Collapse element to override the css default. + Gets or sets the title of the navigation menu. + Default to "Navigation menu". - + - Defines the vertical spacing between the NavGroup and adjacent items. - Needs to be a valid CSS value. + Gets or sets the width of the menu (in pixels). - + - If set, overrides the default expand icon. + Gets or sets whether or not the menu can be collapsed. - + + + + - Allows for specific markup and styling to be applied for the group title - When using this, the contained s and s need to be placed in a ChildContent tag. - When specifying both Title and TitleTemplate, both will be rendered. + Event callback for when the property changes. - + - Gets or sets a callback that is triggered whenever changes. + Called when the user attempts to execute the default action of a menu item. - - + + + If set to then the tree will expand when it is created. + - + - Determines whether the current URI should match the link. + If true, the menu will re-navigate to the current page when the user clicks on the currently selected menu item. - The absolute URI of the current location. - True if the link should be highlighted as active; otherwise, false. - - + + - + - Gets or sets the content to be rendered for the collapse icon when the menu is collapsible. - The default icon will be used if this is not specified. + Navigation manager - + + + + + + + + + + - Gets or sets the title of the navigation menu using the aria-label attribute. - Defaults to "Navigation menu". + An interface for supporting the ownership of . - + - Gets or sets the width of the menu (in pixels). + . - + - Gets or sets whether or not the menu can be collapsed. + Returns if the implementing component + is collapsed, and if expanded. - + - Gets or sets whether a menu with all child links is shown for s when the navigation menu is collapsed. + Returns if the implementing component + is expanded, and if collapsed. - - - - + - Event callback for when the property changes. + Returns if the result of + contains any items, otherwise returns . - + - Adjust the vertical spacing between navlinks. + Gets all items directly + parented by the implementing object. + - + - Gets or sets if a custom toggle for showing/hiding the menu is used. - This is primarily intended to be used in a mobile view + Called by a direct child to register itself with its owner + when it is created. + - + - Navigation manager + Called by a direct child to unregister itself from its owner + when it is being disposed. +