diff --git a/src/commands/getIndexByRoleAndAttributes.ts b/src/commands/getIndexByRoleAndAttributes.ts index 36c0394..72d66a0 100644 --- a/src/commands/getIndexByRoleAndAttributes.ts +++ b/src/commands/getIndexByRoleAndAttributes.ts @@ -1,4 +1,8 @@ -import { matchesAccessibleAttributes, matchesRoles } from "./nodeMatchers"; +import { + matchesAccessibleAttributes, + matchesAccessibleNameRequirement, + matchesRoles, +} from "./nodeMatchers"; import { AccessibilityNode } from "../createAccessibilityTree"; import type { AriaAttributes } from "./types"; import { END_OF_ROLE_PREFIX } from "../flattenTree"; @@ -9,6 +13,9 @@ export interface GetIndexFilters { /** Matches a node only if the node has **all** of these aria attributes */ ariaAttributes?: Readonly; + + /** Matches a node with any of these roles only if it has an accessible name */ + rolesRequiringAccessibleName?: Readonly; } export function getIndexByRoleAndAttributes({ @@ -24,7 +31,11 @@ export function getIndexByRoleAndAttributes({ (node) => !node.spokenRole.startsWith(END_OF_ROLE_PREFIX) && matchesRoles(node, filters.roles) && - matchesAccessibleAttributes(node, filters.ariaAttributes) + matchesAccessibleAttributes(node, filters.ariaAttributes) && + matchesAccessibleNameRequirement( + node, + filters.rolesRequiringAccessibleName + ) ); if (!accessibilityNode) { diff --git a/src/commands/index.ts b/src/commands/index.ts index 4209751..ae7ceac 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -81,6 +81,16 @@ const quickLandmarkNavigationRoles = [ "search", ] as const; +/** + * A form is not exposed as a landmark region unless it has been provided an + * accessible name. An unnamed form keeps the form role, but is not a landmark. + * + * REF: + * - https://www.w3.org/TR/html-aam-1.0/#el-form + * - https://www.w3.org/TR/wai-aria-1.3/#form + */ +const landmarkRolesRequiringAccessibleName = ["form"] as const; + const quickAriaRoleNavigationRoles = [ ...quickLandmarkNavigationRoles, /** @@ -1013,6 +1023,7 @@ export const commands = { */ moveToNextLandmark: getNextIndexByRoleAndAttributes({ roles: quickLandmarkNavigationRoles, + rolesRequiringAccessibleName: landmarkRolesRequiringAccessibleName, }), /** * Move to the previous element with any [`landmark`](https://www.w3.org/TR/wai-aria-1.2/#landmark) @@ -1046,6 +1057,7 @@ export const commands = { */ moveToPreviousLandmark: getPreviousIndexByRoleAndAttributes({ roles: quickLandmarkNavigationRoles, + rolesRequiringAccessibleName: landmarkRolesRequiringAccessibleName, }), ...headingLevelNavigationCommands, }; diff --git a/src/commands/nodeMatchers.ts b/src/commands/nodeMatchers.ts index 7bdc881..69a6dcb 100644 --- a/src/commands/nodeMatchers.ts +++ b/src/commands/nodeMatchers.ts @@ -28,3 +28,14 @@ export function matchesAccessibleAttributes( return true; } + +export function matchesAccessibleNameRequirement( + node: AccessibilityNode, + rolesRequiringAccessibleName?: Readonly +) { + if (!rolesRequiringAccessibleName?.includes(node.role)) { + return true; + } + + return !!node.accessibleName; +} diff --git a/test/int/moveToRole.int.test.ts b/test/int/moveToRole.int.test.ts index e409bdb..74b6685 100644 --- a/test/int/moveToRole.int.test.ts +++ b/test/int/moveToRole.int.test.ts @@ -211,6 +211,40 @@ describe("Move To Role", () => { }); }); + describe("when there is a form without an accessible name in the container", () => { + beforeEach(async () => { + document.body.innerHTML = ` + +
+
Main content
+ `; + + await virtual.start({ container: document.body }); + }); + + it("should skip the unnamed form when navigating to the next landmark", async () => { + await virtual.perform(virtual.commands.moveToNextLandmark); + await virtual.perform(virtual.commands.moveToNextLandmark); + + expect(await virtual.spokenPhraseLog()).toEqual([ + "document", + "navigation, Primary", + "main, Content", + ]); + }); + + it("should skip the unnamed form when navigating to the previous landmark", async () => { + await virtual.perform(virtual.commands.moveToPreviousLandmark); + await virtual.perform(virtual.commands.moveToPreviousLandmark); + + expect(await virtual.spokenPhraseLog()).toEqual([ + "document", + "main, Content", + "navigation, Primary", + ]); + }); + }); + describe("when there are no matching roles in the container", () => { beforeEach(async () => { document.body.innerHTML = "";