Как стать автором
Обновить

Как Discord реализовал навигацию клавиатурой по всему приложению

Время на прочтение10 мин
Количество просмотров9.1K
Автор оригинала: Jon Egeland

Мы делаем Discord доступным для каждого. В 2020 году мы многое сделали, чтобы значительно улучшить ассебилити в приложении. Остаётся ещё много работы, но мы уже сейчас можем рассказать об одном из наших последних проектов - Навигация по клавиатуре.

Она может быть полезна всем пользователям. Ей пользуются те, кто не может нормально использовать мышь или те, кто используют скрин-ридеры. Она круче, чем шорткаты и дает возможность работать максимально эффективно. Для нашей относительно небольшой команды подобные проекты дают значимый результат и в одночасье делают нас лучшими.

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

Четыре части, которые вы видите в Discord в любой момент времени: список серверов, список каналов, сообщения и
список пользователей.
Четыре части, которые вы видите в Discord в любой момент времени: список серверов, список каналов, сообщения и список пользователей.

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

С первого взгляда звучит просто и кажется, что это уже давно решенная задача. Мы тоже так думали! Однако непреодолимые проблемы и ограничения быстро перечеркнули все варианты, которые мы смогли придумать.

Проблема

Если вы когда-либо работали с фокусными стилями в CSS, то вы наверное знакомы с двумя главными опциями которые есть в браузерах на текущий момент: псевдо-класс :focus и свойство outline.

Браузеры как правило задают свои дефолтные стили фокуса этими двумя опциями, но если вы хотите, чтобы стили фокуса были одинаковыми в разных браузерах или у вас есть макет, которого вам нужно придерживаться, то скорее всего вы добавите стили подобно этим

:focus {
	outline: 3px solid deepskyblue;
}

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

Вот примеры обрезания обводки в контейнере со стилем overflow-hidden, несовпадения радиуса обводки и скругленных элементов, а также странные границы элемента заданные padding'ами и margin'ами.

Кажется это всё можно относительно просто исправить. Просто задайте padding вокруг, используйте box-shadow или будьте аккуратнее с overflow: hidden. Тогда всё будет хорошо, верно?

На самом деле это не масштабируемый подход. Ограничение использования CSS-свойств создаст дополнительные сложности при разработке, которые непросто разруливать с помощью девтулзов. Кроме того, мы хотели сделать для наших разработчиков решение, которое будет "работать из коробки".

Конечный результат, который мы хотим получить - клавиатурная навигация, позволяющая любому человеку перемещаться по Discord.

Конечный результат, который мы хотим получить - клавиатурная навигация, позволяющая любому человеку перемещаться по Discord.
Конечный результат, который мы хотим получить - клавиатурная навигация, позволяющая любому человеку перемещаться по Discord.

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

Исследуя проблемы с дефолтными стилями фокуса мы поняли, что нужно отделить рендер обводки от рендера целевого элемента. Также мы поняли, что нельзя сделать просто один верхнеуровневый компонент, потому что новые проблемы, такие как рендеринг поверх скрытых слоев или следование за скроллом делали обводку "плавающей" и оторванными от контента на странице.

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

Вся система построена из двух компонентов: FocusRing для объявления, где обводка должна быть и FocusRingScope для объявления корневого элемента для её рендера.

API

FocusRing

Основной компонент нашего решения - это FocusRing. Сам он ничего не рендерит, однако является обработчиком всего, начиная от определения, когда целевой элемент получает или теряет фокус, заканчивая взаимодействием с FocusRingScope и передачей нескольких пропсов.

Простейший пример использования - нужно просто обернуть целевой элемент в FocusRing.

function Button(props) {
  return (
    <FocusRing offset={-2}>
    	<button {...props} />
    </FocusRing>
  );
}
Пример рендеринга FocusRing вокруг кнопки. Код приведён выше.
Пример рендеринга FocusRing вокруг кнопки. Код приведён выше.

По умолчанию FocusRing берет дочерний компонент и переопределяет для них onBlur и onFocus при помощи React.cloneElement. Кроме того, пропсы FocusRing позволяют задать должно ли кольцо эмулировать :focus или :focus-within, передать CSS-классы для состояния фокуса дочернего компонента и даже передать другой целевой компонент для захвата фокуса или позиционирования обводки.

