From ccf93ed6671fc9676d2f6c68cc7f0c61e6703321 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 31 Oct 2026 13:36:04 +0100 Subject: [PATCH] fix: stop announcing the select all checkbox as mixed with nothing selected --- src/lib/__tests__/select-all-a11y.test.ts | 32 +++++++++++++++++++++++ src/lib/select-all-a11y.ts | 24 +++++++++++++++++ src/screens/EmailListScreen.tsx | 6 +++-- 3 files changed, 60 insertions(+), 2 deletions(-) create mode 100644 src/lib/__tests__/select-all-a11y.test.ts create mode 100644 src/lib/select-all-a11y.ts diff --git a/src/lib/__tests__/select-all-a11y.test.ts b/src/lib/__tests__/select-all-a11y.test.ts new file mode 100644 index 00000000..3ac56311 --- /dev/null +++ b/src/lib/__tests__/select-all-a11y.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import { selectAllA11y } from '../select-all-a11y'; + +// Trash's Select all checkbox was announced "mixed" with nothing selected: +// Android kept the "mixed" a string state had written into the description +// after the state went back to false. The checkbox now never uses 'mixed'. + +const t = (_key: string, fallback: string, values?: Record) => + fallback.replace('{count, plural, one {1 email} other {# emails}}', `${values?.count} emails`); +const read = (rel: string) => fs.readFileSync(path.resolve(__dirname, '..', '..', rel), 'utf8'); + +describe('Select all checkbox for screen readers', () => { + it('is plain unchecked with nothing selected', () => { + expect(selectAllA11y(false, 0, t)).toEqual({ label: 'Select all', checked: false }); + }); + + it('names the count in the label while some rows are selected', () => { + expect(selectAllA11y(false, 3, t)).toEqual({ label: 'Select all, 3 emails selected', checked: false }); + }); + + it('is checked with every row selected', () => { + expect(selectAllA11y(true, 5, t)).toEqual({ label: 'Select all', checked: true }); + }); + + it('never hands the list checkbox a mixed state', () => { + const list = read('screens/EmailListScreen.tsx'); + expect(list).not.toMatch(/'mixed'/); + expect(list).toMatch(/accessibilityLabel=\{selectAll\.label\}\s+accessibilityState=\{\{ checked: selectAll\.checked \}\}/); + }); +}); diff --git a/src/lib/select-all-a11y.ts b/src/lib/select-all-a11y.ts new file mode 100644 index 00000000..ecaa8d02 --- /dev/null +++ b/src/lib/select-all-a11y.ts @@ -0,0 +1,24 @@ +// What a screen reader hears for the list's Select all checkbox. +// +// It used to say checked: 'mixed' while some rows were selected. Android +// writes "mixed" into the view's content description and, going back to +// checked: false, rebuilds that description only for a string state, so it +// kept saying "mixed" with nothing selected (seen in Trash). The count goes +// into the label instead: a changed label always rebuilds the description. + +type Translate = (key: string, fallback: string, values?: Record) => string; + +export function selectAllA11y( + allSelected: boolean, + selectedCount: number, + t: Translate, +): { label: string; checked: boolean } { + const label = t('email_list.batch_actions.select_all', 'Select all'); + if (allSelected || selectedCount === 0) return { label, checked: allSelected }; + const selected = t( + 'email_list.batch_actions.selected_messages', + '{count, plural, one {1 email} other {# emails}} selected', + { count: selectedCount }, + ); + return { label: `${label}, ${selected}`, checked: false }; +} diff --git a/src/screens/EmailListScreen.tsx b/src/screens/EmailListScreen.tsx index dfe21dbf..f0076d2e 100644 --- a/src/screens/EmailListScreen.tsx +++ b/src/screens/EmailListScreen.tsx @@ -23,6 +23,7 @@ import { SwipeableRow } from '../components/SwipeableRow'; import { MoveSheet } from '../components/MoveSheet'; import { TagSheet } from '../components/TagSheet'; import { useTagUndoGroup } from '../lib/tag-undo-group'; +import { selectAllA11y } from '../lib/select-all-a11y'; import { OfflineBanner } from '../components/OfflineBanner'; import { TrashRecoveryLink } from '../components/email/TrashRecoveryLink'; import { @@ -721,6 +722,7 @@ export default function EmailListScreen({ const selectedLabel = t('email_list.batch_actions.selected_messages', '{count, plural, one {1 email} other {# emails}} selected', { count: selectedIds.size }); const allSelected = visibleEmails.length > 0 && visibleEmails.every((e) => selectedIds.has(rowKeyOf(e))); + const selectAll = selectAllA11y(allSelected, selectedIds.size, t); // Refs so row press handlers stay referentially stable across renders. // FlatList rows then skip re-render when the parent re-renders for unrelated @@ -1274,8 +1276,8 @@ export default function EmailListScreen({ onPress={toggleSelectAllVisible} hitSlop={6} accessibilityRole="checkbox" - accessibilityLabel={t('email_list.batch_actions.select_all', 'Select all')} - accessibilityState={{ checked: allSelected ? true : selectionMode ? 'mixed' : false }} + accessibilityLabel={selectAll.label} + accessibilityState={{ checked: selectAll.checked }} > {allSelected ? (