diff --git a/packages/dom/package.json b/packages/dom/package.json index aa4aa61..b7da61a 100644 --- a/packages/dom/package.json +++ b/packages/dom/package.json @@ -35,6 +35,7 @@ "test": "NODE_ENV=test mocha" }, "dependencies": { + "dom-accessibility-api": "^0.5.16", "fast-deep-equal": "^3.1.3", "tslib": "^2.8.1" }, diff --git a/packages/dom/src/lib/ElementAssertion.ts b/packages/dom/src/lib/ElementAssertion.ts index 8207cc9..71aa40e 100644 --- a/packages/dom/src/lib/ElementAssertion.ts +++ b/packages/dom/src/lib/ElementAssertion.ts @@ -1,7 +1,12 @@ import { Assertion, AssertionError } from "@assertive-ts/core"; +import { computeAccessibleDescription } from "dom-accessibility-api"; import equal from "fast-deep-equal"; -import { getAccessibleDescription, isValidAriaPressed } from "./helpers/accessibility"; +import { + describeAccessibleExpectation, + isValidAriaPressed, + matchesAccessibleExpectation, +} from "./helpers/accessibility"; import { isButtonElement, isElementEmpty, normalizeHtml } from "./helpers/dom"; import { getExpectedAndReceivedStyles } from "./helpers/styles"; @@ -293,63 +298,51 @@ export class ElementAssertion extends Assertion { /** * Asserts that the element has an accessible description. * - * The accessible description is computed from the `aria-describedby` - * attribute, which references one or more elements by ID. The text - * content of those elements is combined to form the description. + * The accessible description is computed following the + * [accname](https://www.w3.org/TR/accname/) specification, taking + * into account `aria-describedby`, `aria-description`, and `title`, + * among others. * * @example * ``` * // Check if element has any description - * expect(element).toHaveDescription(); + * expect(element).toHaveAccessibleDescription(); * * // Check if element has specific description text - * expect(element).toHaveDescription('Expected description text'); + * expect(element).toHaveAccessibleDescription("Expected description text"); * * // Check if element description matches a regex pattern - * expect(element).toHaveDescription(/description pattern/i); + * expect(element).toHaveAccessibleDescription(/description pattern/i); * ``` * * @param expectedDescription * - Optional expected description (string or RegExp). * @returns the assertion instance. */ - - public toHaveDescription(expectedDescription?: RegExp | string): this { - const description = getAccessibleDescription(this.actual); + public toHaveAccessibleDescription(expectedDescription?: RegExp | string): this { + const description = computeAccessibleDescription(this.actual); const hasExpectedValue = expectedDescription !== undefined; - const matchesExpectation = (desc: string): boolean => { - if (!hasExpectedValue) { - return Boolean(desc); - } - return expectedDescription instanceof RegExp - ? expectedDescription.test(desc) - : desc === expectedDescription; - }; - - const formatExpectation = (isRegExp: boolean): string => - isRegExp ? `matching ${expectedDescription}` : `"${expectedDescription}"`; + const expectation = describeAccessibleExpectation(expectedDescription); const error = new AssertionError({ actual: description, expected: expectedDescription, message: hasExpectedValue - ? `Expected the element to have description ${formatExpectation(expectedDescription instanceof RegExp)}, ` - + `but received "${description}"` - : "Expected the element to have a description", + ? `Expected the element to have accessible description ${expectation}, but received "${description}"` + : "Expected the element to have an accessible description", }); const invertedError = new AssertionError({ actual: description, expected: expectedDescription, message: hasExpectedValue - ? `Expected the element NOT to have description ${formatExpectation(expectedDescription instanceof RegExp)}, ` - + `but received "${description}"` - : `Expected the element NOT to have a description, but received "${description}"`, + ? `Expected the element NOT to have accessible description ${expectation}, but received "${description}"` + : `Expected the element NOT to have an accessible description, but received "${description}"`, }); return this.execute({ - assertWhen: matchesExpectation(description), + assertWhen: matchesAccessibleExpectation(description, expectedDescription), error, invertedError, }); diff --git a/packages/dom/src/lib/helpers/accessibility.ts b/packages/dom/src/lib/helpers/accessibility.ts index b3e3f7a..421ac83 100644 --- a/packages/dom/src/lib/helpers/accessibility.ts +++ b/packages/dom/src/lib/helpers/accessibility.ts @@ -1,35 +1,24 @@ -function normalizeText(text: string): string { - return text.replace(/\s+/g, " ").trim(); +export function isValidAriaPressed(element: Element): boolean { + const pressedAttribute = element.getAttribute("aria-pressed"); + return pressedAttribute !== null && ["true", "false", "mixed"].includes(pressedAttribute); } -export function getAccessibleDescription(actual: Element): string { - const ariaDescribedBy = actual.getAttribute("aria-describedby"); - - if (!ariaDescribedBy) { - return ""; +export function matchesAccessibleExpectation(actual: string, expected?: RegExp | string): boolean { + if (expected === undefined) { + return Boolean(actual); } - const descriptionIds = ariaDescribedBy.split(/\s+/).filter(Boolean); - - const getElementText = (id: string): null | string => { - const element = actual.ownerDocument.getElementById(id); - - if (!element || !element.textContent) { - return null; - } - - return element.textContent; - }; - - const combinedText = descriptionIds - .map(getElementText) - .filter((text): text is string => text !== null) - .join(" "); - - return normalizeText(combinedText); + return expected instanceof RegExp + ? expected.test(actual) + : actual === expected; } -export function isValidAriaPressed(element: Element): boolean { - const pressedAttribute = element.getAttribute("aria-pressed"); - return pressedAttribute !== null && ["true", "false", "mixed"].includes(pressedAttribute); +export function describeAccessibleExpectation(expected?: RegExp | string): string { + if (expected === undefined) { + return ""; + } + + return expected instanceof RegExp + ? `matching ${expected}` + : `"${expected}"`; } diff --git a/packages/dom/src/lib/helpers/dom.ts b/packages/dom/src/lib/helpers/dom.ts index 7850bc8..4eb8616 100644 --- a/packages/dom/src/lib/helpers/dom.ts +++ b/packages/dom/src/lib/helpers/dom.ts @@ -5,6 +5,8 @@ export function isElementEmpty(element: Element): boolean { return nonCommentChildNodes.length === 0; } +// Callers are internal: the public assertions already reject non-string +// and empty `htmlText` values before reaching here. export function normalizeHtml(htmlText: string, ownerDocument: Document): string { const div = ownerDocument.createElement("div"); div.innerHTML = htmlText; diff --git a/packages/dom/test/unit/lib/ElementAssertion.test.tsx b/packages/dom/test/unit/lib/ElementAssertion.test.tsx index 4b101a0..56ba294 100644 --- a/packages/dom/test/unit/lib/ElementAssertion.test.tsx +++ b/packages/dom/test/unit/lib/ElementAssertion.test.tsx @@ -469,7 +469,7 @@ describe("[Unit] ElementAssertion.test.ts", () => { }); }); - describe(".toHaveDescription", () => { + describe(".toHaveAccessibleDescription", () => { context("when checking for any description", () => { context("when the element has a description", () => { it("returns the assertion instance", () => { @@ -477,11 +477,14 @@ describe("[Unit] ElementAssertion.test.ts", () => { const button = getByTestId("button-single"); const test = new ElementAssertion(button); - expect(test.toHaveDescription()).toBeEqual(test); + expect(test.toHaveAccessibleDescription()).toBeEqual(test); - expect(() => test.not.toHaveDescription()) + expect(() => test.not.toHaveAccessibleDescription()) .toThrowError(AssertionError) - .toHaveMessage('Expected the element NOT to have a description, but received "This is a description"'); + .toHaveMessage( + "Expected the element NOT to have an accessible description, " + + 'but received "This is a description"', + ); }); }); @@ -491,11 +494,11 @@ describe("[Unit] ElementAssertion.test.ts", () => { const button = getByTestId("button-no-description"); const test = new ElementAssertion(button); - expect(() => test.toHaveDescription()) + expect(() => test.toHaveAccessibleDescription()) .toThrowError(AssertionError) - .toHaveMessage("Expected the element to have a description"); + .toHaveMessage("Expected the element to have an accessible description"); - expect(test.not.toHaveDescription()).toBeEqual(test); + expect(test.not.toHaveAccessibleDescription()).toBeEqual(test); }); }); }); @@ -507,12 +510,12 @@ describe("[Unit] ElementAssertion.test.ts", () => { const button = getByTestId("button-single"); const test = new ElementAssertion(button); - expect(test.toHaveDescription("This is a description")).toBeEqual(test); + expect(test.toHaveAccessibleDescription("This is a description")).toBeEqual(test); - expect(() => test.not.toHaveDescription("This is a description")) + expect(() => test.not.toHaveAccessibleDescription("This is a description")) .toThrowError(AssertionError) .toHaveMessage( - 'Expected the element NOT to have description "This is a description", ' + 'Expected the element NOT to have accessible description "This is a description", ' + 'but received "This is a description"', ); }); @@ -524,12 +527,12 @@ describe("[Unit] ElementAssertion.test.ts", () => { const button = getByTestId("button-multiple"); const test = new ElementAssertion(button); - expect(test.toHaveDescription("This is a description Additional info")).toBeEqual(test); + expect(test.toHaveAccessibleDescription("This is a description Additional info")).toBeEqual(test); - expect(() => test.not.toHaveDescription("This is a description Additional info")) + expect(() => test.not.toHaveAccessibleDescription("This is a description Additional info")) .toThrowError(AssertionError) .toHaveMessage( - 'Expected the element NOT to have description "This is a description Additional info", ' + 'Expected the element NOT to have accessible description "This is a description Additional info", ' + 'but received "This is a description Additional info"', ); }); @@ -541,13 +544,14 @@ describe("[Unit] ElementAssertion.test.ts", () => { const button = getByTestId("button-single"); const test = new ElementAssertion(button); - expect(() => test.toHaveDescription("Wrong description")) + expect(() => test.toHaveAccessibleDescription("Wrong description")) .toThrowError(AssertionError) .toHaveMessage( - 'Expected the element to have description "Wrong description", but received "This is a description"', + 'Expected the element to have accessible description "Wrong description", ' + + 'but received "This is a description"', ); - expect(test.not.toHaveDescription("Wrong description")).toBeEqual(test); + expect(test.not.toHaveAccessibleDescription("Wrong description")).toBeEqual(test); }); }); }); @@ -559,12 +563,12 @@ describe("[Unit] ElementAssertion.test.ts", () => { const button = getByTestId("button-single"); const test = new ElementAssertion(button); - expect(test.toHaveDescription(/description/i)).toBeEqual(test); + expect(test.toHaveAccessibleDescription(/description/i)).toBeEqual(test); - expect(() => test.not.toHaveDescription(/description/i)) + expect(() => test.not.toHaveAccessibleDescription(/description/i)) .toThrowError(AssertionError) .toHaveMessage( - "Expected the element NOT to have description matching /description/i, " + "Expected the element NOT to have accessible description matching /description/i, " + 'but received "This is a description"', ); }); @@ -576,14 +580,14 @@ describe("[Unit] ElementAssertion.test.ts", () => { const button = getByTestId("button-single"); const test = new ElementAssertion(button); - expect(() => test.toHaveDescription(/wrong pattern/)) + expect(() => test.toHaveAccessibleDescription(/wrong pattern/)) .toThrowError(AssertionError) .toHaveMessage( - "Expected the element to have description matching /wrong pattern/, " + "Expected the element to have accessible description matching /wrong pattern/, " + 'but received "This is a description"', ); - expect(test.not.toHaveDescription(/wrong pattern/)).toBeEqual(test); + expect(test.not.toHaveAccessibleDescription(/wrong pattern/)).toBeEqual(test); }); }); }); diff --git a/yarn.lock b/yarn.lock index ec47186..dc94837 100644 --- a/yarn.lock +++ b/yarn.lock @@ -51,6 +51,7 @@ __metadata: "@types/react": "npm:^18.3.3" "@types/react-dom": "npm:^18.3.0" "@types/react-test-renderer": "npm:^18" + dom-accessibility-api: "npm:^0.5.16" fast-deep-equal: "npm:^3.1.3" jsdom: "npm:^24.0.0" jsdom-global: "npm:^3.0.2" @@ -5655,7 +5656,7 @@ __metadata: languageName: node linkType: hard -"dom-accessibility-api@npm:^0.5.9": +"dom-accessibility-api@npm:^0.5.16, dom-accessibility-api@npm:^0.5.9": version: 0.5.16 resolution: "dom-accessibility-api@npm:0.5.16" checksum: 10/377b4a7f9eae0a5d72e1068c369c99e0e4ca17fdfd5219f3abd32a73a590749a267475a59d7b03a891f9b673c27429133a818c44b2e47e32fec024b34274e2ca