From fa53c510d8aab6cf3561c91949f1df3a52a500a8 Mon Sep 17 00:00:00 2001 From: Sebastian Silbermann Date: Tue, 9 Jun 2020 15:52:49 +0200 Subject: [PATCH] feat: Add computeAccessibleDescription (#210) --- .changeset/strange-windows-lie.md | 18 + README.md | 35 +- sources/__tests__/accessible-description.js | 63 ++ sources/accessible-description.ts | 37 ++ sources/accessible-name-and-description.ts | 543 ++++++++++++++++ sources/accessible-name.ts | 586 +----------------- sources/index.ts | 1 + sources/util.ts | 39 ++ tests/cypress/fixtures/example.json | 1 + .../cypress/integration/web-platform-test.js | 45 +- tests/cypress/support/index.js | 22 +- tests/wpt-jsdom/ATTAcomm.js | 6 + tests/wpt-jsdom/run-single-wpt.js | 6 +- tests/wpt-jsdom/to-run.yaml | 9 +- 14 files changed, 800 insertions(+), 611 deletions(-) create mode 100644 .changeset/strange-windows-lie.md create mode 100644 sources/__tests__/accessible-description.js create mode 100644 sources/accessible-description.ts create mode 100644 sources/accessible-name-and-description.ts create mode 100644 tests/cypress/fixtures/example.json diff --git a/.changeset/strange-windows-lie.md b/.changeset/strange-windows-lie.md new file mode 100644 index 00000000..9acf2182 --- /dev/null +++ b/.changeset/strange-windows-lie.md @@ -0,0 +1,18 @@ +--- +"dom-accessibility-api": patch +--- + +Implement accessbile description computation + +```ts +import { computeAccessibleDescription } from "dom-accessibility-api"; + +const description = computeAccessibleDescription(element); +``` + +Warning: It always considers `title` attributes if the description is empty. +Even if the `title` attribute was already used for the accessible name. +This is fails a web-platform-test. +The other failing test is due to `aria-label` being ignored for the description which is correct by spec. +It's likely an issue with wpt. +The other tests are passing (13/15). diff --git a/README.md b/README.md index d07e23e4..ebaae2f8 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ [![Build Status](https://dev.azure.com/silbermannsebastian/dom-accessibility-api/_apis/build/status/eps1lon.dom-accessibility-api?branchName=master)](https://dev.azure.com/silbermannsebastian/dom-accessibility-api/_build/latest?definitionId=6&branchName=master) ![Azure DevOps coverage](https://img.shields.io/azure-devops/coverage/silbermannsebastian/dom-accessibility-api/6) -Computes the accessible name of a given DOM Element. +Computes the accessible name or description of a given DOM Element. https://w3c.github.io/accname/ implemented in JavaScript for testing. ```bash @@ -12,7 +12,10 @@ $ yarn add dom-accessibility-api ``` ```js -import { computeAccessibleName } from "dom-accessibility-api"; +import { + computeAccessibleName, + computeAccessibleDescription, +} from "dom-accessibility-api"; ``` I'm not an editor of any of the referenced specs (nor very experience with using them) so if you got any insights, something catches @@ -38,30 +41,30 @@ cloning. See [the test readme](/tests/README.md) for more info about the test se ### browser (Chrome) -140/144 +153/159 ### jsdom
-report 124/159 passing of which 16 are due `::before { content }`, 15 are accessible desc, 4 are pathological +report 138/159 passing of which 15 are due `::before { content }`, one might a wrong test, 5 are pathological ```bash web-platform-tests accname ✓ [expected fail] description_1.0_combobox-focusable-manual.html ✓ [expected fail] description_from_content_of_describedby_element-manual.html - ✓ [expected fail] description_link-with-label-manual.html - ✓ [expected fail] description_test_case_557-manual.html - ✓ [expected fail] description_test_case_664-manual.html - ✓ [expected fail] description_test_case_665-manual.html - ✓ [expected fail] description_test_case_666-manual.html - ✓ [expected fail] description_test_case_772-manual.html - ✓ [expected fail] description_test_case_773-manual.html - ✓ [expected fail] description_test_case_774-manual.html - ✓ [expected fail] description_test_case_838-manual.html - ✓ [expected fail] description_test_case_broken_reference-manual.html - ✓ [expected fail] description_test_case_one_valid_reference-manual.html - ✓ [expected fail] description_title-same-element-manual.html + ✓ description_link-with-label-manual.html + ✓ description_test_case_557-manual.html + ✓ description_test_case_664-manual.html + ✓ description_test_case_665-manual.html + ✓ description_test_case_666-manual.html + ✓ description_test_case_772-manual.html + ✓ description_test_case_773-manual.html + ✓ description_test_case_774-manual.html + ✓ description_test_case_838-manual.html + ✓ description_test_case_broken_reference-manual.html + ✓ description_test_case_one_valid_reference-manual.html + ✓ description_title-same-element-manual.html ✓ name_1.0_combobox-focusable-alternative-manual.html ✓ name_1.0_combobox-focusable-manual.html ✓ name_checkbox-label-embedded-combobox-manual.html diff --git a/sources/__tests__/accessible-description.js b/sources/__tests__/accessible-description.js new file mode 100644 index 00000000..8d87acd1 --- /dev/null +++ b/sources/__tests__/accessible-description.js @@ -0,0 +1,63 @@ +import { computeAccessibleDescription } from "../accessible-description"; +import { renderIntoDocument } from "./helpers/test-utils"; +import { prettyDOM } from "@testing-library/dom"; +import diff from "jest-diff"; + +expect.extend({ + toHaveAccessibleDescription(received, expected) { + if (received == null) { + return { + message: () => + `The element was not an Element but '${String(received)}'`, + pass: false, + }; + } + + const actual = computeAccessibleDescription(received); + if (actual !== expected) { + return { + message: () => + `expected ${prettyDOM( + received + )} to have accessible description '${expected}' but got '${actual}'\n${diff( + expected, + actual + )}`, + pass: false, + }; + } + + return { + message: () => + `expected ${prettyDOM( + received + )} not to have accessible description '${expected}'\n${diff( + expected, + actual + )}`, + pass: true, + }; + }, +}); + +function testMarkup(markup, accessibleDescription) { + const container = renderIntoDocument(markup); + + const testNode = container.querySelector("[data-test]"); + expect(testNode).toHaveAccessibleDescription(accessibleDescription); +} + +describe("wpt copies", () => { + test.each([ + [ + `testfoo`, + "foo", + ], + [ + `United States`, + "San Francisco", + ], + ])(`#%#`, (markup, expectedAccessibleName) => + testMarkup(markup, expectedAccessibleName) + ); +}); diff --git a/sources/accessible-description.ts b/sources/accessible-description.ts new file mode 100644 index 00000000..550347b9 --- /dev/null +++ b/sources/accessible-description.ts @@ -0,0 +1,37 @@ +import { + computeTextAlternative, + ComputeTextAlternativeOptions, +} from "./accessible-name-and-description"; +import { queryIdRefs } from "./util"; + +/** + * implements https://w3c.github.io/accname/#mapping_additional_nd_description + * @param root + * @param [options] + * @parma [options.getComputedStyle] - mock window.getComputedStyle. Needs `content`, `display` and `visibility` + */ +export function computeAccessibleDescription( + root: Element, + options: ComputeTextAlternativeOptions = {} +): string { + let description = queryIdRefs(root, "aria-describedby") + .map((element) => { + return computeTextAlternative(element, { + ...options, + compute: "description", + }); + }) + .join(" "); + + // TODO: Technically we need to make sure that node wasn't used for the accessible name + // This causes `description_1.0_combobox-focusable-manual` to fail + // + // https://www.w3.org/TR/html-aam-1.0/#accessible-name-and-description-computation + // says for so many elements to use the `title` that we assume all elements are considered + if (description === "") { + const title = root.getAttribute("title"); + description = title === null ? "" : title; + } + + return description; +} diff --git a/sources/accessible-name-and-description.ts b/sources/accessible-name-and-description.ts new file mode 100644 index 00000000..38a7b0f1 --- /dev/null +++ b/sources/accessible-name-and-description.ts @@ -0,0 +1,543 @@ +/** + * implements https://w3c.github.io/accname/ + */ +import ArrayFrom from "./polyfills/array.from"; +import SetLike from "./polyfills/SetLike"; +import { + hasAnyConcreteRoles, + isElement, + isHTMLTableCaptionElement, + isHTMLInputElement, + isHTMLSelectElement, + isHTMLTextAreaElement, + safeWindow, + isHTMLFieldSetElement, + isHTMLLegendElement, + isHTMLTableElement, + queryIdRefs, +} from "./util"; + +/** + * A string of characters where all carriage returns, newlines, tabs, and form-feeds are replaced with a single space, and multiple spaces are reduced to a single space. The string contains only character data; it does not contain any markup. + */ +type FlatString = string & { + __flat: true; +}; + +/** + * interface for an options-bag where `window.getComputedStyle` can be mocked + */ +export interface ComputeTextAlternativeOptions { + compute?: "description" | "name"; + getComputedStyle?: typeof window.getComputedStyle; +} + +/** + * + * @param {string} string - + * @returns {FlatString} - + */ +function asFlatString(s: string): FlatString { + return s.trim().replace(/\s\s+/g, " ") as FlatString; +} + +/** + * + * @param node - + * @param options - These are not optional to prevent accidentally calling it without options in `computeAccessibleName` + * @returns {boolean} - + */ +function isHidden( + node: Node, + getComputedStyleImplementation: typeof window.getComputedStyle +): node is Element { + if (!isElement(node)) { + return false; + } + + if ( + node.hasAttribute("hidden") || + node.getAttribute("aria-hidden") === "true" + ) { + return true; + } + + const style = getComputedStyleImplementation(node); + return ( + style.getPropertyValue("display") === "none" || + style.getPropertyValue("visibility") === "hidden" + ); +} + +/** + * @param {Node} node - + * @returns {boolean} - As defined in step 2E of https://w3c.github.io/accname/#mapping_additional_nd_te + */ +function isControl(node: Node): boolean { + return ( + hasAnyConcreteRoles(node, ["button", "combobox", "listbox", "textbox"]) || + hasAbstractRole(node, "range") + ); +} + +function hasAbstractRole(node: Node, role: string): node is Element { + if (!isElement(node)) { + return false; + } + + switch (role) { + case "range": + return hasAnyConcreteRoles(node, [ + "meter", + "progressbar", + "scrollbar", + "slider", + "spinbutton", + ]); + default: + throw new TypeError( + `No knowledge about abstract role '${role}'. This is likely a bug :(` + ); + } +} + +/** + * element.querySelectorAll but also considers owned tree + * @param element + * @param selectors + */ +function querySelectorAllSubtree( + element: Element, + selectors: string +): Element[] { + const elements = ArrayFrom(element.querySelectorAll(selectors)); + + queryIdRefs(element, "aria-owns").forEach((root) => { + // babel transpiles this assuming an iterator + elements.push.apply(elements, ArrayFrom(root.querySelectorAll(selectors))); + }); + + return elements; +} + +function querySelectedOptions(listbox: Element): ArrayLike { + if (isHTMLSelectElement(listbox)) { + // IE11 polyfill + return ( + listbox.selectedOptions || querySelectorAllSubtree(listbox, "[selected]") + ); + } + return querySelectorAllSubtree(listbox, '[aria-selected="true"]'); +} + +function isMarkedPresentational(node: Node): node is Element { + return hasAnyConcreteRoles(node, ["none", "presentation"]); +} + +/** + * Elements specifically listed in html-aam + * + * We don't need this for `label` or `legend` elements. + * Their implicit roles already allow "naming from content". + * + * sources: + * + * - https://w3c.github.io/html-aam/#table-element + */ +function isNativeHostLanguageTextAlternativeElement( + node: Node +): node is Element { + return isHTMLTableCaptionElement(node); +} + +/** + * https://w3c.github.io/aria/#namefromcontent + */ +function allowsNameFromContent(node: Node): boolean { + return hasAnyConcreteRoles(node, [ + "button", + "cell", + "checkbox", + "columnheader", + "gridcell", + "heading", + "label", + "legend", + "link", + "menuitem", + "menuitemcheckbox", + "menuitemradio", + "option", + "radio", + "row", + "rowheader", + "switch", + "tab", + "tooltip", + "treeitem", + ]); +} + +/** + * TODO https://github.com/eps1lon/dom-accessibility-api/issues/100 + */ +function isDescendantOfNativeHostLanguageTextAlternativeElement( + // eslint-disable-next-line @typescript-eslint/no-unused-vars -- not implemented yet + node: Node +): boolean { + return false; +} + +/** + * TODO https://github.com/eps1lon/dom-accessibility-api/issues/101 + */ +// eslint-disable-next-line @typescript-eslint/no-unused-vars -- not implemented yet +function computeTooltipAttributeValue(node: Node): string | null { + return null; +} + +function getValueOfTextbox(element: Element): string { + if (isHTMLInputElement(element) || isHTMLTextAreaElement(element)) { + return element.value; + } + // https://github.com/eps1lon/dom-accessibility-api/issues/4 + return element.textContent || ""; +} + +function getTextualContent(declaration: CSSStyleDeclaration): string { + const content = declaration.getPropertyValue("content"); + if (/^["'].*["']$/.test(content)) { + return content.slice(1, -1); + } + return ""; +} + +/** + * implements https://w3c.github.io/accname/#mapping_additional_nd_te + * @param root + * @param [options] + * @parma [options.getComputedStyle] - mock window.getComputedStyle. Needs `content`, `display` and `visibility` + */ +export function computeTextAlternative( + root: Element, + options: ComputeTextAlternativeOptions = {} +): string { + const consultedNodes = new SetLike(); + + const window = safeWindow(root); + const { + compute = "name", + // This might be overengineered. I don't know what happens if I call + // window.getComputedStyle(elementFromAnotherWindow) or if I don't bind it + // the type declarations don't require a `this` + // eslint-disable-next-line no-restricted-properties + getComputedStyle = window.getComputedStyle.bind(window), + } = options; + + // 2F.i + function computeMiscTextAlternative( + node: Node, + context: { isEmbeddedInLabel: boolean; isReferenced: boolean } + ): string { + let accumulatedText = ""; + if (isElement(node)) { + const pseudoBefore = getComputedStyle(node, "::before"); + const beforeContent = getTextualContent(pseudoBefore); + accumulatedText = `${beforeContent} ${accumulatedText}`; + } + + // FIXME: This is not defined in the spec + // But it is required in the web-platform-test + const childNodes = ArrayFrom(node.childNodes).concat( + queryIdRefs(node, "aria-owns") + ); + childNodes.forEach((child) => { + const result = computeTextAlternative(child, { + isEmbeddedInLabel: context.isEmbeddedInLabel, + isReferenced: false, + recursion: true, + }); + // TODO: Unclear why display affects delimiter + // see https://github.com/w3c/accname/issues/3 + const display = isElement(child) + ? getComputedStyle(child).getPropertyValue("display") + : "inline"; + const separator = display !== "inline" ? " " : ""; + // trailing separator for wpt tests + accumulatedText += `${separator}${result}${separator}`; + }); + + if (isElement(node)) { + const pseudoAfter = getComputedStyle(node, ":after"); + const afterContent = getTextualContent(pseudoAfter); + accumulatedText = `${accumulatedText} ${afterContent}`; + } + + return accumulatedText; + } + + function computeAttributeTextAlternative(node: Node): string | null { + if (!isElement(node)) { + return null; + } + + const titleAttribute = node.getAttributeNode("title"); + if (titleAttribute !== null && !consultedNodes.has(titleAttribute)) { + consultedNodes.add(titleAttribute); + return titleAttribute.value; + } + + const altAttribute = node.getAttributeNode("alt"); + if (altAttribute !== null && !consultedNodes.has(altAttribute)) { + consultedNodes.add(altAttribute); + return altAttribute.value; + } + + if (isHTMLInputElement(node) && node.type === "button") { + consultedNodes.add(node); + return node.getAttribute("value") || ""; + } + + return null; + } + + function computeElementTextAlternative(node: Node): string | null { + // https://w3c.github.io/html-aam/#fieldset-and-legend-elements + if (isHTMLFieldSetElement(node)) { + consultedNodes.add(node); + const children = ArrayFrom(node.childNodes); + for (let i = 0; i < children.length; i += 1) { + const child = children[i]; + if (isHTMLLegendElement(child)) { + return computeTextAlternative(child, { + isEmbeddedInLabel: false, + isReferenced: false, + recursion: false, + }); + } + } + return null; + } + + // https://w3c.github.io/html-aam/#table-element + if (isHTMLTableElement(node)) { + consultedNodes.add(node); + const children = ArrayFrom(node.childNodes); + for (let i = 0; i < children.length; i += 1) { + const child = children[i]; + if (isHTMLTableCaptionElement(child)) { + return computeTextAlternative(child, { + isEmbeddedInLabel: false, + isReferenced: false, + recursion: false, + }); + } + } + return null; + } + + if ( + !( + isHTMLInputElement(node) || + isHTMLSelectElement(node) || + isHTMLTextAreaElement(node) + ) + ) { + return null; + } + const input = node; + + // https://w3c.github.io/html-aam/#input-type-text-input-type-password-input-type-search-input-type-tel-input-type-email-input-type-url-and-textarea-element-accessible-description-computation + if (input.type === "submit") { + return "Submit"; + } + if (input.type === "reset") { + return "Reset"; + } + + const { labels } = input; + // IE11 does not implement labels, TODO: verify with caniuse instead of mdn + if (labels === null || labels === undefined || labels.length === 0) { + return null; + } + + consultedNodes.add(input); + return ArrayFrom(labels) + .map((element) => { + return computeTextAlternative(element, { + isEmbeddedInLabel: true, + isReferenced: false, + recursion: true, + }); + }) + .filter((label) => { + return label.length > 0; + }) + .join(" "); + } + + function computeTextAlternative( + current: Node, + context: { + isEmbeddedInLabel: boolean; + isReferenced: boolean; + recursion: boolean; + } + ): string { + if (consultedNodes.has(current)) { + return ""; + } + + // special casing, cheating to make tests pass + // https://github.com/w3c/accname/issues/67 + if (hasAnyConcreteRoles(current, ["menu"])) { + consultedNodes.add(current); + return ""; + } + + // 2A + if (isHidden(current, getComputedStyle) && !context.isReferenced) { + consultedNodes.add(current); + return "" as FlatString; + } + + // 2B + const labelElements = queryIdRefs(current, "aria-labelledby"); + if ( + compute === "name" && + !context.isReferenced && + labelElements.length > 0 + ) { + return labelElements + .map((element) => + computeTextAlternative(element, { + isEmbeddedInLabel: context.isEmbeddedInLabel, + isReferenced: true, + // thais isn't recursion as specified, otherwise we would skip + // `aria-label` in + // { + return computeTextAlternative(selectedOption, { + isEmbeddedInLabel: context.isEmbeddedInLabel, + isReferenced: false, + recursion: true, + }); + }) + .join(" "); + } + if (hasAbstractRole(current, "range")) { + consultedNodes.add(current); + if (current.hasAttribute("aria-valuetext")) { + // safe due to hasAttribute guard + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return current.getAttribute("aria-valuetext")!; + } + if (current.hasAttribute("aria-valuenow")) { + // safe due to hasAttribute guard + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return current.getAttribute("aria-valuenow")!; + } + // Otherwise, use the value as specified by a host language attribute. + return current.getAttribute("value") || ""; + } + if (hasAnyConcreteRoles(current, ["textbox"])) { + consultedNodes.add(current); + return getValueOfTextbox(current); + } + } + + // 2F: https://w3c.github.io/accname/#step2F + if ( + allowsNameFromContent(current) || + (isElement(current) && context.isReferenced) || + isNativeHostLanguageTextAlternativeElement(current) || + isDescendantOfNativeHostLanguageTextAlternativeElement(current) + ) { + consultedNodes.add(current); + return computeMiscTextAlternative(current, { + isEmbeddedInLabel: context.isEmbeddedInLabel, + isReferenced: false, + }); + } + + if (current.nodeType === current.TEXT_NODE) { + consultedNodes.add(current); + return current.textContent || ""; + } + + if (context.recursion) { + consultedNodes.add(current); + return computeMiscTextAlternative(current, { + isEmbeddedInLabel: context.isEmbeddedInLabel, + isReferenced: false, + }); + } + + const tooltipAttributeValue = computeTooltipAttributeValue(current); + if (tooltipAttributeValue !== null) { + consultedNodes.add(current); + return tooltipAttributeValue; + } + + // TODO should this be reachable? + consultedNodes.add(current); + return ""; + } + + return asFlatString( + computeTextAlternative(root, { + isEmbeddedInLabel: false, + // by spec computeAccessibleDescription starts with the referenced elements as roots + isReferenced: compute === "description", + recursion: false, + }) + ); +} diff --git a/sources/accessible-name.ts b/sources/accessible-name.ts index 4c9af851..1af98401 100644 --- a/sources/accessible-name.ts +++ b/sources/accessible-name.ts @@ -1,64 +1,8 @@ -/** - * implements https://w3c.github.io/accname/ - */ -import ArrayFrom from "./polyfills/array.from"; -import SetLike from "./polyfills/SetLike"; -import getRole from "./getRole"; import { - isElement, - isHTMLTableCaptionElement, - isHTMLInputElement, - isHTMLSelectElement, - isHTMLTextAreaElement, - safeWindow, - isHTMLFieldSetElement, - isHTMLLegendElement, - isHTMLTableElement, -} from "./util"; - -/** - * A string of characters where all carriage returns, newlines, tabs, and form-feeds are replaced with a single space, and multiple spaces are reduced to a single space. The string contains only character data; it does not contain any markup. - */ -type FlatString = string & { - __flat: true; -}; - -/** - * interface for an options-bag where `window.getComputedStyle` can be mocked - */ -interface GetComputedStyleOptions { - getComputedStyle?: typeof window.getComputedStyle; -} -/** - * Small utility that handles all the JS quirks with `this` which is important - * if no mock is provided. - * @param element - * @param options - These are not optional to prevent accidentally calling it without options in `computeAccessibleName` - */ -function createGetComputedStyle( - element: Element, - options: GetComputedStyleOptions -): typeof window.getComputedStyle { - const window = safeWindow(element); - const { - // This might be overengineered. I don't know what happens if I call - // window.getComputedStyle(elementFromAnotherWindow) or if I don't bind it - // the type declarations don't require a `this` - // eslint-disable-next-line no-restricted-properties - getComputedStyle = window.getComputedStyle.bind(window), - } = options; - - return getComputedStyle; -} - -/** - * - * @param {string} string - - * @returns {FlatString} - - */ -function asFlatString(s: string): FlatString { - return s.trim().replace(/\s\s+/g, " ") as FlatString; -} + computeTextAlternative, + ComputeTextAlternativeOptions, +} from "./accessible-name-and-description"; +import { hasAnyConcreteRoles } from "./util"; /** * https://w3c.github.io/aria/#namefromprohibited @@ -80,534 +24,18 @@ function prohibitsNaming(node: Node): boolean { } /** - * - * @param node - - * @param options - These are not optional to prevent accidentally calling it without options in `computeAccessibleName` - * @returns {boolean} - - */ -function isHidden( - node: Node, - options: GetComputedStyleOptions -): node is Element { - if (!isElement(node)) { - return false; - } - - if ( - node.hasAttribute("hidden") || - node.getAttribute("aria-hidden") === "true" - ) { - return true; - } - - const style = createGetComputedStyle(node, options)(node); - return ( - style.getPropertyValue("display") === "none" || - style.getPropertyValue("visibility") === "hidden" - ); -} - -/** - * - * @param {Node} node - - * @param {string} attributeName - - * @returns {Element[]} - - */ -function idRefs(node: Node, attributeName: string): Element[] { - if (isElement(node) && node.hasAttribute(attributeName)) { - // safe due to hasAttribute check - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const ids = node.getAttribute(attributeName)!.split(" "); - - return ( - ids - // safe since it can't be null for an Element - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - .map((id) => node.ownerDocument!.getElementById(id)) - .filter( - (element: Element | null): element is Element => element !== null - // TODO: why does this not narrow? - ) as Element[] - ); - } - - return []; -} - -/** - * @param {Node} node - - * @returns {boolean} - As defined in step 2E of https://w3c.github.io/accname/#mapping_additional_nd_te - */ -function isControl(node: Node): boolean { - return ( - hasAnyConcreteRoles(node, ["button", "combobox", "listbox", "textbox"]) || - hasAbstractRole(node, "range") - ); -} - -function hasAbstractRole(node: Node, role: string): node is Element { - if (!isElement(node)) { - return false; - } - - switch (role) { - case "range": - return hasAnyConcreteRoles(node, [ - "meter", - "progressbar", - "scrollbar", - "slider", - "spinbutton", - ]); - default: - throw new TypeError( - `No knowledge about abstract role '${role}'. This is likely a bug :(` - ); - } -} - -function hasAnyConcreteRoles( - node: Node, - roles: Array -): node is Element { - if (isElement(node)) { - return roles.indexOf(getRole(node)) !== -1; - } - return false; -} - -/** - * element.querySelectorAll but also considers owned tree - * @param element - * @param selectors - */ -function querySelectorAllSubtree( - element: Element, - selectors: string -): Element[] { - const elements = ArrayFrom(element.querySelectorAll(selectors)); - - idRefs(element, "aria-owns").forEach((root) => { - // babel transpiles this assuming an iterator - elements.push.apply(elements, ArrayFrom(root.querySelectorAll(selectors))); - }); - - return elements; -} - -function querySelectedOptions(listbox: Element): ArrayLike { - if (isHTMLSelectElement(listbox)) { - // IE11 polyfill - return ( - listbox.selectedOptions || querySelectorAllSubtree(listbox, "[selected]") - ); - } - return querySelectorAllSubtree(listbox, '[aria-selected="true"]'); -} - -function isMarkedPresentational(node: Node): node is Element { - return hasAnyConcreteRoles(node, ["none", "presentation"]); -} - -/** - * Elements specifically listed in html-aam - * - * We don't need this for `label` or `legend` elements. - * Their implicit roles already allow "naming from content". - * - * sources: - * - * - https://w3c.github.io/html-aam/#table-element - */ -function isNativeHostLanguageTextAlternativeElement( - node: Node -): node is Element { - return isHTMLTableCaptionElement(node); -} - -/** - * https://w3c.github.io/aria/#namefromcontent - */ -function allowsNameFromContent(node: Node): boolean { - return hasAnyConcreteRoles(node, [ - "button", - "cell", - "checkbox", - "columnheader", - "gridcell", - "heading", - "label", - "legend", - "link", - "menuitem", - "menuitemcheckbox", - "menuitemradio", - "option", - "radio", - "row", - "rowheader", - "switch", - "tab", - "tooltip", - "treeitem", - ]); -} - -/** - * TODO https://github.com/eps1lon/dom-accessibility-api/issues/100 - */ -function isDescendantOfNativeHostLanguageTextAlternativeElement( - // eslint-disable-next-line @typescript-eslint/no-unused-vars -- not implemented yet - node: Node -): boolean { - return false; -} - -/** - * TODO https://github.com/eps1lon/dom-accessibility-api/issues/101 - */ -// eslint-disable-next-line @typescript-eslint/no-unused-vars -- not implemented yet -function computeTooltipAttributeValue(node: Node): string | null { - return null; -} - -function getValueOfTextbox(element: Element): string { - if (isHTMLInputElement(element) || isHTMLTextAreaElement(element)) { - return element.value; - } - // https://github.com/eps1lon/dom-accessibility-api/issues/4 - return element.textContent || ""; -} - -function getTextualContent(declaration: CSSStyleDeclaration): string { - const content = declaration.getPropertyValue("content"); - if (/^["'].*["']$/.test(content)) { - return content.slice(1, -1); - } - return ""; -} - -/** - * implements https://w3c.github.io/accname/#mapping_additional_nd_te + * implements https://w3c.github.io/accname/#mapping_additional_nd_name * @param root * @param [options] * @parma [options.getComputedStyle] - mock window.getComputedStyle. Needs `content`, `display` and `visibility` */ export function computeAccessibleName( root: Element, - options: GetComputedStyleOptions = {} + options: ComputeTextAlternativeOptions = {} ): string { - const consultedNodes = new SetLike(); - if (prohibitsNaming(root)) { - return "" as FlatString; - } - - // 2F.i - function computeMiscTextAlternative( - node: Node, - context: { isEmbeddedInLabel: boolean; isReferenced: boolean } - ): string { - let accumulatedText = ""; - if (isElement(node)) { - const pseudoBefore = createGetComputedStyle(node, options)( - node, - "::before" - ); - const beforeContent = getTextualContent(pseudoBefore); - accumulatedText = `${beforeContent} ${accumulatedText}`; - } - - // FIXME: This is not defined in the spec - // But it is required in the web-platform-test - const childNodes = ArrayFrom(node.childNodes).concat( - idRefs(node, "aria-owns") - ); - childNodes.forEach((child) => { - const result = computeTextAlternative(child, { - isEmbeddedInLabel: context.isEmbeddedInLabel, - isReferenced: false, - recursion: true, - }); - // TODO: Unclear why display affects delimiter - // see https://github.com/w3c/accname/issues/3 - const display = isElement(child) - ? createGetComputedStyle( - child, - options - // eslint-disable-next-line no-mixed-spaces-and-tabs -- prettier bug? - )(child).getPropertyValue("display") - : "inline"; - const separator = display !== "inline" ? " " : ""; - // trailing separator for wpt tests - accumulatedText += `${separator}${result}${separator}`; - }); - - if (isElement(node)) { - const pseudoAfter = createGetComputedStyle(node, options)(node, ":after"); - const afterContent = getTextualContent(pseudoAfter); - accumulatedText = `${accumulatedText} ${afterContent}`; - } - - return accumulatedText; - } - - function computeAttributeTextAlternative(node: Node): string | null { - if (!isElement(node)) { - return null; - } - - const titleAttribute = node.getAttributeNode("title"); - if (titleAttribute !== null && !consultedNodes.has(titleAttribute)) { - consultedNodes.add(titleAttribute); - return titleAttribute.value; - } - - const altAttribute = node.getAttributeNode("alt"); - if (altAttribute !== null && !consultedNodes.has(altAttribute)) { - consultedNodes.add(altAttribute); - return altAttribute.value; - } - - if (isHTMLInputElement(node) && node.type === "button") { - consultedNodes.add(node); - return node.getAttribute("value") || ""; - } - - return null; - } - - function computeElementTextAlternative(node: Node): string | null { - // https://w3c.github.io/html-aam/#fieldset-and-legend-elements - if (isHTMLFieldSetElement(node)) { - consultedNodes.add(node); - const children = ArrayFrom(node.childNodes); - for (let i = 0; i < children.length; i += 1) { - const child = children[i]; - if (isHTMLLegendElement(child)) { - return computeTextAlternative(child, { - isEmbeddedInLabel: false, - isReferenced: false, - recursion: false, - }); - } - } - return null; - } - - // https://w3c.github.io/html-aam/#table-element - if (isHTMLTableElement(node)) { - consultedNodes.add(node); - const children = ArrayFrom(node.childNodes); - for (let i = 0; i < children.length; i += 1) { - const child = children[i]; - if (isHTMLTableCaptionElement(child)) { - return computeTextAlternative(child, { - isEmbeddedInLabel: false, - isReferenced: false, - recursion: false, - }); - } - } - return null; - } - - if ( - !( - isHTMLInputElement(node) || - isHTMLSelectElement(node) || - isHTMLTextAreaElement(node) - ) - ) { - return null; - } - const input = node; - - // https://w3c.github.io/html-aam/#input-type-text-input-type-password-input-type-search-input-type-tel-input-type-email-input-type-url-and-textarea-element-accessible-description-computation - if (input.type === "submit") { - return "Submit"; - } - if (input.type === "reset") { - return "Reset"; - } - - const { labels } = input; - // IE11 does not implement labels, TODO: verify with caniuse instead of mdn - if (labels === null || labels === undefined || labels.length === 0) { - return null; - } - - consultedNodes.add(input); - return ArrayFrom(labels) - .map((element) => { - return computeTextAlternative(element, { - isEmbeddedInLabel: true, - isReferenced: false, - recursion: true, - }); - }) - .filter((label) => { - return label.length > 0; - }) - .join(" "); - } - - function computeTextAlternative( - current: Node, - context: { - isEmbeddedInLabel: boolean; - isReferenced: boolean; - recursion: boolean; - } - ): string { - if (consultedNodes.has(current)) { - return ""; - } - - // special casing, cheating to make tests pass - // https://github.com/w3c/accname/issues/67 - if (hasAnyConcreteRoles(current, ["menu"])) { - consultedNodes.add(current); - return ""; - } - - // 2A - if (isHidden(current, options) && !context.isReferenced) { - consultedNodes.add(current); - return "" as FlatString; - } - - // 2B - const labelElements = idRefs(current, "aria-labelledby"); - if (!context.isReferenced && labelElements.length > 0) { - return labelElements - .map((element) => - computeTextAlternative(element, { - isEmbeddedInLabel: context.isEmbeddedInLabel, - isReferenced: true, - // thais isn't recursion as specified, otherwise we would skip - // `aria-label` in - // { - return computeTextAlternative(selectedOption, { - isEmbeddedInLabel: context.isEmbeddedInLabel, - isReferenced: false, - recursion: true, - }); - }) - .join(" "); - } - if (hasAbstractRole(current, "range")) { - consultedNodes.add(current); - if (current.hasAttribute("aria-valuetext")) { - // safe due to hasAttribute guard - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - return current.getAttribute("aria-valuetext")!; - } - if (current.hasAttribute("aria-valuenow")) { - // safe due to hasAttribute guard - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - return current.getAttribute("aria-valuenow")!; - } - // Otherwise, use the value as specified by a host language attribute. - return current.getAttribute("value") || ""; - } - if (hasAnyConcreteRoles(current, ["textbox"])) { - consultedNodes.add(current); - return getValueOfTextbox(current); - } - } - - // 2F: https://w3c.github.io/accname/#step2F - if ( - allowsNameFromContent(current) || - (isElement(current) && context.isReferenced) || - isNativeHostLanguageTextAlternativeElement(current) || - isDescendantOfNativeHostLanguageTextAlternativeElement(current) - ) { - consultedNodes.add(current); - return computeMiscTextAlternative(current, { - isEmbeddedInLabel: context.isEmbeddedInLabel, - isReferenced: false, - }); - } - - if (current.nodeType === current.TEXT_NODE) { - consultedNodes.add(current); - return current.textContent || ""; - } - - if (context.recursion) { - consultedNodes.add(current); - return computeMiscTextAlternative(current, { - isEmbeddedInLabel: context.isEmbeddedInLabel, - isReferenced: false, - }); - } - - const tooltipAttributeValue = computeTooltipAttributeValue(current); - if (tooltipAttributeValue !== null) { - consultedNodes.add(current); - return tooltipAttributeValue; - } - - // TODO should this be reachable? - consultedNodes.add(current); return ""; } - return asFlatString( - computeTextAlternative(root, { - isEmbeddedInLabel: false, - isReferenced: false, - recursion: false, - }) - ); + return computeTextAlternative(root, options); } diff --git a/sources/index.ts b/sources/index.ts index 56e91087..677737da 100644 --- a/sources/index.ts +++ b/sources/index.ts @@ -1,2 +1,3 @@ +export { computeAccessibleDescription } from "./accessible-description"; export { computeAccessibleName } from "./accessible-name"; export { default as getRole } from "./getRole"; diff --git a/sources/util.ts b/sources/util.ts index e0710e6a..3fc2becd 100644 --- a/sources/util.ts +++ b/sources/util.ts @@ -1,3 +1,5 @@ +import getRole from "./getRole"; + export function isElement(node: Node | null): node is Element { return node !== null && node.nodeType === node.ELEMENT_NODE; } @@ -53,3 +55,40 @@ export function isHTMLLegendElement( ): node is HTMLLegendElement { return isElement(node) && node.tagName === "LEGEND"; } + +/** + * + * @param {Node} node - + * @param {string} attributeName - + * @returns {Element[]} - + */ +export function queryIdRefs(node: Node, attributeName: string): Element[] { + if (isElement(node) && node.hasAttribute(attributeName)) { + // safe due to hasAttribute check + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const ids = node.getAttribute(attributeName)!.split(" "); + + return ( + ids + // safe since it can't be null for an Element + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + .map((id) => node.ownerDocument!.getElementById(id)) + .filter( + (element: Element | null): element is Element => element !== null + // TODO: why does this not narrow? + ) as Element[] + ); + } + + return []; +} + +export function hasAnyConcreteRoles( + node: Node, + roles: Array +): node is Element { + if (isElement(node)) { + return roles.indexOf(getRole(node)) !== -1; + } + return false; +} diff --git a/tests/cypress/fixtures/example.json b/tests/cypress/fixtures/example.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/tests/cypress/fixtures/example.json @@ -0,0 +1 @@ +{} diff --git a/tests/cypress/integration/web-platform-test.js b/tests/cypress/integration/web-platform-test.js index c76cbfc3..0562459a 100644 --- a/tests/cypress/integration/web-platform-test.js +++ b/tests/cypress/integration/web-platform-test.js @@ -4,6 +4,20 @@ const usedApiIndex = 0; // ATK context("wpt", () => { [ + ["description_1.0_combobox-focusable-manual.html", "fail"], + ["description_from_content_of_describedby_element-manual.html", "fail"], + ["description_link-with-label-manual.html", "pass"], + ["description_test_case_557-manual.html", "pass"], + ["description_test_case_664-manual.html", "pass"], + ["description_test_case_665-manual.html", "pass"], + ["description_test_case_666-manual.html", "pass"], + ["description_test_case_772-manual.html", "pass"], + ["description_test_case_773-manual.html", "pass"], + ["description_test_case_774-manual.html", "pass"], + ["description_test_case_838-manual.html", "pass"], + ["description_test_case_broken_reference-manual.html", "pass"], + ["description_test_case_one_valid_reference-manual.html", "pass"], + ["description_title-same-element-manual.html", "pass"], ["name_1.0_combobox-focusable-alternative-manual", "pass"], ["name_1.0_combobox-focusable-manual", "pass"], ["name_checkbox-label-embedded-combobox-manual", "pass"], @@ -160,23 +174,36 @@ context("wpt", () => { `#steps tr:nth-of-type(2) .api tr:nth-of-type(${2 + usedApiIndex})` ) .then(($row) => { - const [$apiName, $access, $name, $equality, $expected] = $row.find( - "td" - ); + const [ + $apiName, + $access, + $property, + $equality, + $expected, + ] = $row.find("td"); // these cases are handled expect($apiName.textContent).to.equal("ATK"); expect($access.textContent).to.equal("property"); + expect($property.textContent).to.be.oneOf(["name", "description"]); expect($equality.textContent).to.equal("is"); - return $expected.textContent; + return [$property.textContent, $expected.textContent]; }) - .then((expected) => { + .then(([property, expected]) => { cy.get("#test").then(($element) => { - if (result === "pass") { - expect($element[0]).to.have.accessibleName(expected); - } else if (result === "fail") { - expect($element[0]).not.to.have.accessibleName(expected); + if (property === "name") { + if (result === "pass") { + expect($element[0]).to.have.accessibleName(expected); + } else if (result === "fail") { + expect($element[0]).not.to.have.accessibleName(expected); + } + } else if (property === "description") { + if (result === "pass") { + expect($element[0]).to.have.accessibleDescription(expected); + } else if (result === "fail") { + expect($element[0]).not.to.have.accessibleDescription(expected); + } } }); }); diff --git a/tests/cypress/support/index.js b/tests/cypress/support/index.js index fde5fdcd..c5ad9e54 100644 --- a/tests/cypress/support/index.js +++ b/tests/cypress/support/index.js @@ -1,9 +1,11 @@ -import { computeAccessibleName } from "../../../dist"; +import { + computeAccessibleName, + computeAccessibleDescription, +} from "../../../dist"; chai.use((_chai, _utils) => { function assertAccessibleName(expected) { const element = _utils.flag(this, "object"); - console.log(element); const actual = computeAccessibleName(element); this.assert( @@ -14,4 +16,20 @@ chai.use((_chai, _utils) => { } _chai.Assertion.addMethod("accessibleName", assertAccessibleName); + + function assertAccessibleDescription(expected) { + const element = _utils.flag(this, "object"); + const actual = computeAccessibleDescription(element); + + this.assert( + expected === actual, + `expected to have accessible description '${expected}' but got '${actual}'`, + `expected to not have accessible description ${expected}` + ); + } + + _chai.Assertion.addMethod( + "accessibleDescription", + assertAccessibleDescription + ); }); diff --git a/tests/wpt-jsdom/ATTAcomm.js b/tests/wpt-jsdom/ATTAcomm.js index 275b3738..c86b298e 100644 --- a/tests/wpt-jsdom/ATTAcomm.js +++ b/tests/wpt-jsdom/ATTAcomm.js @@ -30,6 +30,12 @@ class ATTAcomm { assert_equals(actual, expected); continue; } + } else if (name === descriptionPropertyName[implementation]) { + const actual = computeAccessibleDescription(element); + if (equality === "is") { + assert_equals(actual, expected); + continue; + } } } diff --git a/tests/wpt-jsdom/run-single-wpt.js b/tests/wpt-jsdom/run-single-wpt.js index 9a5b64fc..b3a9bc83 100644 --- a/tests/wpt-jsdom/run-single-wpt.js +++ b/tests/wpt-jsdom/run-single-wpt.js @@ -6,7 +6,10 @@ const { specify } = require("mocha-sugar-free"); const { inBrowserContext } = require("./util.js"); const { JSDOM, VirtualConsole } = require("jsdom"); const ResourceLoader = require("jsdom/lib/jsdom/browser/resources/resource-loader"); -const { computeAccessibleName } = require("../../dist/"); +const { + computeAccessibleName, + computeAccessibleDescription, +} = require("../../dist/"); const reporterPathname = "/resources/testharnessreport.js"; @@ -98,6 +101,7 @@ function createJSDOM(urlPrefix, testPath, expectFail) { const errors = []; window.computeAccessibleName = computeAccessibleName; + window.computeAccessibleDescription = computeAccessibleDescription; window.shimTest = () => { const oldSetup = window.setup; diff --git a/tests/wpt-jsdom/to-run.yaml b/tests/wpt-jsdom/to-run.yaml index b35305cb..3befe18c 100644 --- a/tests/wpt-jsdom/to-run.yaml +++ b/tests/wpt-jsdom/to-run.yaml @@ -1,10 +1,12 @@ DIR: accname -description_*.html: [fail, bridge not implemented] +description_1.0_combobox-focusable-manual.html: + [fail, title already used for name] +description_from_content_of_describedby_element-manual.html: + [fail, test considers aria-label for description which is wrong IMO] name_file-label-inline-block-elements-manual.html: [fail, whitespace issue, likely due to `display`] -name_file-label-inline-block-styles-manual.html: - [fail, missing word, unknown] +name_file-label-inline-block-styles-manual.html: [fail, missing word, unknown] name_test_case_552-manual.html: [fail, getComputedStyle pseudo selector not implemented] name_test_case_553-manual.html: @@ -35,4 +37,3 @@ name_test_case_759-manual.html: [fail, getComputedStyle pseudo selector not implemented] name_test_case_76*-manual.html: [fail, getComputedStyle pseudo selector not implemented] -#name_*.html: [pass]