Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 69 additions & 2 deletions src/Virtual.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -216,6 +217,7 @@ export class Virtual {
#disconnectDOMObserver: (() => void) | null = null;
#boundHandleFocusChange: ((event: Event) => Promise<void>) | null = null;
#userEvent: UserEvent | null = null;
#isInternalFocusSetting = false;

#checkContainer() {
if (!this.#container) {
Expand Down Expand Up @@ -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();

Expand All @@ -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;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Following previous comment, can probably early return here if it's not an article change and save some DOM traversal cost

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);
}
}
}
Comment on lines +386 to 395

@cmorten cmorten Oct 12, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My interpretation of the specification is transitioning from one article to another SHOULD focus on the containing article, but if the first element you land on during that transition happens to be a focusable element, we MAY place focus on that in lieu of setting focus on the containing article.

So unless I'm misinterpreting the spec I believe we can drop this else if logic - the spec isn't saying to force focus to every focusable element inside the article as you visit, it is only with respect to the transition of the reading cursor from one article to another. E.g. if say navigating by role type (links etc.) and therefore, unlike simply using .next() you might skip over visiting the parent article itself.

Suggested change
} else if (focusableElement) {
const activeElement =
article.ownerDocument?.activeElement ??
globalThis.document?.activeElement;
if (activeElement !== focusableElement) {
this.#focusElement(focusableElement);
}
}
}
}
}


async #announceLiveRegions(mutations: MutationRecord[]) {
Expand Down Expand Up @@ -388,6 +452,8 @@ export class Virtual {
this.#spokenPhraseLog.push(spokenPhrase);
}

this.#handleFeedRoleFocus(accessibilityNode);

this.#setActiveNode(accessibilityNode);

const spokenPhrase = getSpokenPhrase(accessibilityNode);
Expand Down Expand Up @@ -672,6 +738,7 @@ export class Virtual {
this.#spokenPhraseLog = [];
this.#boundHandleFocusChange = null;
this.#userEvent = null;
this.#isInternalFocusSetting = false;
return;
}

Expand Down
31 changes: 31 additions & 0 deletions src/createAccessibilityTree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -160,6 +162,29 @@ function growTree(
tree.parentDialog = parentDialog;
}

const parentFeed =
tree.role === "feed" && isElement(tree.node)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider an isFeedRole function to mirror isDialogRole above?

? (tree.node as HTMLElement)
: tree.parentFeed ??
(isElement(tree.node)
? ((tree.node as HTMLElement).closest?.('[role~="feed"]') as HTMLElement ?? null)
: null);
Comment on lines +168 to +171

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is the fallback needed here?

If as we recurse we are setting tree.parentFeed then is it possible to be nested in a feed without tree.parentFeed already being set?


if (parentFeed) {
tree.parentFeed = parentFeed;
}

const parentArticle =
parentFeed && tree.role === "article" && isElement(tree.node)
? (tree.node as HTMLElement)
: parentFeed
? tree.parentArticle
: null;
Comment on lines +177 to +182

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The double check for parentFeed feels a bit off?

Suggested change
const parentArticle =
parentFeed && tree.role === "article" && isElement(tree.node)
? (tree.node as HTMLElement)
: parentFeed
? tree.parentArticle
: null;
const parentArticle =
!parentFeed
? null
: tree.role === "article" && isElement(tree.node)
? (tree.node as HTMLElement)
: tree.parentArticle;

Or just nest the whole parentArticle logic inside of the if (parentFeed) { above and then don't need to have the nested ternary?


if (parentArticle) {
tree.parentArticle = parentArticle;
}

node.childNodes.forEach((childNode) => {
if (isHiddenFromAccessibilityTree(childNode)) {
return;
Expand Down Expand Up @@ -207,7 +232,9 @@ function growTree(
node: childNode,
parentAccessibilityNodeTree: null, // Added during flattening
parent: node,
parentArticle,
parentDialog,
parentFeed,
role,
spokenRole,
},
Expand Down Expand Up @@ -276,7 +303,9 @@ function growTree(
node: childNode,
parentAccessibilityNodeTree: null, // Added during flattening
parent: node,
parentArticle,
parentDialog,
parentFeed,
role,
spokenRole,
},
Expand Down Expand Up @@ -334,7 +363,9 @@ export function createAccessibilityTree(
node,
parentAccessibilityNodeTree: null,
parent: null,
parentArticle: null,
parentDialog: null,
parentFeed: null,
role,
spokenRole,
},
Expand Down
15 changes: 1 addition & 14 deletions src/getNodeAccessibilityData/getRole.ts
Original file line number Diff line number Diff line change
@@ -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"]);

Expand Down Expand Up @@ -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));
Expand Down
14 changes: 14 additions & 0 deletions src/isFocusable.ts
Original file line number Diff line number Diff line change
@@ -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);
}
Loading
Loading