Skip to content
Open
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
2 changes: 2 additions & 0 deletions docs/api/queries.md
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,8 @@ await render(<MyComponent />);
const element = screen.getByText('banana');
```

Besides RN's `<Text>`, this query also matches `<PlainText>` from [`react-native-plain-text`](https://github.com/mdjastrzebski/react-native-plain-text), which holds its content in the `text` prop instead of string children.

### `*ByHintText`

> getByA11yHint, getAllByA11yHint, queryByA11yHint, queryAllByA11yHint, findByA11yHint, findAllByA11yHint
Expand Down
4 changes: 3 additions & 1 deletion jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ module.exports = {
setupFilesAfterEnv: ['./jest-setup.ts'],
testPathIgnorePatterns: ['dist/', 'examples/', 'experiments-app/', 'codemods/'],
testTimeout: 60000,
transformIgnorePatterns: ['/node_modules/(?!(@react-native|react-native)/).*/'],
transformIgnorePatterns: [
'/node_modules/(?!(@react-native|react-native|react-native-plain-text)/).*/',
],
snapshotSerializers: ['@relmify/jest-serializer-strip-ansi/always'],
clearMocks: true,
collectCoverageFrom: [
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@
"oxfmt": "^0.52.0",
"react": "19.2.3",
"react-native": "0.85.3",
"react-native-plain-text": "^0.8.2",
"release-it": "^20.0.1",
"test-renderer": "1.2.0",
"tsx": "^4.22.3",
Expand Down
26 changes: 26 additions & 0 deletions src/__tests__/host-component-names.test.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import * as React from 'react';
import { Image, Modal, ScrollView, Switch, Text, TextInput } from 'react-native';
import { PlainText } from 'react-native-plain-text';

import { render, screen } from '..';
import {
getCustomTextValue,
isCustomHostText,
isHostImage,
isHostModal,
isHostScrollView,
Expand All @@ -23,6 +26,29 @@ test('detects raw RCTText component', async () => {
expect(isHostText(screen.root)).toBe(true);
});

// Custom text components, e.g. `<PlainText>` from `react-native-plain-text`,
// hold their content in a prop instead of as string children.
test('detects custom host text component', async () => {
await render(<PlainText>Hello</PlainText>);
expect(isHostText(screen.root)).toBe(true);
expect(isCustomHostText(screen.root)).toBe(true);
});

test('does not detect host Text component as custom host text', async () => {
await render(<Text>Hello</Text>);
expect(isCustomHostText(screen.root)).toBe(false);
});

test('reads custom host text value from its prop', async () => {
await render(<PlainText text="Hello" />);
expect(getCustomTextValue(screen.root)).toBe('Hello');
});

test('reads no custom host text value from other components', async () => {
await render(<Text>Hello</Text>);
expect(getCustomTextValue(screen.root)).toBeUndefined();
});

test('detects host TextInput component', async () => {
await render(<TextInput />);
expect(isHostTextInput(screen.root)).toBe(true);
Expand Down
42 changes: 42 additions & 0 deletions src/helpers/__tests__/accessibility.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import React from 'react';
import { Image, Pressable, Switch, Text, TextInput, TouchableOpacity, View } from 'react-native';
import { PlainText } from 'react-native-plain-text';

import { isHiddenFromAccessibility, isInaccessible, render, screen } from '../..';
import {
Expand Down Expand Up @@ -915,3 +916,44 @@ describe('computeAccessibleName', () => {
expect(computeAccessibleName(screen.getByTestId('parent-no-text'))).toBe('');
});
});

describe('plain text elements', () => {
test('isAccessibilityElement() returns true', async () => {
await render(<PlainText testID="text">Hello</PlainText>);
expect(isAccessibilityElement(screen.getByTestId('text'))).toBe(true);
});

test('getRole() returns "text"', async () => {
await render(<PlainText testID="text">Hello</PlainText>);
expect(getRole(screen.getByTestId('text'))).toBe('text');
});

test('computeAccessibleName() uses the text content', async () => {
await render(<PlainText testID="text">Hello</PlainText>);
expect(computeAccessibleName(screen.getByTestId('text'))).toBe('Hello');
});

test('computeAccessibleName() prefers explicit accessibility label', async () => {
await render(
<PlainText testID="text" accessibilityLabel="Label">
Hello
</PlainText>,
);
expect(computeAccessibleName(screen.getByTestId('text'))).toBe('Label');
});

test('computeAccessibleName() returns empty string for empty text', async () => {
await render(<PlainText testID="text" />);
expect(computeAccessibleName(screen.getByTestId('text'))).toBe('');
});

test('computeAccessibleName() includes plain text children', async () => {
await render(
<View testID="view" accessible>
<PlainText>Hello</PlainText>
<PlainText text="World" />
</View>,
);
expect(computeAccessibleName(screen.getByTestId('view'))).toBe('Hello World');
});
});
28 changes: 27 additions & 1 deletion src/helpers/__tests__/text-content.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import * as React from 'react';
import { Text } from 'react-native';
import { Text, View } from 'react-native';
import { PlainText } from 'react-native-plain-text';

import { render, screen } from '../..';
import { getTextContent } from '../text-content';
Expand Down Expand Up @@ -48,3 +49,28 @@ test('getTextContent with multiple boolean content', async () => {
);
expect(getTextContent(screen.root)).toBe('Hello world');
});

test('getTextContent with plain text content', async () => {
await render(<PlainText>Hello world</PlainText>);
expect(getTextContent(screen.root)).toBe('Hello world');
});

test('getTextContent with plain text `text` prop', async () => {
await render(<PlainText text="Hello world" />);
expect(getTextContent(screen.root)).toBe('Hello world');
});

test('getTextContent with empty plain text', async () => {
await render(<PlainText />);
expect(getTextContent(screen.root)).toBe('');
});

test('getTextContent with nested plain text content', async () => {
await render(
<View>
<PlainText>Hello</PlainText>
<Text> world</Text>
</View>,
);
expect(getTextContent(screen.root)).toBe('Hello world');
});
14 changes: 13 additions & 1 deletion src/helpers/accessibility.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,13 @@ import type { TestInstance } from 'test-renderer';

import { getContainerInstance, getInstanceSiblings, isTestInstance } from './component-tree';
import { findAll } from './find-all';
import { isHostImage, isHostSwitch, isHostText, isHostTextInput } from './host-component-names';
import {
getCustomTextValue,
isHostImage,
isHostSwitch,
isHostText,
isHostTextInput,
} from './host-component-names';
import { getTextContent } from './text-content';
import { isEditableTextInput } from './text-input';

Expand Down Expand Up @@ -286,6 +292,12 @@ export function computeAccessibleName(
return instance.props.placeholder;
}

// Custom host text elements have no children, their content is in a prop.
const customText = getCustomTextValue(instance);
if (customText !== undefined) {
return customText;
}

const parts: AccessibleNamePart[] = [];
for (const child of instance.children) {
if (typeof child === 'string') {
Expand Down
44 changes: 42 additions & 2 deletions src/helpers/host-component-names.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,58 @@
import type { TestInstance } from 'test-renderer';

// Host components that render RN `<Text>`, i.e. text elements holding their
// content as string children.
export const HOST_TEXT_NAMES = ['Text', 'RCTText'];

const HOST_TEXT_INPUT_NAMES = ['TextInput'];
const HOST_IMAGE_NAMES = ['Image'];
const HOST_SWITCH_NAMES = ['RCTSwitch'];
const HOST_SCROLL_VIEW_NAMES = ['RCTScrollView'];
const HOST_MODAL_NAMES = ['Modal'];

// Custom host text component holding its text content in the `text` prop
// instead of as string children: `<PlainText>` from `react-native-plain-text`.
const HOST_PLAIN_TEXT_NAME = 'RNPlainText';

/**
* Checks if the given element is a custom host text element, i.e. one that
* holds its text content in a prop instead of as string children, e.g.
* `<PlainText>` from `react-native-plain-text`.
* @param instance The instance to check.
*/
export function isCustomHostText(instance: TestInstance | null) {
return instance?.type === HOST_PLAIN_TEXT_NAME;
}

/**
* Checks if the given element is a host Text element.
* Checks if the given element is a host text element: either a RN `<Text>`,
* holding its content as string children, or a custom one, holding it in a prop.
* @param instance The instance to check.
*/
export function isHostText(instance: TestInstance | null) {
return typeof instance?.type === 'string' && HOST_TEXT_NAMES.includes(instance.type);
if (typeof instance?.type !== 'string') {
return false;
}

return HOST_TEXT_NAMES.includes(instance.type) || instance.type === HOST_PLAIN_TEXT_NAME;
}

/**
* Returns the text content held in a prop by a custom host text element, or
* `undefined` for any other element.
* @param instance The instance to read.
*/
export function getCustomTextValue(instance: TestInstance | null): string | undefined {
if (instance?.type !== HOST_PLAIN_TEXT_NAME) {
return undefined;
}

const { text } = instance.props;
if (typeof text === 'string') {
return text;
}

return typeof text === 'number' ? String(text) : undefined;
}

/**
Expand Down
9 changes: 9 additions & 0 deletions src/helpers/text-content.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import type { TestInstance } from 'test-renderer';

import { getCustomTextValue } from './host-component-names';

export function getTextContent(instance: TestInstance | string | null): string {
if (!instance) {
return '';
Expand All @@ -9,6 +11,13 @@ export function getTextContent(instance: TestInstance | string | null): string {
return instance;
}

// Custom host text elements, e.g. `<PlainText>` from `react-native-plain-text`,
// hold their content in a prop rather than as string children.
const customText = getCustomTextValue(instance);
if (customText !== undefined) {
return customText;
}

const result: string[] = [];
instance.children?.forEach((child) => {
result.push(getTextContent(child));
Expand Down
9 changes: 9 additions & 0 deletions src/matchers/__tests__/to-have-accessible-name.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import * as React from 'react';
import { Image, Text, TextInput, View } from 'react-native';
import { PlainText } from 'react-native-plain-text';

import { render, screen } from '../..';

Expand Down Expand Up @@ -137,3 +138,11 @@ it('toHaveAccessibleName() rejects non-host element', () => {
Received has value: "This is not a TestInstance""
`);
});

