mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-02 00:06:23 +00:00
fix: let screen readers reach each option in sheets and menus
This commit is contained in:
@@ -0,0 +1,73 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
// Sheets and menus were built as a tappable backdrop (Pressable or
|
||||
// TouchableWithoutFeedback) wrapping a card that swallows taps. Both are
|
||||
// accessible elements by default, and an accessible element hides its
|
||||
// children from VoiceOver and TalkBack: the compose "Attach" and "Schedule
|
||||
// send" sheets read as one element ("Attach, Photos & Videos, Camera, Files,
|
||||
// Cancel") whose options couldn't be picked one by one. The wrappers that
|
||||
// only catch taps are marked accessible={false}, so the options inside stay
|
||||
// reachable.
|
||||
|
||||
const SRC = path.resolve(__dirname, '../..');
|
||||
|
||||
function sourceFiles(dir: string): string[] {
|
||||
return fs.readdirSync(dir, { withFileTypes: true }).flatMap((e) => {
|
||||
const p = path.join(dir, e.name);
|
||||
if (e.isDirectory()) return e.name === '__tests__' ? [] : sourceFiles(p);
|
||||
return e.name.endsWith('.tsx') ? [p] : [];
|
||||
});
|
||||
}
|
||||
|
||||
function openingTags(source: string, name: string): { tag: string; line: number }[] {
|
||||
const out: { tag: string; line: number }[] = [];
|
||||
const re = new RegExp(`<${name}\\b`, 'g');
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = re.exec(source))) {
|
||||
let depth = 0;
|
||||
let i = m.index;
|
||||
for (; i < source.length; i++) {
|
||||
const ch = source[i];
|
||||
if (ch === '{') depth++;
|
||||
else if (ch === '}') depth--;
|
||||
else if (ch === '>' && depth === 0 && source[i - 1] !== '=') break;
|
||||
}
|
||||
out.push({ tag: source.slice(m.index, i + 1), line: source.slice(0, m.index).split('\n').length });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** A wrapper that only catches taps and still hides what it wraps. */
|
||||
export function isHidingWrapper(tag: string): boolean {
|
||||
if (/accessible=\{false\}/.test(tag)) return false;
|
||||
if (/\/\s*>$/.test(tag)) return false; // self-closing: wraps nothing
|
||||
if (tag.startsWith('<TouchableWithoutFeedback')) return true;
|
||||
if (/onPress=\{\(\) => \{\}\}/.test(tag)) return true;
|
||||
return /style=\{styles\.\w*(Backdrop|backdrop|Overlay|overlay)\}/.test(tag);
|
||||
}
|
||||
|
||||
describe('sheet backdrops', () => {
|
||||
it('do not hide the options they wrap from screen readers', () => {
|
||||
const offenders: string[] = [];
|
||||
for (const file of sourceFiles(SRC)) {
|
||||
const source = fs.readFileSync(file, 'utf8');
|
||||
for (const name of ['Pressable', 'TouchableWithoutFeedback']) {
|
||||
for (const { tag, line } of openingTags(source, name)) {
|
||||
if (isHidingWrapper(tag)) offenders.push(`${path.relative(SRC, file)}:${line}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
|
||||
it('recognises the wrappers it looks for', () => {
|
||||
expect(isHidingWrapper('<Pressable style={styles.modalBackdrop} onPress={onClose}>')).toBe(true);
|
||||
expect(isHidingWrapper('<Pressable style={styles.card} onPress={() => {}}>')).toBe(true);
|
||||
expect(isHidingWrapper('<TouchableWithoutFeedback onPress={onClose}>')).toBe(true);
|
||||
expect(isHidingWrapper('<Pressable accessible={false} style={styles.modalBackdrop} onPress={onClose}>')).toBe(false);
|
||||
expect(isHidingWrapper('<Pressable style={styles.pickerOverlay} onPress={close} />')).toBe(false);
|
||||
expect(isHidingWrapper('<Pressable style={styles.row} onPress={open}>')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -74,9 +74,9 @@ export function CalendarEditSheet({ visible, calendar, onSave, onClose }: Calend
|
||||
|
||||
return (
|
||||
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} transparent animationType="slide" onRequestClose={onClose}>
|
||||
<TouchableWithoutFeedback onPress={onClose}>
|
||||
<TouchableWithoutFeedback accessible={false} onPress={onClose}>
|
||||
<View style={[styles.backdrop, { paddingLeft: sheetInsets.left, paddingRight: sheetInsets.right }]}>
|
||||
<TouchableWithoutFeedback>
|
||||
<TouchableWithoutFeedback accessible={false}>
|
||||
<View style={styles.sheet}>
|
||||
<Text style={styles.title}>
|
||||
{calendar
|
||||
|
||||
@@ -160,9 +160,9 @@ export function CalendarShareSheet({ calendar, onShare, onClose }: CalendarShare
|
||||
|
||||
return (
|
||||
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible transparent animationType="slide" onRequestClose={onClose}>
|
||||
<TouchableWithoutFeedback onPress={onClose}>
|
||||
<TouchableWithoutFeedback accessible={false} onPress={onClose}>
|
||||
<View style={[styles.backdrop, { paddingLeft: sheetInsets.left, paddingRight: sheetInsets.right }]}>
|
||||
<TouchableWithoutFeedback>
|
||||
<TouchableWithoutFeedback accessible={false}>
|
||||
<View style={styles.sheet}>
|
||||
<View style={styles.titleRow}>
|
||||
<Users size={18} color={c.textMuted} />
|
||||
|
||||
@@ -160,9 +160,9 @@ export default function ShareSheet({ node, onClose, onChanged }: ShareSheetProps
|
||||
|
||||
return (
|
||||
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible transparent animationType="slide" onRequestClose={onClose}>
|
||||
<TouchableWithoutFeedback onPress={onClose}>
|
||||
<TouchableWithoutFeedback accessible={false} onPress={onClose}>
|
||||
<View style={[styles.backdrop, { paddingLeft: sheetInsets.left, paddingRight: sheetInsets.right }]}>
|
||||
<TouchableWithoutFeedback>
|
||||
<TouchableWithoutFeedback accessible={false}>
|
||||
<View style={styles.sheet}>
|
||||
<View style={styles.titleRow}>
|
||||
<Icon size={18} color={c.textMuted} />
|
||||
|
||||
@@ -196,8 +196,8 @@ export function ContentSendersSettings() {
|
||||
animationType="fade"
|
||||
onRequestClose={() => setModalOpen(false)}
|
||||
>
|
||||
<Pressable style={styles.modalBackdrop} onPress={() => setModalOpen(false)}>
|
||||
<Pressable style={styles.modalCard} onPress={() => {}}>
|
||||
<Pressable accessible={false} style={styles.modalBackdrop} onPress={() => setModalOpen(false)}>
|
||||
<Pressable accessible={false} style={styles.modalCard} onPress={() => {}}>
|
||||
<View style={styles.modalHeader}>
|
||||
<Text style={styles.modalTitle}>{t('settings.email_behavior.trusted_senders.modal_title', 'Trusted Senders')}</Text>
|
||||
<Pressable
|
||||
|
||||
@@ -298,7 +298,7 @@ export function Select({ value, onChange, options, style }: SelectProps) {
|
||||
animationType="fade"
|
||||
onRequestClose={() => setOpen(false)}
|
||||
>
|
||||
<Pressable style={styles.modalBackdrop} onPress={() => setOpen(false)}>
|
||||
<Pressable accessible={false} style={styles.modalBackdrop} onPress={() => setOpen(false)}>
|
||||
<View style={styles.modalContent}>
|
||||
<ScrollView>
|
||||
{options.map((opt) => {
|
||||
|
||||
@@ -377,8 +377,8 @@ function OptionsSheet({
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
return (
|
||||
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} transparent animationType="fade" onRequestClose={onClose}>
|
||||
<Pressable style={styles.modalBackdrop} onPress={onClose}>
|
||||
<Pressable style={styles.scheduleCard} onPress={() => {}}>
|
||||
<Pressable accessible={false} style={styles.modalBackdrop} onPress={onClose}>
|
||||
<Pressable accessible={false} style={styles.scheduleCard} onPress={() => {}}>
|
||||
{!!title && <Text style={styles.modalTitle} numberOfLines={2}>{title}</Text>}
|
||||
{options.map((opt) => (
|
||||
<Pressable
|
||||
@@ -2647,8 +2647,8 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
||||
|
||||
{/* Text / background colour palette */}
|
||||
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={!!colorTarget} transparent animationType="fade" onRequestClose={() => setColorTarget(null)}>
|
||||
<Pressable style={styles.modalBackdrop} onPress={() => setColorTarget(null)}>
|
||||
<Pressable style={styles.scheduleCard} onPress={() => {}}>
|
||||
<Pressable accessible={false} style={styles.modalBackdrop} onPress={() => setColorTarget(null)}>
|
||||
<Pressable accessible={false} style={styles.scheduleCard} onPress={() => {}}>
|
||||
<Text style={styles.modalTitle}>
|
||||
{colorTarget === 'background'
|
||||
? t('email_composer.toolbar.background_color', 'Background color')
|
||||
@@ -2716,8 +2716,8 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
||||
animationType="fade"
|
||||
onRequestClose={() => setScheduleSheetOpen(false)}
|
||||
>
|
||||
<Pressable style={styles.modalBackdrop} onPress={() => setScheduleSheetOpen(false)}>
|
||||
<Pressable style={styles.scheduleCard} onPress={() => {}}>
|
||||
<Pressable accessible={false} style={styles.modalBackdrop} onPress={() => setScheduleSheetOpen(false)}>
|
||||
<Pressable accessible={false} style={styles.scheduleCard} onPress={() => {}}>
|
||||
<Text style={styles.modalTitle}>
|
||||
{t('email_composer.schedule_send', 'Schedule send')}
|
||||
</Text>
|
||||
@@ -2750,8 +2750,8 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
||||
|
||||
{customStage !== null && Platform.OS === 'ios' && (
|
||||
<Modal supportedOrientations={MODAL_ORIENTATIONS} transparent animationType="fade" onRequestClose={() => setCustomStage(null)}>
|
||||
<Pressable style={styles.modalBackdrop} onPress={() => setCustomStage(null)}>
|
||||
<Pressable style={styles.scheduleCard} onPress={() => {}}>
|
||||
<Pressable accessible={false} style={styles.modalBackdrop} onPress={() => setCustomStage(null)}>
|
||||
<Pressable accessible={false} style={styles.scheduleCard} onPress={() => {}}>
|
||||
<DateTimePicker
|
||||
value={customDraftRef.current}
|
||||
mode="datetime"
|
||||
|
||||
@@ -1609,9 +1609,9 @@ export default function EmailListScreen({
|
||||
animationType="fade"
|
||||
onRequestClose={() => setFilterMenuOpen(false)}
|
||||
>
|
||||
<TouchableWithoutFeedback onPress={() => setFilterMenuOpen(false)}>
|
||||
<TouchableWithoutFeedback accessible={false} onPress={() => setFilterMenuOpen(false)}>
|
||||
<View style={styles.filterBackdrop}>
|
||||
<TouchableWithoutFeedback>
|
||||
<TouchableWithoutFeedback accessible={false}>
|
||||
<View style={styles.filterMenu}>
|
||||
<View style={styles.filterMenuHeader}>
|
||||
<Text style={styles.filterMenuTitle}>{t('email_list.filter_title', 'Filter emails')}</Text>
|
||||
|
||||
@@ -1179,9 +1179,9 @@ function PromptModal(props: PromptModalProps) {
|
||||
animationType="fade"
|
||||
onRequestClose={props.onCancel}
|
||||
>
|
||||
<TouchableWithoutFeedback onPress={props.onCancel}>
|
||||
<TouchableWithoutFeedback accessible={false} onPress={props.onCancel}>
|
||||
<View style={styles.backdrop}>
|
||||
<TouchableWithoutFeedback>
|
||||
<TouchableWithoutFeedback accessible={false}>
|
||||
<View style={styles.dialog}>
|
||||
<Text style={styles.title}>{props.title}</Text>
|
||||
<TextInput
|
||||
@@ -1246,9 +1246,9 @@ function ActionsSheet({
|
||||
const modified = modifiedOf(target);
|
||||
return (
|
||||
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible transparent animationType="slide" onRequestClose={onClose}>
|
||||
<TouchableWithoutFeedback onPress={onClose}>
|
||||
<TouchableWithoutFeedback accessible={false} onPress={onClose}>
|
||||
<View style={[styles.backdrop, { paddingLeft: sheetInsets.left, paddingRight: sheetInsets.right }]}>
|
||||
<TouchableWithoutFeedback>
|
||||
<TouchableWithoutFeedback accessible={false}>
|
||||
<View style={styles.sheet}>
|
||||
<Text style={styles.sheetTitle} numberOfLines={1}>
|
||||
{target.displayName}
|
||||
@@ -1370,9 +1370,9 @@ function FolderPickerSheet({ target, allNodes, rootLabel, t, onClose, onPick }:
|
||||
const currentParent = target.parentId ?? null;
|
||||
return (
|
||||
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible transparent animationType="slide" onRequestClose={onClose}>
|
||||
<TouchableWithoutFeedback onPress={onClose}>
|
||||
<TouchableWithoutFeedback accessible={false} onPress={onClose}>
|
||||
<View style={[styles.backdrop, { paddingLeft: sheetInsets.left, paddingRight: sheetInsets.right }]}>
|
||||
<TouchableWithoutFeedback>
|
||||
<TouchableWithoutFeedback accessible={false}>
|
||||
<View style={styles.sheet}>
|
||||
<Text style={styles.sheetTitle} numberOfLines={1}>
|
||||
{t('files.move_to', 'Move to…')} · {target.displayName}
|
||||
@@ -1427,9 +1427,9 @@ function SortSheet({ visible, sortKey, sortDir, t, onClose, onChangeKey, onChang
|
||||
const keys: FilesSortKey[] = ['name', 'size', 'modified'];
|
||||
return (
|
||||
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible transparent animationType="slide" onRequestClose={onClose}>
|
||||
<TouchableWithoutFeedback onPress={onClose}>
|
||||
<TouchableWithoutFeedback accessible={false} onPress={onClose}>
|
||||
<View style={[styles.backdrop, { paddingLeft: sheetInsets.left, paddingRight: sheetInsets.right }]}>
|
||||
<TouchableWithoutFeedback>
|
||||
<TouchableWithoutFeedback accessible={false}>
|
||||
<View style={styles.sheet}>
|
||||
<Text style={styles.sheetTitle}>{t('files.settings_default_sort', 'Sort')}</Text>
|
||||
{keys.map((k) => (
|
||||
|
||||
@@ -326,8 +326,8 @@ export default function ScheduledScreen({ navigation }: Props) {
|
||||
|
||||
{/* Per-message actions */}
|
||||
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={!!actionsFor} transparent animationType="fade" onRequestClose={() => setActionsFor(null)}>
|
||||
<Pressable style={styles.modalBackdrop} onPress={() => setActionsFor(null)}>
|
||||
<Pressable style={styles.card} onPress={() => {}}>
|
||||
<Pressable accessible={false} style={styles.modalBackdrop} onPress={() => setActionsFor(null)}>
|
||||
<Pressable accessible={false} style={styles.card} onPress={() => {}}>
|
||||
<Text style={styles.cardTitle} numberOfLines={2}>
|
||||
{actionsFor?.subject || t('email_composer.no_subject', '(No Subject)')}
|
||||
</Text>
|
||||
@@ -349,8 +349,8 @@ export default function ScheduledScreen({ navigation }: Props) {
|
||||
|
||||
{/* Reschedule presets */}
|
||||
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={!!rescheduleFor && customStage === null} transparent animationType="fade" onRequestClose={() => setRescheduleFor(null)}>
|
||||
<Pressable style={styles.modalBackdrop} onPress={() => setRescheduleFor(null)}>
|
||||
<Pressable style={styles.card} onPress={() => {}}>
|
||||
<Pressable accessible={false} style={styles.modalBackdrop} onPress={() => setRescheduleFor(null)}>
|
||||
<Pressable accessible={false} style={styles.card} onPress={() => {}}>
|
||||
<Text style={styles.cardTitle}>{t('email_list.reschedule_send', 'Reschedule')}</Text>
|
||||
{reschedulePresets.map((preset) => (
|
||||
<Pressable
|
||||
@@ -379,8 +379,8 @@ export default function ScheduledScreen({ navigation }: Props) {
|
||||
|
||||
{customStage !== null && Platform.OS === 'ios' && (
|
||||
<Modal supportedOrientations={MODAL_ORIENTATIONS} transparent animationType="fade" onRequestClose={() => setCustomStage(null)}>
|
||||
<Pressable style={styles.modalBackdrop} onPress={() => setCustomStage(null)}>
|
||||
<Pressable style={styles.card} onPress={() => {}}>
|
||||
<Pressable accessible={false} style={styles.modalBackdrop} onPress={() => setCustomStage(null)}>
|
||||
<Pressable accessible={false} style={styles.card} onPress={() => {}}>
|
||||
<DateTimePicker
|
||||
value={customDraftRef.current}
|
||||
mode="datetime"
|
||||
|
||||
Reference in New Issue
Block a user