From 563e22a86132e8ac9cb3917d0dd8392c48c73779 Mon Sep 17 00:00:00 2001 From: AdamMagued Date: Wed, 30 Sep 2026 03:33:13 +0300 Subject: [PATCH] feat(feed): support feed role article focus management --- src/Virtual.ts | 71 +++- src/createAccessibilityTree.ts | 31 ++ src/getNodeAccessibilityData/getRole.ts | 15 +- src/isFocusable.ts | 14 + test/int/feedRole.int.test.ts | 461 ++++++++++++++++++++++++ test/isFocusable.test.ts | 43 +++ 6 files changed, 619 insertions(+), 16 deletions(-) create mode 100644 src/isFocusable.ts create mode 100644 test/int/feedRole.int.test.ts create mode 100644 test/isFocusable.test.ts diff --git a/src/Virtual.ts b/src/Virtual.ts index 051de4a..a8eb8e8 100644 --- a/src/Virtual.ts +++ b/src/Virtual.ts @@ -10,6 +10,7 @@ import { import { getLiveSpokenPhrase, LIVE } from "./getLiveSpokenPhrase"; import { type UserEvent, userEvent } from "@testing-library/user-event"; import { flattenTree } from "./flattenTree"; +import { FOCUSABLE_SELECTOR } from "./isFocusable"; import { getElementNode } from "./commands/getElementNode"; import { getItemText } from "./getItemText"; import { getSpokenPhrase } from "./getSpokenPhrase"; @@ -124,7 +125,7 @@ const defaultUserEventOptions = { */ /** - * TODO: When an assistive technology reading cursor moves from one article to + * When an assistive technology reading cursor moves from one article to * another, assistive technologies SHOULD set user agent focus on the article * that contains the reading cursor. If the reading cursor lands on a focusable * element inside the article, the assistive technology MAY set focus on that @@ -216,6 +217,7 @@ export class Virtual { #disconnectDOMObserver: (() => void) | null = null; #boundHandleFocusChange: ((event: Event) => Promise) | null = null; #userEvent: UserEvent | null = null; + #isInternalFocusSetting = false; #checkContainer() { if (!this.#container) { @@ -303,8 +305,16 @@ export class Virtual { } async #handleFocusChange({ target }: Event) { + if (this.#isInternalFocusSetting) { + return; + } + await tick(); + if (this.#isInternalFocusSetting) { + return; + } + this.#invalidateTreeCache(); const tree = this.#getAccessibilityTree(); @@ -323,11 +333,65 @@ export class Virtual { this.#updateState(newActiveNode, true); } + #focusElement(element: HTMLElement) { + if (typeof element?.focus !== "function") { + return; + } + + this.#isInternalFocusSetting = true; + + try { + element.focus(); + } finally { + this.#isInternalFocusSetting = false; + } + } + #focusActiveElement() { // Is only called following a null guard for `this.#activeNode`. const target = getElementNode(this.#activeNode!); - target?.focus(); + this.#focusElement(target); + } + + #handleFeedRoleFocus(accessibilityNode: AccessibilityNode) { + const article = accessibilityNode.parentArticle; + + if (!article) { + return; + } + + const previousArticle = this.#activeNode?.parentArticle ?? null; + const isArticleChange = previousArticle !== article; + + const targetElement = getElementNode(accessibilityNode); + const focusableCandidate = + targetElement.matches?.(FOCUSABLE_SELECTOR) + ? targetElement + : (targetElement.closest?.(FOCUSABLE_SELECTOR) as HTMLElement | null); + + const focusableElement = + focusableCandidate && + focusableCandidate !== article && + article.contains(focusableCandidate) + ? focusableCandidate + : null; + + if (isArticleChange) { + if (focusableElement) { + this.#focusElement(focusableElement); + } else { + this.#focusElement(article); + } + } else if (focusableElement) { + const activeElement = + article.ownerDocument?.activeElement ?? + globalThis.document?.activeElement; + + if (activeElement !== focusableElement) { + this.#focusElement(focusableElement); + } + } } async #announceLiveRegions(mutations: MutationRecord[]) { @@ -388,6 +452,8 @@ export class Virtual { this.#spokenPhraseLog.push(spokenPhrase); } + this.#handleFeedRoleFocus(accessibilityNode); + this.#setActiveNode(accessibilityNode); const spokenPhrase = getSpokenPhrase(accessibilityNode); @@ -672,6 +738,7 @@ export class Virtual { this.#spokenPhraseLog = []; this.#boundHandleFocusChange = null; this.#userEvent = null; + this.#isInternalFocusSetting = false; return; } diff --git a/src/createAccessibilityTree.ts b/src/createAccessibilityTree.ts index 4868197..aa6ebd1 100644 --- a/src/createAccessibilityTree.ts +++ b/src/createAccessibilityTree.ts @@ -19,7 +19,9 @@ export interface AccessibilityNode { node: Node; parentAccessibilityNodeTree: AccessibilityNodeTree | null; parent: Node | null; + parentArticle: HTMLElement | null; parentDialog: HTMLElement | null; + parentFeed: HTMLElement | null; role: string; spokenRole: string; } @@ -160,6 +162,29 @@ function growTree( tree.parentDialog = parentDialog; } + const parentFeed = + tree.role === "feed" && isElement(tree.node) + ? (tree.node as HTMLElement) + : tree.parentFeed ?? + (isElement(tree.node) + ? ((tree.node as HTMLElement).closest?.('[role~="feed"]') as HTMLElement ?? null) + : null); + + if (parentFeed) { + tree.parentFeed = parentFeed; + } + + const parentArticle = + parentFeed && tree.role === "article" && isElement(tree.node) + ? (tree.node as HTMLElement) + : parentFeed + ? tree.parentArticle + : null; + + if (parentArticle) { + tree.parentArticle = parentArticle; + } + node.childNodes.forEach((childNode) => { if (isHiddenFromAccessibilityTree(childNode)) { return; @@ -207,7 +232,9 @@ function growTree( node: childNode, parentAccessibilityNodeTree: null, // Added during flattening parent: node, + parentArticle, parentDialog, + parentFeed, role, spokenRole, }, @@ -276,7 +303,9 @@ function growTree( node: childNode, parentAccessibilityNodeTree: null, // Added during flattening parent: node, + parentArticle, parentDialog, + parentFeed, role, spokenRole, }, @@ -334,7 +363,9 @@ export function createAccessibilityTree( node, parentAccessibilityNodeTree: null, parent: null, + parentArticle: null, parentDialog: null, + parentFeed: null, role, spokenRole, }, diff --git a/src/getNodeAccessibilityData/getRole.ts b/src/getNodeAccessibilityData/getRole.ts index 48314a9..5b54db3 100644 --- a/src/getNodeAccessibilityData/getRole.ts +++ b/src/getNodeAccessibilityData/getRole.ts @@ -1,6 +1,7 @@ import { ALL_ROLES, ARIARole, getRole as getHtmlAriaRole } from "html-aria"; import { getLocalName } from "../getLocalName"; import { isElement } from "../isElement"; +import { isFocusable } from "../isFocusable"; export const presentationRoles = new Set(["presentation", "none"]); @@ -36,20 +37,6 @@ export const globalStatesAndProperties = [ "aria-roledescription", ]; -const FOCUSABLE_SELECTOR = [ - "input:not([type=hidden]):not([disabled])", - "button:not([disabled])", - "select:not([disabled])", - "textarea:not([disabled])", - '[contenteditable=""]', - '[contenteditable="true"]', - "a[href]", - "[tabindex]:not([disabled])", -].join(", "); - -function isFocusable(node: HTMLElement) { - return node.matches(FOCUSABLE_SELECTOR); -} function hasGlobalStateOrProperty(node: HTMLElement) { return globalStatesAndProperties.some((global) => node.hasAttribute(global)); diff --git a/src/isFocusable.ts b/src/isFocusable.ts new file mode 100644 index 0000000..29bd739 --- /dev/null +++ b/src/isFocusable.ts @@ -0,0 +1,14 @@ +export const FOCUSABLE_SELECTOR = [ + "input:not([type=hidden]):not([disabled])", + "button:not([disabled])", + "select:not([disabled])", + "textarea:not([disabled])", + '[contenteditable=""]', + '[contenteditable="true"]', + "a[href]", + "[tabindex]:not([disabled])", +].join(", "); + +export function isFocusable(node: HTMLElement) { + return node.matches(FOCUSABLE_SELECTOR); +} diff --git a/test/int/feedRole.int.test.ts b/test/int/feedRole.int.test.ts new file mode 100644 index 0000000..8517908 --- /dev/null +++ b/test/int/feedRole.int.test.ts @@ -0,0 +1,461 @@ +import { virtual } from "../../src/index.js"; + +describe("feed role focus behavior", () => { + afterEach(async () => { + await virtual.stop(); + document.body.innerHTML = ""; + }); + + it("should set focus on the article when moving cursor into an article in a feed", async () => { + document.body.innerHTML = ` +
Header content
+
+
+

Article 1 Title

+

Article 1 Text

+
+
+ `; + + const art1 = document.getElementById("art1")!; + + await virtual.start({ container: document.body }); + + expect(await virtual.lastSpokenPhrase()).toBe("document"); + + await virtual.next(); + expect(await virtual.lastSpokenPhrase()).toBe("banner"); + expect(document.activeElement).not.toBe(art1); + + await virtual.next(); + expect(await virtual.lastSpokenPhrase()).toBe("Header content"); + + await virtual.next(); + expect(await virtual.lastSpokenPhrase()).toBe("end of banner"); + + await virtual.next(); + expect(await virtual.lastSpokenPhrase()).toBe("feed"); + + await virtual.next(); + expect(await virtual.lastSpokenPhrase()).toBe("article"); + expect(document.activeElement).toBe(art1); + }); + + it("should transition focus to the new article when moving between articles in a feed", async () => { + document.body.innerHTML = ` +
+
+

Article 1

+

Text 1

+
+
+

Article 2

+

Text 2

+
+
+ `; + + const art1 = document.getElementById("art1")!; + const art2 = document.getElementById("art2")!; + + await virtual.start({ container: document.body }); + + expect(await virtual.lastSpokenPhrase()).toBe("document"); + + // Move to feed + await virtual.next(); + expect(await virtual.lastSpokenPhrase()).toBe("feed"); + + // Move to Article 1 + await virtual.next(); + expect(await virtual.lastSpokenPhrase()).toBe("article"); + expect(document.activeElement).toBe(art1); + + // Read through Article 1 + await virtual.next(); + expect(await virtual.lastSpokenPhrase()).toBe("heading, Article 1, level 2"); + expect(document.activeElement).toBe(art1); + + await virtual.next(); + expect(await virtual.lastSpokenPhrase()).toBe("paragraph"); + expect(document.activeElement).toBe(art1); + + await virtual.next(); + expect(await virtual.lastSpokenPhrase()).toBe("Text 1"); + expect(document.activeElement).toBe(art1); + + await virtual.next(); + expect(await virtual.lastSpokenPhrase()).toBe("end of paragraph"); + expect(document.activeElement).toBe(art1); + + await virtual.next(); + expect(await virtual.lastSpokenPhrase()).toBe("end of article"); + expect(document.activeElement).toBe(art1); + + // Transition from Article 1 to Article 2 + await virtual.next(); + expect(await virtual.lastSpokenPhrase()).toBe("article"); + expect(document.activeElement).toBe(art2); + }); + + it("should focus a focusable element when landing directly on it inside an article", async () => { + document.body.innerHTML = ` +
+
+

Article 1

+ + +

Comments

+
+
+ `; + + const art1 = document.getElementById("art1")!; + const btn1 = document.getElementById("btn1")!; + const btn2 = document.getElementById("btn2")!; + + await virtual.start({ container: document.body }); + + expect(await virtual.lastSpokenPhrase()).toBe("document"); + + // feed + await virtual.next(); + expect(await virtual.lastSpokenPhrase()).toBe("feed"); + + // article (art1) + await virtual.next(); + expect(await virtual.lastSpokenPhrase()).toBe("article"); + expect(document.activeElement).toBe(art1); + + // heading + await virtual.next(); + expect(document.activeElement).toBe(art1); + + // Move to btn1 + await virtual.next(); + expect(await virtual.lastSpokenPhrase()).toBe("button, Like"); + expect(document.activeElement).toBe(btn1); + + // Move to btn2 + await virtual.next(); + expect(await virtual.lastSpokenPhrase()).toBe("button, Share"); + expect(document.activeElement).toBe(btn2); + + // Move to paragraph + await virtual.next(); + expect(await virtual.lastSpokenPhrase()).toBe("paragraph"); + expect(document.activeElement).toBe(btn2); + }); + + it("should focus the focusable element in lieu of the containing article when transitioning directly onto it", async () => { + document.body.innerHTML = ` + +
+ +
+ `; + + const art1 = document.getElementById("art1")!; + const leadLink = document.getElementById("leadLink")!; + const artFocusSpy = jest.spyOn(art1, "focus"); + + await virtual.start({ container: document.body }); + + // Move to link in article using perform command + await virtual.perform(virtual.commands.moveToNextLink); + expect(await virtual.lastSpokenPhrase()).toBe("link, Action"); + expect(document.activeElement).toBe(leadLink); + expect(artFocusSpy).not.toHaveBeenCalled(); + }); + + it("should not repeatedly re-trigger focus when moving within the same article unless changing to a focusable element", async () => { + document.body.innerHTML = ` +
+
+

Article Title

+

First paragraph

+

Second paragraph

+ +

Third paragraph

+
+
+ `; + + const art1 = document.getElementById("art1")!; + const btn1 = document.getElementById("btn1")!; + const artFocusSpy = jest.spyOn(art1, "focus"); + const btnFocusSpy = jest.spyOn(btn1, "focus"); + + await virtual.start({ container: document.body }); + + // document + expect(await virtual.lastSpokenPhrase()).toBe("document"); + + // feed + await virtual.next(); + expect(await virtual.lastSpokenPhrase()).toBe("feed"); + + // art1 (article) -> sets focus once + await virtual.next(); + expect(await virtual.lastSpokenPhrase()).toBe("article"); + expect(document.activeElement).toBe(art1); + expect(artFocusSpy).toHaveBeenCalledTimes(1); + + // Move to heading + await virtual.next(); + expect(document.activeElement).toBe(art1); + expect(artFocusSpy).toHaveBeenCalledTimes(1); + + // Move to paragraph 1 + await virtual.next(); + await virtual.next(); + await virtual.next(); + expect(document.activeElement).toBe(art1); + expect(artFocusSpy).toHaveBeenCalledTimes(1); + + // Move to paragraph 2 + await virtual.next(); + await virtual.next(); + await virtual.next(); + expect(document.activeElement).toBe(art1); + expect(artFocusSpy).toHaveBeenCalledTimes(1); + + // Move to button (focusable element) + await virtual.next(); + expect(await virtual.lastSpokenPhrase()).toBe("button, Like"); + expect(document.activeElement).toBe(btn1); + expect(btnFocusSpy).toHaveBeenCalledTimes(1); + expect(artFocusSpy).toHaveBeenCalledTimes(1); + + // Move to paragraph 3 (non-focusable, same article) + await virtual.next(); + expect(document.activeElement).toBe(btn1); + expect(artFocusSpy).toHaveBeenCalledTimes(1); + + // Move to end of article + await virtual.next(); + await virtual.next(); + await virtual.next(); + expect(await virtual.lastSpokenPhrase()).toBe("end of article"); + expect(document.activeElement).toBe(btn1); + expect(artFocusSpy).toHaveBeenCalledTimes(1); + }); + + it("should not trigger feed-specific focus shifts for non-feed articles", async () => { + document.body.innerHTML = ` +
+
+

Standalone Article

+

Standalone text

+ +
+
+ `; + + const standalone = document.getElementById("standalone")!; + const standaloneBtn = document.getElementById("standaloneBtn")!; + const artFocusSpy = jest.spyOn(standalone, "focus"); + const btnFocusSpy = jest.spyOn(standaloneBtn, "focus"); + + await virtual.start({ container: document.body }); + + while ((await virtual.lastSpokenPhrase()) !== "end of document") { + await virtual.next(); + } + + expect(artFocusSpy).not.toHaveBeenCalled(); + expect(btnFocusSpy).not.toHaveBeenCalled(); + expect(document.activeElement).toBe(document.body); + }); + + it("should transition focus backward when navigating with previous()", async () => { + document.body.innerHTML = ` +
+
+

Text 1

+
+
+

Text 2

+
+
+ `; + + const art1 = document.getElementById("art1")!; + const art2 = document.getElementById("art2")!; + + await virtual.start({ container: document.body }); + + // Navigate into Article 2 + while ((await virtual.lastSpokenPhrase()) !== "Text 2") { + await virtual.next(); + } + expect(document.activeElement).toBe(art2); + + // Navigate backward into Article 1 + while ((await virtual.lastSpokenPhrase()) !== "Text 1") { + await virtual.previous(); + } + expect(document.activeElement).toBe(art1); + }); + + it("should handle custom role='article' elements in a feed", async () => { + document.body.innerHTML = ` +
+
+

Custom article content

+
+
+ `; + + const customArt = document.getElementById("customArt")!; + + await virtual.start({ container: document.body }); + + // document -> feed -> article + await virtual.next(); + await virtual.next(); + expect(await virtual.lastSpokenPhrase()).toBe("article"); + expect(document.activeElement).toBe(customArt); + }); + + it("should handle nested articles within a feed", async () => { + document.body.innerHTML = ` +
+
+

Outer Article

+
+

Inner Article

+
+
+
+ `; + + const outerArt = document.getElementById("outerArt")!; + const innerArt = document.getElementById("innerArt")!; + + await virtual.start({ container: document.body }); + + // document -> feed -> outer article + await virtual.next(); + await virtual.next(); + expect(await virtual.lastSpokenPhrase()).toBe("article"); + expect(document.activeElement).toBe(outerArt); + + await virtual.next(); + expect(await virtual.lastSpokenPhrase()).toBe("heading, Outer Article, level 2"); + expect(document.activeElement).toBe(outerArt); + + // Move into inner article + await virtual.next(); + expect(await virtual.lastSpokenPhrase()).toBe("article"); + expect(document.activeElement).toBe(innerArt); + + await virtual.next(); + expect(await virtual.lastSpokenPhrase()).toBe("heading, Inner Article, level 3"); + expect(document.activeElement).toBe(innerArt); + + // Move to end of inner article + await virtual.next(); + expect(await virtual.lastSpokenPhrase()).toBe("end of article"); + expect(document.activeElement).toBe(innerArt); + + // Move to end of outer article + await virtual.next(); + expect(await virtual.lastSpokenPhrase()).toBe("end of article"); + expect(document.activeElement).toBe(outerArt); + }); + + it("should handle moving cursor out of a feed cleanly without disturbing focus", async () => { + document.body.innerHTML = ` +
+
+

Text

+
+
+
Footer content
+ `; + + const art1 = document.getElementById("art1")!; + + await virtual.start({ container: document.body }); + + // document -> feed -> article + await virtual.next(); + await virtual.next(); + expect(document.activeElement).toBe(art1); + + // Navigate to footer + while ((await virtual.lastSpokenPhrase()) !== "Footer content") { + await virtual.next(); + } + + expect(await virtual.lastSpokenPhrase()).toBe("Footer content"); + // Focus remains on last focused element without crashing + expect(document.activeElement).toBe(art1); + }); + + it("should handle an article without tabindex gracefully", async () => { + document.body.innerHTML = ` +
+
+

Content without tabindex

+
+
+ `; + + await virtual.start({ container: document.body }); + + // document -> feed -> article + await virtual.next(); + await virtual.next(); + expect(await virtual.lastSpokenPhrase()).toBe("article"); + // Calling next did not throw, activeElement remains body + expect(document.activeElement).toBe(document.body); + }); + + it("should handle aria-owns feed relationships", async () => { + document.body.innerHTML = ` +
+
+

Owned content

+
+ `; + + const ownedArt = document.getElementById("ownedArt")!; + + await virtual.start({ container: document.body }); + + // document -> feed -> article + await virtual.next(); + await virtual.next(); + expect(await virtual.lastSpokenPhrase()).toBe("article"); + expect(document.activeElement).toBe(ownedArt); + }); + + it("should not focus disabled elements inside an article", async () => { + document.body.innerHTML = ` +
+
+ +
+
+ `; + + const art1 = document.getElementById("art1")!; + const disabledBtn = document.getElementById("disabledBtn")!; + + await virtual.start({ container: document.body }); + + // document -> feed -> article + await virtual.next(); + await virtual.next(); + expect(document.activeElement).toBe(art1); + + await virtual.next(); + expect(await virtual.lastSpokenPhrase()).toBe("button, Cannot Click, disabled"); + expect(document.activeElement).toBe(art1); + expect(document.activeElement).not.toBe(disabledBtn); + }); +}); diff --git a/test/isFocusable.test.ts b/test/isFocusable.test.ts new file mode 100644 index 0000000..be0ba64 --- /dev/null +++ b/test/isFocusable.test.ts @@ -0,0 +1,43 @@ +import { FOCUSABLE_SELECTOR, isFocusable } from "../src/isFocusable"; + +describe("isFocusable", () => { + it("should identify focusable elements correctly", () => { + document.body.innerHTML = ` + + + + + + + + + +
Editable
+
Editable 2
+ Link + Not a link +
Tabindex 0
+
Tabindex -1
+
Disabled Tabindex
+

Plain paragraph

+ `; + + expect(isFocusable(document.getElementById("input1")!)).toBe(true); + expect(isFocusable(document.getElementById("hidden1")!)).toBe(false); + expect(isFocusable(document.getElementById("disabledInput")!)).toBe(false); + expect(isFocusable(document.getElementById("btn1")!)).toBe(true); + expect(isFocusable(document.getElementById("disabledBtn")!)).toBe(false); + expect(isFocusable(document.getElementById("select1")!)).toBe(true); + expect(isFocusable(document.getElementById("disabledSelect")!)).toBe(false); + expect(isFocusable(document.getElementById("textarea1")!)).toBe(true); + expect(isFocusable(document.getElementById("disabledTextarea")!)).toBe(false); + expect(isFocusable(document.getElementById("ce1")!)).toBe(true); + expect(isFocusable(document.getElementById("ce2")!)).toBe(true); + expect(isFocusable(document.getElementById("link1")!)).toBe(true); + expect(isFocusable(document.getElementById("notLink")!)).toBe(false); + expect(isFocusable(document.getElementById("tabindex0")!)).toBe(true); + expect(isFocusable(document.getElementById("tabindexMinus1")!)).toBe(true); + expect(isFocusable(document.getElementById("para")!)).toBe(false); + expect(FOCUSABLE_SELECTOR).toBeDefined(); + }); +});