diff --git a/src/helpers/accessiblity.ts b/src/helpers/accessiblity.ts index 7419d3356..aed19ced6 100644 --- a/src/helpers/accessiblity.ts +++ b/src/helpers/accessiblity.ts @@ -4,7 +4,8 @@ import { StyleSheet, } from 'react-native'; import { ReactTestInstance } from 'react-test-renderer'; -import { getHostSiblings } from './component-tree'; +import { getTextContent } from './text-content'; +import { getHostSiblings, getUnsafeRootElement } from './component-tree'; import { getHostComponentNames } from './host-component-names'; type IsInaccessibleOptions = { @@ -233,3 +234,23 @@ export function isElementSelected( const { accessibilityState, 'aria-selected': ariaSelected } = element.props; return ariaSelected ?? accessibilityState?.selected ?? false; } + +export function getAccessibleName( + element: ReactTestInstance +): string | undefined { + const label = getAccessibilityLabel(element); + if (label) { + return label; + } + + const labelElementId = getAccessibilityLabelledBy(element); + if (labelElementId) { + const rootElement = getUnsafeRootElement(element); + const labelElement = rootElement?.findByProps({ nativeID: labelElementId }); + if (labelElement) { + return getTextContent(labelElement); + } + } + + return getTextContent(element); +} diff --git a/src/matchers/__tests__/to-have-accessible-name.test.tsx b/src/matchers/__tests__/to-have-accessible-name.test.tsx new file mode 100644 index 000000000..1a355efc7 --- /dev/null +++ b/src/matchers/__tests__/to-have-accessible-name.test.tsx @@ -0,0 +1,134 @@ +import * as React from 'react'; +import { View, Text, TextInput } from 'react-native'; +import { render, screen } from '../..'; +import '../extend-expect'; + +test('toHaveAccessibleName() handles view with "accessibilityLabel" prop', () => { + render(); + const element = screen.getByTestId('view'); + expect(element).toHaveAccessibleName('Test label'); + expect(element).not.toHaveAccessibleName('Other label'); +}); + +test('toHaveAccessibleName() handles view with "aria-label" prop', () => { + render(); + const element = screen.getByTestId('view'); + expect(element).toHaveAccessibleName('Test label'); + expect(element).not.toHaveAccessibleName('Other label'); +}); + +test('toHaveAccessibleName() handles view with "accessibilityLabelledBy" prop', async () => { + render( + + External label + + + ); + + const element = screen.getByTestId('input'); + expect(element).toHaveAccessibleName('External label'); + expect(element).not.toHaveAccessibleName('Other label'); +}); + +test('toHaveAccessibleName() handles nested "accessibilityLabelledBy"', async () => { + render( + <> + + External label + + + + ); + + const element = screen.getByTestId('input'); + expect(element).toHaveAccessibleName('External label'); + expect(element).not.toHaveAccessibleName('Other label'); +}); + +test('toHaveAccessibleName() handles view with nested "accessibilityLabelledBy" with no text', async () => { + render( + <> + + + + + + ); + + const element = screen.getByTestId('text-input'); + expect(element).not.toHaveAccessibleName(); +}); + +test('toHaveAccessibleName() handles view with "aria-labelledby" prop', async () => { + render( + + External label + + + ); + + const element = screen.getByTestId('input'); + expect(element).toHaveAccessibleName('External label'); + expect(element).not.toHaveAccessibleName('Other label'); +}); + +test('toHaveAccessibleName() handles view with implicit accessible name', () => { + render(Text); + const element = screen.getByTestId('view'); + expect(element).toHaveAccessibleName('Text'); + expect(element).not.toHaveAccessibleName('Other text'); +}); + +test('toHaveAccessibleName() supports calling without expected name', () => { + render(); + const element = screen.getByTestId('view'); + + expect(element).toHaveAccessibleName(); + expect(() => expect(element).not.toHaveAccessibleName()) + .toThrowErrorMatchingInlineSnapshot(` + "expect(element).not.toHaveAccessibleName() + + Expected element not to have accessible name: + undefined + Received: + Test label" + `); +}); + +test('toHaveAccessibleName() handles a view without name when called without expected name', () => { + render(); + const element = screen.getByTestId('view'); + + expect(element).not.toHaveAccessibleName(); + expect(() => expect(element).toHaveAccessibleName()) + .toThrowErrorMatchingInlineSnapshot(` + "expect(element).toHaveAccessibleName() + + Expected element to have accessible name: + undefined + Received: + " + `); +}); + +it('toHaveAccessibleName() rejects non-host element', () => { + const nonElement = 'This is not a ReactTestInstance'; + + expect(() => expect(nonElement).toHaveAccessibleName()) + .toThrowErrorMatchingInlineSnapshot(` + "expect(received).toHaveAccessibleName() + + received value must be a host element. + Received has type: string + Received has value: "This is not a ReactTestInstance"" + `); + + expect(() => expect(nonElement).not.toHaveAccessibleName()) + .toThrowErrorMatchingInlineSnapshot(` + "expect(received).not.toHaveAccessibleName() + + received value must be a host element. + Received has type: string + Received has value: "This is not a ReactTestInstance"" + `); +}); diff --git a/src/matchers/extend-expect.d.ts b/src/matchers/extend-expect.d.ts index 55cb2df53..7d76db0c1 100644 --- a/src/matchers/extend-expect.d.ts +++ b/src/matchers/extend-expect.d.ts @@ -18,6 +18,7 @@ export interface JestNativeMatchers { toBeVisible(): R; toContainElement(element: ReactTestInstance | null): R; toHaveAccessibilityValue(expectedValue: AccessibilityValueMatcher): R; + toHaveAccessibleName(expectedName?: TextMatch, options?: TextMatchOptions): R; toHaveDisplayValue(expectedValue: TextMatch, options?: TextMatchOptions): R; toHaveProp(name: string, expectedValue?: unknown): R; toHaveStyle(style: StyleProp