diff --git a/src/components/TagSheet.tsx b/src/components/TagSheet.tsx index 8afddb13..9bed8218 100644 --- a/src/components/TagSheet.tsx +++ b/src/components/TagSheet.tsx @@ -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 onToggle(token, !applied)} + {...tagRowAccessibility(tagCheckedState(selectedEmails, token))} style={({ pressed }) => [styles.row, tagRowIndent(kw.depth), pressed && styles.rowPressed]} > @@ -136,6 +138,7 @@ export function TagSheet({ visible, onClose, keywords, selectedEmails, onToggle onToggle(token, !applied)} + {...tagRowAccessibility(tagCheckedState(selectedEmails, token))} style={({ pressed }) => [styles.row, pressed && styles.rowPressed]} > diff --git a/src/lib/__tests__/tag-row-a11y.test.ts b/src/lib/__tests__/tag-row-a11y.test.ts new file mode 100644 index 00000000..b41d8012 --- /dev/null +++ b/src/lib/__tests__/tag-row-a11y.test.ts @@ -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); + }); +}); diff --git a/src/lib/tag-row-a11y.ts b/src/lib/tag-row-a11y.ts new file mode 100644 index 00000000..2c0634d3 --- /dev/null +++ b/src/lib/tag-row-a11y.ts @@ -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 | 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 } }; +}