Более сложный пример с использованиям некоторых пропсов будет выглядеть так:

function SearchBar() {
  const containerRef = React.useRef<HTMLDivElement>(null);
  const inputRef = React.useRef<HTMLInputElement>(null);
  return (
    <FocusRing focusTarget={inputRef} ringTarget={containerRef}>
      <div className={styles.container} ref={containerRef}>
      	<input type="text" ref={inputRef} placeholder="Search" />
      	<div className={styles.icon}>
      		<ClearIcon />
      	</div>
      </div>
    </FocusRing>
  );
}

В этом примере рефы focusTarget и ringTarget говорят FocusRing перехватывать фокус инпута, но рендерить кольцо вокруг всего контейнера.

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

Вот пример SearchBar с отрендеренным FocusRing вокруг всего контейнера

FocusRingScope

FocusRingScope - это обертка для рендера обводки. Как было упомянуто ранее, обводка не может быть отрендерена в одном руте DOM-дерева и вместо этого она должна быть на более мелких элементах страницы (таких как прокручиваемые блоки или абсолютно-спозиционрованные контейнеры). FocusRingScope является корневым (родительским) для таких блоков. За рендеринг FocusRing будет отвечать ближайший родительский FocusRingScope.

Это позволяет покрывать ситуации, когда некоторые элементы могут быть частично закрыты или прокручены за пределы вьюпорта и рендеринг обводки должен то учитывать. Всё что требуется дляFocusRingScope - это ссылка на контейнер, который он использует для вычисления позиции обводки.

function Scroller({children}) {
  const containerRef = React.useRef<HTMLDivElement>(null);
  return (
    <div style={{overflow: 'scroll'}} ref={containerRef}>
      <FocusRingScope containerRef={containerRef}>
        {children}
      </FocusRingScope>
    </div>
  );
}

Под капотом компоненты взаимодействуют через Context.FocusRing предоставляет контекст с элементом, вокруг которого отрендерена обводка. Затем он вычисляет положение и стили. В конце FocusRingScope делает подписку на целевой компонент, чтобы обновляться при каждом его изменении.

Единственное, что нужно помнить разработчику - это включить FocusRingScope в корень приложения. В противном случае обводке негде будет рендериться по умолчанию.

Остающиеся проблемы

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

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

Не сломать анимации

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

Но когда элемент двигается по странице очень сложно понять, где он находится в текущий момент и продолжает ли он двигаться.

Пример того, как обводка не успевает за положением движущегося целевого элемента.

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

Делать что-либо на каждом кадре - не идеальный вариант. Можно, конечно, использовать хотя бы requestAnimationFrame, чтобы избежать повторного рендера, когда положение элементов не изменилось, но конечным результатом неизбежно будет постоянный цикл, который обращается к DOM на каждом кадре. Мы исследовали возможность снижения оверхеда, повесили лисенеры на события animationstart и animationend и запускали цикл только между этими событиями, но из-за всплытия событий в DOM, не всегда гарантируется правильное обновление.

На самом деле от браузера нужен способ определять, когда обвязка элемента изменилась и уметь выполнять коллбэк в этот момент. requestAnimationFrame позволяет вам понять что-то типа "я собираюсь изменить что-то на следующем кадре", но вы не можете понять обратного - "сообщи, когда что-то изменится на следующем кадре", чтобы иметь возможность перехватывать эти изменения нативно. Что-то вроде window.onAnimationFrame было бы здесь прекрасным дополнением. Однако в настоящее время не похоже, что есть какие-либо пропозалы по такому хуку, что означает, что реальное решение вряд ли появится в ближайшее время.

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

Пример кольца фокусировки, которое почти не отстает от анимации.

Адаптивные формы

Одна из первых проблем, вызванных использованием стандартного CSS заключается в том, что он крайне ограничен в возможностях его кастомизации. Несмотря на то, что обводка очень похожа на border, вы не можете задать outline-radius или что-либо еще для регулировки смещения обводки относительно элемента.

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

Сравнение обводки с константным радиусом границы с обводкой с адаптивным радиусом для соответствия целевому элементу.
Сравнение обводки с константным радиусом границы с обводкой с адаптивным радиусом для соответствия целевому элементу.