test('toHaveAccessibleName() handles plain text element', async () => {
await render(<PlainText testID="text">Hello</PlainText>);
const element = screen.getByTestId('text');
expect(element).toHaveAccessibleName('Hello');
expect(element).toHaveAccessibleName();
expect(element).not.toHaveAccessibleName('World');
});
13 changes: 13 additions & 0 deletions src/matchers/__tests__/to-have-text-content.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import * as React from 'react';
import { Text, View } from 'react-native';
import { PlainText } from 'react-native-plain-text';

import { render, screen } from '../..';

Expand Down Expand Up @@ -80,3 +81,15 @@ test('toHaveTextContent() on null element', () => {
Received has value: null"
`);
});

test('toHaveTextContent() supports plain text', async () => {
await render(
<View testID="view">
<PlainText>Hello</PlainText>
<PlainText text=" World" />
</View>,
);

expect(screen.getByTestId('view')).toHaveTextContent('Hello World');
expect(screen.getByTestId('view')).not.toHaveTextContent('Hello there');
});
9 changes: 9 additions & 0 deletions src/queries/__tests__/role.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
TouchableWithoutFeedback,
View,
} from 'react-native';
import { PlainText } from 'react-native-plain-text';

import { render, screen } from '../..';

Expand Down Expand Up @@ -994,3 +995,11 @@ test('error message renders the element tree, preserving only helpful props', as
/>"
`);
});

