Основные принципы

Компоненты Lemu настраиваются общим набором визуальных пропсов. Словарь объявлен в одном файле — src/types/visual.ts; каждый компонент библиотеки берёт типы оттуда.

ПропЗначенияЧто задаёт
sizexs · sm · md · lg · xlМасштаб: габариты, отступы, кегль
radiusnone · sm · lg · circleФорму углов
variantdefault · primary · secondary · success · warning · dangerСмысл — и цвет через него
appearancesolid · outline · dashed · ghostВес подачи: насколько громко компонент заявляет о себе
colorлюбое CSS-значениеЦвет, который приходит из данных

Зачем это нужно

  • Знание переносится. Освоив один компонент, вы знаете API остальных: имена пропов и наборы значений совпадают, автодополнение подсказывает одно и то же.
  • Плотность задаётся одним решением. Плотный блок — size="sm" на всех его частях, просторный — lg. Достаточно выбрать ступень; подбирать каждому компоненту свою величину не нужно.
  • Композиция без сюрпризов. Компоненты вкладываются друг в друга — аватар в ячейку, бейдж в карточку, кнопка в тулбар — и ступень передаётся вглубь: ячейка size="md" сама укрупняет вложенный аватар.
  • Код читается как текст. variant="danger" appearance="ghost" описывает намерение; ревью видит его без похода в исходники.
  • Библиотека растёт, API остаётся прежним. Новый компонент берёт готовый словарь вместо собственных tone, kind, type и level — его API известен ещё до того, как компонент написан.

Один узор для разных компонентов

Кнопка интерактивна, бейдж статичен; у них разная разметка, разные роли и разные части внутри. Настраиваются они одинаково, буква в букву:

Удалено

Каждый компонент трактует словарь по-своему

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

  • size у Avatar — диаметр; у Cell — высота строки, паддинги, кегль заголовка и размер вложенного аватара; у Checkbox — сторона квадрата и кегль подписи.
  • variant у Button красит саму кнопку; у Cell — всю строку списка; у Checkboxотмеченное состояние, неотмеченный чекбокс всегда нейтральный.
  • radius="circle" у Button даёт «таблетку»; у ButtonIcon — круглую кнопку; у Avatar — круглый аватар.
  • color у Badge красит весь бейдж; у Avatar.Fallback — подложку с инициалами; у Card — фон карточки.

Умолчания у каждого компонента тоже свои — они выбраны по типичному сценарию использования. Кнопка по умолчанию primary, потому что чаще всего зовёт к действию; бейдж — default, потому что чаще всего лишь помечает; ячейка списка без variant и appearance остаётся вовсе неокрашенной, как обычная строка.

size — шкала ступеней

size задаёт ступень плотности: xs — плотные списки и таблицы, sm — базовый размер интерфейса, md — заголовки и формы, lg и xl — акцентные и посадочные экраны. Величину ступени каждый компонент выбирает по своей роли: sm-кнопка выше sm-бейджа — метке ни к чему занимать столько же места, сколько действию.

TU
xs
TU
sm
TU
md
TU
lg
TU
xl

Шкала гарантирует порядок величин: md крупнее sm у любого компонента, поэтому одна ступень на весь блок даёт согласованную плотность. Внутри компонента размер меняет всё сразу — отступы, кегль, иконки и вложенные компоненты масштабируются вместе.

Ступень наследуется

size — единственный каскадирующий проп: ступень, заданная контейнеру, доходит до всех вложенных компонентов ДС. Ячейка size="lg" сама укрупняет вложенные аватар, бейдж и кнопку; свой size им не нужен. Явный size на вложенном компоненте перебивает каскад.

TU
Ячейка sm
Аватар, бейдж и кнопка — без своего size
каскад
TU
Ячейка md
Аватар, бейдж и кнопка — без своего size
каскад
TU
Ячейка lg
Аватар, бейдж и кнопка — без своего size
каскад

Класс ступени ставит наследуемые CSS-флаги --visual-size-*, а величину компонент выбирает по ним уже в своём модуле. Отсюда два следствия:

  • Дефолт size живёт в CSS-фолбэке шкалы. JS-значения по умолчанию у пропа size нет: собственный класс перебивал бы ступень контейнера.
  • Оверлеи каскад не пробивает. Диалоги, меню, тосты и выпадающие списки рендерятся порталом в body; их плотность собственная и от триггера не зависит.

