From 66ec41cd1174e79761bde9bc9a3d6a97fd377989 Mon Sep 17 00:00:00 2001 From: Ivan Galiatin Date: Tue, 21 Nov 2023 00:44:13 +0100 Subject: [PATCH] Add isSubtreeInaccessible pass-through --- README.md | 38 ++++++++ src/__tests__/inaccessible.test.tsx | 142 ++++++++++++++++++++++++++++ src/config.ts | 14 +++ src/index.ts | 2 + src/tree/accessibility-tree.ts | 9 +- src/tree/compute-properties.ts | 5 +- src/tree/role-helpers.ts | 5 +- src/types/matchers.d.ts | 2 +- src/types/types.ts | 6 +- 9 files changed, 214 insertions(+), 9 deletions(-) create mode 100644 src/__tests__/inaccessible.test.tsx create mode 100644 src/config.ts diff --git a/README.md b/README.md index 487a753..82ca478 100644 --- a/README.md +++ b/README.md @@ -117,6 +117,44 @@ test('accessible dialog has the correct accessibility tree', () => { Container nodes in the DOM, such as non-semantic `
` and `` 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 diff --git a/src/__tests__/inaccessible.test.tsx b/src/__tests__/inaccessible.test.tsx new file mode 100644 index 0000000..e047cd7 --- /dev/null +++ b/src/__tests__/inaccessible.test.tsx @@ -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( +
+ +

Visible paragraph

+
+ ); + + expect(container.firstChild).toHaveA11yTree( + byRole('generic', [byRole('paragraph', ['Visible paragraph'])]) + ); + }); + + it('skips subtrees with aria-hidden property', () => { + const { container } = render( +
+
+

Hidden paragraph

+
+

Visible paragraph

+
+ ); + + 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( +
+
+

Not hidden paragraph

+
+

Visible paragraph

+
+ ); + + expect(container.firstChild).toHaveA11yTree( + byRole('generic', [ + byRole('paragraph', ['Not hidden paragraph']), + byRole('paragraph', ['Visible paragraph']), + ]) + ); + }); + + it('skips subtrees with visibility: hidden', () => { + const { container } = render( +
+
+

Invisible paragraph

+
+

Visible paragraph

+
+ ); + + expect(container.firstChild).toHaveA11yTree( + byRole('generic', [byRole('paragraph', ['Visible paragraph'])]) + ); + }); + + it('skips subtrees with display: none', () => { + const { container } = render( +
+
+

Hidden paragraph

+
+

Visible paragraph

+
+ ); + + expect(container.firstChild).toHaveA11yTree( + byRole('generic', [byRole('paragraph', ['Visible paragraph'])]) + ); + }); + + it('skips subtrees with custom isSubtreeInaccessible function', () => { + const { container } = render( +
+
+

Hidden paragraph

+
+
+

Invisible paragraph

+
+

Visible paragraph

+
+ ); + + 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( +
+
+

Hidden paragraph

+
+
+

Invisible paragraph

+
+

Visible paragraph

+
+ ); + + expect(container.firstChild).toHaveA11yTree( + byRole('generic', [byRole('paragraph', ['Visible paragraph'])]) + ); + + configToolkit({ + isInaccessibleOptions: { + isSubtreeInaccessible: undefined, + }, + }); + }); +}); diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..700ba79 --- /dev/null +++ b/src/config.ts @@ -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): void => { + Object.assign(config, options); +}; diff --git a/src/index.ts b/src/index.ts index 5ecdd08..6beba20 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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'; diff --git a/src/tree/accessibility-tree.ts b/src/tree/accessibility-tree.ts index 1d80a8c..c496590 100644 --- a/src/tree/accessibility-tree.ts +++ b/src/tree/accessibility-tree.ts @@ -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) => @@ -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, @@ -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; } @@ -92,6 +91,7 @@ export const getAccessibilityTree = ( isNonLandmarkSubtree: context.isNonLandmarkSubtree || isNonLandmarkRole(element, role), + isInaccessibleOptions, }); } @@ -112,5 +112,6 @@ export const getAccessibilityTree = ( return assembleTree(element, { isListSubtree: userListSubtree, isNonLandmarkSubtree: userNonLandmarkSubtree, + isInaccessibleOptions, }); }; diff --git a/src/tree/compute-properties.ts b/src/tree/compute-properties.ts index 3a66cb4..8210e5d 100644 --- a/src/tree/compute-properties.ts +++ b/src/tree/compute-properties.ts @@ -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 +) { let roles = []; // TODO: This violates html-aria which does not allow any role on every element if (element.hasAttribute('role')) { diff --git a/src/tree/role-helpers.ts b/src/tree/role-helpers.ts index 04afa0c..8e21548 100644 --- a/src/tree/role-helpers.ts +++ b/src/tree/role-helpers.ts @@ -15,7 +15,10 @@ const elementRoleList = buildElementRoleList(elementRoles); function getImplicitAriaRoles( currentNode: HTMLElement, - { isListSubtree, isNonLandmarkSubtree }: A11yTreeNodeContext + { + isListSubtree, + isNonLandmarkSubtree, + }: Pick ) { let result: ARIARoleDefinitionKeyExtended[] = []; diff --git a/src/types/matchers.d.ts b/src/types/matchers.d.ts index 01ff555..64e7df6 100644 --- a/src/types/matchers.d.ts +++ b/src/types/matchers.d.ts @@ -1,4 +1,4 @@ -import { A11yTreeNodeMatch, A11yTreeNodeContext } from './types'; +import type { A11yTreeNodeMatch, A11yTreeNodeContext } from './types'; type MatcherOptions = A11yTreeNodeContext; diff --git a/src/types/types.ts b/src/types/types.ts index 8f71063..9606665 100644 --- a/src/types/types.ts +++ b/src/types/types.ts @@ -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 = @@ -39,6 +40,7 @@ export type TextMatcher = string | number | RegExp | TextMatcherFunction; export type A11yTreeNodeContext = { isListSubtree?: boolean; isNonLandmarkSubtree?: boolean; + isInaccessibleOptions?: IsInaccessibleOptions; }; export type A11yTreeNodeState = {