Справа налево. Как перевернуть интерфейс сайта под RTL

в 5:46, , рубрики: 2GIS, bidi, css, logical properties, RTL, арабский, Блог компании 2ГИС, локализация, Локализация продуктов, Программирование, Разработка веб-сайтов

image

Мы недавно перевели онлайн-версию 2ГИС на арабский язык, и в прошлой статье я рассказал о необходимой для этого теории — что такое dir="rtl", по каким правилам отображается текст смешанной направленности и как держать себя в руках.

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

В этой статье я расскажу, как быстро сделать прототип, что сделать со сборкой CSS и какие костыли разложить в JS, замечу немного об особенностях перевода и локализации, напомню про логические свойства CSS и затрону тему RTL в CSS-in-JS.

Переворачиваем стили

Когда я применил к тегу атрибут dir="rtl", поменялся только неявный порядок элементов — например, порядок ячеек таблицы или flex-элементов. С явно заданными в стилях значениями ничего не произошло.

Возьмём стили какой-нибудь нотификации, расположенной снизу справа:

.tooltip {
  position: 'absolute';
  bottom: 10px;
  right: 10px;
}

dir="rtl" никак не повлияет на эти стили — в RTL-версии тултип будет так же справа, хотя ожидается он слева.

Что делать? Нужно заменить right: 10px на left: 10px. И так со всеми остальными стилями. Абсолютное позиционирование, margin/padding, выравнивание текста — всё нужно для арабской версии перевернуть в другую сторону.

Быстрый прототип

Для начала можно, не задумываясь, поменять все вхождения left на right и немножко поколдовать с shorthand значениями:

  • left: 0 → right: 0
  • padding-left: 4px → padding-right: 4px
  • margin: 0 16px 0 0 → margin: 0 0 0 16px

Для этого подходит плагин postcss-rtl. Удобно — нужно только вбросить его в список всех postcss-плагинов проекта. Он заменяет все направленные правила на зеркальные и оборачивает это в [dir="rtl"]. Например:

/* input */

.foo {
  color: red;
  margin-left: 16px;
}

/* output */

[dir] .foo {
  color: red;
}

[dir="ltr"] .foo {
  margin-left: 16px;
}

[dir="rtl"] .foo {
  margin-right: 16px;
}

После этого нужно только задать dir="rtl" и автоматически применятся только нужные правила. Всё работает и кажется, что почти всё готово к продакшену, но это решение годится только для быстрого прототипа:

  • увеличивается специфичность каждого правила. Это не обязательно будет проблемой, но хотелось бы этого избежать;
  • такие манипуляции порождают баги. Например, может ломаться порядок свойств;
  • заметно растёт размер css-файла. К каждому селектору добавляется [dir], каждое направленное свойство дублируется. В нашем случае размер увеличился на одном проекте на 21%, на другом — на 35%:

исходный размер (gzip) двунаправленный размер (gzip) распухло на
2gis.ru 272.3 kB 329.7 kB 21%
m.2gis.ru 24.5 kB 33.2 kB 35%
habr.com 33.1 kB 41.4 kB 25%

Есть вариант лучше?

Нужно собирать стили для LTR и RTL раздельно. Тогда не нужно будет трогать селекторы и размер css почти не изменится.

Для этого я выбрал:

  1. RTLCSS — эта библиотека под капотом у postcss-rtl.
  2. webpack-rtl-plugin — готовое решение для стилей, собираемых через ExtractTextPlugin. Тот же RTLCSS под капотом.

И стал собирать RTL и LTR в разные файлы — styles.css и styles.rtl.css. Единственный минус сборки в разные файлы — нельзя заменить dir на лету, не загружая предварительно нужный файл.

RTLCSS позволяет использовать директивы, чтобы контролировать обработку конкретных правил, например:

.foo {
  /*rtl:ignore*/
  right: 0;
}

.bar {
   font-size:16px/*rtl:14px*/;
}

Какие ещё есть решения?

Все существующие решения почти не отличаются от RTLCSS.

  • css-flip от Twitter;
  • cssjanus от Wikimedia;
  • да и postcss-rtl поддерживает параметр onlyDirection, с помощью которого можно собирать стили только для одной направленности, но размер всё равно растёт — например, для мобильного 2ГИС это 18% вместо 35% (24.5 kB → 29 kB).