variant, appearance, radius и color не каскадируют: цвет и подача — решение про конкретный компонент.

radius — форма углов

Бейдж
TU

От прямых углов none до circle. Крайнее значение читается по форме компонента: вытянутый превращается в «таблетку», квадратный — в круг.

variant — смысл действия

defaultprimarysecondarysuccesswarningdanger

Вариант выбирают по смыслу, цвет приходит из темы: удаление — danger, подтверждение — success, второстепенное действие — secondary, нейтральный элемент — default. При смене палитры темы интерфейс перекрашивается целиком и сохраняет смысловые акценты.

appearance — вес подачи

solidoutlinedashedghost

Один и тот же смысл можно подать с разной громкостью: solid — сплошная заливка, outline — обводка, dashed — та же обводка пунктиром (обычно для черновых и «добавить» состояний), ghost — приглушённая подложка цветом варианта.

variant и appearance — две независимые оси. Первая отвечает на вопрос «что это значит», вторая — «насколько это важно здесь». На экране обычно одно solid-действие, всё остальное — outline и ghost.

color — цвет из данных

Метка задачи, тег, проект, событие календаря, аватар пользователя: их цвет хранится в данных и в шесть вариантов не укладывается. Для таких случаев есть color — он принимает любое CSS-значение и перекрывает цвет варианта. Контрастный цвет текста компонент подбирает сам.

Стабильный цвет

Чтобы цвет сущности совпадал во всех списках и сессиях, берите его у хелпера getStableColor: одна и та же строка всегда даёт один и тот же цвет палитры темы.

дизайнбэкенддокументациярелиз
АД
ПС
МК

Свои компоненты на visual()

Словарь — публичный API пакета: приложения строят собственные компоненты в том же языке. Типы забираются выборкой Visual<...> из общего интерфейса, классы и inline-стиль отдаёт хелпер visual():

import { visual, type Visual } from "@tulls/ui";
import clsx from "clsx";
import styles from "./chip.module.css";

interface ChipProps extends Visual<"size" | "variant" | "appearance"> {
  children: React.ReactNode;
}

export function Chip({
  variant = "default", // смысловые дефолты — в JS
  appearance = "ghost",
  size, // дефолт size — в CSS-фолбэке, иначе сломается каскад
  children,
}: ChipProps) {
  const vis = visual({ variant, appearance, size });
  return (
    <span className={clsx(styles.Root, vis.className)} style={vis.style}>
      {children}
    </span>
  );
}

Слой пишет только переменные контракта — где их применить, решает CSS компонента. Читать var(--visual-*) можно только на элементе с классами visual() (переменные не наследуются); частям компонента значения раздаются через приватные --_*.

Классы слояПеременные
Size--*флаги ступени --visual-size-{xs,sm,md,lg,xl} (наследуются — это и есть каскад)
Variant--*, Colored--visual-base, --visual-text
Appearance--*--visual-bg, -fg, -border, -border-style, -bg-hover, -border-hover, -bg-active, -border-active
Radius--*--visual-radius

Свою шкалу компонент объявляет guard-цепочкой: переменная var(--visual-size-md) <величина> валидна только на активной ступени, а цепочка фолбэков выбирает первую валидную; последний фолбэк — дефолтная ступень компонента. Имена ступеней — --_step-<роль>-<ступень>, дефолт цепочки — --_<роль>-default, свёрнутый итог — описательное имя без step (--_avatar-size, --_cell-padding):

.Root {
  --_step-height-sm: var(--visual-size-sm) 24px;
  --_step-height-md: var(--visual-size-md) 32px;
  height: var(--_step-height-sm, var(--_step-height-md, 24px));

  background: var(--visual-bg, transparent);
  color: var(--visual-fg, inherit);
  border-radius: var(--visual-radius, var(--radius-1x));
}

Селекторы слоя обёрнуты в :where() — их специфичность равна нулю, и компонент переопределяет трактовку любого значения обычным одиночным классом в своём модуле, не завися от порядка модулей в бандле.

Шпаргалка

  • variant — смысл, appearance — вес, size и radius — плотность и форма, color — данные.
  • Для состояния используйте variant, для идентичности данных — color: тема должна уметь перекрасить смысл.
  • Кастомный цвет задавайте только через color — компонент сам подберёт контрастный текст и приглушённые состояния. Цвет, заданный мимо словаря, через style или className, слой не увидит.