From 630116b334e22db853a95cd64e20b7df9f2b6dc8 Mon Sep 17 00:00:00 2001 From: Matthew Wang Date: Sat, 9 Jul 2022 00:56:20 +0000 Subject: [PATCH] [New] add `getAccessibleChildText` util --- .../src/util/getAccessibleChildText-test.js | 96 +++++++++++++++++++ src/util/getAccessibleChildText.js | 55 +++++++++++ 2 files changed, 151 insertions(+) create mode 100644 __tests__/src/util/getAccessibleChildText-test.js create mode 100644 src/util/getAccessibleChildText.js diff --git a/__tests__/src/util/getAccessibleChildText-test.js b/__tests__/src/util/getAccessibleChildText-test.js new file mode 100644 index 000000000..02a0b4e1d --- /dev/null +++ b/__tests__/src/util/getAccessibleChildText-test.js @@ -0,0 +1,96 @@ +import expect from 'expect'; +import { elementType } from 'jsx-ast-utils'; +import getAccessibleChildText from '../../../src/util/getAccessibleChildText'; +import JSXAttributeMock from '../../../__mocks__/JSXAttributeMock'; +import JSXElementMock from '../../../__mocks__/JSXElementMock'; + +describe('getAccessibleChildText', () => { + it('returns the aria-label when present', () => { + expect(getAccessibleChildText(JSXElementMock( + 'a', + [JSXAttributeMock('aria-label', 'foo')], + ), elementType)).toBe('foo'); + }); + + it('returns the aria-label instead of children', () => { + expect(getAccessibleChildText(JSXElementMock( + 'a', + [JSXAttributeMock('aria-label', 'foo')], + [{ type: 'JSXText', value: 'bar' }], + ), elementType)).toBe('foo'); + }); + + it('skips elements with aria-hidden=true', () => { + expect(getAccessibleChildText(JSXElementMock( + 'a', + [JSXAttributeMock('aria-hidden', 'true')], + ), elementType)).toBe(''); + }); + + it('returns literal value for JSXText child', () => { + expect(getAccessibleChildText(JSXElementMock( + 'a', + [], + [{ type: 'JSXText', value: 'bar' }], + ), elementType)).toBe('bar'); + }); + + it('returns literal value for JSXText child', () => { + expect(getAccessibleChildText(JSXElementMock( + 'a', + [], + [{ type: 'Literal', value: 'bar' }], + ), elementType)).toBe('bar'); + }); + + it('returns recursive value for JSXElement child', () => { + expect(getAccessibleChildText(JSXElementMock( + 'a', + [], + [JSXElementMock( + 'span', + [], + [{ type: 'Literal', value: 'bar' }], + )], + ), elementType)).toBe('bar'); + }); + + it('skips children with aria-hidden-true', () => { + expect(getAccessibleChildText(JSXElementMock( + 'a', + [], + [JSXElementMock( + 'span', + [], + [JSXElementMock( + 'span', + [JSXAttributeMock('aria-hidden', 'true')], + )], + )], + ), elementType)).toBe(''); + }); + + it('joins multiple children properly - no spacing', () => { + expect(getAccessibleChildText(JSXElementMock( + 'a', + [], + [{ type: 'Literal', value: 'foo' }, { type: 'Literal', value: 'bar' }], + ), elementType)).toBe('foo bar'); + }); + + it('joins multiple children properly - with spacing', () => { + expect(getAccessibleChildText(JSXElementMock( + 'a', + [], + [{ type: 'Literal', value: ' foo ' }, { type: 'Literal', value: ' bar ' }], + ), elementType)).toBe('foo bar'); + }); + + it('skips unknown elements', () => { + expect(getAccessibleChildText(JSXElementMock( + 'a', + [], + [{ type: 'Literal', value: 'foo' }, { type: 'Unknown' }, { type: 'Literal', value: 'bar' }], + ), elementType)).toBe('foo bar'); + }); +}); diff --git a/src/util/getAccessibleChildText.js b/src/util/getAccessibleChildText.js new file mode 100644 index 000000000..9763db1a8 --- /dev/null +++ b/src/util/getAccessibleChildText.js @@ -0,0 +1,55 @@ +// @flow + +import type { JSXElement, JSXOpeningElement, Node } from 'ast-types-flow'; + +import { getProp, getLiteralPropValue } from 'jsx-ast-utils'; + +import isHiddenFromScreenReader from './isHiddenFromScreenReader'; + +/** + * Returns a new "standardized" string: all whitespace is collapsed to one space, + * and the string is lowercase + * @param {string} input + * @returns lowercase, single-spaced, trimmed string + */ +function standardizeSpaceAndCase(input: string): string { + return input.trim().replace(/\s\s+/g, ' ').toLowerCase(); +} + +/** + * Returns the (recursively-defined) accessible child text of a node, which (in-order) is: + * 1. The element's aria-label + * 2. If the element is a direct literal, the literal value + * 3. Otherwise, merge all of its children + * @param {JSXElement} node - node to traverse + * @returns child text as a string + */ +export default function getAccessibleChildText(node: JSXElement, elementType: (JSXOpeningElement) => string): string { + const ariaLabel = getLiteralPropValue(getProp(node.openingElement.attributes, 'aria-label')); + // early escape-hatch when aria-label is applied + if (ariaLabel) return standardizeSpaceAndCase(ariaLabel); + + // skip if aria-hidden is true + if ( + isHiddenFromScreenReader( + elementType(node.openingElement), + node.openingElement.attributes, + ) + ) { + return ''; + } + + const rawChildText = node.children + .map((currentNode: Node): string => { + // $FlowFixMe JSXText is missing in ast-types-flow + if (currentNode.type === 'Literal' || currentNode.type === 'JSXText') { + return String(currentNode.value); + } + if (currentNode.type === 'JSXElement') { + return getAccessibleChildText(currentNode, elementType); + } + return ''; + }).join(' '); + + return standardizeSpaceAndCase(rawChildText); +}