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

Как мы создавали UI Kit: все о стилизации комплексных React-компонентов

Время на прочтение 8 мин
Количество просмотров 10K
Интерфейс единой диспетчерской
Интерфейс единой диспетчерской

Привет, Хабр! Меня зовут Павел, и я руковожу frontend-направлением в ЕВРАЗе. В рамках цифровой трансформации компании моя команда разрабатывает огромное количество интерфейсов. Только с 2019 года их число превысило 20, и у каждого свой уникальный UX/UI. Несмотря на все разнообразие, проекты являются частью общей дизайн-системы, где повторяются те или иные элементы.

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

Как мы строили UI Kit

UI Kit включил не только атомарные компоненты, такие как кнопки, поля ввода, селекторы, но и комплексные – вроде наборов графиков, фильтров, панелей.

Поскольку проект должен содержать весь возможный набор компонентов, мы сформулировали к нему ряд требований:

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

  2. Слабая связанность компонентов или полное ее отсутствие с целью оптимизации размера загружаемого бандла.

  3. Инкапсуляция компонента. Разработчик не должен вникать в реализацию компонента, чтобы внести какие-либо изменения. Компонент должен предоставлять все необходимые интерфейсы, которые должны быть задокументированы.

Пункты 1 и 3 оказались взаимосвязаны, и мы столкнулись с проблемой для компонентов со сложной структурой – либо не даем в достаточной мере стилизовать компонент, либо нарушаем инкапсуляцию.

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

Button.jsx

function Button({ className }) {
	return (
    <button
      className={joinClassNames('button', className)} ...
    >
      ...
    </button>
  );
}

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

Рассмотрим фрагмент компонента переключателя:

Switcher.jsx

function Switcher(className) {
	...
	return (
		<label className={joinClassNames('switcher', className)}>
			<input type="checkbox" className="switcher__input" ...>
			<div className="switcher__marker" />
		<label>
	);
}

Switcher.css

.switcher {
	font-size: 16px;
}

.switcher__input {
	clip: rect(1px, 1px, 1px, 1px);
	clip-path: inset(50%);
	height: 1px;
	margin: -1px;
	overflow: hidden;
	padding: 0;
	position: absolute;
	width: 1px;
}

.switcher__marker {
	background-color: #eee;
	border-radius: 0.625em;
	height: 1.125em;
	position: relative;
	width: 2em;
}

.switcher__marker::before {
	background-color: white;
	border-radius: 50%;
	content: '';
	height: calc(1.125em - 0.25em);
	left: 0.125em;
	position: absolute;
	top: 0.125em;
	width: calc(1.125em - 0.25em);
}

:checked + .switcher__marker {
	background-color: #ae4;
}

:checked + .switcher__marker::before {
	left: 100%;
	transform: translateX(-1em);
}

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

Проблема: как стилизовать дочерние элементы в компоненте?

Решение 1

Первое, что приходит в голову, — это добавить атрибут для компонента, например, color.

<Switcher color="#334453" />

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

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

Записываем это как требование к компоненту «Не использовать стилизующие атрибуты».

Решение 2

Из легальных способов остается className и style.

Значение атрибута style логичнее будет применить к корневому компоненту, как и className, так как нужно как минимум управлять внешними отступами, позиционированием и поэтому для стилизации других элементов мы эти атрибуты не используем.

В таком случае классифицируем стиль по назначению, например, так:

<Switcher markerStyle={{backgroundColor: '#334453'}} />

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

Еще стоит понимать, что атрибут style имеет максимальный приоритет для применения стилей, поэтому контролировать порядок переопределения уже будет проблематично.

Записываем и этот способ как не подходящий.

Решение 3

Остается только способ с className, для которого можно воспользоваться вариантом от style, переняв все те же проблемы. Либо при добавлении стилей пользоваться составными селекторами.

<!-- usage -->
<Switcher className="some_class">

<!-- output -->
<label className="switcher some_class">...</label>
.some_class .switcher__marker {...}
/* или */
.some_class > .switcher__marker {...}

Таким образом, весь список классов нужно вынести в документацию к компоненту, и опять же подобный подход рушит всю инкапсуляцию компонента. В том числе становится бесполезным, если начать использовать CSS Modules или CSS In JS.

Решение 4

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

Почему полная стилизация дочернего элемента — это плохая практика?

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

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

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

Что такое кастомные свойства?

Кастомные свойства — это CSS-свойства, которых нет в основной спецификации. Их определяет сам разработчик. В дальнейшем значения этих свойств можно применить к стандартным CSS-свойствам.

Синтаксис

/* Объявление */
--some-property-name: some-property-value;

/* Использование */
margin: var(--some-property-name);

Для того чтобы воспользоваться значением свойства, используется CSS-функция var.

Особенности css-свойств

CSS-свойства можно разделить на два вида: сквозные и локальные.

Значения сквозных свойств применяются к тем же свойствам дочерних элементов, как значение по умолчанию (наследуются). Например: font-size, font-weight.

Локальные свойства используются по месту и никем не наследуются. Например: margin, padding.

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

function Switcher({className}) {
	...
	return (
		<label className={['switcher', className].join(' ')}>
			<input type="checkbox" className="switcher__input" ...>
			<div className="switcher__marker" />
		<label>
	)
}
.switcher {
	/* Инициализация кастомных свойств */
	--background-color: #eee;
	--background-color-active: #ae4;
	--marker-background-color: white;
	--marker-background-color-active: white;
	/* =============================== */
	font-size: 16px;
}

.switcher__marker {
	background-color: var(--background-color);
	border-radius: 0.625em;
	height: 1.125em;
	position: relative;
	width: 2em;
}

.switcher__marker::before {
	background-color: var(--marker-background-color);
	border-radius: 50%;
	content: '';
	height: calc(1.125em - 0.25em);
	left: 0.125em;
	position: absolute;
	top: 0.125em;
	width: calc(1.125em - 0.25em);
}

:checked + .switcher__marker {
	background-color: var(--background-color-active);
}

:checked + .switcher__marker::before {
	background-color: var(--marker-background-color-active);
}

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

<Switcher /> Исходный компонент
<Switcher className="switcher_with_new_style"> Компонент с новыми стилями
.switcher_with_new_style {
	--background-color: silver;
	--background-color-active: blue;
	--marker-background-color-active: yellow;
}

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

Если в компоненте понадобится использование сквозных свойств, то в таком случае указывать кастомные свойства в основном блоке не нужно – достаточно сразу применить его к нужному CSS-свойству.

.switcher {
	/* Инициализация кастомных свойств */
	/* Убрали свойство --background-color */
	--background-color-active: #ae4;
	--marker-background-color: white;
	--marker-background-color-active: white;
	/* =============================== */
	font-size: 16px;
}

.switcher__marker {
	background-color: var(--background-color); /* Но здесь его оставили */
	border-radius: 0.625em;
	height: 1.125em;
	position: relative;
	width: 2em;
}

Теперь неважно, в каком из родительских компонентов мы укажем значение для свойства background-color, оно применяется ко всем компонентам switcher внутри этих блоков. Значение по умолчанию можно задать, передав второй аргумент в функцию var.

.switcher__marker {
	background-color: var(--background-color, #eee);
	...
}

Но остается еще одна проблема, связанная с именами классов. Даже при использовании БЭМ есть вероятность, что разработчик присвоит имя класса, которое используется для элемента блока, другому элементу. В таком случае применение CSS-модулей должно обеспечить полную инкапсуляцию компонента.

CSS-Modules для обеспечения полной инкапсуляции

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

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

В таком случае организация файла стилей компонента следующая:

:global(.component_class_name) {
	...
}

.element_class_name {
	...
}
import styles from './Component.module.css';

function Component({className}) {
	return (
		<div className={['component_class_name', className].join(' ')}>
			<div className={styles.element_class_name} />
		</div>
	)
}

:global — сделает селектор глобальным и не будет применять трансформацию имени класса. Если в файле стиля компонента есть только глобальный селектор, то стили импортируются как обычно:

import './Component.module.css';

Именование свойств

С именованием пока не все так гладко, есть ряд рекомендаций, однако в конечном итоге все зависит от разработчика. Как правило, имена вырабатываются по мере использования UI Kit’а, на основе отзывов разработчиков и ревью.

Что в итоге?

А в итоге мы получили гибкий способ кастомизации компонентов из UI Kit. Избавились от «уродливых» конструкций в коде, оберток и провайдеров. Избавились от жесткой привязки к теме оформления, а сама тема стала выглядеть аккуратнее. Разработчики больше не хватаются за голову, когда заказчик просит «поджать отступы…». Благодаря кастомным свойствам появляется контроль, что можно стилизовать в компоненте, а что нельзя. CSS Modules позволяют закрыть доступ к внутренней реализации компонента. Таким образом, компонент становится мобильным, переиспользуемым и соответствующим принципам HTML + CSS.

Теги:
Хабы:
+4
Комментарии 5
Комментарии Комментарии 5

Публикации

Информация

Сайт
www.evraz.com
Дата регистрации
Численность
свыше 10 000 человек