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 + + + Image Alt + , + ); + 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: