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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
122 changes: 120 additions & 2 deletions src/helpers/__tests__/accessiblity.test.tsx
Original file line number Diff line number Diff line change
@@ -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 () => {
Expand Down Expand Up @@ -476,3 +481,116 @@ describe('computeAriaDisabled', () => {
expect(computeAriaDisabled(screen.getByText('ARIA Disabled Text'))).toBe(true);
});
});

describe('computeAccessibleName', () => {
test('basic cases', async () => {
await render(
<>
<View testID="aria-label" aria-label="ARIA Label" />
<View testID="accessibility-label" accessibilityLabel="Accessibility Label" />
<View testID="text-content">
<Text>Text Content</Text>
</View>
<TextInput testID="text-input" placeholder="Text Input" />
<Image testID="image" alt="Image Alt" src="https://example.com/image.jpg" />
</>,
);
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(
<>
<View testID="aria-label" aria-label="ARIA Label" accessibilityLabel="Accessibility Label">
<Text>Text Content</Text>
</View>
<View testID="accessibility-label" accessibilityLabel="Accessibility Label">
<Text>Text Content</Text>
</View>
<View testID="text-content">
<Text>Text Content</Text>
</View>
</>,
);
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(
<>
<View testID="multiple-text">
<Text>Hello</Text>
<Text>World</Text>
</View>
<View testID="nested-views">
<View>
<Text>Hello</Text>
</View>
<View>
<Text>World</Text>
</View>
</View>
<View testID="child-with-label">
<View aria-label="Hello" />
<Text>World</Text>
</View>
<View testID="deeply-nested">
<View>
<View>
<Text>Hello</Text>
</View>
</View>
<Text>World</Text>
</View>
<View testID="child-label-over-text">
<View aria-label="Hello">
<Text>Ignored</Text>
</View>
<Text>World</Text>
</View>
<View testID="child-accessibility-label-over-text">
<View accessibilityLabel="Hello">
<Text>Ignored</Text>
</View>
<Text>World</Text>
</View>
</>,
);
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(
<>
<TextInput testID="text-input" placeholder="Placeholder" />
<View testID="parent">
<TextInput placeholder="Placeholder" />
<Text>Hello</Text>
</View>
<View testID="parent-no-text">
<TextInput placeholder="Placeholder" />
</View>
</>,
);
expect(computeAccessibleName(screen.getByTestId('text-input'))).toBe('Placeholder');
expect(computeAccessibleName(screen.getByTestId('parent'))).toBe('Hello');
expect(computeAccessibleName(screen.getByTestId('parent-no-text'))).toBe('');
});
});
34 changes: 32 additions & 2 deletions src/helpers/accessibility.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<Role | AccessibilityRole, true>>;
Expand Down
6 changes: 3 additions & 3 deletions src/queries/__tests__/role.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<TouchableOpacity accessibilityRole="button" testID="target-button">
<Text>Save</Text>
<Text>Save</Text>
<Text>As</Text>
</TouchableOpacity>,
);

// 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 () => {
Expand Down
9 changes: 1 addition & 8 deletions src/queries/role.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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) => {
Expand Down
4 changes: 2 additions & 2 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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:
Expand Down