diff --git a/.babelrc.js b/.babelrc.js index 9870ed13..84bcb43e 100644 --- a/.babelrc.js +++ b/.babelrc.js @@ -42,6 +42,9 @@ module.exports = { return { visitor: { + ExportAllDeclaration(path, state) { + rewriteRelativeImports(path.node); + }, ExportNamedDeclaration(path, state) { rewriteRelativeImports(path.node); }, diff --git a/.changeset/curly-ghosts-sing.md b/.changeset/curly-ghosts-sing.md new file mode 100644 index 00000000..d56d8e28 --- /dev/null +++ b/.changeset/curly-ghosts-sing.md @@ -0,0 +1,8 @@ +--- +"dom-accessibility-api": patch +--- + +Add `isInaccessible` and `isSubtreeInaccessible`. + +`isInaccessible` implements https://www.w3.org/TR/wai-aria-1.2/#tree_exclusion. +`isSubtreeInaccessible` can be used to inject a memoized version of that function into `isInaccessible`. diff --git a/sources/__tests__/is-inaccessible.js b/sources/__tests__/is-inaccessible.js new file mode 100644 index 00000000..66f7aebf --- /dev/null +++ b/sources/__tests__/is-inaccessible.js @@ -0,0 +1,133 @@ +import { isInaccessible, isSubtreeInaccessible } from "../is-inaccessible"; +import { cleanup, renderIntoDocument } from "./helpers/test-utils"; + +afterEach(() => { + jest.restoreAllMocks(); +}); + +describe("isInaccessible", () => { + test.each([ + ["
", false], + ['', false], + ['', false], + [ + ' ', + false, + ], + ["", true], + ['', true], + ['', true], + ['', true], + ])("markup #%#", (markup, expectedIsInaccessible) => { + const container = renderIntoDocument(markup); + expect(container).not.toBe(null); + + const testNode = container.querySelector("[data-test]"); + expect(isInaccessible(testNode)).toBe(expectedIsInaccessible); + }); + + test("isSubtreeInaccessible implementation can be injected", () => { + const container = renderIntoDocument( + `` + ); + const testNode = container.querySelector("[data-test]"); + + // accessible since we ignored styles + expect( + isInaccessible(testNode, { + // ignore subtree accessibility + // A more useful usecase would be caching these results for repeated calls of `isInaccessible` + isSubtreeInaccessible: () => false, + }) + ).toBe(false); + }); + + test("window.getComputedStyle implementation can be injected", () => { + jest.spyOn(window, "getComputedStyle"); + const container = renderIntoDocument( + `` + ); + const testNode = container.querySelector("[data-test]"); + + // accessible since we ignored styles + expect( + isInaccessible(testNode, { + // mock `getComputedStyle` with an empty CSSDeclaration + getComputedStyle: () => { + const styles = document.createElement("div").style; + + return styles; + }, + }) + ).toBe(false); + expect(window.getComputedStyle).toHaveBeenCalledTimes(0); + }); + + test("throws if ownerDocument is not associated to a window", () => { + expect(() => + isInaccessible(document.createElement("div"), { + // mocking no available window + // https://developer.mozilla.org/en-US/docs/Web/API/Document/defaultView + getComputedStyle: null, + }) + ).toThrowErrorMatchingInlineSnapshot( + `"Owner document of the element needs to have an associated window."` + ); + }); +}); + +describe("isSubtreeInaccessible", () => { + test.each([ + ["", false], + ['', false], + ['', false], + [ + ' ', + false, + ], + ["", true], + ['', true], + ['', true], + ])("markup #%#", (markup, expectedIsInaccessible) => { + const container = renderIntoDocument(markup); + expect(container).not.toBe(null); + + const testNode = container.querySelector("[data-test]"); + expect(isSubtreeInaccessible(testNode)).toBe(expectedIsInaccessible); + }); + + test("window.getComputedStyle implementation can be injected", () => { + jest.spyOn(window, "getComputedStyle"); + const container = renderIntoDocument( + `` + ); + const testNode = container.querySelector("[data-test]"); + + // accessible since we ignored styles + expect( + isSubtreeInaccessible(testNode, { + // mock `getComputedStyle` with an empty CSSDeclaration + getComputedStyle: () => { + const styles = document.createElement("div").style; + + return styles; + }, + }) + ).toBe(false); + expect(window.getComputedStyle).toHaveBeenCalledTimes(0); + }); + + test("throws if ownerDocument is not associated to a window", () => { + expect(() => + isSubtreeInaccessible(document.createElement("div"), { + // mocking no available window + // https://developer.mozilla.org/en-US/docs/Web/API/Document/defaultView + getComputedStyle: null, + }) + ).toThrowErrorMatchingInlineSnapshot( + `"Owner document of the element needs to have an associated window."` + ); + }); +}); + +afterEach(cleanup); diff --git a/sources/index.ts b/sources/index.ts index 677737da..e26d4f40 100644 --- a/sources/index.ts +++ b/sources/index.ts @@ -1,3 +1,4 @@ export { computeAccessibleDescription } from "./accessible-description"; export { computeAccessibleName } from "./accessible-name"; export { default as getRole } from "./getRole"; +export * from "./is-inaccessible"; diff --git a/sources/is-inaccessible.ts b/sources/is-inaccessible.ts new file mode 100644 index 00000000..048cf67a --- /dev/null +++ b/sources/is-inaccessible.ts @@ -0,0 +1,87 @@ +export interface IsInaccessibleOptions { + getComputedStyle?: typeof window.getComputedStyle; + /** + * Can be used to return cached results from previous isSubtreeInaccessible calls. + */ + isSubtreeInaccessible?: (element: Element) => boolean; +} + +/** + * Partial implementation https://www.w3.org/TR/wai-aria-1.2/#tree_exclusion + * which should only be used for elements with a non-presentational role i.e. + * `role="none"` and `role="presentation"` will not be excluded. + * + * Implements aria-hidden semantics (i.e. parent overrides child) + * Ignores "Child Presentational: True" characteristics + * + * @param element + * @param options + * @returns {boolean} true if excluded, otherwise false + */ +export function isInaccessible( + element: Element, + options: IsInaccessibleOptions = {} +): boolean { + const { + getComputedStyle = element.ownerDocument.defaultView?.getComputedStyle, + isSubtreeInaccessible: isSubtreeInaccessibleImpl = isSubtreeInaccessible, + } = options; + if (typeof getComputedStyle !== "function") { + throw new TypeError( + "Owner document of the element needs to have an associated window." + ); + } + // since visibility is inherited we can exit early + if (getComputedStyle(element).visibility === "hidden") { + return true; + } + + let currentElement: Element | null = element; + while (currentElement) { + if (isSubtreeInaccessibleImpl(currentElement, { getComputedStyle })) { + return true; + } + + currentElement = currentElement.parentElement; + } + + return false; +} + +export interface IsSubtreeInaccessibleOptions { + getComputedStyle?: typeof window.getComputedStyle; +} + +/** + * + * @param element + * @param options + * @returns {boolean} - `true` if every child of the element is inaccessible + */ +export function isSubtreeInaccessible( + element: Element, + options: IsSubtreeInaccessibleOptions = {} +): boolean { + const { + getComputedStyle = element.ownerDocument.defaultView?.getComputedStyle, + } = options; + if (typeof getComputedStyle !== "function") { + throw new TypeError( + "Owner document of the element needs to have an associated window." + ); + } + + if ((element as HTMLElement).hidden === true) { + return true; + } + + if (element.getAttribute("aria-hidden") === "true") { + return true; + } + + if (getComputedStyle(element).display === "none") { + return true; + } + + return false; +}