Skip to content

feat(feed): support feed role article focus management - #241

Open
AdamMagued wants to merge 1 commit into
guidepup:mainfrom
AdamMagued:feat/feed-role-focus-behaviour
Open

AdamMagued wants to merge 1 commit into
guidepup:mainfrom
AdamMagued:feat/feed-role-focus-behaviour

Conversation

@AdamMagued

Copy link
Copy Markdown
Contributor

Description

Resolves #13.

This PR implements W3C WAI-ARIA feed role focus management according to the ARIA 1.2/1.3 specification:

  • When the virtual cursor transitions into an article belonging to a feed (role="feed") or moves between different articles within a feed, user agent focus (.focus()) is set on the containing article.
  • If the reading cursor lands directly on a focusable element inside the article, focus is set on that element in lieu of setting focus on the containing article.
  • Moving within the same article does not repeatedly re-trigger focus unless changing to a focusable element.
  • Non-feed articles do not trigger feed-specific focus shifts.
  • Internal focus shifts are tracked to avoid disrupting reading cursor state.

Verification

  • Added unit tests for focusable selector detection (test/isFocusable.test.ts).
  • Added comprehensive integration tests (test/int/feedRole.int.test.ts) covering all specification focus behaviors.
  • Ran full CI suite (yarn ci), passing with 100% test pass rate (59 suites, 349 tests), clean linting, and full typechecks.

}

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?

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

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?

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

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?

Comment thread src/Virtual.ts
Comment on lines +386 to 395
} else if (focusableElement) {
const activeElement =
article.ownerDocument?.activeElement ??
globalThis.document?.activeElement;

if (activeElement !== focusableElement) {
this.#focusElement(focusableElement);
}
}
}

@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);
}
}
}
}
}

Comment thread src/Virtual.ts

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

expect(document.activeElement).toBe(art2);
});

it("should focus a focusable element when landing directly on it inside an article", async () => {

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.

This I think we drop?

// Move to button (focusable element)
await virtual.next();
expect(await virtual.lastSpokenPhrase()).toBe("button, Like");
expect(document.activeElement).toBe(btn1);

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.

Suggested change
expect(document.activeElement).toBe(btn1);
expect(document.activeElement).toBe(art1);

expect(document.activeElement).toBe(ownedArt);
});

it("should not focus disabled elements inside an article", async () => {

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.

Great edge-case test, like it - sim. to previous comments if we can adjust to test the "in lieu of..." like behaviour rather than auto-focus then still get good value out of it, i.e. jump to disabled button (or sim) from outside of the feed/article and assert that it's the article that get's focus not the disabled control.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: support feed role focus behaviour

2 participants