Доступність та інтернаціоналізація
Доступність та інтернаціоналізація
Відкриття: застосунок для всіх
Уявіть людину з порушенням зору, яка відкриває ваш застосунок. Вона вмикає VoiceOver (iOS) або TalkBack (Android) — і телефон починає зачитувати вміст екрана вголос. Якщо ваша кнопка «Додати» не має accessibilityLabel, VoiceOver скаже: «Зображення, кнопка». Якщо ж ви написали accessibilityLabel="Додати нову поїздку" — вона почує зрозумілий опис і зможе користуватись застосунком самостійно.
Але доступність — це не лише для людей з обмеженими можливостями. Це також:
- Старші користувачі із збільшеним шрифтом системи.
- Люди в галасливих місцях без можливості прочитати дрібний текст.
- Водії, що керують застосунком голосом.
- Розробники, що пишуть UI-тести — адже Maestro і Detox використовують
accessibilityLabelдля знаходження елементів.
У цій статті ми розберемо повний стек доступності React Native: від теорії роботи скрінрідерів — до практичної реалізації доступної форми. Також розберемо інтернаціоналізацію (i18n) — переклад рядків інтерфейсу на кілька мов, починаючи з UA.
View, Text, Pressable, TextInput) і базовими хуками React.Як працюють скрінрідери
VoiceOver (iOS) і TalkBack (Android)
Скрінрідер — це системний сервіс, який «читає» інтерфейс застосунку і надає аудіо і тактильний зворотний зв'язок. Він не бачить пікселі — він читає дерево доступності (Accessibility Tree).
Ключова думка: скрінрідер не бачить як виглядає кнопка — він читає метадані які ви надаєте через props доступності.
Жести скрінрідера
При увімкненому VoiceOver/TalkBack стандартні жести змінюють поведінку:
| Жест | VoiceOver (iOS) | TalkBack (Android) |
|---|---|---|
| Свайп вправо/вліво | Перейти до наступного/попереднього елемента | Аналогічно |
| Подвійний тап | Активувати елемент (аналог onPress) | Аналогічно |
| Три пальці вгору/вниз | Скрол | Аналогічно |
| Тап і утримання | Контекстне меню | Довгий тап |
| Два пальці вгору | Читати з поточного місця | — |
Це означає: звичайний тап більше не спрацьовує при увімкненому скрінрідері. onPress спрацьовує лише від подвійного тапу (VoiceOver) або від активації (TalkBack). Pressable і TouchableOpacity обробляють це автоматично — тому важливо їх використовувати.
Як тестувати
Увімкнути VoiceOver на iOS
Налаштування → Доступність → VoiceOver → Увімкнути. Або: тричі натиснути кнопку Side/Home. У симуляторі: Hardware → Accessibility Inspector.
Увімкнути TalkBack на Android
Налаштування → Доступність → TalkBack → Увімкнути. В емуляторі: Extended Controls → Accessibility.
Accessibility Inspector (Xcode)
Найзручніший інструмент для iOS розробки: Xcode → Open Developer Tool → Accessibility Inspector. Дозволяє переглядати Accessibility Tree без фізичного телефону і без увімкнення VoiceOver.
React Native Accessibility Inspector
У React Native DevTools (Metro) є вбудований Accessibility Inspector — показує accessibility props кожного компонента в дереві.
Основні props доступності
accessible: позначити елемент як доступний
accessible={true} означає, що елемент є єдиним вузлом в accessibility tree — всі дочірні елементи об'єднуються в один. Для View за замовчуванням false, для Pressable і TextInput — true.
// Картка як єдиний доступний елемент:
<View accessible={true} accessibilityLabel="Поїздка до Карпат, 5 жовтня">
<Image source={...} />
<Text>Карпати</Text>
<Text>5 жовтня</Text>
</View>
// VoiceOver зачитає: "Поїздка до Карпат, 5 жовтня"
// а не окремо кожен Text
accessibilityLabel: що зачитає скрінрідер
Найважливіший prop доступності. Якщо не вказаний — скрінрідер намагається вгадати за Text дочірніми елементами. Для іконок, зображень і кнопок без тексту — обов'язковий:
// ❌ Погано: скрінрідер скаже "Кнопка" або нічого
<Pressable onPress={handleAdd}>
<Icon name="plus" />
</Pressable>
// ✅ Добре: зрозумілий опис дії
<Pressable
onPress={handleAdd}
accessibilityLabel="Додати нову поїздку"
>
<Icon name="plus" />
</Pressable>
Правила для гарного accessibilityLabel:
- Описуйте що робить елемент, а не як він виглядає.
- Не включайте слово «кнопка» — роль VoiceOver додає сам.
- Для динамічних даних — включайте контекст:
"Поїздка Карпати, 5 жовтня, 3 місця". - Мова label повинна відповідати мові інтерфейсу.
accessibilityHint: додаткова підказка
accessibilityHint зачитується після accessibilityLabel і описує що станеться при активації. Це необов'язкова підказка — VoiceOver показує її лише якщо користувач налаштував «Читати підказки»:
<Pressable
accessibilityLabel="Видалити поїздку"
accessibilityHint="Двічі торкніться, щоб безповоротно видалити поїздку"
onPress={handleDelete}
>
<Icon name="trash" />
</Pressable>
accessibilityHint те, що вже є в accessibilityLabel. Hint — це додаткова, уточнювальна інформація про наслідки дії. Особливо корисний для деструктивних або неочевидних операцій.accessibilityRole: семантичні ролі
accessibilityRole повідомляє скрінрідеру яку роль виконує елемент у інтерфейсі. VoiceOver автоматично додає відповідне оголошення: «кнопка», «заголовок», «посилання» тощо.
<h1>–<h6> у вебі.accessibilityElementsHidden={true} або accessible={false}.accessibilityState={{ checked }}.accessibilityState={{ checked }}.accessibilityState={{ selected }}.accessibilityValue={{ min: 0, max: 100, now: 45 }}.accessibilityValue.Приклад: семантично правильна картка
function TripCard({ trip, onPress, onDelete }) {
return (
<Pressable
accessible={true}
accessibilityRole="button"
accessibilityLabel={`Поїздка ${trip.name}, ${trip.date}`}
accessibilityHint="Двічі торкніться для перегляду деталей"
onPress={onPress}
style={styles.card}
>
{/* Зображення — декоративне, ховаємо від скрінрідера */}
<Image
source={trip.image}
style={styles.image}
accessible={false}
accessibilityElementsHidden={true}
/>
{/* Заголовок поїздки */}
<View>
<Text
style={styles.name}
accessibilityRole="header"
>
{trip.name}
</Text>
<Text style={styles.date}>{trip.date}</Text>
</View>
{/* Кнопка видалення */}
<Pressable
accessibilityRole="button"
accessibilityLabel={`Видалити поїздку ${trip.name}`}
accessibilityHint="Дія незворотна"
onPress={onDelete}
hitSlop={12}
>
<Icon name="trash" />
</Pressable>
</Pressable>
);
}
accessibilityState: стан елемента
accessibilityState — об'єкт з булевими полями, що описують поточний стан інтерактивного елемента:
disabled: true скрінрідер все одно зачитує елемент, але не дозволяє активувати. Використовуйте разом з Pressable disabled={true}.'mixed' — проміжний стан (тільки iOS). VoiceOver: «увімкнено» / «вимкнено».Приклад: кастомний прапорець
function AccessibleCheckbox({ label, checked, onToggle }) {
return (
<Pressable
accessible={true}
accessibilityRole="checkbox"
accessibilityLabel={label}
accessibilityState={{ checked }}
onPress={onToggle}
style={styles.row}
>
<View style={[styles.box, checked && styles.boxChecked]}>
{checked && <Icon name="check" color="white" size={14} />}
</View>
<Text style={styles.label}>{label}</Text>
</Pressable>
);
}
// VoiceOver при checked=true: "Отримувати сповіщення, прапорець, увімкнено"
// VoiceOver при checked=false: "Отримувати сповіщення, прапорець, вимкнено"
Приклад: кастомний Switch
function AccessibleSwitch({ label, value, onValueChange }) {
return (
<View style={styles.row}>
<Text style={styles.label}>{label}</Text>
<Pressable
accessibilityRole="switch"
accessibilityLabel={label}
accessibilityState={{ checked: value }}
onPress={() => onValueChange(!value)}
>
<View style={[styles.track, value && styles.trackOn]}>
<View style={[styles.thumb, value && styles.thumbOn]} />
</View>
</Pressable>
</View>
);
}
accessibilityValue: числові значення
Для елементів із числовим значенням (прогрес, слайдер, лічильник) — accessibilityValue:
// Progress bar:
<View
accessibilityRole="progressbar"
accessibilityLabel="Завантаження поїздки"
accessibilityValue={{ min: 0, max: 100, now: progress }}
style={styles.progressBar}
>
<View style={[styles.fill, { width: `${progress}%` }]} />
</View>
// VoiceOver: "Завантаження поїздки, індикатор прогресу, 45%"
// Рейтинг:
<View
accessible={true}
accessibilityRole="image"
accessibilityLabel="Рейтинг готелю"
accessibilityValue={{ text: `${rating} зірок з 5` }}
>
<Stars rating={rating} />
</View>
// VoiceOver: "Рейтинг готелю, 4.2 зірок з 5"
Приховування від скрінрідера
accessibilityElementsHidden
Ховає компонент і всіх нащадків від скрінрідера. Ідеально для декоративних зображень, фонових елементів або контенту, що вже представлений в іншій формі:
// Декоративна іконка поруч з текстом
<View style={styles.row}>
<Icon
name="star"
accessibilityElementsHidden={true} // ховаємо іконку
/>
<Text>Улюблені місця</Text>
</View>
// VoiceOver прочитає лише "Улюблені місця"
importantForAccessibility (Android)
Android-еквівалент для контролю над accessibility tree:
// 'auto' — система вирішує (дефолт)
// 'yes' — включити (навіть якщо система б проігнорувала)
// 'no' — виключити цей елемент (але нащадки залишаються)
// 'no-hide-descendants' — виключити і всіх нащадків
<View importantForAccessibility="no-hide-descendants">
{/* Весь цей View і його діти ігноруються TalkBack */}
<DecorativeAnimation />
</View>
accessibilityElementsHidden. На Android — importantForAccessibility. Для крос-платформного коду — застосовуйте обидва одночасно на різних платформах через Platform.select.Touch Targets: зони торкання
Мінімальний розмір
Одна з найпоширеніших проблем доступності — занадто маленькі зони торкання. Людині з тремором рук або великими пальцями складно потрапити у кнопку 24×24 пікселі.
Стандарти:
- Apple HIG (iOS): мінімум 44×44 pt (logical pixels)
- Material Design (Android): мінімум 48×48 dp
- WCAG 2.5.5 (Target Size): мінімум 44×44 CSS pixels (рівень AAA)
- WCAG 2.5.8 (Target Size Minimum, рівень AA, WCAG 2.2): мінімум 24×24 CSS pixels
На практиці — прагніть до 44×44 pt для будь-якого інтерактивного елемента.
hitSlop: розширення зони без зміни розміру
hitSlop розширює зону реакції на торкання без зміни візуального розміру:
// Маленька іконка — велика зона торкання:
<Pressable
hitSlop={{ top: 12, bottom: 12, left: 12, right: 12 }}
onPress={handleClose}
style={{ width: 20, height: 20 }} // 20pt візуально
>
{/* Але зона торкання — 44×44pt */}
<Icon name="x" size={20} />
</Pressable>
// Або коротко — однакові відступи з усіх боків:
<Pressable hitSlop={12} onPress={handleBack}>
<Icon name="chevron-left" size={22} />
</Pressable>
hitSlop — найкраще рішення для іконок у toolbar, tab bar і header. Ви не змінюєте layout — лише збільшуєте «невидиму» зону реакції навколо елемента.Перевірка розмірів автоматично
У процесі розробки корисно перевіряти мінімальні розміри:
// Утиліта для дев-режиму:
function MinTouchTarget({ children, style, ...props }) {
const handleLayout = ({ nativeEvent: { layout } }) => {
if (__DEV__) {
const { width, height } = layout;
if (width < 44 || height < 44) {
console.warn(
`Touch target занадто маленький: ${width.toFixed(0)}×${height.toFixed(0)}pt. Мінімум 44×44pt`
);
}
}
};
return (
<Pressable style={style} onLayout={handleLayout} {...props}>
{children}
</Pressable>
);
}
AccessibilityInfo API
AccessibilityInfo — статичний API для програмного доступу до стану скрінрідера і оголошення повідомлень:
Перевірка стану скрінрідера
import { AccessibilityInfo } from 'react-native';
// Асинхронна перевірка:
const isEnabled = await AccessibilityInfo.isScreenReaderEnabled();
// Або через хук:
import { useState, useEffect } from 'react';
function useScreenReader() {
const [isEnabled, setIsEnabled] = useState(false);
useEffect(() => {
// Початковий стан
AccessibilityInfo.isScreenReaderEnabled().then(setIsEnabled);
// Слухач змін
const subscription = AccessibilityInfo.addEventListener(
'screenReaderChanged',
setIsEnabled
);
return () => subscription.remove();
}, []);
return isEnabled;
}
announceForAccessibility: програмне оголошення
Надсилає повідомлення скрінрідеру без зміни фокусу. Ідеально для оповіщення про динамічні зміни:
// Після успішного збереження:
AccessibilityInfo.announceForAccessibility('Поїздку збережено успішно');
// Після помилки:
AccessibilityInfo.announceForAccessibility('Помилка: введіть назву поїздки');
// При оновленні лічильника:
AccessibilityInfo.announceForAccessibility(`Додано ${count} елементів до списку`);
announceForAccessibility на Android може перебивати поточне читання. Викликайте його лише після значущих подій (збереження, помилка, завантаження), а не при кожній зміні стану.setAccessibilityFocus: перемістити фокус
При відкритті модального вікна або появі важливого елемента — корисно автоматично перемістити фокус скрінрідера:
import { AccessibilityInfo, findNodeHandle, useRef } from 'react';
function Modal({ title, onClose }) {
const titleRef = useRef(null);
useEffect(() => {
// Коли Modal змонтовано — переміщуємо фокус на заголовок
const timer = setTimeout(() => {
if (titleRef.current) {
const node = findNodeHandle(titleRef.current);
if (node) AccessibilityInfo.setAccessibilityFocus(node);
}
}, 100); // невелика затримка для анімації
return () => clearTimeout(timer);
}, []);
return (
<View accessible={true} accessibilityViewIsModal={true}>
<Text ref={titleRef} accessibilityRole="header">{title}</Text>
<Pressable accessibilityLabel="Закрити вікно" onPress={onClose}>
<Icon name="x" />
</Pressable>
</View>
);
}
accessibilityViewIsModal={true} — повідомляє VoiceOver, що це модальне вікно, і фокус не повинен виходити за його межі.
Всі методи AccessibilityInfo
useReducedMotion з Reanimated.'screenReaderChanged', 'reduceMotionChanged', 'boldTextChanged' тощо.Dynamic Type: масштабування шрифту
Що таке Dynamic Type
Dynamic Type (iOS) і Font Scale (Android) — це системне налаштування, що дозволяє користувачу збільшити або зменшити розмір тексту по всій ОС. Це критично важливо для людей з порушеннями зору.
iOS підтримує 12 рівнів масштабу (XS → XXXL + Accessibility sizes від 310% до 430%). Android — від 85% до 200% і вище на деяких пристроях.
Якщо ваш застосунок ігнорує масштаб шрифту — тексти залишаються дрібними навіть при максимальному системному налаштуванні.
allowFontScaling
Всі Text компоненти за замовчуванням поважають системний масштаб шрифту (allowFontScaling={true}). Це правильна поведінка — не вимикайте без необхідності:
// ✅ Правильно — масштаб враховується (дефолт):
<Text style={{ fontSize: 16 }}>Назва поїздки</Text>
// ⚠️ Вимкнути тільки якщо є вагома причина:
<Text style={{ fontSize: 10 }} allowFontScaling={false}>
© 2024
</Text>
maxFontSizeMultiplier: обмеження масштабу
Для деяких елементів — наприклад, іконок-емодзі або елементів UI де текст має не перекривати layout — можна обмежити максимальний множник:
<Text
style={{ fontSize: 16 }}
maxFontSizeMultiplier={1.5} // максимум 150% від базового розміру
>
{trip.name}
</Text>
// TabBar label — не даємо рости більш ніж вдвічі:
<Text style={{ fontSize: 12 }} maxFontSizeMultiplier={2.0}>
Поїздки
</Text>
fontSize. 1.5 = максимум 150%. 0 = без обмежень (аналог allowFontScaling={true}).'caption1', 'caption2', 'footnote', 'subheadline', 'callout', 'body', 'headline', 'title1', 'title2', 'title3', 'largeTitle'. Замість fontSize відповідає iOS системним стилям тексту.Адаптивні layouts для великих шрифтів
При великих шрифтах layout може ламатись — текст виходить за межі, кнопки стають занадто тісними. Використовуйте гнучкий layout:
// ❌ Фіксована висота — поламається при великому шрифті:
<View style={{ height: 44, flexDirection: 'row', alignItems: 'center' }}>
<Text>Назва поїздки</Text>
<Icon name="chevron" />
</View>
// ✅ Гнучка висота — адаптується:
<View style={{ minHeight: 44, flexDirection: 'row', alignItems: 'center', flexWrap: 'wrap' }}>
<Text style={{ flex: 1 }}>Назва поїздки</Text>
<Icon name="chevron" />
</View>
Живий приклад: dynamic font scaling
Loading…
react-native-web · not a real device
import { useState } from 'react';
import { View, Text, Pressable, StyleSheet, ScrollView } from 'react-native';
const SCALES = [
{ label: 'XS', value: 0.8 },
{ label: 'M', value: 1.0 },
{ label: 'XL', value: 1.3 },
{ label: 'XXL', value: 1.6 },
{ label: 'AX', value: 2.2 },
];
export default function App() {
const [scale, setScale] = useState(1.0);
return (
<View style={styles.container}>
<Text style={styles.sectionLabel}>Розмір шрифту системи</Text>
<View style={styles.scaleRow}>
{SCALES.map((s) => (
<Pressable
key={s.label}
onPress={() => setScale(s.value)}
style={[styles.scaleBtn, scale === s.value && styles.scaleBtnActive]}
>
<Text style={[styles.scaleBtnText, scale === s.value && styles.scaleBtnTextActive]}>
{s.label}
</Text>
</Pressable>
))}
</View>
<ScrollView style={{ flex: 1 }}>
<View style={styles.card}>
<Text style={[styles.header, { fontSize: 22 * scale }]}>🏔 Карпати 2024</Text>
<Text style={[styles.body, { fontSize: 15 * scale }]}>
5 днів трекінгу через Чорногірський хребет. Маршрут складності «середній».
</Text>
<View style={styles.row}>
<Text style={[styles.tag, { fontSize: 12 * scale }]}>🗓 5 жовтня</Text>
<Text style={[styles.tag, { fontSize: 12 * scale }]}>📍 Карпати</Text>
</View>
</View>
<View style={[styles.navItem, { minHeight: 44 }]}>
<Text style={[styles.navLabel, { fontSize: 16 * scale }]}>Деталі поїздки</Text>
<Text style={{ fontSize: 18 * scale, color: '#94A3B8' }}>›</Text>
</View>
<View style={[styles.navItem, { minHeight: 44 }]}>
<Text style={[styles.navLabel, { fontSize: 16 * scale }]}>Список місць</Text>
<Text style={{ fontSize: 18 * scale, color: '#94A3B8' }}>›</Text>
</View>
<View style={[styles.navItem, { minHeight: 44 }]}>
<Text style={[styles.navLabel, { fontSize: 16 * scale }]}>Фотографії</Text>
<Text style={{ fontSize: 18 * scale, color: '#94A3B8' }}>›</Text>
</View>
</ScrollView>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#F8FAFC' },
sectionLabel: { fontSize: 12, fontWeight: '700', color: '#64748B', textTransform: 'uppercase', padding: 16, paddingBottom: 8 },
scaleRow: { flexDirection: 'row', paddingHorizontal: 16, gap: 8, marginBottom: 16 },
scaleBtn: { paddingHorizontal: 12, paddingVertical: 6, borderRadius: 8, backgroundColor: '#E2E8F0' },
scaleBtnActive: { backgroundColor: '#3B82F6' },
scaleBtnText: { fontSize: 12, fontWeight: '700', color: '#475569' },
scaleBtnTextActive: { color: 'white' },
card: { margin: 16, backgroundColor: 'white', borderRadius: 16, padding: 16, gap: 8, shadowColor: '#000', shadowOpacity: 0.06, shadowRadius: 8, elevation: 3 },
header: { fontWeight: '800', color: '#1E293B' },
body: { color: '#475569', lineHeight: undefined },
row: { flexDirection: 'row', gap: 8, flexWrap: 'wrap' },
tag: { backgroundColor: '#EFF6FF', color: '#3B82F6', paddingHorizontal: 10, paddingVertical: 4, borderRadius: 20, fontWeight: '600' },
navItem: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', backgroundColor: 'white', paddingHorizontal: 16, marginBottom: 1, paddingVertical: 12 },
navLabel: { color: '#1E293B', fontWeight: '500' },
});
Колірний контраст
Стандарти WCAG
WCAG 2.1 встановлює мінімальні вимоги до контрасту між текстом і фоном:
| Рівень | Звичайний текст | Великий текст (≥18pt або ≥14pt bold) |
|---|---|---|
| AA (мінімум) | 4.5:1 | 3:1 |
| AAA (підвищений) | 7:1 | 4.5:1 |
Для вимірювання контрасту використовуйте інструменти:
- WebAIM Contrast Checker
- Colour Contrast Analyser
- Accessibility Inspector у Xcode (автоматична перевірка)
Приклади поганого і доброго контрасту
// ❌ Погано: сірий текст на білому (#94A3B8 на #FFFFFF = 2.5:1)
<Text style={{ color: '#94A3B8', backgroundColor: 'white' }}>
Опис поїздки
</Text>
// ✅ Добре: темний текст (#475569 на #FFFFFF = 4.7:1)
<Text style={{ color: '#475569', backgroundColor: 'white' }}>
Опис поїздки
</Text>
// ✅ Відмінно: (#1E293B на #FFFFFF = 16.8:1)
<Text style={{ color: '#1E293B', backgroundColor: 'white' }}>
Назва поїздки
</Text>
#94A3B8 (slate-400) або #9CA3AF (gray-400) на білому фоні. Контраст ~2.5:1 — це вдвічі нижче мінімуму WCAG AA. Використовуйте #64748B (slate-500, контраст ~4.7:1) або темніше.Дальтонізм та Not Relying on Color Alone
~8% чоловіків і ~0.5% жінок мають певну форму дальтонізму. Правило: ніколи не використовуйте колір як єдиний спосіб передачі інформації:
// ❌ Погано: помилка і успіх відрізняються лише кольором
<Text style={{ color: isError ? 'red' : 'green' }}>
{message}
</Text>
// ✅ Добре: колір + іконка + текст-індикатор
<View style={styles.messageRow}>
<Icon name={isError ? 'x-circle' : 'check-circle'} color={isError ? '#EF4444' : '#10B981'} />
<Text style={[styles.message, { color: isError ? '#DC2626' : '#059669' }]}>
{isError ? `Помилка: ${message}` : `Успішно: ${message}`}
</Text>
</View>
Інтернаціоналізація (i18n): рядки UA
Навіщо i18n з першого дня
Навіть якщо ви плануєте лише українську мову — правильна i18n-архітектура з першого рядка коду дає:
- Можливість легко додати англійську, польську чи іншу мову пізніше.
- Централізоване управління рядками — зміна тексту в одному місці.
- Зручний процес для перекладачів (без зміни коду).
- Автоматичну підтримку правил числівників (1 поїздка, 2 поїздки, 5 поїздок).
expo-localization
expo-localization надає інформацію про мову, регіон і локаль пристрою:
npx expo install expo-localization i18next react-i18next
import * as Localization from 'expo-localization';
// Мова пристрою:
console.log(Localization.locale); // 'uk-UA', 'en-US', 'pl-PL'
console.log(Localization.locales); // ['uk-UA', 'en-US'] — пріоритетний список
// Регіон і часовий пояс:
console.log(Localization.region); // 'UA'
console.log(Localization.timezone); // 'Europe/Kyiv'
// RTL (право-ліво, для арабської, іврит):
console.log(Localization.isRTL); // false для UA/EN
Базова i18n з i18next
Найпопулярніший підхід — i18next з react-i18next:
// i18n/config.ts
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import * as Localization from 'expo-localization';
const resources = {
uk: {
translation: {
// Загальне
'common.save': 'Зберегти',
'common.cancel': 'Скасувати',
'common.delete': 'Видалити',
'common.loading': 'Завантаження...',
'common.error': 'Сталася помилка',
// Поїздки
'trips.title': 'Мої подорожі',
'trips.add': 'Додати поїздку',
'trips.empty': 'Ще немає поїздок',
'trips.count_one': '{{count}} поїздка',
'trips.count_few': '{{count}} поїздки',
'trips.count_many': '{{count}} поїздок',
'trips.delete_confirm': 'Видалити поїздку «{{name}}»?',
// Форма
'form.name': 'Назва',
'form.name_placeholder': 'Наприклад: Карпати 2024',
'form.date': 'Дата',
'form.required': 'Обов\'язкове поле',
},
},
en: {
translation: {
'common.save': 'Save',
'common.cancel': 'Cancel',
'common.delete': 'Delete',
'common.loading': 'Loading...',
'common.error': 'An error occurred',
'trips.title': 'My Trips',
'trips.add': 'Add Trip',
'trips.empty': 'No trips yet',
'trips.count_one': '{{count}} trip',
'trips.count_other': '{{count}} trips',
'trips.delete_confirm': 'Delete trip "{{name}}"?',
'form.name': 'Name',
'form.name_placeholder': 'e.g. Carpathians 2024',
'form.date': 'Date',
'form.required': 'Required field',
},
},
};
// Визначаємо мову за системними налаштуваннями:
const deviceLocale = Localization.locale.split('-')[0]; // 'uk', 'en', 'pl'
const supportedLocales = ['uk', 'en'];
const lng = supportedLocales.includes(deviceLocale) ? deviceLocale : 'uk';
i18n.use(initReactI18next).init({
resources,
lng,
fallbackLng: 'uk',
interpolation: { escapeValue: false }, // React вже екранує
});
export default i18n;
Ініціалізація у _layout.tsx
// app/_layout.tsx
import '@/i18n/config'; // ініціалізуємо i18n одразу
import { I18nextProvider } from 'react-i18next';
import i18n from '@/i18n/config';
export default function RootLayout() {
return (
<I18nextProvider i18n={i18n}>
{/* решта layout */}
</I18nextProvider>
);
}
Використання у компонентах
import { useTranslation } from 'react-i18next';
export function TripsScreen() {
const { t } = useTranslation();
const [trips, setTrips] = useState([...]);
return (
<View>
{/* Простий рядок */}
<Text accessibilityRole="header">{t('trips.title')}</Text>
{/* З параметром */}
<Text>{t('trips.count', { count: trips.length })}</Text>
// → "3 поїздки" (UA) або "3 trips" (EN)
{/* Підтвердження з назвою */}
<Text>{t('trips.delete_confirm', { name: selectedTrip.name })}</Text>
// → "Видалити поїздку «Карпати 2024»?"
{/* Порожній стан */}
{trips.length === 0 && (
<Text>{t('trips.empty')}</Text>
)}
</View>
);
}
Правило числівників в українській мові
Українська мова має складніші правила числівників ніж англійська:
| Кількість | Форма | Приклад |
|---|---|---|
| 1, 21, 31... | one | «1 поїздка» |
| 2, 3, 4, 22, 23... | few | «3 поїздки» |
| 5–20, 25–30... | many | «7 поїздок» |
| 0, дроби | other | «0 поїздок» |
i18next з i18next-icu або вбудованою підтримкою plural forms автоматично обирає правильну форму:
// i18next автоматично обирає форму за `count`:
t('trips.count', { count: 1 }) // → "1 поїздка"
t('trips.count', { count: 3 }) // → "3 поїздки"
t('trips.count', { count: 10 }) // → "10 поїздок"
Міні-проєкт: «A11y pass» — доступна форма
Що будуємо
Один екран форми додавання поїздки з повними accessibility props:
- Заголовок з
accessibilityRole="header". - TextInput-и з
accessibilityLabel,accessibilityHint, правильнимkeyboardType. - Dropdown-picker зі
accessibilityRole="combobox"іaccessibilityState={{ expanded }}. - Кнопки з
accessibilityLabelіhitSlop={12}. - Inline-помилки з
accessibilityRole="alert"— автоматично озвучуються. - Індикатор прогресу відправлення з
accessibilityValue. - Підтримка Dynamic Type (
maxFontSizeMultiplier={1.5}). - Рядки через
t()з i18next.
Живий preview: форма з a11y
Loading…
react-native-web · not a real device
import { useState } from 'react';
import {
View, Text, TextInput, Pressable,
ScrollView, StyleSheet, AccessibilityInfo,
} from 'react-native';
function Field({ label, required, error, hint, children }) {
return (
<View style={styles.field} accessible={false}>
<View style={styles.labelRow}>
<Text style={styles.fieldLabel} maxFontSizeMultiplier={1.5}>
{label}
</Text>
{required && (
<Text
style={styles.required}
accessibilityLabel="обов'язкове поле"
maxFontSizeMultiplier={1.5}
>
*
</Text>
)}
</View>
{children}
{hint && !error && (
<Text style={styles.hint} maxFontSizeMultiplier={1.4}>{hint}</Text>
)}
{error && (
<Text
style={styles.error}
accessibilityRole="alert"
maxFontSizeMultiplier={1.4}
>
⚠ {error}
</Text>
)}
</View>
);
}
export default function App() {
const [name, setName] = useState('');
const [date, setDate] = useState('');
const [notes, setNotes] = useState('');
const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState(false);
const [errors, setErrors] = useState<Record<string, string>>({});
const validate = () => {
const e: Record<string, string> = {};
if (!name.trim()) e.name = 'Введіть назву поїздки';
if (!date.trim()) e.date = 'Вкажіть дату';
else if (!/^\d{2}\.\d{2}\.\d{4}$/.test(date)) e.date = 'Формат: ДД.ММ.РРРР';
return e;
};
const handleSave = () => {
const e = validate();
setErrors(e);
if (Object.keys(e).length > 0) {
AccessibilityInfo.announceForAccessibility(
`Форма містить ${Object.keys(e).length} помилки. Перевірте поля.`
);
return;
}
setSaving(true);
setTimeout(() => {
setSaving(false);
setSaved(true);
AccessibilityInfo.announceForAccessibility('Поїздку збережено успішно');
}, 1500);
};
if (saved) {
return (
<View style={styles.successScreen}>
<Text style={styles.successIcon}>✅</Text>
<Text style={styles.successText} accessibilityRole="header">Збережено!</Text>
<Pressable
style={styles.btn}
onPress={() => { setSaved(false); setName(''); setDate(''); setNotes(''); }}
accessibilityLabel="Додати ще одну поїздку"
>
<Text style={styles.btnText}>Додати ще</Text>
</Pressable>
</View>
);
}
return (
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
<Text style={styles.title} accessibilityRole="header" maxFontSizeMultiplier={1.3}>
✈️ Нова поїздка
</Text>
<Field label="Назва" required error={errors.name} hint="Наприклад: Карпати 2024">
<TextInput
style={[styles.input, errors.name && styles.inputError]}
value={name}
onChangeText={setName}
placeholder="Назва поїздки"
placeholderTextColor="#94A3B8"
accessibilityLabel="Назва поїздки"
accessibilityHint="Введіть унікальну назву для легкого пошуку"
accessibilityRequired={true}
returnKeyType="next"
maxFontSizeMultiplier={1.4}
/>
</Field>
<Field label="Дата від'їзду" required error={errors.date} hint="Формат: 05.10.2024">
<TextInput
style={[styles.input, errors.date && styles.inputError]}
value={date}
onChangeText={setDate}
placeholder="ДД.ММ.РРРР"
placeholderTextColor="#94A3B8"
keyboardType="numbers-and-punctuation"
accessibilityLabel="Дата від'їзду"
accessibilityHint="Введіть дату у форматі день крапка місяць крапка рік"
accessibilityRequired={true}
maxFontSizeMultiplier={1.4}
/>
</Field>
<Field label="Нотатки" hint="Необов'язково — план, пакувальний список тощо">
<TextInput
style={[styles.input, { height: 80, textAlignVertical: 'top' }]}
value={notes}
onChangeText={setNotes}
placeholder="Ваші нотатки..."
placeholderTextColor="#94A3B8"
multiline
accessibilityLabel="Нотатки до поїздки"
accessibilityHint="Необов'язкове поле для плану або пакувального списку"
maxFontSizeMultiplier={1.4}
/>
</Field>
{saving && (
<View
accessibilityRole="progressbar"
accessibilityLabel="Збереження поїздки"
accessibilityValue={{ text: 'Зберігається...' }}
style={styles.progressBar}
>
<View style={styles.progressFill} />
</View>
)}
<View style={styles.actions}>
<Pressable
style={[styles.btn, styles.btnSecondary]}
onPress={() => { setName(''); setDate(''); setNotes(''); setErrors({}); }}
accessibilityLabel="Очистити форму"
accessibilityRole="button"
hitSlop={8}
>
<Text style={styles.btnSecondaryText} maxFontSizeMultiplier={1.3}>Очистити</Text>
</Pressable>
<Pressable
style={[styles.btn, saving && styles.btnDisabled]}
onPress={handleSave}
accessibilityLabel={saving ? 'Зберігається...' : 'Зберегти поїздку'}
accessibilityRole="button"
accessibilityState={{ disabled: saving, busy: saving }}
disabled={saving}
hitSlop={8}
>
<Text style={styles.btnText} maxFontSizeMultiplier={1.3}>
{saving ? 'Зберігається...' : 'Зберегти'}
</Text>
</Pressable>
</View>
</ScrollView>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#F8FAFC' },
content: { padding: 16, gap: 4, paddingBottom: 40 },
title: { fontSize: 24, fontWeight: '800', color: '#1E293B', marginBottom: 16 },
field: { gap: 6, marginBottom: 16 },
labelRow: { flexDirection: 'row', alignItems: 'center', gap: 4 },
fieldLabel: { fontSize: 14, fontWeight: '700', color: '#374151' },
required: { fontSize: 16, color: '#EF4444', fontWeight: '700' },
hint: { fontSize: 12, color: '#94A3B8' },
error: { fontSize: 12, color: '#EF4444', fontWeight: '600' },
input: { borderWidth: 1.5, borderColor: '#E2E8F0', borderRadius: 12, paddingHorizontal: 14, paddingVertical: 12, fontSize: 15, color: '#1E293B', backgroundColor: 'white' },
inputError: { borderColor: '#EF4444', backgroundColor: '#FFF5F5' },
progressBar: { height: 4, backgroundColor: '#E2E8F0', borderRadius: 2, overflow: 'hidden', marginBottom: 16 },
progressFill: { position: 'absolute', left: 0, top: 0, bottom: 0, width: '60%', backgroundColor: '#3B82F6' },
actions: { flexDirection: 'row', gap: 12 },
btn: { flex: 1, backgroundColor: '#3B82F6', borderRadius: 14, paddingVertical: 16, alignItems: 'center' },
btnSecondary: { backgroundColor: '#F1F5F9', flex: 0.5 },
btnDisabled: { backgroundColor: '#CBD5E1' },
btnText: { color: 'white', fontWeight: '700', fontSize: 16 },
btnSecondaryText: { color: '#475569', fontWeight: '700', fontSize: 16 },
successScreen: { flex: 1, alignItems: 'center', justifyContent: 'center', gap: 16, backgroundColor: '#F8FAFC' },
successIcon: { fontSize: 64 },
successText: { fontSize: 24, fontWeight: '800', color: '#1E293B' },
});
A11y Checklist для кожного екрана
Використовуйте як чеклист при ревью будь-якого нового екрана:
- Головний заголовок екрана має
accessibilityRole="header" - Секційні заголовки теж мають
role="header" - Порядок елементів у accessibility tree відповідає візуальному порядку
- Модальні вікна мають
accessibilityViewIsModal={true}
- Кожна кнопка/посилання має
accessibilityLabel - Іконки без тексту мають
accessibilityLabel - Деструктивні дії мають
accessibilityHint -
accessibilityRoleправильно відображає тип елемента - Кнопки
disabledмаютьaccessibilityState={{ disabled: true }} - Мінімум 44×44pt зона торкання (або
hitSlop)
- TextInput має
accessibilityLabel(не тільки placeholder) - Обов'язкові поля мають
accessibilityRequired={true} - Повідомлення про помилки мають
accessibilityRole="alert" - Після помилки —
announceForAccessibilityз описом -
returnKeyTypeіonSubmitEditingдля навігації між полями
- Інформативні зображення мають
accessibilityLabel - Декоративні зображення мають
accessible={false} - Іконки зі значенням мають
accessibilityLabel
-
allowFontScalingне вимкнений (або є обґрунтування) - Layout не ламається при scale 1.5× і 2×
-
maxFontSizeMultiplierвстановлений для елементів з фіксованими розмірами -
minHeightзамістьheightде текст може рости
- Основний текст: мінімум 4.5:1 контраст на фоні
- Placeholder і secondary текст: мінімум 4.5:1
- Стан помилки позначений не лише кольором (іконка + текст)
- Стан успіху позначений не лише кольором
Підключення до Nomad: feat: a11y labels and reduced motion
У Nomad accessibility і i18n підвищують якість продукту для широкої аудиторії:
Всі екрани
- Заголовки з
accessibilityRole="header" - Картки поїздок як єдиний
accessible={true}елемент announceForAccessibilityпісля CRUD операцій
Форми
accessibilityLabel+accessibilityHintна всіх поляхaccessibilityRole="alert"для помилок валідаціїaccessibilityRequiredна обов'язкових полях
Карта
- Маркери з
accessibilityLabel="[назва місця], [адреса]" - FAB кнопка «+» з
accessibilityLabel="Додати місце на карті"
i18n + Reduced Motion
- Всі рядки через
t()— готовність до EN перекладу ReduceMotion.Systemу всіх Reanimated анімаціях (розділ 24)useReducedMotion()для вимкнення parallax і складних анімацій
Поширені помилки
View за замовчуванням не є accessible елементом — навіть якщо додати accessibilityLabel, скрінрідер його не зачитає. Завжди додавайте accessible={true} разом з accessibilityLabel на View.accessibilityElementsHidden={true} (iOS) або importantForAccessibility="no" (Android). Без цього VoiceOver зачитує «зображення» після кожного тексту.accessibilityLabel залишається завжди. Ніколи не покладайтесь лише на placeholder як на опис поля.<Text style={errorStyle}> не гарантує озвучення. Додайте accessibilityRole="alert" (iOS автоматично зачитує) і викликайте AccessibilityInfo.announceForAccessibility() після встановлення помилки.height: 44 = завжди 44pt незалежно від шрифту. При scale 2.0 текст виходить за межі або обрізається. Замініть на minHeight: 44 + paddingVertical: 12 для вертикального масштабування.i18next-plural-rules або вбудовану підтримку pluralSeparator.Підсумок
Скрінрідери та дерево доступності
- VoiceOver (iOS) і TalkBack (Android) читають Accessibility Tree
accessible,accessibilityLabel,accessibilityHintaccessibilityRole— семантика елементаaccessibilityState—disabled,selected,checked,expanded
Touch Targets та Focus
- Мінімум 44×44pt (iOS) / 48×48dp (Android)
hitSlopдля розширення без зміни layoutAccessibilityInfo.setAccessibilityFocusпри появі модалокAccessibilityInfo.announceForAccessibilityдля динамічних змін
Dynamic Type та контраст
allowFontScaling={true}— не вимикати!maxFontSizeMultiplierдля обмеження зростуminHeightзамістьheightдля гнучких layouts- Мінімум 4.5:1 для звичайного тексту (WCAG AA)
i18n: рядки UA
expo-localizationдля мови пристроюi18next+react-i18next— стандарт галузі- Plural forms для UK: one/few/many
- Всі рядки через
t()з першого дня
Push і локальні сповіщення
Локальні нагадування, Android канали, обробка тапу і навігація до потрібного екрана у React Native / Expo Notifications
EAS Build та профілі збірки
eas.json профілі development/preview/production, credentials, versioning, Development Client та внутрішній розподіл у Expo Application Services