Всем привет, сегодня я расскажу вам как разрабатывал кнопку для XMars UI проекта. О да, вроде мелочь, но есть о чем рассказать. Я опущу детали которые связаны с добавлением нового компонента в опенсорс проект. Более детально я расскажу про проект в отдельной статье.
Введение
XMars UI — это один из моих новых опенсорс проектов. Простая библиотека UI компонентов под HTML / CSS и React. В будущем планирую поддерживать Vue. Пока в ней только кнопка и иконки :)
Проект родился как идея в рамках Telegram Contest, cуть которого заключалась в разработке веб версии клиента. Вместе с коллегой мы решили, а почему бы и не принять в этом участие. Роли поделились так, что на мне верстка, а когда коллега разберется с авторизацией, то я подключусь писать компоненты. Все бы хорошо, но авторизоваться в Телеграмме не так просто. В итоге мы нечего не отправили, а я наверстал кучу всего и выходит — зря. Но как говорит Варламов, ваш проект уже чего-то стоит, раз вы потратили на него свое время. С этим сложно не согласится, ведь если переводить на часы и денежки, то только настройка Webpack в самом начале проекта уже не бесплатно. Смотря на все это безобразия, решил надо как-то выкинуть на опенсорс. Что один бутстрап использовать? Хочется свой UI фреймворк под другие свои проекты.
The Button
Кнопка в интерфейсе — пожалуй главный элемент с помощью которого пользователь взаимодействует с приложением. Следовательно, это один из первых компонентов любого UI фреймворка / библиотеки.
В дизайне Телеграм, не так много вариаций кнопок:
Я выделил 3 основных (default, accent, primary), круглую с иконкой и зеленую. Есть еще полу прозрачная, но опустим ее. По большей части разрабатывая XMars UI я стараюсь исходить из потребностей, не придумал куда бы понадобилась прозрачная кнопка.
Пользователю библиотеки должно быть удобно использовать CSS классы. Я не фанат таких систем нейминга как БЭМ. Мне больше нравится, то как Bootstrap задает имена классам. Но я бы упростил еще немного. Вместо .btn .btn-primary
— просто .btn .primary
. А в случае с React компонентом, будет выглядеть так:
<Button primary>Hey</Button>
Такая же кнопка но ripple effect:
<Button primary ripple>Hey</Button>
HTML / CSS
UI библиотека не должна быть привязана к какому-либо UI фреймворку. В будущем планирую натянуть верстку и на Vue
компоненты. По этому начнем с простого HTML / CSS.
Под капотом у проекта Tailwindcss, это utility-first CSS framework, то есть фреймворк который предоставляет вам утилиты, вместо полноценных компонентов.
Помимо Tailwindcss, используется PostCSS для миксинов, переменных и вложенных стилей
Более детально об использовании такого фреймворка и как настроен проект, я расскажу в отдельной статье. На данным этапе достаточно того, что у нас есть такой мощный инструментарий и для создания компонентов он используется по полной.
Тег <button>
имеет ряд дефолтных стилей которые нам необходимо либо убрать, либо переопределить.
В случае с Tailwindcss, тег кнопки имеет такой стиль:
Все лишнее по умолчанию убрано. Можно лепить, что хочешь не боясь, что на каком-то состоянии выпадет дефолтный бордер. Но тут же оговорочка, дефолтный outline
все таки нужно прибить:
Кнопка в XMars UI имеет класс .btn
:
<button class="btn">Button</button>
Добавляем этот класс в наши стили:
.btn {
@apply text-black text-base leading-snug;
@apply py-3 px-4 border-none rounded-lg;
@apply inline-block cursor-pointer no-underline;
&:focus {
@apply outline-none;
}
}
Помимо того, что Tailwindcss предоставляет классы которые вы можете использовать, он предоставляет своего рода mixins
. @apply
это не SCSS или какой-то плагин под PostCSS. Это синтаксис самого Tailwindcss. Стили, которые применяются в целом семантически понятны из названия. Единственно py-3
и px-4
могут вызывать вопросы. Первый это padding по y, то есть по вертикали, а именно — padding-top: 0.75rem;
padding-bottom: 0.75rem;
. Следовательно, px-4
по горизонтали — padding-right: 1rem;
, padding-left: 1rem;
.
Дизайн, который предоставил Телегерамм мягко говоря плохо задокументирован и такие вещи как border-radius
кнопки приходится брать линейкой прямо из изображения. Когда нибудь задумывались, что именно означанют значения в border-radius
?
Это буквально радиус получаемого круга в угле. Если по колхозу, то можно изменить линейкой как показано на картинке выше. Так я и сделал используя прямоугольное выделение в Gimp.
border-radius
у кнопок в дизайне равен 10px, к сожалению такого класса из коробки в Tailwindcss нет, но мне визуально хватило rounded-lg
который равен 8px
при дефолтном размере шрифта (rem).
Вот что получилось на данный момент, я закрасил кнопку в серый, что бы было видно края:
Далее нам необходимо сделать эффект на :hover
. Тут дизайнеры из Телеграмм решили пролить немного света и указали цвет как 0.08% от #707579
. Я вижу два варианта, просто взять цвет пипеткой или же сделать как задокументировано. Первый вариант проще, но на прспективу не самый хороший. Дело в том, что если задний фон будет отличаться от белого, то на :hover
мы будем получать конкретный цвет, терять "легкость" и прозрачность кнопки. По этому лучше последовать документации и заложить альфа самца канал. Сделать это можно бесчисленным количеством способов, например использовать SCSS функции по работе с цветом. Но в проекте нет SCSS, а из за одного цвета подключать какой-то плагин к PostCSS не хочется, сделаем все очень просто. В Chrome, есть колопикер который позволяет трансформировать цвета в разные системы, вбиваем туда HEX цвета #707579
, переводим в rgba
и задаем альфа канал — 0.08%.
Вуаля! Что-то у меня резко флешбэчнула картинка:
Получаем — rgba(112, 117, 121, 0.08)
.
(:hover)
Далее скучно и без особых усилий, я добавил остальные состояния:
&:hover {
background-color: var(--grey04);
}
&.accent {
color: var(--blue01);
}
&.primary {
@apply text-white;
background-color: var(--blue01);
&:hover {
background-color: var(--blue02);
}
}
React компонент
Изначально, кнопка версталась под контест Телеграмма и использовать какой-либо фреймворк было нельзя. Пришлось, реализовал ripple effect на чистом JS. Мне бы очень хотелось, что бы так и осталось, но пока проектом занимаешься в одиночку, приходится чем-то жертвовать.
Компоненты, которые требуют какой-либо логики, например такой, как ripple effect, будут реализованы и доступны только в виде React компонентов.
Завернуть кнопку в React компонент особого труда не составляет:
import React, { FunctionComponent } from 'react';
export interface ButtonProps {
}
const Button: FunctionComponent<ButtonProps> = (props) => {
return (
<button className="btn">props.children</button>
);
}
export default Button;
Данная кнопка будет отображаться в заданном стиле, но по факту от нее толку мало. Нам необходимо дать возможность пользователю кастомизировать кнопку, добавлять собственные стили, навешивать обработчики события и так далее.
Для того, что бы пользователь мог передать все необходимое, для начала нужно побороть Typescript, иначе даже onClick
не даст нормально передать. Немного подредактировав интерфейс ButtonProps
, решаем проблему:
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement>
после чего мы можем смело делать деструкцию props
:
<button className="btn" {...props}>props.children</button>
Подобное использование кнопки будет вести себя как ожидается:
<Button onClick={() => alert()}>Hey</Button>
Далее добавим стили кнопки и возможность прописывать кастомный (вдруг кому-то понадобится) класс. Для этих целей отлично подойдет npm пакет classnames.
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
primary?: boolean,
accent?: boolean,
additionalClass?: string,
}
...
const classNames = classnames(
'btn',
{
primary
},
{
accent
},
additionalClass
);
...
<button className={classNames} {...props}>props.children</button>
Класс btn
устанавливается всегда, а вот primary
и accent
только если равны true
. Classnames добавляет класс, если в значении у него логическое true
, используя сокращение из ES6 получается простая запись { primary }
, вместо { primary: true }
.
additionalClass
— строка, и если она будет пустая или undefined, для нас особой роли не играет, просто к элементу нечего не добавится.
По началу я присваивал props
следующим образом:
{...omit(props, ['additionalClass', 'primary'])}
Опуская, все, что не относится к props
элемента кнопки, но в этом нет необходимости так как React не отренедрит лишнее.
Ripple Effect
Собственно так это и выглядит, но желательно, что бы "волна" расходилась от места клика.
Способов сделать такую анимацию бесчисленное количество, это как шутка про синий квадрат.
Но погуглив, посмотрев примеры на codepen, стало понятно, что в большинстве случаев, реализуется "волна" через дочерний элемент, который расширяется и пропадает.
Позиционируется он внутри кнопки по координатам клика. В XMars UI на данный момент я решил не реализовать данный эффект на onPress
как это делает Material UI, но в будущем планирую доработать. Пока только на onClick
.
На картинке выше вся магия. На клик создается дочерний элемент кнопки, позиционируется абсолютно, по центру клика и расширяется. Свойство overflow: hidden
, не дает "волне" выйти за пределы кнопки. Элемент необходимо удалить по окончанию анимации.
Сначала определим стили, где можно, по максимуму используем Tailwindcss:
.with-ripple {
@apply relative overflow-hidden;
@keyframes ripple {
to {
@apply opacity-0;
transform: scale(2.5);
}
}
.ripple {
@apply absolute;
z-index: 1;
border-radius: 50%;
background-color: var(--grey04);
transform: scale(0);
animation: ripple 0.6s linear;
}
&.primary {
.ripple {
background-color: var(--black02);
}
}
}
Элементу отвечающему за эффект будет присвоен класс .ripple
. border-radius: 50%;
равняется кругу (по 50% скругления на угол * 2), у кнопки позиционирование относительное, у .ripple
— абсолютное кнопке. Анимация очень простая, "волна" увеличиваясь становится прозрачной за 0.6 секунды. Цвет фона такой же как :hover
и складываясь, два прозрачных цвета "волны" и кнопки дают нам желаемый результат. На синей .primary
кнопке это уже не так принципиально и там можно использовать не прозрачный цвет.
На клик необходимо создавать элемент "волны". Поэтому создаем под это дело стейт и добавляем кнопке соответствующий обработчик клика, но таким образом, что бы он не мешал пользовательскому onClick.
...
const [rippleElements, setRippleElements] = useState<JSX.Element[]>([]);
...
function renderRippleElements() {
return rippleElements;
}
return (
<button
className={classNames}
{...props}
onClick={(event) => {
if (props.onClick) {
props.onClick(event);
}
if (ripple) {
onRippleClick(event);
}
}}
>
{children}
{renderRippleElements()}
</button>
);
rippleElements
— массив JSX элементов, функция рендера тут может показаться излишней, но это больше дело стиля и заделки на будущее.
function onRippleClick(event: React.MouseEvent<HTMLButtonElement, MouseEvent>) {
var rect = event.currentTarget.getBoundingClientRect();
const d = Math.max(event.currentTarget.clientWidth, event.currentTarget.clientHeight);
const left = event.clientX - rect.left - d/2 + 'px';
const top = event.clientY - rect.top - d/2 + 'px';
const rippleElement = newRippleElement(d, left, top);
setRippleElements([...rippleElements, rippleElement]);
}
function newRippleElement(d: number, left: string, top: string) {
const key = uuid();
return (
<div
key={key}
className="ripple"
style={{width: d, height: d, left, top}}
onAnimationEnd={() => onAnimationEnd(key)}
>
</div>
);
}
onRippleClick
обработчик которыый создает "волны". По клику на кнопке, мы узнаем размеры кнопки, которые используются для правильного позиционирования круга, после чего все необходимое передается в функцию newRippleElement
которая в свою очередь просто создает div
элемент с классом ripple
, здавая необходимые стили для позиционирования.
Из главных вещей стоит выделить onAnimationEnd
. Данный ивент нам необходим для отчистки DOM от уже отработавших элементов.
function onAnimationEnd(key: string) {
setRippleElements(rippleElements => rippleElements.filter(element => element.key !== key));
}
Очень важно не забыть, передать в аргументы текущие rippleElements
, иначе можно получить массив со старыми значениями, и все будет работать не так как задумано.
Полный код кнопки:
import React, { FunctionComponent, ButtonHTMLAttributes, useState } from 'react';
import uuid from 'uuid/v4';
import classnames from 'classnames';
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
primary?: boolean,
accent?: boolean,
circle?: boolean,
ripple?: boolean,
additionalClass?: string,
}
const Button: FunctionComponent<ButtonProps> = (props) => {
const [rippleElements, setRippleElements] = useState<JSX.Element[]>([]);
const {primary, accent, circle, ripple, additionalClass, children} = props;
const classNames = classnames(
'btn',
{
primary
},
{
'with-ripple': ripple
},
{
circle
},
{
accent
},
additionalClass
);
function onAnimationEnd(key: string) {
setRippleElements(rippleElements => rippleElements.filter(element => element.key !== key));
}
function onRippleClick(event: React.MouseEvent<HTMLButtonElement, MouseEvent>) {
var rect = event.currentTarget.getBoundingClientRect();
const d = Math.max(event.currentTarget.clientWidth, event.currentTarget.clientHeight);
const left = event.clientX - rect.left - d/2 + 'px';
const top = event.clientY - rect.top - d/2 + 'px';
const rippleElement = newRippleElement(d, left, top);
setRippleElements([...rippleElements, rippleElement]);
}
function newRippleElement(d: number, left: string, top: string) {
const key = uuid();
return (
<div
key={key}
className="ripple"
style={{width: d, height: d, left, top}}
onAnimationEnd={() => onAnimationEnd(key)}
>
</div>
);
}
function renderRippleElements() {
return rippleElements;
}
return (
<button
className={classNames}
{...props}
onClick={(event) => {
if (props.onClick) {
props.onClick(event);
}
if (ripple) {
onRippleClick(event);
}
}}
>
{children}
{renderRippleElements()}
</button>
);
}
export default Button;
Итоговый результат можно поклацать здесь
Заключение
Достаточно много было опущено, например как настроен проект, как пишется документация, тесты под новый компонент в проекте. Я постараюсь покрыть эти темы отдельными публикациями.