Skip to content

Commit

Permalink
feat: Implement isInaccessible and isSubtreeInaccessible (#343)
Browse files Browse the repository at this point in the history
  • Loading branch information
eps1lon committed Oct 8, 2021
1 parent bf58d26 commit 3d755c2
Show file tree
Hide file tree
Showing 5 changed files with 232 additions and 0 deletions.
3 changes: 3 additions & 0 deletions .babelrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,9 @@ module.exports = {

return {
visitor: {
ExportAllDeclaration(path, state) {
rewriteRelativeImports(path.node);
},
ExportNamedDeclaration(path, state) {
rewriteRelativeImports(path.node);
},
Expand Down
8 changes: 8 additions & 0 deletions .changeset/curly-ghosts-sing.md
Original file line number Diff line number Diff line change
@@ -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`.
133 changes: 133 additions & 0 deletions sources/__tests__/is-inaccessible.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import { isInaccessible, isSubtreeInaccessible } from "../is-inaccessible";
import { cleanup, renderIntoDocument } from "./helpers/test-utils";

afterEach(() => {
jest.restoreAllMocks();
});

describe("isInaccessible", () => {
test.each([
["<div data-test />", false],
['<div data-test aria-hidden="false" />', false],
['<div data-test style="visibility: visible" />', false],
[
'<div style="visibility: hidden;"><div data-test style="visibility: visible;"/></div>',
false,
],
["<div data-test hidden />", true],
['<div data-test style="display: none;"/>', true],
['<div data-test style="visibility: hidden;"/>', true],
['<div data-test aria-hidden="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(
`<div style="display: none;"><button data-test /></div>`
);
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(
`<button data-test style="display: none;" />`
);
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([
["<div data-test />", false],
['<div data-test aria-hidden="false" />', false],
['<div data-test style="visibility: hidden" />', false],
[
'<div style="visibility: hidden;"><div data-test style="visibility: visible;"/></div>',
false,
],
["<div data-test hidden />", true],
['<div data-test style="display: none;"/>', true],
['<div data-test aria-hidden="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(
`<button data-test style="display: none;" />`
);
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);
1 change: 1 addition & 0 deletions sources/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export { computeAccessibleDescription } from "./accessible-description";
export { computeAccessibleName } from "./accessible-name";
export { default as getRole } from "./getRole";
export * from "./is-inaccessible";
87 changes: 87 additions & 0 deletions sources/is-inaccessible.ts
Original file line number Diff line number Diff line change
@@ -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;
}

0 comments on commit 3d755c2

Please sign in to comment.