Skip to content

Commit

Permalink
Merge pull request #1 from trurl-master/visibility-checks
Browse files Browse the repository at this point in the history
Add isSubtreeInaccessible pass-through
  • Loading branch information
trurl-master committed Nov 20, 2023
2 parents c2009d5 + 1d9e176 commit c731954
Show file tree
Hide file tree
Showing 11 changed files with 217 additions and 12 deletions.
38 changes: 38 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,44 @@ test('accessible dialog has the correct accessibility tree', () => {
Container nodes in the DOM, such as non-semantic `<div>` and `<span>` elements, can clutter the accessibility tree and obscure meaningful hierarchy in tests. The Accessibility Testing Toolkit automatically prunes these nodes (_except for the root node_), simplifying test assertions by focusing on semantically significant elements. This approach reduces test fragility against markup changes and enhances clarity, allowing developers to concentrate on the core accessibility features of their components. By ignoring container nodes, the toolkit promotes a development workflow that prioritizes user experience over structural implementation details.
#### Handling Visibility
When determining whether elements in the DOM are accessible, certain attributes and CSS properties signal that an element, along with its children, should not be considered visible:
- Elements with the `hidden` attribute or `aria-hidden="true"`.
- Styles that set `display: none` or `visibility: hidden`.
In testing environments, relying on attribute checks may be necessary since `getComputedStyle` may not reflect styles defined in external stylesheets.
##### Enhancing Visibility Detection
Extend default visibility checks with custom logic to handle additional cases. In this example we consider elements with the `hidden` or `invisible` (used for example by `TailwindCSS`) classes as inaccessible:
```ts
import { isSubtreeInaccessible as originalIsSubtreeInaccessible } from 'accessibility-testing-toolkit';

function isSubtreeInaccessible(element: HTMLElement): boolean {
// Include original checks and additional conditions for TailwindCSS classes
return (
originalIsSubtreeInaccessible(element) ||
element.classList.contains('hidden') ||
element.classList.contains('invisible')
);
}

// Set globally in jest-setup.js
configToolkit({
isInaccessibleOptions: { isSubtreeInaccessible },
});

// Or per matcher
expect(element).toHaveA11yTree(expectedTree, {
isInaccessibleOptions: { isSubtreeInaccessible },
});
```
By leveraging both the library's default visibility logic and custom class checks, this approach effectively accommodates the use of utility-first CSS frameworks within visibility determination processes.
#### Calculating roles
The toolkit follows standardized role definitions, with some customizations to provide more specific roles for certain elements, similar to the approach used by Google Chrome
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "accessibility-testing-toolkit",
"version": "1.0.2",
"version": "1.0.3",
"author": "Ivan Galiatin",
"license": "MIT",
"description": "A toolkit for testing accessibility",
Expand Down
142 changes: 142 additions & 0 deletions src/__tests__/inaccessible.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import { render } from '@testing-library/react';
import { byRole } from '../helpers/by-role';
import { configToolkit } from '../config';

describe('inaccessible', () => {
it('skips subtrees with hidden property', () => {
const { container } = render(
<div>
<div hidden>
<p>Hidden paragraph</p>
</div>
<p>Visible paragraph</p>
</div>
);

expect(container.firstChild).toHaveA11yTree(
byRole('generic', [byRole('paragraph', ['Visible paragraph'])])
);
});

it('skips subtrees with aria-hidden property', () => {
const { container } = render(
<div>
<div aria-hidden>
<p>Hidden paragraph</p>
</div>
<p>Visible paragraph</p>
</div>
);

expect(container.firstChild).toHaveA11yTree(
byRole('generic', [byRole('paragraph', ['Visible paragraph'])])
);
});

it("doesn't skipp subtrees with aria-hidden property set to false", () => {
const { container } = render(
<div>
<div aria-hidden="false">
<p>Not hidden paragraph</p>
</div>
<p>Visible paragraph</p>
</div>
);

expect(container.firstChild).toHaveA11yTree(
byRole('generic', [
byRole('paragraph', ['Not hidden paragraph']),
byRole('paragraph', ['Visible paragraph']),
])
);
});

it('skips subtrees with visibility: hidden', () => {
const { container } = render(
<div>
<div style={{ visibility: 'hidden' }}>
<p>Invisible paragraph</p>
</div>
<p>Visible paragraph</p>
</div>
);

expect(container.firstChild).toHaveA11yTree(
byRole('generic', [byRole('paragraph', ['Visible paragraph'])])
);
});

it('skips subtrees with display: none', () => {
const { container } = render(
<div>
<div style={{ display: 'none' }}>
<p>Hidden paragraph</p>
</div>
<p>Visible paragraph</p>
</div>
);

expect(container.firstChild).toHaveA11yTree(
byRole('generic', [byRole('paragraph', ['Visible paragraph'])])
);
});

it('skips subtrees with custom isSubtreeInaccessible function', () => {
const { container } = render(
<div>
<div className="hidden">
<p>Hidden paragraph</p>
</div>
<div className="invisible">
<p>Invisible paragraph</p>
</div>
<p>Visible paragraph</p>
</div>
);

expect(container.firstChild).toHaveA11yTree(
byRole('generic', [byRole('paragraph', ['Visible paragraph'])]),
{
isInaccessibleOptions: {
isSubtreeInaccessible: (element) =>
// tailwindcss classes: hidden, invisible
element.classList.contains('hidden') ||
element.classList.contains('invisible'),
},
}
);
});

it('skips subtrees with custom isSubtreeInaccessible function set globally', () => {
configToolkit({
isInaccessibleOptions: {
isSubtreeInaccessible: (element) =>
// tailwindcss classes: hidden, invisible
element.classList.contains('hidden') ||
element.classList.contains('invisible'),
},
});

const { container } = render(
<div>
<div className="hidden">
<p>Hidden paragraph</p>
</div>
<div className="invisible">
<p>Invisible paragraph</p>
</div>
<p>Visible paragraph</p>
</div>
);

expect(container.firstChild).toHaveA11yTree(
byRole('generic', [byRole('paragraph', ['Visible paragraph'])])
);

configToolkit({
isInaccessibleOptions: {
isSubtreeInaccessible: undefined,
},
});
});
});
14 changes: 14 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import type { IsInaccessibleOptions } from 'dom-accessibility-api';

type Config = {
isInaccessibleOptions?: IsInaccessibleOptions;
};

const config: Config = {
isInaccessibleOptions: undefined,
};

export const getConfig = (): typeof config => config;
export const configToolkit = (options: Partial<Config>): void => {
Object.assign(config, options);
};
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,5 @@ export {
} from './types/types';
export { getAccessibilityTree } from './tree/accessibility-tree';
export { pruneContainerNodes } from './tree/prune-container-nodes';
export { isSubtreeInaccessible } from 'dom-accessibility-api';
export { configToolkit, getConfig } from './config';
9 changes: 5 additions & 4 deletions src/tree/accessibility-tree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
import { isDefined } from '../type-guards';
import { StaticText } from './leafs';
import { MatcherOptions } from '../types/matchers';
import { getConfig } from '../config';

// if a descendant of an article, aside, main, nav or section element, or an element with role=article, complementary, main, navigation or region
const isNonLandmarkRole = (element: HTMLElement, role: string) =>
Expand All @@ -32,9 +33,6 @@ const isNonLandmarkRole = (element: HTMLElement, role: string) =>
['aricle', 'complementary', 'main', 'navigation', 'region'].includes(role);

const isList = (role: HTMLElement['role']) => role === 'list';
// ['list', 'listbox', 'menu', 'menubar', 'radiogroup', 'tablist'].includes(
// role ?? ''
// );

const defaultOptions = {
isListSubtree: false,
Expand All @@ -47,13 +45,14 @@ export const getAccessibilityTree = (
isListSubtree: userListSubtree = defaultOptions.isListSubtree,
isNonLandmarkSubtree:
userNonLandmarkSubtree = defaultOptions.isNonLandmarkSubtree,
isInaccessibleOptions = getConfig().isInaccessibleOptions,
}: MatcherOptions = defaultOptions
): A11yTreeNode | null => {
function assembleTree(
element: HTMLElement,
context: A11yTreeNodeContext
): A11yTreeNode | null {
if (isInaccessible(element)) {
if (isInaccessible(element, context.isInaccessibleOptions)) {
return null;
}

Expand Down Expand Up @@ -92,6 +91,7 @@ export const getAccessibilityTree = (
isNonLandmarkSubtree:
context.isNonLandmarkSubtree ||
isNonLandmarkRole(element, role),
isInaccessibleOptions,
});
}

Expand All @@ -112,5 +112,6 @@ export const getAccessibilityTree = (
return assembleTree(element, {
isListSubtree: userListSubtree,
isNonLandmarkSubtree: userNonLandmarkSubtree,
isInaccessibleOptions,
});
};
5 changes: 4 additions & 1 deletion src/tree/compute-properties.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,10 @@ function computeAriaValueText(element: HTMLElement) {
return valueText === null ? undefined : valueText;
}

function computeRoles(element: HTMLElement, context: A11yTreeNodeContext) {
function computeRoles(
element: HTMLElement,
context: Pick<A11yTreeNodeContext, 'isListSubtree' | 'isNonLandmarkSubtree'>
) {
let roles = [];
// TODO: This violates html-aria which does not allow any role on every element
if (element.hasAttribute('role')) {
Expand Down
5 changes: 4 additions & 1 deletion src/tree/role-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,10 @@ const elementRoleList = buildElementRoleList(elementRoles);

function getImplicitAriaRoles(
currentNode: HTMLElement,
{ isListSubtree, isNonLandmarkSubtree }: A11yTreeNodeContext
{
isListSubtree,
isNonLandmarkSubtree,
}: Pick<A11yTreeNodeContext, 'isListSubtree' | 'isNonLandmarkSubtree'>
) {
let result: ARIARoleDefinitionKeyExtended[] = [];

Expand Down
2 changes: 1 addition & 1 deletion src/types/matchers.d.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { A11yTreeNodeMatch, A11yTreeNodeContext } from './types';
import type { A11yTreeNodeMatch, A11yTreeNodeContext } from './types';

type MatcherOptions = A11yTreeNodeContext;

Expand Down
6 changes: 4 additions & 2 deletions src/types/types.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { ARIARoleDefinitionKey } from 'aria-query';
import { StaticText } from '../tree/leafs';
import type { ARIARoleDefinitionKey } from 'aria-query';
import type { StaticText } from '../tree/leafs';
import type { IsInaccessibleOptions } from 'dom-accessibility-api';

export type AsNonLandmarkRoles = 'HeaderAsNonLandmark' | 'FooterAsNonLandmark';
export type VirtualRoles =
Expand Down Expand Up @@ -39,6 +40,7 @@ export type TextMatcher = string | number | RegExp | TextMatcherFunction;
export type A11yTreeNodeContext = {
isListSubtree?: boolean;
isNonLandmarkSubtree?: boolean;
isInaccessibleOptions?: IsInaccessibleOptions;
};

export type A11yTreeNodeState = {
Expand Down

0 comments on commit c731954

Please sign in to comment.