Капча — это элемент сайта, с которым сталкивается почти каждый пользователь и про который редко вспоминают, если всё работает как надо. Наша капча верно служила нам, но была устаревшей, а легаси‑код серьёзно ограничивал возможность вносить изменения. Кроме того, были сложности с переключением по сайту с помощью табов и голосового помощника. Всё это подтолкнуло нас не просто обновить капчу, а переписать её с нуля и добавить инклюзию.

Меня зовут Завен Агаджанян, я веб‑разработчик экосистемных продуктов VK ID и в этой статье расскажу, как мы писали капчу с нуля и что из этого вышло.

Немного предыстории

Давайте посмотрим, как выглядела старая капча на вебе.

Старая капча
Старая капча

Что же с ней не так:

  • устаревший дизайн;

  • маленькая картинка;

  • отсутствует возможность обновления картинки;

  • легаси‑код;

  • отсутствует инклюзия.

Перед тем как приступить к созданию новой капчи на React, нам всё же нужно было «причесать» существующую — для удобства пользователей в переходный период.

С помощью кастома мы обновили дизайн, увеличили картинку, добавили возможность её обновления, сделали текст более читабельным и переписали старый классовый компонент. На изображении ниже можно посмотреть, что получилось.

Легаси-капча, но после небольших изменений
Легаси-капча, но после небольших изменений

Мы решили основные проблемы старой капчи, обновили её и приступили к главному — созданию капчи с нуля. Откровенно говоря, никто не хочет иметь дело с легаси‑кодом, тем более если он написан на ванильном JavaScript. Мы с командой активно продвигали идею переписать капчу на функциональном React. Так и поддерживать легче, и можно начать использовать наши UI‑библиотеки — они упрощают вёрстку и позволяют дизайнерам без проблем вносить любые изменения.

Поэтому хоп‑хоп, кодим‑кодим, а на выходе:

Новая версия капчи
Новая версия капчи

Иногда переписать с нуля лучше, чем поддерживать код, который до вас трогали ещё во времена Криштиану Роналду в мадридском «Реале».

С предысторией создания новой капчи мы завершили, теперь перейдём к основной части.

Инклюзия в капче

Создав новую капчу, мы решили пойти дальше и сделать её удобной для слабовидящих и незрячих людей. Попробуйте представить, что вам нужно пройти капчу без мышки или тачпада. Получится? Да, с помощью табуляции вы дойдёте до условного инпута и введёте символы с картинки. А теперь представьте, что вы проходите капчу с выключенным монитором, или добавлен какой‑нибудь из эффектов в виде opacity с крайне маленьким значением, или эффект блюра.

Заблюренная версия капчи
Заблюренная версия капчи

Сможете разобрать символы? Вот и у меня не получилось, хотя я сам блюрил эту картинку. С похожими проблемами сталкиваются слабовидящие пользователи. Мы стали думать, какие инструменты помогут сделать капчу инклюзивной.

Звуковая капча

Одним из решений было добавить звуковую капчу.

Графическая капча с возможностью переключиться на аудиокапчу
Графическая капча с возможностью переключиться на аудиокапчу
Звуковая капча
Звуковая капча

При создании важно было учесть все технические и нетехнические моменты. Например, когда качать звуковую дорожку — есть несколько вариантов.

  • Скачивать при воспроизведении. Это самый правильный вариант. Мы же не хотим, чтобы пользователь скачивал что‑то, что ему не понадобится в будущем, особенно на мобильные телефоны.

  • Скачивать при переключении на звуковую капчу. Вариант обсуждаемый, но не самый приятный. Многие пользователи переключаются на него случайно или ради интереса.

  • Скачивать вместе с графической капчей. Самый неправильный вариант. Это лишняя нагрузка на устройство, поедание трафика, отсутствие оптимизации.

Поддержка для слабовидящих

Мы запустили капчу в разных местах нашей экосистемы, чтобы посмотреть, как она работает. Само собой, мы не ограничились нашим локальным тестированием и подключили экспертную команду инклюзии. Вместе мы формулировали текстовки, проверяли фокусирование при различных действиях, настройку голосового помощника.