test('supports plain text elements', async () => {
await render(<PlainText testID="text">Hello</PlainText>);

expect(screen.getByRole('text').props.testID).toBe('text');
expect(screen.getByRole('text', { name: 'Hello' }).props.testID).toBe('text');
expect(screen.queryByRole('text', { name: 'World' })).toBeNull();
});
23 changes: 23 additions & 0 deletions src/queries/__tests__/text.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import * as React from 'react';
import { Button, Image, Text, TextInput, TouchableOpacity, View } from 'react-native';
import { PlainText } from 'react-native-plain-text';

import { getDefaultNormalizer, render, screen, within } from '../..';

Expand Down Expand Up @@ -540,3 +541,25 @@ test('byText should return host component', async () => {
await render(<Text>hello</Text>);
expect(screen.getByText('hello').type).toBe('Text');
});

test('byText matches plain text', async () => {
await render(<PlainText testID="text">Hello World</PlainText>);
expect(screen.getByText('Hello World').props.testID).toBe('text');
});

test('byText matches plain text passed by `text` prop', async () => {
await render(<PlainText testID="text" text="Hello World" />);
expect(screen.getByText('Hello World').props.testID).toBe('text');
});

test('byText supports text match options for plain text', async () => {
await render(<PlainText testID="text">Hello World</PlainText>);
expect(screen.getByText('hello world', { exact: false }).props.testID).toBe('text');
expect(screen.getByText(/hello/i).props.testID).toBe('text');
expect(screen.queryByText('Hello')).toBeNull();
});

test('byText does not match plain text without content', async () => {
await render(<PlainText testID="text" />);
expect(screen.queryByText('Hello World')).toBeNull();
});
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 @@ -322,6 +322,8 @@ await render(<MyComponent />);
const element = screen.getByText('banana');
```

Besides RN's `<Text>`, this query also matches `<PlainText>` from [`react-native-plain-text`](https://github.com/mdjastrzebski/react-native-plain-text), which holds its content in the `text` prop instead of string children.

### `*ByHintText` {#by-hint-text}

> getByA11yHint, getAllByA11yHint, queryByA11yHint, queryAllByA11yHint, findByA11yHint, findAllByA11yHint
Expand Down
11 changes: 11 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -3259,6 +3259,7 @@ __metadata:
pretty-format: "npm:^30.4.1"
react: "npm:19.2.3"
react-native: "npm:0.85.3"
react-native-plain-text: "npm:^0.8.2"
redent: "npm:^3.0.0"
release-it: "npm:^20.0.1"
test-renderer: "npm:1.2.0"
Expand Down Expand Up @@ -9454,6 +9455,16 @@ __metadata:
languageName: node
linkType: hard

"react-native-plain-text@npm:^0.8.2":
version: 0.8.2
resolution: "react-native-plain-text@npm:0.8.2"
peerDependencies:
react: "*"
react-native: "*"
checksum: 10c0/1be4bbd466fa6326b87c7605a3def1ab88748e79fb0f6c66a670166b18770f6d0c7d069afd86dfab1bcc2772d3de99644a99f06421f3d8c3f52a846fe84d1db2
languageName: node
linkType: hard

"react-native@npm:0.85.3":
version: 0.85.3
resolution: "react-native@npm:0.85.3"
Expand Down
Loading