Когда нужны директивы?

Когда стили не должны зависеть от направленности

Например, угол поворота стрелки, указывающей направление ветра:

image

.arrow._nw {
    /*rtl:ignore*/
    transform: rotate(135deg);
}

Или фейд у номера телефона — числа всегда пишутся слева направо, значит, и градиент должен быть всегда справа:

image

image

Когда нужно центрировать иконку

Это частный случай предыдущего пункта. Если центрируем несимметричную иконку через отступы/позиционирование, мы смещаем её блок в сторону, и если это смещение отразить, иконка «съедет» в другую сторону:

image

Лучше в таких ситуациях центрировать иконку в самой svg:

Справа налево. Как перевернуть интерфейс сайта под RTL - 6

Когда нужно изолировать целый виджет, который не должен реагировать на RTL

В нашем случае это карта. Мы оборачиваем все её стили при сборке в блочные директивы: /*rtl:begin:ignore*/ ... /*rtl:end:ignore*/.

Есть ли вариант ещё лучше?

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

Logical properties

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

Мы уже пользуемся чем-то похожим во флексах и гридах — например, flex-start, flex-end, grid-row-start, grid-column-end отвязаны от «лево/право».

Вместо понятий left, right, top и bottom предлагается использовать inline-start, inline-end, block-start и block-end. Вместо width и heightinline-size и block-size. А вместо шортхендов a b c dlogical a d c b (логические шортхенды идут против часовой стрелки). Также для существующих шортхендов появляются новые парные версии — padding-block, margin-inline, border-color-inline, и т.д.

  • left: 0 → inset-inline-start: 0
  • padding-left: 4px → padding-inline-start: 4px
  • margin: 0 16px 0 0 → margin: logical 0 0 0 16px
  • padding-top: 8px; padding-bottom: 16px → padding-block: 8px 16px
  • margin-left: 4px; margin-right: 8px → margin-inline: 4px 8px
  • text-align: right → text-align: end

А ещё появляется долгожданный шортхенд для позиционирования:

  • left: 4px; right: 8px → inset-inline: 4px 8px
  • top: 8px; bottom: 16px → inset-block: 8px 16px
  • top: 0; right: 2px; bottom: 2px; left: 0 → inset: logical 0 0 2px 2px

Это уже доступно в firefox без флагов и в вебкитовых браузерах под флагом.

Плюсы — решение нативное, будет работать вообще без сборки/плагинов, если нужные браузеры поддерживаются. Нет потребности в директивах — просто пиши left вместо inline-start, когда имеется в виду физическое «слева».

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

Как подключить?

Самый простой способ — postcss-logical. Без параметра dir собирает стили для обоих направлений аналогично postcss-rtl, с заданным параметром dir — только для указанной направленности:

.banner {
  color: #222222;
  inset: logical 0 5px 10px;
  padding-inline: 20px 40px;
  resize: block;
  transition: color 200ms;
}

/* becomes */

.banner {
  color: #222222;
  top: 0; left: 5px; bottom: 10px; right: 5px;

  &:dir(ltr) {
    padding-left: 20px; padding-right: 40px;
  }

  &:dir(rtl) {
    padding-right: 20px; padding-left: 40px;
  }

  resize: vertical;
  transition: color 200ms;
}

/* or, when used with { dir: 'ltr' } */

.banner {
  color: #222222;
  top: 0; left: 5px; bottom: 10px; right: 5px;
  padding-left: 20px; padding-right: 40px;
  resize: vertical;
  transition: color 200ms;
}

Как убедить команду начать писать offset-inline-start вместо left?

Никак. Но мы у себя на проекте решили упростить — писать start: 0 вместо offset-inline-start: 0, как только все привыкнут, начну навязывать валидную запись :)

RTL + CSS-in-JS = ️️<3

CSS-in-JS не нужно собирать заранее. Значит, можно в рантайме определять направленность компонентов и выбирать, какие переворачивать, а какие нет. Полезно, если нужно вставить какой-то виджет, не поддерживающий RTL вообще.

