Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,7 @@ getByLabelText(text: TextMatch, options?: { exact?: boolean; normalizer?: Functi
```

Matches by `aria-label`/`accessibilityLabel` or text content of element referenced by `aria-labelledby`/`accessibilityLabelledBy`.
When multiple elements are referenced, their text content is joined with spaces in the referenced order and matched as a single label.

#### `*ByPlaceholderText`

Expand Down
34 changes: 34 additions & 0 deletions src/helpers/__tests__/accessibility.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -433,6 +433,17 @@ describe('computeAriaLabel', () => {
expect(computeAriaLabel(screen.getByTestId('text-content'))).toBeUndefined();
});

test('does not fall back to aria-label when aria-labelledby resolves to empty text', async () => {
await render(
<View>
<View testID="subject" aria-label="Internal Label" aria-labelledby="empty-label" />
<View nativeID="empty-label" />
</View>,
);

expect(computeAriaLabel(screen.getByTestId('subject'))).toEqual('');
});

test('label priority', async () => {
await render(
<View>
Expand Down Expand Up @@ -463,6 +474,29 @@ describe('computeAriaLabel', () => {
expect(computeAriaLabel(screen.getByTestId('subject'))).toEqual('External');
});

test('supports accessibilityLabelledBy array with a single item', async () => {
await render(
<View>
<View testID="subject" accessibilityLabelledBy={['ext-label']} />
<Text nativeID="ext-label">External</Text>
</View>,
);

expect(computeAriaLabel(screen.getByTestId('subject'))).toEqual('External');
});

test('concatenates labels referenced by accessibilityLabelledBy array', async () => {
await render(
<View>
<View testID="subject" accessibilityLabelledBy={['first-label', 'second-label']} />
<Text nativeID="first-label">First</Text>
<Text nativeID="second-label">Second</Text>
</View>,
);

expect(computeAriaLabel(screen.getByTestId('subject'))).toEqual('First Second');
});

test('supports Image with alt prop', async () => {
await render(<Image testID="subject" alt="Image Alt" />);
expect(computeAriaLabel(screen.getByTestId('subject'))).toEqual('Image Alt');
Expand Down
44 changes: 34 additions & 10 deletions src/helpers/accessibility.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,17 +150,23 @@ export function computeAriaModal(instance: TestInstance): boolean | undefined {
}

export function computeAriaLabel(instance: TestInstance): string | undefined {
const labelElementId =
instance.props['aria-labelledby'] ?? instance.props.accessibilityLabelledBy;
if (labelElementId) {
const labelElementIds = getAriaLabelledByIds(instance);
if (labelElementIds.length > 0) {
const container = getContainerInstance(instance);
const labelInstance = findAll(
container,
(node) => isTestInstance(node) && node.props.nativeID === labelElementId,
{ includeHiddenElements: true },
);
if (labelInstance.length > 0) {
return getTextContent(labelInstance[0]);
const labelTexts = labelElementIds
.map((labelElementId) => {
const labelInstance = findAll(
container,
(node) => isTestInstance(node) && node.props.nativeID === labelElementId,
{ includeHiddenElements: true },
);

return labelInstance.length > 0 ? getTextContent(labelInstance[0]) : undefined;
})
.filter((labelText): labelText is string => labelText !== undefined);

if (labelTexts.length > 0) {
return labelTexts.join(' ').trim().replace(/\s+/g, ' ');
}
}

Expand All @@ -177,6 +183,24 @@ export function computeAriaLabel(instance: TestInstance): string | undefined {
return undefined;
}

function getAriaLabelledByIds(instance: TestInstance): string[] {
const ariaLabelledBy = instance.props['aria-labelledby'];
if (typeof ariaLabelledBy === 'string') {
return [ariaLabelledBy];
}

const accessibilityLabelledBy = instance.props.accessibilityLabelledBy;
if (Array.isArray(accessibilityLabelledBy)) {
return accessibilityLabelledBy;
}

if (typeof accessibilityLabelledBy === 'string') {
return [accessibilityLabelledBy];
}

return [];
}

// See: https://github.com/callstack/react-native-testing-library/wiki/Accessibility:-State#busy-state
export function computeAriaBusy({ props }: TestInstance): boolean {
return props['aria-busy'] ?? props.accessibilityState?.busy ?? false;
Expand Down
14 changes: 14 additions & 0 deletions src/queries/__tests__/label-text.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,20 @@ test('getByLabelText supports accessibilityLabelledBy', async () => {
expect(screen.getByLabelText(/input/)).toBe(screen.getByTestId('textInput'));
});

test('getByLabelText matches concatenated accessibilityLabelledBy array labels', async () => {
await render(
<>
<Text nativeID="first-label">First</Text>
<Text nativeID="second-label">Second</Text>
<TextInput testID="textInput" accessibilityLabelledBy={['first-label', 'second-label']} />
</>,
);

expect(screen.getByLabelText('First Second')).toBe(screen.getByTestId('textInput'));
expect(screen.queryByLabelText('First')).toBeNull();
expect(screen.queryByLabelText('Second')).toBeNull();
});

test('getByLabelText supports nested accessibilityLabelledBy', async () => {
await render(
<>
Expand Down
2 changes: 2 additions & 0 deletions website/docs/14.x/docs/api/jest-matchers.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,8 @@ Checks if an element has the specified accessible name. Accepts `string` or `Reg

The accessible name comes from `aria-labelledby`, `accessibilityLabelledBy`, `aria-label`, and `accessibilityLabel` props. For `Image` elements, the `alt` prop is also used. If none are present, the element's text content is used.

When `accessibilityLabelledBy` references multiple elements with an array, their text content is joined with spaces in the referenced order and matched as a single accessible name. `aria-labelledby` follows React Native's single `nativeID` value behavior.

Without a `name` parameter (or with `undefined`), it only checks whether the element has any accessible name.

### `toHaveProp()`
Expand Down
2 changes: 2 additions & 0 deletions website/docs/14.x/docs/api/queries.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,8 @@ Returns a `TestInstance` with matching label:
- or by matching text content of view referenced by [`aria-labelledby`](https://reactnative.dev/docs/accessibility#aria-labelledby-android)/[`accessibilityLabelledBy`](https://reactnative.dev/docs/accessibility#accessibilitylabelledby-android) prop
- or by matching the [`alt`](https://reactnative.dev/docs/image#alt) prop on `Image` elements

When `accessibilityLabelledBy` references multiple elements with an array, their text content is joined with spaces in the referenced order and matched as a single label. `aria-labelledby` follows React Native's single `nativeID` value behavior.

```jsx
import { render, screen } from '@testing-library/react-native';

Expand Down