From 632f687111424dabdeb231f3774d9ed46a7d44b6 Mon Sep 17 00:00:00 2001 From: Craig Morten <124147726+jlp-craigmorten@users.noreply.github.com> Date: Sat, 13 Apr 2024 15:40:22 +0100 Subject: [PATCH] fix: implement radio group logic (#73) --- .../getSet.ts | 104 ++++++++++- .../index.ts | 122 ++++++------- test/int/accessibleValue.int.test.ts | 8 +- test/int/radioGroup.int.test.tsx | 161 ++++++++++++++++++ 4 files changed, 317 insertions(+), 78 deletions(-) create mode 100644 test/int/radioGroup.int.test.tsx diff --git a/src/getNodeAccessibilityData/getAccessibleAttributeLabels/getLabelFromImplicitHtmlElementValue/getSet.ts b/src/getNodeAccessibilityData/getAccessibleAttributeLabels/getLabelFromImplicitHtmlElementValue/getSet.ts index 0e575c2..0ad18e0 100644 --- a/src/getNodeAccessibilityData/getAccessibleAttributeLabels/getLabelFromImplicitHtmlElementValue/getSet.ts +++ b/src/getNodeAccessibilityData/getAccessibleAttributeLabels/getLabelFromImplicitHtmlElementValue/getSet.ts @@ -1,4 +1,5 @@ import type { AccessibilityNodeTree } from "../../../createAccessibilityTree"; +import { isElement } from "../../../isElement"; const getFirstNestedChildrenByRole = ({ role, @@ -15,22 +16,104 @@ const getFirstNestedChildrenByRole = ({ return getFirstNestedChildrenByRole({ role, tree: child }); }); -const getSiblingsByRoleAndLevel = ({ +const getParentByRole = ({ role, tree, }: { role: string; tree: AccessibilityNodeTree; -}): AccessibilityNodeTree[] => { +}): AccessibilityNodeTree => { let parentTree = tree; while (parentTree.role !== role && parentTree.parentAccessibilityNodeTree) { parentTree = parentTree.parentAccessibilityNodeTree; } + return parentTree; +}; + +const getSiblingsByRoleAndLevel = ({ + role, + parentRole = role, + tree, +}: { + role: string; + parentRole?: string; + tree: AccessibilityNodeTree; +}): AccessibilityNodeTree[] => { + const parentTree = getParentByRole({ role: parentRole, tree }); + return getFirstNestedChildrenByRole({ role, tree: parentTree }); }; +const getFormOwnerTree = ({ tree }: { tree: AccessibilityNodeTree }) => + getParentByRole({ role: "form", tree }); + +const getRadioInputsByName = ({ + name, + tree, +}: { + name: string; + tree: AccessibilityNodeTree; +}): AccessibilityNodeTree[] => + tree.children.flatMap((child) => { + if (isElement(child.node) && child.node.getAttribute("name") === name) { + return child; + } + + return getRadioInputsByName({ name, tree: child }); + }); + +/** + * The radio button group that contains an input element a also contains all + * the other input elements b that fulfill all of the following conditions: + * + * - The input element b's type attribute is in the Radio Button state. + * - Either a and b have the same form owner, or they both have no form owner. + * - Both a and b are in the same tree. + * - They both have a name attribute, their name attributes are not empty, and + * the value of a's name attribute equals the value of b's name attribute. + * + * REF: https://html.spec.whatwg.org/multipage/input.html#radio-button-group + */ +const getRadioGroup = ({ + node, + tree, +}: { + node: HTMLElement; + tree: AccessibilityNodeTree; +}) => { + /** + * Authors SHOULD ensure that elements with role radio are explicitly grouped + * in order to indicate which ones affect the same value. This is achieved by + * enclosing the radio elements in an element with role radiogroup. If it is + * not possible to make the radio buttons DOM children of the radiogroup, + * authors SHOULD use the aria-owns attribute on the radiogroup element to + * indicate the relationship to its children. + */ + if (node.localName !== "input") { + return getSiblingsByRoleAndLevel({ + role: "radio", + parentRole: "radiogroup", + tree, + }); + } + + if (!node.hasAttribute("name")) { + return []; + } + + const name = node.getAttribute("name")!; + + if (!name) { + return []; + } + + const formOwnerTree = getFormOwnerTree({ tree }); + + return getRadioInputsByName({ name, tree: formOwnerTree }); +}; + const getChildrenByRole = ({ role, tree, @@ -75,16 +158,29 @@ const getChildrenByRole = ({ * REF: https://www.w3.org/TR/core-aam-1.2/#mapping_additional_position */ export const getSet = ({ + node, role, tree, }: { - role: string; + node: HTMLElement; tree: AccessibilityNodeTree; -}): AccessibilityNodeTree[] => { + role: string; +}): Pick[] => { if (role === "treeitem") { return getSiblingsByRoleAndLevel({ role, tree }); } + /** + * With aria-setsize value reflecting number of type=radio input elements + * within the radio button group and aria-posinset value reflecting the + * elements position within the radio button group. + * + * REF: https://www.w3.org/TR/html-aam-1.0/#el-input-radio + */ + if (role === "radio") { + return getRadioGroup({ node, tree }); + } + return getChildrenByRole({ role, tree, diff --git a/src/getNodeAccessibilityData/getAccessibleAttributeLabels/getLabelFromImplicitHtmlElementValue/index.ts b/src/getNodeAccessibilityData/getAccessibleAttributeLabels/getLabelFromImplicitHtmlElementValue/index.ts index dfb8b1b..42afbfa 100644 --- a/src/getNodeAccessibilityData/getAccessibleAttributeLabels/getLabelFromImplicitHtmlElementValue/index.ts +++ b/src/getNodeAccessibilityData/getAccessibleAttributeLabels/getLabelFromImplicitHtmlElementValue/index.ts @@ -14,6 +14,55 @@ const headingLocalNameToLevelMap: Record = { h6: "6", }; +const getNodeSet = ({ + node, + role, + tree, +}: { + node: HTMLElement; + tree: AccessibilityNodeTree | null; + role: string; +}): Pick[] | null => { + if (!tree) { + return null; + } + + /** + * When an article is in the context of a feed, the author MAY specify + * values for aria-posinset and aria-setsize. + * + * REF: https://www.w3.org/TR/wai-aria-1.2/#article + * + * This is interpreted as the author being allowed to specify a value when + * nested in a feed, but there are no requirements in the specifications + * for an article role to expose an implicit value, even within a feed. + */ + if (role === "article") { + return null; + } + + /** + * While the row role can be used in a table, grid, or treegrid, the semantics + * of aria-expanded, aria-posinset, aria-setsize, and aria-level are only + * applicable to the hierarchical structure of an interactive tree grid. + * Therefore, authors MUST NOT apply aria-expanded, aria-posinset, + * aria-setsize, and aria-level to a row that descends from a table or grid, + * and user agents SHOULD NOT expose any of these four properties to assistive + * technologies unless the row descends from a treegrid. + * + * REF: https://www.w3.org/TR/wai-aria-1.2/#row + */ + if (role === "row" && !hasTreegridAncestor(tree)) { + return null; + } + + return getSet({ + node, + role, + tree, + }); +}; + type Mapper = ({ node, tree, @@ -92,43 +141,12 @@ const mapHtmlElementAriaToImplicitValue: Record = { * REF: https://www.w3.org/TR/wai-aria-1.2/#aria-posinset */ "aria-posinset": ({ node, tree, role }) => { - if (!tree) { - return ""; - } + const nodeSet = getNodeSet({ node, role, tree }); - /** - * When an article is in the context of a feed, the author MAY specify - * values for aria-posinset and aria-setsize. - * - * REF: https://www.w3.org/TR/wai-aria-1.2/#article - * - * This is interpreted as the author being allowed to specify a value when - * nested in a feed, but there are no requirements in the specifications - * for an article role to expose an implicit value, even within a feed. - */ - if (role === "article") { + if (!nodeSet?.length) { return ""; } - /** - * While the row role can be used in a table, grid, or treegrid, the semantics - * of aria-expanded, aria-posinset, aria-setsize, and aria-level are only - * applicable to the hierarchical structure of an interactive tree grid. - * Therefore, authors MUST NOT apply aria-expanded, aria-posinset, - * aria-setsize, and aria-level to a row that descends from a table or grid, - * and user agents SHOULD NOT expose any of these four properties to assistive - * technologies unless the row descends from a treegrid. - * - * REF: https://www.w3.org/TR/wai-aria-1.2/#row - */ - if (role === "row" && !hasTreegridAncestor(tree)) { - return ""; - } - - const nodeSet = getSet({ - role, - tree, - }); const index = nodeSet.findIndex((child) => child.node === node); return `${index + 1}`; @@ -152,45 +170,13 @@ const mapHtmlElementAriaToImplicitValue: Record = { * * REF: https://www.w3.org/TR/wai-aria-1.2/#aria-setsize */ - "aria-setsize": ({ tree, role }) => { - if (!tree) { - return ""; - } + "aria-setsize": ({ node, tree, role }) => { + const nodeSet = getNodeSet({ node, role, tree }); - /** - * When an article is in the context of a feed, the author MAY specify - * values for aria-posinset and aria-setsize. - * - * REF: https://www.w3.org/TR/wai-aria-1.2/#article - * - * This is interpreted as the author being allowed to specify a value when - * nested in a feed, but there are no requirements in the specifications - * for an article role to expose an implicit value, even within a feed. - */ - if (role === "article") { + if (!nodeSet?.length) { return ""; } - /** - * While the row role can be used in a table, grid, or treegrid, the semantics - * of aria-expanded, aria-posinset, aria-setsize, and aria-level are only - * applicable to the hierarchical structure of an interactive tree grid. - * Therefore, authors MUST NOT apply aria-expanded, aria-posinset, - * aria-setsize, and aria-level to a row that descends from a table or grid, - * and user agents SHOULD NOT expose any of these four properties to assistive - * technologies unless the row descends from a treegrid. - * - * REF: https://www.w3.org/TR/wai-aria-1.2/#row - */ - if (role === "row" && !hasTreegridAncestor(tree)) { - return ""; - } - - const nodeSet = getSet({ - role, - tree, - }); - return `${nodeSet.length}`; }, }; diff --git a/test/int/accessibleValue.int.test.ts b/test/int/accessibleValue.int.test.ts index 0410d6e..bad283c 100644 --- a/test/int/accessibleValue.int.test.ts +++ b/test/int/accessibleValue.int.test.ts @@ -78,9 +78,7 @@ describe("Placeholder Attribute Property", () => { await virtual.next(); await virtual.next(); - expect(await virtual.lastSpokenPhrase()).toBe( - "radio, Label, not checked, position 1, set size 1" - ); + expect(await virtual.lastSpokenPhrase()).toBe("radio, Label, not checked"); await virtual.stop(); }); @@ -95,9 +93,7 @@ describe("Placeholder Attribute Property", () => { await virtual.next(); await virtual.next(); - expect(await virtual.lastSpokenPhrase()).toBe( - "radio, Label, not checked, position 1, set size 1" - ); + expect(await virtual.lastSpokenPhrase()).toBe("radio, Label, not checked"); await virtual.stop(); }); diff --git a/test/int/radioGroup.int.test.tsx b/test/int/radioGroup.int.test.tsx new file mode 100644 index 0000000..f3c53fd --- /dev/null +++ b/test/int/radioGroup.int.test.tsx @@ -0,0 +1,161 @@ +import React from "react"; +import { render } from "@testing-library/react"; +import { virtual } from "../../src/index.js"; + +it("should work for sibling radio inputs", async () => { + const { container } = render( +
+
+ Example + + + + +
+
+ ); + + await virtual.start({ container }); + + while ((await virtual.lastSpokenPhrase()) !== "end of main") { + await virtual.next(); + } + + expect(await virtual.spokenPhraseLog()).toEqual([ + "main", + "group, Example", + "legend, Example", + "radio, Option 1, not checked, position 1, set size 2", + "Option 1", + "radio, Option 2, not checked, position 2, set size 2", + "Option 2", + "end of group, Example", + "end of main", + ]); + await virtual.stop(); +}); + +it("should work for nested radio inputs", async () => { + const { container } = render( +
+
+ Example +
+ + +
+
+ + +
+
+
+ ); + + await virtual.start({ container }); + + while ((await virtual.lastSpokenPhrase()) !== "end of main") { + await virtual.next(); + } + + expect(await virtual.spokenPhraseLog()).toEqual([ + "main", + "group, Example", + "legend, Example", + "radio, Option 1, not checked, position 1, set size 2", + "Option 1", + "radio, Option 2, not checked, position 2, set size 2", + "Option 2", + "end of group, Example", + "end of main", + ]); + await virtual.stop(); +}); + +it("should work for radio inputs when some are hidden", async () => { + const { container } = render( +
+
+ Example + + + + +
+
+ ); + + await virtual.start({ container }); + + while ((await virtual.lastSpokenPhrase()) !== "end of main") { + await virtual.next(); + } + + expect(await virtual.spokenPhraseLog()).toEqual([ + "main", + "group, Example", + "legend, Example", + "radio, Option 1, not checked, position 1, set size 1", + "Option 1", + "end of group, Example", + "end of main", + ]); + await virtual.stop(); +}); + +it("should consider parent forms when group inputs", async () => { + const { container } = render( +
+
+
+ Example A + + + + +
+
+
+
+ Example B + + + + +
+
+
+ ); + + await virtual.start({ container }); + + while ((await virtual.lastSpokenPhrase()) !== "end of main") { + await virtual.next(); + } + + expect(await virtual.spokenPhraseLog()).toEqual([ + "main", + "form", + "group, Example A", + "legend, Example A", + "radio, Option 1a, not checked, position 1, set size 2", + "Option 1a", + "radio, Option 2a, not checked, position 2, set size 2", + "Option 2a", + "end of group, Example A", + "end of form", + "form", + "group, Example B", + "legend, Example B", + "radio, Option 1b, not checked, position 1, set size 2", + "Option 1b", + "radio, Option 2b, not checked, position 2, set size 2", + "Option 2b", + "end of group, Example B", + "end of form", + "end of main", + ]); + await virtual.stop(); +});