В целом задача состоит в том, чтобы превращать объекты типа { paddingInlineStart: '4px' } (или { paddingLeft: '4px' }, если не удалось перейти на логические свойства) в объекты типа { paddingRight: '4px' }:

  1. Вооружаемся bidi-css-js или rtl-css-js. Они предоставляют функцию, которая принимает объект стилей и возвращает трансформированный под нужную направленность.
  2. ???
  3. PROFIT!

Пример с реактом

Оборачиваем каждый стилизуемый компонент в HOC, принимающий стили:

export default withStyles(styles)(Button);

Он берёт из контекста направленность компонента и выбирает конечные стили:

function withStyles(styles) {
  const { ltrStyles, rtlStyles } = bidi(styles);

  return function WithStyles(WrappedComponent) {
    ...
    render() {
      return <WrappedComponent
        {...this.props}
        styles={this.context.dir === 'rtl' ? rtlStyles : ltrStyles} />;
      };
    };
    ...
  };
}

А направленность в контекст прокидывает провайдер:

<DirectionProvider dir="rtl">
  ...
  <Button />
  ...

Похожий подход используют airbnb: https://github.com/airbnb/react-with-styles-interface-aphrodite#built-in-rtl-support, если на проекте уже используется aphrodite, можно воспользоваться этим готовым решением.

Для JSS всё ещё проще — нужно только подключить jss-rtl:

jss.use(rtl());

styled-components

const Button = styled.button`
  background: #222;
  margin-left: 12px;
`;

Что делать, если мы работаем с шаблонными строками, а не с объектами? Всё сложно, но выход есть — вычислять название свойства из направления, заданного в props:

const marginStart = props =>
  props.theme.dir === "rtl" ? "margin-left" : "margin-right";

const Button = styled.button`
  background: #222;
  ${marginStart}: 12px;
`;

Но кажется проще перейти со строк на объекты, styled-components умеют это с версии 3.3.0.

Особенности перевода и локализации

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

Показываем настоящему арабу, и...

Оказывается, не каждый носитель арабского языка знает, что такое Twitter. Это касается почти всех слов на английском. Для такого случая есть арабский транслит: «تويتر».

Оказывается, в арабском свои запятые, и то, что мы везде по коду конкатенировали через «,», на арабском нужно конкатенировать через «،».

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

Оказывается, в Дубае не бывает отрицательной температуры и знак «плюс» в прогнозе «+40» не имеет никакого смысла.

Не просто взял и отзеркалил стили

Если мы делаем dir="auto" на блочный элемент и его контент окажется LTR, текст прибьётся к левой стороне контейнера, даже если вокруг RTL. Это можно просто вылечить, явно задав text-align: right. Можно даже применить это ко всей странице в арабской версии — значение этого свойства наследуется.

Иконки тоже автоматически не отзеркалятся. А без этого направленные иконки, такие как стрелки в галерее, могут смотреть в неправильную сторону. Представь себе, это единственный случай, в котором стрелки, сделанные через border, оправдали себя!

Для отражения иконок поможет незамысловатая трансформация:

[dir="rtl"] .my-icon {
  transform: scaleX(-1);
}

Она, правда, не поможет, если в иконке содержатся буквы или цифры. Тогда придётся сделать две разные иконки и вставлять их условно:

image

А ещё, оказывается, не все элементы интерфейса нужно зеркалить. Например, в нашем случае мы решили оставить чекбоксы обычными:

image

image

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

Пользовательский ввод

Даже если мы полностью контролируем все данные своего приложения, это может быть пользовательский ввод. Например, название файла. Можно даже исхитриться и выдать .js файл за .png, — такая уязвимость была в Телеграме:

прикольная_картинка*U+202E*gnp.js → прикольная_картинкаsj.png

В таких случаях стоит отфильтровать неуместные utf-символы из строки.

Переворачиваем скрипты

В RTL-джаваскрипте синтаксис немного меняется. Цикл, который выглядел так:

