Що react-native-web мовчки губить для екранного диктора
· 5 хв читання · Команда Тямки
Як аудит доступності вебверсії Тямки знайшов три речі, які React Native for Web 0.21 приймає без жодного попередження і у вебі ігнорує, і як ми це обійшли в спільній кнопці та слотах HUD.
У цій статті
До 26 вересня перемикач «Звуки» у вебверсії Тямки потрапляв у знімок ARIA-дерева як switch "Звуки", без стану. Диктор міг сказати «Звуки, перемикач», але не «увімкнено». Стан губився в React Native for Web 0.21, і ніде не зʼявлялося жодного попередження. Таких місць ми знайшли три. Бібліотека не передає в DOM accessibilityState, робить із ролі text звичайний div, якому ARIA не дозволяє мати назву, і підпис губиться, а AccessibilityInfo.announceForAccessibility у вебі нічого не робить. Нижче розповідаємо, як ми це помітили, де це видно у вихідному коді бібліотеки і що змінили в себе.
Як ми це знайшли
Тямка написана на Expo і React Native, один код для браузера, Telegram, iOS і Android. Чому так, ми розповідали в окремому записі. Першою гравці побачать саме вебверсію, тож перед її запуском ми влаштували їй аудит доступності, задача 72 на нашій дошці.
Перевіряли в Playwright 1.63 і Chromium, вікно 402×874, українською й англійською. Для кожного екрана, від головного через усі пʼятнадцять ігор до результатів і магазину, знімали ARIA-дерево сторінки, складали список усіх елементів, на які можна перевести фокус, з роллю, підписом і розміром, а після старту кожної гри чотири секунди читали живу область. Playwright будує цей знімок зі сторінки за правилами ARIA для ролей, назв і станів. Це близько до того, що отримує диктор, хоча й не те саме.
Здебільшого все було гаразд. Жодного елемента, меншого за 44 на 44 чи без підпису, кожна клітинка поля — окрема кнопка з позицією і станом у підписі, наприклад «Рядок 2, стовпець 2». Усього аудит записав 16 проблем, із них пʼять пріоритету P1. Три з цих пʼяти дали пропси доступності React Native, які вебзбірка ігнорувала.
Як зняти таке дерево у себе
Свої скрипти аудиту ми в репозиторій не клали, але сам знімок робиться одним викликом Playwright.
const tree = await page.locator('body').ariaSnapshot();Результат виглядає як YAML зі списком ролей і назв, наприклад switch "Звуки" [checked]. Якщо в рядку немає стану чи числа, яке ви бачите на екрані або чуєте на телефоні, диктор у браузері, найімовірніше, його теж не отримає.
Стан перемикача
Нашу спільну кнопку Tap використовують усі перемикачі й радіокнопки гри. Стан вона передавала так, як заведено в React Native.
<Pressable
…
accessibilityRole={role}
accessibilityLabel={label}
accessibilityState={{ disabled, checked }}
disabled={disabled}У вихідному коді createDOMProps, модуля, що перетворює пропси на атрибути DOM, читаються aria-checked і старий accessibilityChecked. Про accessibilityState там немає ані слова, а View не має його в списку пропсів, які передає далі. Попередження про застарілі пропси accessibility* в цьому файлі є, але закоментовані, тому консоль мовчить. Стан «вимкнено» у нас не загубився лише тому, що Pressable сам ставить aria-disabled зі свого пропса disabled. Та сама біда була в магазині, де дві радіокнопки варіантів покупки стояли без позначки, яку з них вибрано.
Перше виправлення було в один рядок, aria-checked={checked}. За три хвилини ми його звузили. Tap малює і звичайні кнопки, а специфікація ARIA не дозволяє aria-checked на ролі button.
accessibilityState={{ disabled, checked }}
aria-checked={role === 'switch' || role === 'radio' ? checked : undefined}React Native 0.86 розуміє aria-checked і на iOS та Android, тож від accessibilityState можна було відмовитися зовсім. Ми його лишили, бо на телефонах усе й так працювало, а найменша правка — це один новий пропс.
Роль text і зниклий підпис
Лічильник життів у HUD малює три фігурки без жодного числа. Для диктора в нього був підпис «Життя 3 з 3».
<View style={…} accessible accessibilityRole="text" accessibilityLabel={label} testID="lives">У вебі число з дерева зникало. propsToAriaRole перекладає ролі React Native на ролі ARIA, і text там перекладається в null, тобто ролі немає взагалі. Без ролі View стає div, а підпис усе одно лягає на нього як aria-label. Елемент без ролі має роль generic, а ARIA 1.2 забороняє давати generic назву, тож браузер і диктор мають право цей підпис не помічати. У нашому знімку його вже не було.
Так само пропали ще два підписи. У грі «Потрібні суми» ціль рядка чи стовпця мала в підписі поточну суму й позначку «готово», а в «Сітці слів» панель слова мала там секунди, що лишилися.
Обхід простий. Усе, що говорить лише підписом, отримало роль image. Вона стає img, а img назву мати може, тож у дереві тепер img "Життя 3 з 3" і img "Рядок 1, сума 12, зараз 12, готово". Ціна теж є. Диктор, найімовірніше, назве лічильник зображенням, хоча це не зовсім так. Ми вирішили, що це краще, ніж лічильник, який мовчить. Пізніше ту саму роль отримав рахунок на екрані результатів, бо поки число рахувалося вгору, його підпис губився так само.
Оголошення, яких немає
Третю знахідку аудит не відніс до P1, бо оболонку гри ми від початку писали з обходом. В AccessibilityInfo для вебу функції announceForAccessibility і setAccessibilityFocus порожні, isScreenReaderEnabled завжди повертає true, а події screenReaderChanged немає. Дізнатися у браузері, чи працює диктор, не вийде.
Тому у вебі оголошення йде в приховану живу область, Text з accessibilityLiveRegion="polite", який стає aria-live="polite". А жива область говорить, лише коли змінюється її вміст. У «Трубах» кожна розвʼязана головоломка оголошує одне й те саме «Розвʼязано», і React не чіпає DOM, коли новий текст дорівнює старому, тож друге оголошення мовчало. Це вже поведінка браузера і React, а не бібліотеки. Тепер, якщо повідомлення збігається з попереднім, ми додаємо в кінець нерозривний пробіл, і текст для області стає новим.
Чого ми не перевіряли
Виправлення лягли в Tap, у слоти базового скіна і ще трьох скінів зі своїм лічильником життів. Наступного ранку ті самі знімки на тестовій збірці показали всі виправлення. Живим диктором, VoiceOver, NVDA чи TalkBack, аудит не перевіряли. Усе написане вище про те, що показав знімок Playwright у Chromium, а не про те, що почує людина.
Ігри з перемикачами, лічильником життів і полями, про які тут ідеться, зібрані на сторінці ігор. «Потрібні суми» і «Труби» є серед логічних ігор, а «Сітка слів» серед словесних ігор.