Один из центральных моментов — узнать, насколько слабовидящим людям удобно пользоваться нашей капчей.

Впервую очередь мы обращали внимание на взаимодействие с голосовым помощником. Основная задача скринридера — помочь слабовидящему пользователю понять, что перед ним и на каком элементе он сейчас находится. От себя рекомендую попробовать различные скринридеры как для системы Windows (NVDA или JAWS), так и для macOS (VoiceOver).

Мы проверяли, как скринридеры воспринимают наши элементы. С этим нам помогала семантическая вёрстка с информацией, которую впоследствии и озвучивает скринридер.

Фокус на элементе «Обновить» у капчи
Фокус на элементе «Обновить» у капчи
Фокус на элементе «Введите код с картинки» у капчи
Фокус на элементе «Введите код с картинки» у капчи

Важно не забывать, что мы можем изменять настройки нашего скринридера и способны заставить его читать быстрее или изменить голос.

Управление фокусом пользователя

Настройка автофокуса — идеальный вариант для ВКонтакте, так как наша капча появляется в виде модального окна. Если она возникла на экране, то скринридер должен её озвучить. Так пользователь поймёт, что пришло время подтвердить свою человеческую сущность.

А теперь вопрос: нужно ли делать автофокус на разных элементах модалки? Момент спорный. Например, на модальные окна и поп‑апы в большинстве случаев нужно. Но в других, когда пользователь, к примеру, ввёл неверные данные, важно не форсировать поведение, а дать скринридеру озвучить ошибку.

Табуляция

Если бы мы просто добавляли что‑то на страницу, то никаких проблем бы не было. Но в наши задачи входило поймать фокус пользователя и заставить его находиться внутри капчи, поэтому пришлось немного помучиться с табуляцией.

Переходы и пара фишек, которые я рекомендую проверить. В первую очередь пройдитесь по вашей страничке с помощью Tab или Shift + Tab. Так вы узнаете, получается ли «навестись» на все ключевые элементы, или что‑то пропускается. Ещё рекомендую попробовать быстрый режим, или быструю навигацию (в VoiceOver она включается одновременным нажатием левой и правой стрелок), — управление с помощью стрелок.

С какими проблемами мы столкнулись при попытке поддержки табуляции:

  1. Прокидывание везде параметра tab index. Не везде он нужен, зачастую не нужен вовсе. Не стоит его добавлять для элементов, которые по умолчанию tabbable: <a>, <area>, <button>, <input>, <object>, <select> и <textarea>. Подробнее можно посмотреть тут.

  2. Неправильное использование или неиспользование атрибута role. Он нужен не только для семантики, но и как обозначение, которое скринридеры озвучат при наведении на элемент. Частый пример — использование кастомных элементов, где нет поддержки.

  3. Неправильная работа с aria‑label. Иногда достаточно добавить в уже tabbable‑элементы текст, после чего скринридер его прочитает. Например, к тегам <button> и <a> нужен текст, а в картинки — тег alt.

  4. Неработающая табуляция. Причины могут быть разные, их мы рассмотрим ниже.

Первая проблема, с которой мы столкнулись, — необходимость удержать внимание пользователя внутри модалки с капчей и исключить возможность вылезти из неё с помощью табуляции. На помощь пришли кастомные табы. Вот как это выглядело на примере.

Создали рефы на каждый необходимый для нас элемент:

const playButtonRef = useRef<HTMLButtonElement>(null);
const imageElementRef = useRef<HTMLImageELement>(null);

......................................................

const handleKeyDown = useCallback((e: KeyboardEvent) => {
  const focused = document.activeElement;
  // список всех рефов, перечисленных выше

  ..............

  // не забываем про e.shiftKey и сделать автофокус на наш элемент, 
  // с которого мы хотим показывать нашу модалку
  if (e.key === 'Tab' && focused) {
    // не забываем избавляться от дефолтного поведения
    e.preventDefault();
    switch(focused) {
      // Вот тут уже настройка табуляции по собственному желанию
      // и в нужном порядке
    }
  }
}, [/* а сюда те элементы, из-за которых функция должна отрабатывать иначе. 
Например, состояние (ошибка, лоадер) */]);

