diff --git a/package.json b/package.json
index 806b43ac7..6b75cc9fd 100644
--- a/package.json
+++ b/package.json
@@ -90,7 +90,7 @@
"prettier": "^3.6.2",
"react": "19.2.3",
"react-native": "0.84.0",
- "react-native-gesture-handler": "^2.29.1",
+ "react-native-gesture-handler": "^2.30.0",
"release-it": "^19.0.6",
"test-renderer": "0.14.0",
"typescript": "^5.9.3",
diff --git a/src/helpers/__tests__/accessiblity.test.tsx b/src/helpers/__tests__/accessiblity.test.tsx
index 64331884d..b768c84be 100644
--- a/src/helpers/__tests__/accessiblity.test.tsx
+++ b/src/helpers/__tests__/accessiblity.test.tsx
@@ -1,8 +1,13 @@
import React from 'react';
-import { Pressable, Switch, Text, TextInput, TouchableOpacity, View } from 'react-native';
+import { Image, Pressable, Switch, Text, TextInput, TouchableOpacity, View } from 'react-native';
import { isHiddenFromAccessibility, isInaccessible, render, screen } from '../..';
-import { computeAriaDisabled, computeAriaLabel, isAccessibilityElement } from '../accessibility';
+import {
+ computeAccessibleName,
+ computeAriaDisabled,
+ computeAriaLabel,
+ isAccessibilityElement,
+} from '../accessibility';
describe('isHiddenFromAccessibility', () => {
test('returns false for accessible elements', async () => {
@@ -476,3 +481,116 @@ describe('computeAriaDisabled', () => {
expect(computeAriaDisabled(screen.getByText('ARIA Disabled Text'))).toBe(true);
});
});
+
+describe('computeAccessibleName', () => {
+ test('basic cases', async () => {
+ await render(
+ <>
+
+
+
+ Text Content
+
+
+
+ >,
+ );
+ expect(computeAccessibleName(screen.getByTestId('aria-label'))).toBe('ARIA Label');
+ expect(computeAccessibleName(screen.getByTestId('accessibility-label'))).toBe(
+ 'Accessibility Label',
+ );
+ expect(computeAccessibleName(screen.getByTestId('text-content'))).toBe('Text Content');
+ expect(computeAccessibleName(screen.getByTestId('text-input'))).toBe('Text Input');
+ expect(computeAccessibleName(screen.getByTestId('image'))).toBe('Image Alt');
+ });
+
+ test('basic precedence', async () => {
+ await render(
+ <>
+
+ Text Content
+
+
+ Text Content
+
+
+ Text Content
+
+ >,
+ );
+ expect(computeAccessibleName(screen.getByTestId('aria-label'))).toBe('ARIA Label');
+ expect(computeAccessibleName(screen.getByTestId('accessibility-label'))).toBe(
+ 'Accessibility Label',
+ );
+ expect(computeAccessibleName(screen.getByTestId('text-content'))).toBe('Text Content');
+ });
+
+ test('concatenates children accessible names', async () => {
+ await render(
+ <>
+
+ Hello
+ World
+
+
+
+ Hello
+
+
+ World
+
+
+
+
+ World
+
+
+
+
+ Hello
+
+
+ World
+
+
+
+ Ignored
+
+ World
+
+
+
+ Ignored
+
+ World
+
+ >,
+ );
+ expect(computeAccessibleName(screen.getByTestId('multiple-text'))).toBe('Hello World');
+ expect(computeAccessibleName(screen.getByTestId('nested-views'))).toBe('Hello World');
+ expect(computeAccessibleName(screen.getByTestId('child-with-label'))).toBe('Hello World');
+ expect(computeAccessibleName(screen.getByTestId('deeply-nested'))).toBe('Hello World');
+ expect(computeAccessibleName(screen.getByTestId('child-label-over-text'))).toBe('Hello World');
+ expect(computeAccessibleName(screen.getByTestId('child-accessibility-label-over-text'))).toBe(
+ 'Hello World',
+ );
+ });
+
+ test('TextInput placeholder is used only for the element itself', async () => {
+ await render(
+ <>
+
+
+
+ Hello
+
+
+
+
+ >,
+ );
+ expect(computeAccessibleName(screen.getByTestId('text-input'))).toBe('Placeholder');
+ expect(computeAccessibleName(screen.getByTestId('parent'))).toBe('Hello');
+ expect(computeAccessibleName(screen.getByTestId('parent-no-text'))).toBe('');
+ });
+});
diff --git a/src/helpers/accessibility.ts b/src/helpers/accessibility.ts
index 02f5d7ee9..2187d0c58 100644
--- a/src/helpers/accessibility.ts
+++ b/src/helpers/accessibility.ts
@@ -244,8 +244,38 @@ export function computeAriaValue(element: HostElement): AccessibilityValue {
};
}
-export function computeAccessibleName(element: HostElement): string | undefined {
- return computeAriaLabel(element) ?? getTextContent(element);
+type ComputeAccessibleNameOptions = {
+ root?: boolean;
+};
+
+export function computeAccessibleName(
+ element: HostElement,
+ options?: ComputeAccessibleNameOptions,
+): string | undefined {
+ const label = computeAriaLabel(element);
+ if (label) {
+ return label;
+ }
+
+ if (isHostTextInput(element) && element.props.placeholder && options?.root !== false) {
+ return element.props.placeholder;
+ }
+
+ const parts = [];
+ for (const child of element.children) {
+ if (typeof child === 'string') {
+ if (child) {
+ parts.push(child);
+ }
+ } else {
+ const childLabel = computeAccessibleName(child, { root: false });
+ if (childLabel) {
+ parts.push(childLabel);
+ }
+ }
+ }
+
+ return parts.join(' ');
}
type RoleSupportMap = Partial>;
diff --git a/src/queries/__tests__/role.test.tsx b/src/queries/__tests__/role.test.tsx
index 7a112b5bd..c07c4c92a 100644
--- a/src/queries/__tests__/role.test.tsx
+++ b/src/queries/__tests__/role.test.tsx
@@ -135,16 +135,16 @@ describe('supports name option', () => {
expect(screen.getByRole('button', { name: 'Save' }).props.testID).toBe('target-button');
});
- test('returns an element that has the corresponding role when several children include the name', async () => {
+ test('returns an element that has the corresponding role when several children provide the name', async () => {
await render(
Save
- Save
+ As
,
);
// assert on the testId to be sure that the returned element is the one with the accessibilityRole
- expect(screen.getByRole('button', { name: 'Save' }).props.testID).toBe('target-button');
+ expect(screen.getByRole('button', { name: 'Save As' }).props.testID).toBe('target-button');
});
test('returns an element that has the corresponding role and a children with a matching accessibilityLabel', async () => {
diff --git a/src/queries/role.ts b/src/queries/role.ts
index 8e199b36d..f37cb79ee 100644
--- a/src/queries/role.ts
+++ b/src/queries/role.ts
@@ -17,7 +17,6 @@ import { matchAccessibilityValue } from '../helpers/matchers/match-accessibility
import { matchStringProp } from '../helpers/matchers/match-string-prop';
import { matches, type TextMatch } from '../matches';
import type { StringWithAutocomplete } from '../types';
-import { getQueriesForElement } from '../within';
import type {
FindAllByQuery,
FindByQuery,
@@ -40,14 +39,8 @@ export type ByRoleOptions = CommonQueryOptions &
const matchAccessibleNameIfNeeded = (node: HostElement, name?: TextMatch) => {
if (name == null) return true;
- // TODO: rewrite computeAccessibleName for real world a11y compliance
const accessibleName = computeAccessibleName(node);
- if (matches(name, accessibleName)) {
- return true;
- }
-
- const { queryAllByText, queryAllByLabelText } = getQueriesForElement(node);
- return queryAllByText(name).length > 0 || queryAllByLabelText(name).length > 0;
+ return matches(name, accessibleName);
};
const matchAccessibleStateIfNeeded = (node: HostElement, options?: ByRoleOptions) => {
diff --git a/yarn.lock b/yarn.lock
index fa3a316d6..831217c5d 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -3008,7 +3008,7 @@ __metadata:
pretty-format: "npm:^30.2.0"
react: "npm:19.2.3"
react-native: "npm:0.84.0"
- react-native-gesture-handler: "npm:^2.29.1"
+ react-native-gesture-handler: "npm:^2.30.0"
redent: "npm:^3.0.0"
release-it: "npm:^19.0.6"
test-renderer: "npm:0.14.0"
@@ -9291,7 +9291,7 @@ __metadata:
languageName: node
linkType: hard
-"react-native-gesture-handler@npm:^2.29.1":
+"react-native-gesture-handler@npm:^2.30.0":
version: 2.30.0
resolution: "react-native-gesture-handler@npm:2.30.0"
dependencies: