fix: stop TalkBack reading a button as busy after it finishes loading

This commit is contained in:
Linus Rath
2026-10-31 14:14:42 +01:00
parent 9b0bba7c15
commit 350b0e8e74
2 changed files with 45 additions and 1 deletions
+4 -1
View File
@@ -64,7 +64,10 @@ export default function Button({
disabled={isDisabled} disabled={isDisabled}
hitSlop={HIT_SLOP[size]} hitSlop={HIT_SLOP[size]}
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel={accessibilityLabel} // A text button names itself by its text: React Native only replaces a
// content description with a non-empty one, so without it TalkBack kept
// reading "busy" after the spinner went away.
accessibilityLabel={accessibilityLabel ?? (typeof children === 'string' ? children : undefined)}
accessibilityState={{ disabled: isDisabled, busy: loading }} accessibilityState={{ disabled: isDisabled, busy: loading }}
> >
{loading ? ( {loading ? (
@@ -0,0 +1,41 @@
import { describe, expect, it, vi } from 'vitest';
// React Native resets a view's content description only when the new one is
// non-empty. A Button that showed its spinner (TalkBack reads "busy") and
// then its text again kept "busy" as its name when it passed no label, so
// TalkBack read Save as "busy". A text button names itself by its text.
vi.mock('react-native', () => ({
Pressable: 'Pressable',
Text: 'Text',
ActivityIndicator: 'ActivityIndicator',
StyleSheet: { create: <T,>(s: T) => s },
}));
vi.mock('../../theme/colors', () => ({ useColors: () => ({}) }));
vi.mock('react', async (importOriginal) => {
const actual = await importOriginal<typeof import('react')>();
const useMemo = <T,>(fn: () => T) => fn();
return { ...actual, useMemo, default: { ...actual, useMemo } };
});
import Button from '../Button';
type Rendered = { props: { accessibilityLabel?: string; accessibilityState?: { busy?: boolean } } };
const render = (props: Parameters<typeof Button>[0]) => Button(props) as unknown as Rendered;
describe('Button accessibility name', () => {
it('names a text button by its text, while loading and after', () => {
expect(render({ children: 'Save', loading: true }).props.accessibilityLabel).toBe('Save');
expect(render({ children: 'Save', loading: false }).props.accessibilityLabel).toBe('Save');
expect(render({ children: 'Save', loading: true }).props.accessibilityState?.busy).toBe(true);
});
it('keeps an explicit label', () => {
expect(render({ children: 'Save', accessibilityLabel: 'Save draft' }).props.accessibilityLabel).toBe('Save draft');
});
it('leaves a button with element children to its label prop', () => {
expect(render({ children: null, accessibilityLabel: 'Close' }).props.accessibilityLabel).toBe('Close');
expect(render({ children: null }).props.accessibilityLabel).toBeUndefined();
});
});