plaint text support

This commit is contained in:
Maciej Jastrzębski
2026-09-04 15:17:11 +02:00
parent 17a3435962
commit 5ea3657a79
15 changed files with 216 additions and 5 deletions
+2
View File
@@ -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
+3 -1
View File
@@ -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: [
+1
View File
@@ -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",
@@ -1,10 +1,12 @@
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 {
isHostImage,
isHostModal,
isHostPlainText,
isHostScrollView,
isHostSwitch,
isHostText,
@@ -23,6 +25,19 @@ test('detects raw RCTText component', async () => {
expect(isHostText(screen.root)).toBe(true);
});
// Plain text components, e.g. `<PlainText>` from `react-native-plain-text`,
// hold their content in the `text` prop instead of string children.
test('detects host plain text component', async () => {
await render(<PlainText>Hello</PlainText>);
expect(isHostText(screen.root)).toBe(true);
expect(isHostPlainText(screen.root)).toBe(true);
});
test('does not detect host Text component as plain text', async () => {
await render(<Text>Hello</Text>);
expect(isHostPlainText(screen.root)).toBe(false);
});
test('detects host TextInput component', async () => {
await render(<TextInput />);
expect(isHostTextInput(screen.root)).toBe(true);
@@ -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 {
@@ -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');
});
});
+27 -1
View File
@@ -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';
@@ -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');
});
+13 -1
View File
@@ -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 {
getHostPlainTextValue,
isHostImage,
isHostSwitch,
isHostText,
isHostTextInput,
} from './host-component-names';
import { getTextContent } from './text-content';
import { isEditableTextInput } from './text-input';
@@ -286,6 +292,12 @@ export function computeAccessibleName(
return instance.props.placeholder;
}
// Plain text host elements have no children, their content is in `text` prop.
const plainText = getHostPlainTextValue(instance);
if (plainText !== undefined) {
return plainText;
}
const parts: AccessibleNamePart[] = [];
for (const child of instance.children) {
if (typeof child === 'string') {
+37 -2
View File
@@ -1,6 +1,12 @@
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'];
// Host components that hold their text content in the `text` prop instead of
// string children, e.g. `<PlainText>` from `react-native-plain-text`.
const HOST_PLAIN_TEXT_NAMES = ['RNPlainText'];
const HOST_TEXT_INPUT_NAMES = ['TextInput'];
const HOST_IMAGE_NAMES = ['Image'];
const HOST_SWITCH_NAMES = ['RCTSwitch'];
@@ -8,11 +14,40 @@ const HOST_SCROLL_VIEW_NAMES = ['RCTScrollView'];
const HOST_MODAL_NAMES = ['Modal'];
/**
* Checks if the given element is a host Text element.
* Checks if the given element is a host plain text element, i.e. one that holds
* its text content in the `text` prop, e.g. `<PlainText>` from
* `react-native-plain-text`.
* @param instance The instance to check.
*/
export function isHostPlainText(instance: TestInstance | null) {
return typeof instance?.type === 'string' && HOST_PLAIN_TEXT_NAMES.includes(instance.type);
}
/**
* Checks if the given element is a host text element: either a RN `<Text>`,
* holding its content as string children, or a plain text one, holding it in
* the `text` 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) || HOST_PLAIN_TEXT_NAMES.includes(instance.type);
}
/**
* Returns the text held directly by a host plain text element, if any.
* @param instance The instance to read.
*/
export function getHostPlainTextValue(instance: TestInstance | null): string | undefined {
if (instance == null || !isHostPlainText(instance)) {
return undefined;
}
const { text } = instance.props;
return typeof text === 'string' || typeof text === 'number' ? String(text) : undefined;
}
/**
+9
View File
@@ -1,5 +1,7 @@
import type { TestInstance } from 'test-renderer';
import { getHostPlainTextValue } from './host-component-names';
export function getTextContent(instance: TestInstance | string | null): string {
if (!instance) {
return '';
@@ -9,6 +11,13 @@ export function getTextContent(instance: TestInstance | string | null): string {
return instance;
}
// Plain text host elements, e.g. `<PlainText>` from `react-native-plain-text`,
// hold their content in the `text` prop rather than as string children.
const plainText = getHostPlainTextValue(instance);
if (plainText !== undefined) {
return plainText;
}
const result: string[] = [];
instance.children?.forEach((child) => {
result.push(getTextContent(child));
@@ -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 '../..';
@@ -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');
});
@@ -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 '../..';
@@ -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
View File
@@ -10,6 +10,7 @@ import {
TouchableWithoutFeedback,
View,
} from 'react-native';
import { PlainText } from 'react-native-plain-text';
import { render, screen } from '../..';
@@ -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
View File
@@ -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 '../..';
@@ -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
View File
@@ -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
+11
View File
@@ -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"
@@ -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"