Repository navigation
feat(feed): support feed role article focus management #241
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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<void>) | 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); | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
Comment on lines
+386
to
395
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Suggested change
|
||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| 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; | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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) | ||||||||||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Consider an |
||||||||||||||||||||||||||
| ? (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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is the fallback needed here? If as we recurse we are setting |
||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The double check for
Suggested change
Or just nest the whole |
||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| 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, | ||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| 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); | ||
| } |
There was a problem hiding this comment.
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