......................................................

useEffect(() => {
  document.addEventListener('keydown', handleKeyDown);
  return () => {
    document.removeEventListener('keydown', handleKeyDown);
  }
}, [handleKeyDown])

Позже мы столкнулись со второй проблемой: на экране пользователя сразу два модальных окна, и на каждом стоит autofocus. Если фокус выходит за пределы одной модалки, то она возвращает его обратно на себя. Сюр, но и такое может быть. Обе модалки постоянно переключали фокус через таб на себя, когда мы выходили из одной в другую. Как мы решили эту проблему?

Ввели усовершенствованную версию — это когда нам нужно залочить пользователя в нашей капче и не позволить ему выходить из неё. Пофиксили это с помощью записывания элемента, на который сфокусировались, в переменную. Да, выглядит не очень, зато работает.

// Создаём новую переменную. В неё записываем первый элемент, на который необходимо произвести фокусировку 
const [focusedElement, setFocusedElement] = useState<CaptchaFocusElements>(CaptchaType.IMAGE)
 
const playButtonRef = useRef<HTMLButtonElement>(null);
const refreshButtonRef = useRef<HTMLButtonELement>(null);
......................................................

const handleKeyDown = useCallback((e: KeyboardEvent) => {
  const refresh = refreshButtonRef.current;
  // список всех рефов, перечисленных выше

  ..............

  // не забываем про !e.shiftKey, сделать фокус на наш элемент и записать его в стейт, 
  // с которого мы хотим показывать нашу модалку
  if (e.key === 'Tab' && e.shiftKey && focused) {
    // не забываем избавляться от дефолтного поведения
    e.preventDefault();
    switch(focused) {
        // На примере, когда активной кнопкой выступает кнопка «Обновить капчу»
        case CaptchaFocusElements.PLAY_BUTTON: {
          // И здесь, помимо фокусировки, мы должны засетать новый сфокусированный элемент
          setFocusedElement(CaptchaFocusElements.REFRESH_BUTTON);
          refresh.focus();
        }
      // Вот тут уже настройка табуляции по собственному желанию
      // и в нужном порядке
    }
  }
}, [/* а сюда — те элементы, из-за которых функция должна отрабатывать иначе. 
Например, состояние (ошибка, лоадер) */]);

......................................................

useEffect(() => {
  document.addEventListener('keydown', handleKeyDown);
  return () => {
    document.removeEventListener('keydown', handleKeyDown);
  }
}, [handleKeyDown])

В таком случае и поведение по умолчанию не нужно, потому что фокусировкой управляем мы вручную. Нет необходимости смотреть на активный элемент, так как он скачет.

Послевкусие

Мы проделали большую работу, чтобы написать новую капчу. Теперь она оформлена в удобном дизайне и доступна для слабовидящих и незрячих пользователей. А главное — никакого легаси‑кода. В процессе мы разобрались, как работать с инклюзией, скринридерами, управлять фокусом, учитывать эдж‑кейсы. После создания новой капчи мы выдохнули: легаси‑код в прошлом.

Если вы до сих пор не работали с поддержкой инклюзии, то советую добавить её в ваше приложение. Как говорится, если принесёшь пользу хотя бы одному человеку — сделай это!

Комментарии (4)


  1. ermouth
    27.12.2024 16:23

    Как слабовидящие заметят наушники, если они меньше самой капчи, которую – как вы презюмируете – слабовидящим и так не разглядеть?


    1. datacompboy
      27.12.2024 16:23

      Экраным читалкам важны метки для доступности, по которым её найти проще, чем по иконке на экране


      1. ermouth
        27.12.2024 16:23

        Ок, логично. Но всё равно непонятно почему оно сразу не читает, а переключает на визуальный интерфейс, который требует дополнительных действий.


        1. datacompboy
          27.12.2024 16:23

          Представляю, капча где прямо в HTML прописан код... :rofl: