Темизация UI kit: как мы подружили SCSS с CSS Variables
Всем привет, меня зовут Виталик, я senior фронтенд-разработчик Skyeng. Наша команда делает онлайн-платформу Vimbox для изучения английского языка. Примерно год назад мы с дизайнером доделали небольшой UI kit, искоренивший хаос в интерфейсе и кодовой базе.
Оказалось, что в компании не мы одни хотели UI kit, и к нам стали приходить другие команды за советом «как написать собственный». Нам удалось отговорить их от этой затеи, пообещав темизировать свой — это сэкономило компании сотни часов разработки. Выбирая решение, мы рассмотрели Angular Material, кастомизированные сборки и CSS Variables и в итоге остановились на последних, несмотря на их слабую совместимость с SCSS, основой имевшегося UI kit. Под катом — подробности того, что мы сделали.
Проблема
Первый UI kit состоял из шрифтов, палитры, набора элементов для создания форм (инпут, кнопка и тд), системы управления svg иконками. Ещё был реализован popup и tooltip на основе Angular materials. Он был заточен под работу только с «классическим» Vimbox: многие вещи были осознанно намертво зашиты и не допускали изменений извне. А у Skyeng начали появляться новые продукты на той же платформе, например, для детей.
Разработчики новых направлений, зная, что у нас что-то есть, пришли за советами. Причем, к нашему удивлению, приходили уже с макетами своих UI kit’ов: они собирались разрабатывать свои решения с нуля, т.к. им нужен был другой внешний вид компонентов. Было ясно, что что-то идет не так, и мы предложили доработать нашу библиотеку, добавив возможность ее темизации.
Аргументация была простая: на проектирование нашего UI kit ушло 200 часов UX дизайна и более 500 часов разработки. Это время, необходимое для создания системы шрифтов, цветов и около 10 базовых компонентов. Соответственно, если писать для каждого продукта отдельную библиотеку, компания потратит N*500 часов времени разработчиков. Мы считали, что совершенствование нашего UI kit обойдется дешевле, плюс это действие не придется повторять для каждого продукта.
Наши аргументы были приняты, смежные направления согласились подождать, и мы отправились искать техническое решение.
Исходные данные
Наши инструменты: Angular, SCSS.
Поддерживаем мы только современные браузеры и, с некоторыми ограничениями, IE11. Что здорово упрощает жизнь.
Все наши UI kit компоненты были пронизаны общими стилями, которые мы складывали в UI kit.var.scss
в качестве SCSS констант:
@mixin fontSizeXl {
@include fontSize(18px, 26px);
}
$colorSkillListening: #9679e0;
$colorSkillListeningText: #7754d1;
$colorSkillListeningBackground: mix($colorSkillListening, #ffffff, 16%);
$colorSkillListeningBackgroundHover: mix($colorSkillListening, #ffffff, 8%);
Задача
- Все новые продукты собираются из уже имеющихся элементов, присутствующих во «взрослом» Vimbox — комнаты для занятий, личные кабинеты и т.д.
- Дизайнеры должны иметь широкую свободу реализации творческих замыслов, отличительных особенностей и специфических требований новых продуктов.
- При этом сохраняется преемственность, т.е. сколь бы кислотные цвета и безумные шрифты ни выдумал дизайнер, принадлежность результата его работы к экосистеме Skyeng остается очевидной.
- Все это добавляется к уже имеющемуся UI kit, сохраняя все его преимущества.
Поехали!
Итак, результат от нас ждут вчера, надо быстро провести технические ревью и обсудить варианты. На первых встречах мы определили круг возможных решений:
Angular Material
Мы не любим писать велосипеды, поэтому в первую очередь обратились к Angular Material. В компонентах динамические стили вынесены в отдельный {component}-theme.scss
файл. Эти стили привязываются к глобальному селектору компонента.
CSS Variables
У нас отличный повод попробовать модный CSS Variables. План — пересадить кастомизируемые части UI kit на CSS Variables. В компонентах используются те же SCSS-константы, но вместо конкретных значений в них прописаны CSS vars.
Кастомизированные сборки
Мы любим простые решения, почему бы не попробовать изменить сборку? Каждая команда создает свой файлик с настройкой темы. При сборке для всех кастомных тем создаются отдельные бандлы со своей темой.
Решение
Неделю мы изучали каждый вариант, обсуждали, откладывали решение и снова изучали.
Мы любим новые технологии и следим за ними, но внедряем только в том случае, если они дают нам реальные бонусы. Мы знали о CSS Variables, нам хотелось их попробовать, но отсутствие функций SCSS вызывало большую печаль. И все же плюсы этого варианта были очевидны, мы решили разобраться, как и какие функции SCSS мы используем, можно ли подружить их с CSS vars.
Разбираемся с проблемами CSS vs SCSS
Поэкспериментировав, мы поняли, что основная проблема состоит в отсутствии поддержки #hex в CSS: в SCSS мы пишем rgba(#ffffff, 0.4)
, а в CSS то же самое требует другого набора параметров — rgba(255, 255, 255, 0.4)
. У нас все работает с #hex, и мы очень, очень не хотим это менять. Мы нашли решения, расскажу в порядке поступления.
Lighten & Darken
Наш дизайнер придумал палитру, состоящую из небольшого количества базовых цветов, расширяющуюся за счет SCSS-функций lighten
и darken
:
// $color – базовый цвет
base: $color,
background: mix($color, #ffffff, 16%),
backgroundHover: mix($color, #ffffff, 8%),
hover: lighten($color, 5),
focused: darken($color, 5),
...more transformations...
Мы попробовали найти аналог lighten
и darken
в CSS, но ничего не обнаружили. Думали несколько дней, пока не осознали, что для кастомизации нам нужно избавиться от этих функций внутри библиотеки, вынеся их наружу. Ведь каждая команда может захотеть придумать свою собственную формулу изменения цвета при изменении фокуса — например, коллегам из Kids нужно больше контрастности.
Получилось простое решение — перенести наши преобразования на сторону платформы, которая будет инициализировать тему. И вот для платформы мы пишем функцию, которая автоматически создает нужные значения:
@function getMainColors($color, $colorText) {
$colors: (
text: $colorText,
base: $color,
background: mix($color, #ffffff, 16%),
backgroundHover: mix($color, #ffffff, 8%),
lightenLess: lighten($color, 5),
darkenLess: darken($color, 5),
lightenMore: lighten($color, 20),
);
@return $colors;
}
Платформа использует ее при инициализации цветов:
//platform
$colorValues: (
brand: getMainColors(#5d9cec, #4287df),
positive: getMainColors(#8cc152, #55a900),
accent: getMainColors(#ff3d6f, #ff255d),
wrong: getMainColors(#ff6666, #fe4f44),
)
RGBA
В своем UI kit мы используем функцию rgba
. С ее помощью мы регулируем прозрачность базовых цветов. Но если в SCSS rgba работает с форматом #hex, то CSS этого не может. Пришлось написать функцию, которая раскладывает #hex значение в r/g/b:
// returns `r, g, b` from `#hex` for `rgba(var(--smth))` usage
@function rgbValuesFromHex($hex) {
@return red($hex), green($hex), blue($hex);
}
Ну и поскольку мы не хотим ко всей палитре генерировать RGB значения ручками, создаем отдельную функцию, делающую это рекурсивно для каждого цвета в коллекции:
// adds `fieldRgb: r, g, b` fields to map for each `field: #hex` for `rgba(var(--smth-rgb))` usage
@function withRgbValues($map) {
$rgbValues: ();
@each $name, $value in $map {
$formattedValue: ();
@if type-of($value) == 'map' {
$rgbValues: map-merge($rgbValues, (#{$name}: withRgbValues($value)));
} @else {
//добавляем рядом с цветом, rgb версию с постфиксом Rgb в имени
$rgbValues: map-merge($rgbValues, (#{$name}Rgb: rgbValuesFromHex($value)));
}
}
@return map-merge($map, $rgbValues);
}
В итоге вот так выглядит инициализация палитры со сгенерированными значениями RGB:
$colorValues: withRgbValues(
(
text: (
base: #242d34,
secondary: #50575c,
label: #73797d,
placeholder: #969b9e,
inversed: #ffffff,
inversedSecondary: #dadada,
),
brand: getMainColors(#5d9cec, #4287df),
positive: getMainColors(#8cc152, #55a900),
accent: getMainColors(#ff3d6f, #ff255d),
wrong: getMainColors(#ff6666, #fe4f44),
//...etc
На выходе получаем карту цветов SCSS, которую затем можно отдать в метод, превращающий ее в CSS variables. Для того, чтобы достать из темы RGB значение, написали функцию:
@function getUiKitRgbVar($path...) {
$path: set-nth($path, -1, #{nth($path, -1)}Rgb); //постфикс который мы добавили в функции выше
@return getFromMap($uiKitBaseVars, $path...);
}
//пример вызова
border-color: rgba(getUiKitRgbVar(color, brand, base), $opacity64);
Превращаем SCSS const в CSS vars
Первый шаг — завести зеркальную структуру (аналогичную SCSS), в которой хранятся имена CSS Variables:
$colorCssVars: withRgbCssVars(
(
text: (
base: getColorCssVar(text, base),
secondary: getColorCssVar(text, secondary),
label: getColorCssVar(text, label),
placeholder: getColorCssVar(text, placeholder),
inversed: getColorCssVar(text, inversed),
//и так далее
getColorCssVar
— метод, добавляющий префиксы названиям переменных. Добавляем префикс --sky
, чтобы избежать коллизии с внешними библиотеками. А также добавляем к --sky
префикс библиотеки - UI kit
, чтобы избежать коллизий с внутренними библиотеками. Получился --sky- UI kit
:
@function getColorCssVar($parts...) {
@return getUiKitCssVar(color, $parts...);
}
@function getUiKitCssVar($parts...) {
$uiKitCssVarPrefix: '--sky- UI kit';
$cssVar: $uiKitCssVarPrefix;
@each $part in $parts {
$cssVar: $cssVar + '-' + $part;
}
@return $cssVar;
}
Например, для getColorCssVar(text, base)
на выходе получим --sky- UI kit-color-text-base
.
Финальный штрих — рекурсивный миксин, инициализирующий значения из структуры SCSS в переменные с названиями из структуры CSS Var:
//вешаем миксин на главный тег нашего приложения
:root {
@include uiKitThemeCssVars($uiKitDefaultTheme); //uiKitDefaultTheme – SCSS структура библиотеки с дефолтными значениями темы
}
@mixin uiKitThemeCssVars($theme) {
$cssVarsList: createVarsList($theme, $uiKitBaseCssVars); //18+, страшный метод превращающий Map в List, $uiKitBaseCssVars структура имён css переменных
@each $cssVar, $value in $cssVarsList {
#{$cssVar}: $value;
}
}
Пример использования темы на платформе:
.popup {
font-family: getUiKitVar(font, family);
background-color: getUiKitVar(color, background, base);
...
}
Что в итоге
Мы смогли использовать CSS Variables, cохранив возможность использования SCSS функций. Создали возможность кастомизации внешности компонентов. Написали пару рекурсивных методов для автоматизации расширения темы. Ну и главное — потратили 30 часов разработки вместо N*500.
Profit!