fix: tell screen readers which tags the picker has checked

This commit is contained in:
Linus Rath
2026-10-31 20:01:07 +01:00
parent 7c39730616
commit bac51fbc46
3 changed files with 55 additions and 0 deletions
+3
View File
@@ -3,6 +3,7 @@ import {
View, Text, StyleSheet, ScrollView, Pressable, Modal, Animated, Easing,
} from 'react-native';
import { X, Check, Tag } from './icons';
import { tagCheckedState, tagRowAccessibility } from '../lib/tag-row-a11y';
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
import { useSheetDrag } from '../lib/use-sheet-drag';
@@ -120,6 +121,7 @@ export function TagSheet({ visible, onClose, keywords, selectedEmails, onToggle
<Pressable
key={kw.id}
onPress={() => onToggle(token, !applied)}
{...tagRowAccessibility(tagCheckedState(selectedEmails, token))}
style={({ pressed }) => [styles.row, tagRowIndent(kw.depth), pressed && styles.rowPressed]}
>
<Tag size={16} color={dot} fill={dot} />
@@ -136,6 +138,7 @@ export function TagSheet({ visible, onClose, keywords, selectedEmails, onToggle
<Pressable
key={`unknown:${id}`}
onPress={() => onToggle(token, !applied)}
{...tagRowAccessibility(tagCheckedState(selectedEmails, token))}
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
>
<Tag size={16} color={c.tags.gray.dot} fill={c.tags.gray.dot} />
+27
View File
@@ -0,0 +1,27 @@
// Tag picker rows showed a check mark but exposed no checked state.
import { describe, expect, it } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
import { tagCheckedState, tagRowAccessibility } from '../tag-row-a11y';
describe('tag picker row state', () => {
const a = { keywords: { $label1: true } };
const b = { keywords: {} };
it('is checked when every selected email has the tag', () => {
expect(tagCheckedState([a, a], '$label1')).toBe(true);
expect(tagRowAccessibility(true)).toEqual({ accessibilityRole: 'checkbox', accessibilityState: { checked: true } });
});
it('is mixed when only some have it, unchecked when none do', () => {
expect(tagCheckedState([a, b], '$label1')).toBe('mixed');
expect(tagCheckedState([b], '$label1')).toBe(false);
expect(tagCheckedState([], '$label1')).toBe(false);
});
it('is spread onto both kinds of tag row', () => {
const source = fs.readFileSync(path.resolve(__dirname, '../../components/TagSheet.tsx'), 'utf8');
const spreads = source.match(/\{\.\.\.tagRowAccessibility\(/g) ?? [];
expect(spreads.length).toBe(2);
});
});
+25
View File
@@ -0,0 +1,25 @@
// What a tag picker row tells a screen reader. The rows drew a check mark
// but said nothing about it, so TalkBack read a tag as a plain button. As in
// the webmail tag picker (components/email/tag-picker.tsx, role
// menuitemcheckbox with aria-checked true / "mixed" / false): checked when
// every selected email has the tag, mixed when only some do.
export type TagCheckedState = boolean | 'mixed';
export function tagCheckedState(
selectedEmails: ReadonlyArray<{ keywords?: Record<string, boolean> | null }>,
token: string,
): TagCheckedState {
if (selectedEmails.length === 0) return false;
const withTag = selectedEmails.filter((e) => !!e.keywords?.[token]).length;
if (withTag === 0) return false;
return withTag === selectedEmails.length ? true : 'mixed';
}
/** The row's role and state props. */
export function tagRowAccessibility(checked: TagCheckedState): {
accessibilityRole: 'checkbox';
accessibilityState: { checked: TagCheckedState };
} {
return { accessibilityRole: 'checkbox', accessibilityState: { checked } };
}