From 350b0e8e740b6a49c4d99b655ea8d9ae7c0220b4 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 31 Oct 2026 14:14:42 +0100 Subject: [PATCH] fix: stop TalkBack reading a button as busy after it finishes loading --- src/components/Button.tsx | 5 ++- src/components/__tests__/button-label.test.ts | 41 +++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) create mode 100644 src/components/__tests__/button-label.test.ts diff --git a/src/components/Button.tsx b/src/components/Button.tsx index 78820fee..fec4d44d 100644 --- a/src/components/Button.tsx +++ b/src/components/Button.tsx @@ -64,7 +64,10 @@ export default function Button({ disabled={isDisabled} hitSlop={HIT_SLOP[size]} 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 }} > {loading ? ( diff --git a/src/components/__tests__/button-label.test.ts b/src/components/__tests__/button-label.test.ts new file mode 100644 index 00000000..6424e083 --- /dev/null +++ b/src/components/__tests__/button-label.test.ts @@ -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: (s: T) => s }, +})); +vi.mock('../../theme/colors', () => ({ useColors: () => ({}) })); +vi.mock('react', async (importOriginal) => { + const actual = await importOriginal(); + const useMemo = (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[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(); + }); +});