Кастомизировать радиус обводки оказалось на удивление просто. У нас уже есть целевой элемент в контексте, поэтому при запросе его положения, мы также получаем стиль элемента и в частности его border-radius. После этого можно адаптировать border-radius обводки везде автоматически через кастомные CSS-свойства.

function AdaptiveRadiusRing() {
  const {ringTarget} = React.useContext(FocusRingContext);

  const radius = React.useMemo(
    () => window.getComputedStyle(ringTarget).borderRadius,
    [ringTarget]
  );
  
  const style = {
    '__adaptive-ring-radius': radius,
  };
  return <div className={styles.ring} {...props} style={style} />;
}

// In CSS
// .ring {
//   border-radius: var(--__adaptive-ring-radius, 4px);
// }
view raw

Круто то, что getComputedStyle, будет возвращать нужное значение в случае, если радиус разный для разных сторон элемента, поэтому то, что может показаться сложным, на самом деле будет обрабатываться просто по умолчанию без сложностей для разработчиков. Мы можем легко расширить эту функциональность на другие свойства в будущем. Например, для z-index для случаев, когда элемент абсолютно-спозиционирован или для чего-либо другого.

Здесь нет лучшего решения. Было бы неплохо, если бы свойство outline имело дочернее свойство outline-radius или outline-radius наследовался бы от border-radius. Но в данном случае это не помогло бы из-за множества других проблем с псевдо-классом :focus и свойством outline. А сейчас мы имеем контроль и любое количество дополнительного контроля всегда приветствуется.

Адаптивные цвета

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

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

Пример полупрозрачного белого контура обводки на ярко-зеленом фоне
Пример полупрозрачного белого контура обводки на ярко-зеленом фоне

На первый взгляд, это кажется достаточно простым для реализации: как и в случае с border-radius и z-index, мы можем просто получить элемент и установить нужный цвет в зависимости от данных, верно? Но не существует прямого способа получить цвет фона, на котором расположен элемент (по крайней мере в современных браузерах). Вы можете получить background-color элемента, но это не одно и то же. Если просто посмотреть на фон контейнера FocusRingScope, то можно не покрыть случаи, когда кто-то разместит div с бэкграундом где-то между обводкой и FocusRingScope.

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

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

Хотя это вполне работоспособное решение, в ближайшем будущем может появиться лучший способ. Обсуждается вопрос о включении в CSS значения currentBackgroundColor. В идеале это значение также можно было бы запрашивать и пропустить весь ручной обход предков, который требуется в настоящее время. И еще одна функция, предложенная в проекте CSS Color Level 5 - это функция color-contrast, которая позволит авторам указывать список цветов, а браузеру автоматически выбирать наиболее заметный цвет.

Будущее

Здесь мы рассмотрели только один небольшой (но самый не тривиальный) аспект всего нашего проекта "Клавиатурная навигация". Хотя нашей основной целью было просто интегрировать клавиатурную навигацию в существующее приложение, мы также хотели убедиться, что сможем поддерживать её в будущем и избежать большинства регрессий по мере роста и развития приложения.

В общем, нам пришлось решать еще целый ряд задач, каждая из которых имела свои сложности. Вот лишь несколько примеров: создание новых утилит для автоматического управления фокусом во всем приложении, редизайн старых фич для обеспечения поддержки правильной семантики, а также рефакторинг многих основных UI-компонентов для обеспечения работы с клавиатурой по умолчанию.

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

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

Мы заопенсорсили весь код! Поэтому, если вы хотите узнать, как мы решали, стоящие перед нами проблемы или вы хотите сделать такую же фичу в вашем приложения, то вы можете ознакомиться с кодом на GitHub.

Навигация по клавиатуре является основополагающей частью создания доступного интерфейса, которым может наслаждаться каждый. Мы рады, что наконец-то внедрили в Discord полную, высококачественную реализацию. Это проект, который затрагивает каждую часть приложения и создает совершенно новый способ взаимодействия с ним.

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

Теги:
Хабы:
Всего голосов 11: ↑11 и ↓0+11
Комментарии3

Публикации

Истории

Работа

Ближайшие события