for (let i = 0; i < arr.length; i++) {

Теперь нужно писать так:

for (++i; length.arr > i; let 0 = i) {

Шутка.

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

Смекалочка

В некоторых ситуациях сложно внедрить поддержку RTL в какой-то компонент системы. Тогда нужно попробовать адаптировать этот компонент под RTL снаружи, но оставить LTR внутри.

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

image

Можно отразить слайдер с помощью transform: scaleX(-1). Тогда придётся инвертировать работу с мышью (клики и драги) относительно центра слайдера. Плохой вариант.

Есть другой вариант — развернуть ось в другую сторону, изменив только передачу и получение значений из слайдера. Если это линейная шкала, вместо набора значений [10, 100, 1000] передадим набор [N-1000, N-100, N-10], а в хендлере преобразуем обратно. Для логарифмической шкалы вместо набора [10, 100, 1000] передадим [1/1000, 1/100, 1/10]:

function flipSliderValues(values, scale, isRtl) {
  if (!isRtl) {
      return values;
  }

  if (scale === 'log') {
      // [A, B] --> [1/B, 1/A]
      return values.map(x => 1 / x).reverse();
  }

  // [A, B] --> [MAX-B, MAX-A]
  return values.map(x => Number.MAX_SAFE_INTEGER - x).reverse();
};

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

Storybook

В отличие от проверки вёрстки под каким-нибудь IE9, для проверки вёрстки под RTL не нужно запускать отдельный браузер. Можно даже верстать и видеть вёрстку LTR и RTL одновременно в одном окне. Для этого можно, например, сделать декоратор в сторибуке, который рендерит сразу две версии истории:

image

На скриншоте видно, что без изоляции text-overflow: ellipsis ведёт себя не так, как хотелось бы — лучше сразу пофиксить.

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

Нерешаемые проблемы

Знание теории не помогает решить абсолютно все задачи. Приведу один пример.

Подсказчик, появляющийся по направлению текста при вводе. Когда мы говорим о мультиязычном вводе, мы не можем заранее знать, с какой стороны выводить эту подсказку:

Справа налево. Как перевернуть интерфейс сайта под RTL - 12
Справа налево. Как перевернуть интерфейс сайта под RTL - 13

Нужно стараться избегать таких проблем на этапе дизайна и иногда отказываться от очевидных для LTR решений, никак не применимых в RTL. В данном кейсе можно при навигации по подсказкам подставлять весь текст целиком (как, например, делает Яндекс или Google).

Выводы

RTL — это не просто «перевернуть всё»

Нужно учитывать особенности языка, что-то переворачивать не нужно, что-то нужно адаптировать иначе. Где-то в логике совсем нужно отказаться от «право»/«лево».

Очень сложно что-то сделать без знания языка

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

Итоговый рецепт

Сложно описать в одном списке всё, о чём шла речь в двух статьях. Пошагового руководства не будет, но вот главное, что нужно сделать:

  • обязательно найди носителя языка и покажи ему прототипы как можно раньше;
  • собирай стили для LTR и RTL в разные файлы. Для этого подходят rtlcss и webpack-rtl-plugin;
  • добавь исключения для всего, что переворачивать не нужно и явно отрази то, что не перевернулось само;
  • изолируй весь контент произвольной направленности с помощью <bdi> и dir="auto";
  • явно задай text-align на всю страницу;
  • избегай left/right в js-коде, когда имеешь в виду начало и конец.

Готовься к тому, что больше всего времени придётся потратить на самые мелкие детали.

Быть заранее готовым — не сложно

И немного советов для тех, кто пока что не собирается адаптировать сайт под RTL, но хочет подстелить соломку:

  • не используй свойство direction не по назначению;
  • на всякий случай всё-таки изолируй весь произвольный контент (да и ведь даже в английском интерфейсе юзеры могут что-нибудь написать на арабском и всё сломать);
  • если есть возможность, используй логические css-свойства;
  • проверяй вёрстку не только в разных браузерах, но и иногда в RTL, хотя бы ради любопытства. А лучше ненавязчиво контролируй вёрстку под RTL при помощи инструментов вроде storybook;
  • не допускай хардкода языковых конструкций (например, конкатенация строк через запятую), по возможности конфигурируй всё, включая знаки препинания. Это пригодится не только для RTL — к примеру, на греческом языке вопросительный знак — «;».

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

Автор: Vladislav Sapozhnikov

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js