fix: stop announcing the select all checkbox as mixed with nothing selected

This commit is contained in:
Linus Rath
2026-10-31 13:52:38 +01:00
parent 9f64f8abb3
commit ccf93ed667
3 changed files with 60 additions and 2 deletions
+32
View File
@@ -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<string, string | number>) =>
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 \}\}/);
});
});
+24
View File
@@ -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, string | number>) => 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 };
}
+4 -2
View File
@@ -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 ? (
<SquareCheck size={18} color={c.primary} />