@brandup/ui-kit
v1.1.1
Published
Base UI kit: reset, typography, form field styles, PopupManager and app middleware.
Maintainers
Readme
@brandup/ui-kit
Базовый пакет UI-кита: сброс стилей, типографика, стили полей ввода, модальное окно, PopupManager и middleware для @brandup/ui-app.
Установка
npm i @brandup/ui-kitПакеты кита поставляются исходниками: TypeScript и Less, без сборки. Бандлер проекта
собирает их вместе со своим кодом — это даёт теме подстановку переменных на сборке и убирает
из дерева зависимостей вторую копию @brandup/ui. Взамен от проекта требуется настройка,
и она не сводится к установке пакета.
Что должен уметь бандлер
| Что | Зачем |
| --- | --- |
| Транспилировать .ts внутри node_modules/@brandup | пакеты приезжают исходниками; конфигурация «не трогать node_modules» на них и спотыкается |
| Компилировать .less с modifyVars темы | иначе кит соберётся со своими умолчаниями, а uikit.vars.less проекта ни на что не повлияет |
| Отдавать .svg строкой (asset/source) | иконки внутри кита вставляются в разметку как текст, а не подключаются адресом |
webpack
Ключевая строка — exclude: обычное exclude: /node_modules/ оставляет исходники кита
babel'у неизвестными, и сборка падает на первом же из них — Module parse failed: Unexpected
token в node_modules/@brandup/ui-kit/source/index.ts.
const path = require("path");
const MiniCssExtractPlugin = require("mini-css-extract-plugin");
const parseLessVars = require("@brandup/ui-kit/build/parse-less-vars.cjs");
module.exports = {
entry: "./src/index.ts",
resolve: { extensions: [".ts", ".js"] },
module: {
rules: [
{
test: /\.(ts|js)$/,
// пакеты кита — единственное, что транспилируется из node_modules
exclude: { and: [/node_modules/], not: [/@brandup/] },
use: "babel-loader",
},
{
test: /\.less$/,
use: [
MiniCssExtractPlugin.loader,
{ loader: "css-loader", options: { importLoaders: 1 } },
{
loader: "less-loader",
options: { lessOptions: { modifyVars: parseLessVars("uikit.vars.less") } },
},
],
},
{ test: /\.svg$/, type: "asset/source" },
],
},
plugins: [new MiniCssExtractPlugin()],
output: { path: path.resolve(__dirname, "dist"), clean: true },
};babel.config.js к нему — обычный:
module.exports = {
presets: ["@babel/preset-env", "@babel/preset-typescript"],
plugins: ["@babel/plugin-transform-runtime"],
};Зависимости сборки: webpack webpack-cli babel-loader @babel/core @babel/preset-env
@babel/preset-typescript @babel/plugin-transform-runtime @babel/runtime css-loader less
less-loader mini-css-extract-plugin.
Более полный конфиг — с темой отдельным файлом, оптимизацией SVG и разделением чанков — лежит в примере.
vite
Vite сам разбирает TypeScript и Less, поэтому от проекта нужна одна настройка — тема.
Из зависимостей достаточно less. Конфигурация в проекте с "type": "module" — таким его
создаёт npm create vite:
import { createRequire } from "node:module";
// parse-less-vars — CommonJS-модуль: из ESM-конфигурации он берётся через createRequire
const parseLessVars = createRequire(import.meta.url)("@brandup/ui-kit/build/parse-less-vars.cjs");
export default {
css: { preprocessorOptions: { less: { modifyVars: parseLessVars("uikit.vars.less") } } },
};В проекте без "type": "module" то же самое пишется обычным require и module.exports.
Отдельно исключать пакеты кита из optimizeDeps не нужно: их предсборка проходит вместе
со стилями и в dev, и в build.
Тема
uikit.vars.less в корне проекта — переопределения входов кита. Файла может
не быть вовсе: тогда parseLessVars бросит ошибку, поэтому пустой файл лучше создать сразу.
Начинать стоит с палитры — семи цветов и пропорций, из которых выведено остальное
(см. Палитра).
Подключение middleware
Зарегистрируйте uiKitMiddlewareFactory в сборщике приложения. Middleware автоматически регистрирует команду ui-popup-toggle для управления попапами.
import { ApplicationBuilder } from "@brandup/ui-app";
import { uiKitMiddlewareFactory } from "@brandup/ui-kit";
const builder = new ApplicationBuilder({});
builder.useMiddleware(uiKitMiddlewareFactory);
const app = builder.build({ basePath: "/" });
app.run();Кнопка
Кнопку кит оформляет классом, а не тегом: кнопкой бывает и <a> (переход, оформленный
действием), а чужой <button> внутри стороннего виджета наш вид только сломал бы.
<button type="button" class="ui-button primary">Сохранить</button>
<button type="button" class="ui-button">Отмена</button>
<a href="/" class="ui-button ghost">Пропустить</a>Вид, тон, размер и состояние — отдельные классы, и они складываются на одной кнопке:
ui-button primary danger mini — маленькая залитая кнопка разрушающего действия. Имена короткие,
в стилях всегда пишутся при .ui-button (см. Соглашение).
| Класс | Что делает |
| --- | --- |
| — | Обычное действие: рамка и заливка поля ввода |
| primary | Главное действие набора — залито акцентом |
| ghost | Третьестепенное — ни рамки, ни заливки, вес ссылки при раскладке кнопки |
| danger | Тон разрушающего действия: меняет акцент, поэтому идёт с любым видом |
| mini | Уменьшенная — 32px вместо 46px, значок 16px |
| wide | Во всю ширину — узкая колонка, телефон |
| disabled | Выключена. У <button> то же делает атрибут; класс нужен ссылке |
| loading | Ждём ответа: подпись гаснет, поверх неё кольцо, нажатия не проходят |
loading меняет только вид, поэтому скринридеру о состоянии скажет хост — aria-busy="true"
рядом с классом. Ширина кнопки при этом не меняется: подпись остаётся на месте и становится
прозрачной, иначе соседи по ряду дёргались бы на каждый запрос.
Если ссылки у вас оформлены (a { text-decoration: underline } и подобное), исключите из них
кнопки — a:not(.ui-button): правило с тегом и псевдоклассом (a:hover) весит больше одного
класса, и ссылка-кнопка получила бы по наведению чужой цвет и подчёркивание.
Фокус с клавиатуры кнопка показывает рамкой (:focus-visible): у полей ввода кит гасит outline,
показывая фокус их собственным видом, а у кнопки такого вида нет — без рамки путь по Tab
становится невидимым.
Настраивается переменными, переопределить их можно и на поддереве:
.admin-panel {
--button-accent: #2f6feb;
--button-radius: 22px;
--button-height: 40px;
}| Переменная | По умолчанию |
| --- | --- |
| --button-height / --button-height-mini | 46px (высота поля ввода) / 32px |
| --button-padding-lr / --button-padding-lr-mini | 15px / 10px |
| --button-gap | 8px — между значком и подписью |
| --button-radius, --button-border-width, --button-border-color, --button-fill | от поля ввода |
| --button-color, --button-font-size, --button-font-weight | #222, 14px, 500 |
| --button-accent / --button-accent-color | заливка и подпись залитой кнопки |
| --danger--button-accent | #d64545 |
| --hover--button-tint / --active--button-tint | 12% / 20% — насколько темнее под курсором |
| --disabled--button-opacity | 0.5 |
| --focus--button-ring-width / --focus--button-ring-offset / --focus--button-ring-color | выведены из общих --focus-ring-* |
| --button-spinner-size | 16px |
Значок внутри кнопки следует за цветом подписи (--svg-fill: currentColor), поэтому на залитой
кнопке он не остаётся тёмным. Вращение кольца отключается вместе с остальной анимацией
(prefers-reduced-motion) — вместо него кольцо ровно пульсирует.
Чекбокс, тумблер и радио
Вид элемента выбора следует за его смыслом, а смысл объявляет разметка. Чекбокс — «отмечено или нет» в наборе, который уходит вместе с формой; тумблер — «включено или выключено» прямо сейчас, и в ARIA у него своя роль. Роль и задаёт вид:
<label><input type="checkbox" /> Согласен с условиями</label>
<label><input type="checkbox" role="switch" /> Показывать уведомления</label>
<label><input type="radio" name="delivery" /> Самовывоз</label>До версии, в которой это разошлось, input[type=checkbox] кит безусловно рисовал тумблером —
обычного чекбокса получить было нельзя, — а input[type=radio] не оформлял вовсе, и радиокнопка
стояла в форме нативной рядом с оформленными полями. Скринридер при этом читал «флажок» там, где
нарисован переключатель.
Что нужно поправить при переходе. Если у вас <input type="checkbox"> стоял ради вида
переключателя — допишите ему role="switch", иначе на его месте окажется квадратный чекбокс.
Третий вид чекбокса — «часть набора отмечена» — ставится только из сценария, атрибута для него в разметке нет:
checkboxElem.indeterminate = true; // вместо галочки — чёрточкаСостояния общие с полем ввода: наведение, readonly, disabled, :user-invalid. Отмеченный
элемент под курсором темнеет — на долю --hover--checkbox-tint, общую с кнопкой. У выключенного
отметка гасится, но остаётся видимой — иначе выключенный отмеченный не отличить от выключенного
пустого. Фокус все трое показывают кольцом браузера: своего вида фокуса у них нет, и кит его
больше не снимает.
readonly у чекбокса и радио в HTML не существует — браузер такой атрибут не читает. Кит рисует
по нему закрытый вид и отменяет переключение, в том числе с клавиатуры: пробел на таком элементе
ничего не меняет. Отмена включается сама при импорте пакета — не с регистрацией middleware
и не вызовом из кода: закрытый вид рисуют стили, безусловно всем, кто их подключил, и приди
поведение отдельно, у части проектов элемент выглядел бы закрытым и при этом менялся.
Нажатия при этом до элемента доходят: отменяется только переключение, само событие не гасится — обработчик хоста (подсказка, почему поле закрыто) его увидит. Курсор над таким элементом обычный, а правила наведения его пропускают, чтобы он не отзывался на указатель как рабочий.
Скринридеру про закрытое поле скажет aria-readonly — его пишет хост. Заменой disabled это
не является: выключенное значение с формой не уходит, а прочитанное — уходит, ради чего
readonly обычно и берут.
| Переменная | По умолчанию |
| --- | --- |
| --checkbox-size | 20px — сторона чекбокса и диаметр радио |
| --checkbox-fill-checked | @accent — заливка отмеченного |
| --checkbox-mark | @accent-contrast — галочка и чёрточка поверх заливки |
| --checkbox-mark-width | 2px — толщина галочки |
| --radio-dot | @accent-contrast — точка внутри радио |
| --hover--checkbox-tint | 12% — насколько темнеет отмеченный под курсором |
| --toggler-height / --toggler-padding | 30px / 3px — высота тумблера и поля вокруг круглешка |
| --toggler-round-fill | цвет круглешка |
Переезд круглешка отключается вместе с остальной анимацией (prefers-reduced-motion).
PopupManager
Статический менеджер для управления всплывающими панелями.
Соседние попапы друг друга вытесняют: два меню в шапке одновременно раскрытыми быть не должны. Вложенный ложится поверх родителя — подменю, панель смайликов внутри раскрытой панели. Родство определяется по кнопке: стоящая внутри открытого попапа раскрывает подменю, стоящая на странице — самостоятельный попап, который открытые вытесняет.
Разметка
Добавьте CSS-класс ui-popup на элемент попапа.
<!-- Кнопка-инициатор рядом с попапом — команда ui-popup-toggle регистрируется middleware -->
<button data-command="ui-popup-toggle">Меню</button>
<div class="ui-popup">...</div>API
import { PopupManager } from "@brandup/ui-kit";
// Показать попап (открытый другой закрывается; этот же — остаётся открытым)
PopupManager.open(popupElem, {
initiator: buttonElem, // необязательно: кнопка, от которой раскрылся попап
onClose: () => { }, // необязательно: callback при закрытии
});
// Переключить — поведение кнопки: открытый закрывается. Возвращает, открыт ли попап после вызова
const opened = PopupManager.toggle(popupElem, { initiator: buttonElem }); // boolean
// Закрыть попап и всё, что раскрыто из него
PopupManager.close(popupElem);
// ...или закрыть все
PopupManager.close();
// close() принимает попап, поэтому обработчиком её передают обёрнутой:
// someApi.on("navigate", () => PopupManager.close());
// Проверить, открыт ли какой-либо попап
PopupManager.isOpened(); // boolean
// ...или именно этот
PopupManager.isOpened(popupElem); // boolean
// Сколько открыто и какой сверху — тот, которому достанется Escape
PopupManager.count; // number
PopupManager.current; // HTMLElement | nullopen() только показывает: повторный вызов по уже открытому попапу ничего не меняет — это нужно
тому, кто показывает попап по внешнему событию или перерисовывает его содержимое. Закрытие
повторным нажатием по кнопке — работа toggle(), ею же пользуется команда ui-popup-toggle.
Позиционирование
По умолчанию координаты попапа — дело проекта: правило вида left: calc(100% + 10px) работает
как работало, кит его не трогает. Но такой попап у правого края экрана уезжает за него, поэтому
менеджер умеет ставить его сам:
PopupManager.toggle(popupElem, { initiator: buttonElem, position: true });С position: true попап встаёт под кнопкой, переворачивается на другую сторону, если на своей
не помещается, и сдвигается вдоль неё, чтобы не вылезти за край экрана. Пока попап открыт,
он держится за якорем: прокрутка страницы или внутреннего контейнера его не бросает.
PopupManager.toggle(popupElem, {
initiator: buttonElem,
position: {
placement: "right-start", // сторона и выравнивание; по умолчанию "bottom-start"
gap: 8, // зазор до якоря, по умолчанию 4
viewportPadding: 12, // насколько близко подпускать к краю экрана, по умолчанию 8
flip: false, // не переворачивать
fallback: "bestFit", // не влезая нигде — вставать туда, где места больше
flipAlign: true, // менять выравнивание, если не помещается вдоль стороны
shift: false, // не сдвигать
clampCross: true, // прижимать и поперёк стороны, по умолчанию нет
anchor: fieldElem, // якорь, отличный от кнопки
},
});anchor нужен, когда кнопка меньше того, у чего попап должен стоять: список раскрывают кнопкой
внутри поля ввода, а вставать он должен по всему полю.
Как выбирается место
Сторона выбирается сама: если на запрошенной попап не помещается, а на противоположной помещается,
он переворачивается — это flip, он включён по умолчанию. Свободное место меряется от краёв экрана
с отступом viewportPadding, а не от краёв блока, в котором лежит кнопка. Считается это заново на
каждом кадре прокрутки, поэтому открытый попап переворачивается на ходу, когда кнопка уезжает к низу
экрана.
Не помещаясь ни на одной стороне, попап по умолчанию остаётся на запрошенной — там его хотя бы ждут,
а к краю прижмёт shift. fallback: "bestFit" меняет это: попап встаёт туда, где свободного места
больше. Это про длинное меню на низком экране — обрезано оно будет в любом случае, но с большей
стороны видно больше.
Выравнивание вдоль стороны само не меняется: bottom-start держит попап по левому краю кнопки, даже
если он свешивается вправо. flipAlign: true включает и это — start меняется на end и обратно,
когда с запрошенным попап уходит за край экрана. У center противоположного нет, поэтому за ним
пробуются оба конца.
Важно: flipAlign, как и всё здесь, считает от края экрана. Попап, свешивающийся за свой блок,
но оставшийся на экране, переполнением не считается — если нужно выровнять его по правому краю кнопки
всегда, задайте placement: "bottom-end" явно.
clampCross прижимает элемент к экрану и поперёк стороны. По умолчанию этого не делается: поперёк
элемент держится за якорь зазором, и сдвинуть его там значило бы оторвать от того, к чему он
относится. Нужно оно тому, кто принадлежит не точке, а всему полю: панель форматирования стоит над
текстом, и над самой верхней строкой места нет — лечь на текст лучше, чем уйти за край экрана.
Сторона задаётся как <сторона> или <сторона>-<выравнивание>: top, bottom, left, right
и start, center, end. Выравнивание без стороны не задаётся — оно всегда вдоль неё.
Ниже @adaptive-tablet-small попап показывается окном по центру (см. Узкий экран),
и в этом режиме координаты не пишутся: признак объявляет сам стиль токеном --popup-window-mode,
чтобы граница не жила ещё и числом в сценарии.
Расчёт доступен и отдельно — для подсказки, своего меню, чего угодно:
import { computePosition, positionElement, trackPosition } from "@brandup/ui-kit";
// чистый расчёт: прямоугольники числами, числа обратно
computePosition(anchorRect, { width, height }, { width, height }, { placement: "top-center" });
// разовая установка и слежение, пока не позовут отписку
const { side, align } = positionElement(tooltipElem, wordElem, { placement: "top-center" });
const stop = trackPosition(tooltipElem, wordElem);Результат говорит, где элемент встал на самом деле: после переворота это не та сторона, которую
просили. side и align отдаются отдельными полями — по ним рисуют хвостик подсказки и
направление появления. Поле placement всегда полное: на запрос bottom вернётся bottom-center,
поэтому сравнивать его с тем, что передали, не нужно — для этого есть side и align.
Место считается не по экрану, а по коробке, в которой элемент видно на самом деле: clippingRect
обходит предков и сужает экран каждым, кто режет содержимое. Обёртка страницы с overflow: hidden
высотой по содержимому обрезает всё, что раскрывается ниже последнего элемента, — экран при этом
сообщает, что место есть, и расчёт по экрану оставлял бы элемент там, где его обрезали. Оси
спрашиваются порознь: overflow-x: clip с overflow-y: visible — рабочая пара, и считать такую
коробку режущей по обеим отняло бы ровно тот запас, ради которого её так и написали.
Фиксированный элемент раскладывается от вьюпорта, поэтому overflow: hidden по дороге вверх до
него не достаёт — если только предок не сделал себя контейнером через transform или filter.
Абсолютному достают все. Границу можно задать и руками — опцией boundary; clippingRect
экспортирован отдельно, им пользуется дропдаун, который позиционируется своими классами.
Координаты вьюпортные, элемент ставится position: fixed. Предок с transform, filter или
contain делает контейнером себя, и координаты пересчитываются в его систему — попап внутри
блока с анимацией перехода встаёт там, где нужно. Пересчёт делается по замеру: трансформация,
появившаяся уже во время показа, до следующего замера не учитывается.
Свои инлайновые position / left / top / right / bottom и поля margin элемента
запоминаются и возвращаются на место, когда слежение снимают. positionElement берёт элемент
на себя не на время вызова, а начиная с него: координаты держатся, только пока никто их не
трогает. Тому, кто поставил элемент один раз и дальше пользуется им для другого, нужен
clearPosition; тому, кто переставляет его снова и снова, — не нужен ничего.
Размер элемента и якоря отслеживается: список, догрузивший строки, или поле, выросшее вместе с текстом, пересчитываются сами, без прокрутки и изменения окна.
Состояния
Пока попап открыт, классы расставлены так:
| Элемент | Класс | Имя |
| --------- | ------------------- | ---------------------------- |
| Попап | ui-popup-opened | UIKIT.POPUP.CLASS.OPENED |
| Инициатор | ui-popup-expanded | UIKIT.POPUP.CLASS.EXPANDED |
| body | body-popup-opened | UIKIT.POPUP.CLASS.BODY |
Классы состояния страницы кит именует с префикса body-, а не ui-: по имени видно, что искать
класс нужно на <body>, а не на элементе компонента.
Класс на body — точка расширения для страницы: например, чтобы придержать фон, пока попап открыт.
Ставит и снимает его менеджер слоёв, считая слои: попап поверх модального окна, закрывшись,
не отпустит страницу, которую придержало окно.
Инициатору, кроме класса, проставляется состояние для скринридера: aria-expanded (true/false)
и aria-controls с идентификатором попапа. Идентификатора нет в разметке — менеджер выдаёт свой.
Закрытие
Попап закрывается кликом мимо себя, повторным кликом по инициатору, клавишей Escape и навигацией
приложения. Свои обработчики Escape компоненту не нужны — попап встаёт в стек слоёв,
и нажатие снимает только верхний слой: попап, раскрытый над модальным окном, закрывается один,
окно под ним остаётся.
С вложенными это работает так же поштучно: Escape снимает подменю и оставляет меню под ним,
клик внутри родителя закрывает раскрытое из него, клик мимо всех закрывает цепочку целиком.
Закрытие идёт сверху вниз, поэтому фокус возвращается по цепочке — из подменю на его кнопку
в родителе, а не сразу на страницу.
Фокус возвращается на инициатор сам — но только если к моменту закрытия он оставался внутри попапа (то есть в попап зашли с клавиатуры). Фокус, оставшийся в поле — так открывают панель смайликов — или уведённый пользователем в другое место, менеджер не трогает.
Узкий экран
До @adaptive-tablet-small (850px) попап позиционируется относительно инициатора. Ниже этой ширины
рядом с кнопкой места уже нет, поэтому попап отрывается от неё и показывается окном по центру экрана
с затемнением — position: fixed, ширина по экрану до --popup-window-max-width, прокрутка страницы
под ним придержана через body.body-popup-opened. Собственные top / left / width попапа в этом
режиме перекрываются: правила кита идут с body в селекторе и весят больше, чем два класса.
Настраивается переменными --popup-backdrop, --popup-window-inset, --popup-window-max-width.
Геометрия этого режима объявлена через !important, и это осознанно. Ниже брейкпоинта попап
перестаёт быть поверхностью у кнопки и становится окном по центру — а всё, что проект написал,
чтобы поставить его у кнопки (left: calc(100% + 10px), top, ширина), должно здесь перестать
действовать. Такие правила пишут вложенными настолько, насколько вложена разметка проекта, и весом
это не выиграть: селектор кита — (0,2,1), а любое правило из трёх классов его перебивает. Тогда
left, безобидный для абсолютно позиционированного блока, уводит ставший fixed попап за край
экрана, и от него остаётся одна подложка. Поднимать вес бесполезно — потребитель вложит на уровень
глубже, не собираясь ни с чем спорить. Размеры окна при этом остаются настраиваемыми: их задают
переменные выше, а не эти объявления.
Ловушки фокуса в этом режиме нет: за экраном попапа остаются достижимые с клавиатуры элементы
страницы. Если попап — полноценный диалог, берите Modal.
Прокрутку держит overflow: hidden на body — в Safari на iOS этого не всегда достаточно, страница
под попапом может тянуться. Оговорка общая для попапа, модального окна и списка дропдауна.
Modal
Базовое модальное окно: затемнение, шапка с заголовком и крестиком, тело. Наследник наполняет body в своём конструкторе и живёт до close(); само окно знает только про рамку, слои и закрытие.
import { Modal } from "@brandup/ui-kit";
class ConfirmModal extends Modal {
constructor() {
super({ title: "Удалить?", className: "confirm-modal", closeOnBackdrop: false });
this.body.append(/* ... */);
}
}
const modal = new ConfirmModal();
modal.onClosed(() => {
/* отпустить то, что придержали на время окна */
});| Параметр | По умолчанию | Что задаёт |
| --- | --- | --- |
| title | — | Заголовок в шапке |
| className | — | Дополнительный класс на корне окна |
| closeOnBackdrop | true | Закрывать по клику вне окна |
| closeButton | true | Крестик в шапке. С false шапка без заголовка не рисуется вовсе, а закрытие остаётся за Esc, подложкой и кнопками самого окна |
| Член | Описание |
| --- | --- |
| body | Тело окна — его наполняет наследник |
| close() | Закрыть окно (то же делают крестик, Esc и клик по подложке) |
| onClosing() | Хук наследника перед закрытием — окно ещё на экране: отдать результат, прибрать за собой |
| onClosed(handler) | Подписка на закрытие — окна уже нет: срабатывает ровно один раз, чем бы оно ни кончилось; на уже закрытом окне — сразу |
Закрытие идёт через систему команд кита (ui-modal-close), поэтому свои кнопки закрытия достаточно объявить тем же data-command. Пока окно открыто, на <body> висит класс body-modal-opened (UIKIT.MODAL.CLASS.BODY) — страница под ним не прокручивается. Класс считается по числу открытых окон: закрытое поверх другого окно прокрутку не вернёт.
Фокус и доступность
Окно — полноценный диалог, поэтому фокусом занимается менеджер слоёв:
- фокус заводится внутрь окна сразу после того, как наследник наполнил тело (первый элемент, до которого доходит Tab, а если такого нет — само окно);
- окно, поставившее фокус само (поле правки, кнопка по умолчанию), менеджер не перебивает;
TabиShift+Tabне уходят из окна на страницу под ним, а по кругу возвращаются в него — в том числе из окна, лежащего ниже;- по закрытию фокус возвращается туда, откуда окно открыли. Подписчики
onClosedвыполняются после этого — вернуть каретку в своё поле они могут сами; Escapeснимает только верхнее окно;- окно объявляет себя
role="dialog"сaria-modal="true", а заголовок изtitle— своим именем черезaria-labelledby.
Имена
Классы, команды, события и подписи каждый пакет кита отдаёт одним объектом из names.ts —
россыпи *_CLASS / *_COMMAND / CHANGE_EVENT больше нет. Модуль имён ничего не импортирует
и не имеет побочных эффектов: имена можно взять, не притаскивая стили и код контрола.
// коротким путём — без стилей и кода кита
import { UIKIT } from "@brandup/ui-kit/names";
// или из общего входа, если пакет и так подключён
import { UIKIT } from "@brandup/ui-kit";
UIKIT.POPUP.CLASS.ROOT; // "ui-popup" — сам попап
UIKIT.POPUP.CLASS.OPENED; // "ui-popup-opened"
UIKIT.POPUP.CLASS.EXPANDED; // "ui-popup-expanded" — на кнопке-инициаторе
UIKIT.POPUP.CLASS.BODY; // "body-popup-opened" — на <body>
UIKIT.POPUP.COMMAND.TOGGLE; // "ui-popup-toggle"
UIKIT.MODAL.CLASS.ROOT; // "ui-modal"
UIKIT.MODAL.CLASS.BODY; // "body-modal-opened"
UIKIT.MODAL.CLASS.ELEMENT; // части окна: BACKDROP, WINDOW, HEADER, TITLE, BODY, CLOSE
UIKIT.MODAL.COMMAND.CLOSE; // "ui-modal-close"
UIKIT.MODAL.TEXT.CLOSE; // подпись крестика
UIKIT.SCROLLABLE.CLASS; // "ui-scrollable"Так же устроены INPUT, DROPDOWN, TEXTBOX, RICHEDITOR и MESSAGEEDITOR — каждый в своём
пакете и с тем же коротким путём: @brandup/ui-dropdown/names, @brandup/ui-textbox/names
и так далее. Модуль имён ничего не импортирует, поэтому за одной строкой класса не приходят
ни стили, ни код контрола — этим путём пакеты кита берут имена друг у друга.
Соглашение
Длина имени показывает, откуда класс видно:
| Вид | Примеры | Кому принадлежит |
| --- | --- | --- |
| ui-<блок> и ui-<блок>-<часть> | ui-textbox, ui-textbox-input, ui-popup, ui-richeditor-toolbar | то, что видно вне корня контрола: сам корень, поле-носитель, окна и панели в body |
| короткое имя | modal-window, decorator, emoji-group, invalid, expanded | часть внутри своего корня и состояние — их всегда пишут вложенно |
| body-<...>-opened | body-popup-opened, body-modal-opened, body-dropdown-opened | состояние всей страницы, его ищут на body |
Полное имя обязано быть уникальным на странице — по нему элемент находят снаружи или пишет
в разметке сам хост (поле-носитель ui-textbox-input: класс на нём прячет поле до того, как
контрол соберётся). Внутри корня повторять имя пакета незачем; имя под-блока в коротком остаётся,
когда одного слова мало: toolbar-button, а не button.
Рядом с именами кит отдаёт отдельным входом признаки окружения — их берёт база ввода, которой общий вход не нужен:
import { IS_TOUCH_DEVICE, isCoarsePointer, hasUserScrolled, resetUserScroll } from "@brandup/ui-kit/env";Третий такой вход — @brandup/ui-kit/text с textTag(): он собирает элемент, вставляя строку
текстом, а не разметкой, и нужен всем, кто показывает чужой текст (подсветка сообщения, заголовки
окон) — тоже без остального кита.
Чем пакет отдаёт наружу, записано в его exports: сам пакет, ./names, ./env и ./text
(у кита), стили (./source/*.less, ./vars.less, ./build/*) и иконки. Путь внутрь source/*.ts
не разрешается — то, на что можно опираться, объявлено явно.
Слои
Всё, что показывается поверх страницы, — попап, модальное окно, раскрытый список DropDown —
встаёт в общий стек слоёв. Стек нужен там, где слои оказываются друг над другом: попап внутри
окна, список внутри попапа. Он решает три вещи, которые слой в одиночку решить не может:
Escape— слушатель один на весь стек, и нажатие закрывает только верхний слой. Слой, разобравшийся с клавишей сам (preventDefault), менеджер не трогает — так строка ввода ссылки в панели форматирования отменяет правку, не закрывая ничего вокруг.- Придержанная прокрутка — класс на
bodyсчитается по числу попросивших его слоёв и снимается по последнему. - Фокус — ловушка внутри слоя и возврат туда, откуда слой открыли.
Компонентам кита ничего для этого делать не нужно — они уже клиенты стека. Менеджер нужен своему слою: подсказке, боковой панели, галерее.
import { LayerManager } from "@brandup/ui-kit";
const layer = LayerManager.push({
close: () => panel.hide(), // обязательное: чем закрыть слой
element: panelElem, // корень слоя — для ловушки и проверки, внутри ли фокус
bodyClass: "body-my-panel-opened", // класс на body, пока слой открыт (префикс body- — соглашение кита)
trapFocus: true, // держать фокус внутри
returnFocus: buttonElem, // куда вернуть фокус (по умолчанию — откуда взяли; false — не возвращать)
closeOnEscape: true, // по умолчанию да
});
// закрывшись своим путём, слой снимает себя со стека
layer.release();| Член | Описание |
| --- | --- |
| LayerManager.push(options) | Поставить слой; возвращает Layer |
| LayerManager.closeTop() | Закрыть верхний слой; false — закрывать было нечего |
| LayerManager.closeAll() | Закрыть все слои сверху вниз — это делает UiKitMiddleware при навигации |
| LayerManager.count | Сколько слоёв открыто |
| Layer.isTop | Верхний ли слой — то есть ему ли достанется Escape |
| Layer.isOpened | Открыт ли слой |
| Layer.release() | Снять слой со стека, не закрывая его: зовётся из его собственного закрытия |
Возврат фокуса делается, только если к моменту закрытия он остался внутри слоя или потерялся
на body. Фокус, уведённый пользователем в другое место, — его выбор, и менеджер его не забирает.
Порядок слоёв
Кто над кем рисуется, задаётся переменными, а не числами по файлам пакетов:
| Переменная | По умолчанию | Слой |
| --- | --- | --- |
| --layer-popup | 1000 | Попап, панель форматирования редактора |
| --layer-dropdown | var(--layer-popup) | Раскрытый список DropDown |
| --layer-modal | 2000 | Модальное окно — над всем, что раскрыто на странице |
Прокручиваемые области
Класс ui-scrollable оформляет полосу прокрутки одинаково во всех компонентах кита — тонкая, без стрелок, со скруглённым ползунком, «парящая»: не касается краёв коробки. Над полосой курсор всегда обычная стрелка, а не курсор элемента (над полем ввода это была бы текстовая каретка).
import { UIKIT } from "@brandup/ui-kit";
const list = DOM.tag("div", { class: UIKIT.SCROLLABLE.CLASS });Настраивается CSS-переменными прямо на элементе:
| Переменная | По умолчанию | Что задаёт |
| --- | --- | --- |
| --scrollbar-size | 6px | Толщина видимой полосы (место под неё — толщина плюс --scrollbar-edge-inset с обеих сторон) |
| --scrollbar-thumb | #8696a0 | Цвет ползунка |
| --scrollbar-thumb-radius | 3px | Скругление ползунка |
| --scrollbar-thumb-min | 30px | Минимальная длина ползунка — на длинном содержимом он не вырождается в точку |
| --scrollbar-track-inset | 6px | Отступ вдоль полосы: её концы не доходят до углов коробки |
| --scrollbar-edge-inset | 6px | Отступ поперёк: полоса отходит от края коробки |
Оформление держится на ::-webkit-scrollbar: стандартные scrollbar-width/scrollbar-color не задаются намеренно — Blink при них отдаёт системную полосу со стрелками. Браузерам без ::-webkit-scrollbar (Firefox) стандартные свойства выдаются отдельным правилом; отступы и минимальная длина там не действуют — Firefox ими не управляется.
У элемента со скруглением полосу лучше уводить внутрь — на вложенный прокручиваемый элемент, иначе угол выглядит срезанным (так сделаны TextBox и MessageEditor).
Полоса прокрутки страницы
Место под полосой прокрутки страницы зарезервировано всегда — scrollbar-gutter: stable на html и body. Без этого страница дёргается по ширине каждый раз, когда прокрутку придерживают (body-popup-opened, body-modal-opened) или контент перестаёт её требовать: полоса пропадает, вьюпорт становится шире на её толщину.
Ценой этого на странице, которой прокрутка не нужна, остаётся пустая полоса. Если такое поведение не нужно, выключите резерв переменной:
:root {
--scrollbar-gutter: auto;
}Браузеры без поддержки scrollbar-gutter (Safari до 18.2) объявление игнорируют, но там, где полосы накладные (iOS, Android), ширина и так не прыгает.
Утилиты
import { IS_TOUCH_DEVICE, isCoarsePointer, hasUserScrolled, resetUserScroll } from "@brandup/ui-kit";
// true на touch-устройствах (мобильные, планшеты)
if (IS_TOUCH_DEVICE) { ... }
// true, когда основной указатель грубый — палец или стилус. В отличие от IS_TOUCH_DEVICE
// ноутбук с сенсорным экраном сюда не попадает: работают на нём мышью
if (isCoarsePointer()) { ... }
// true, если пользователь сам прокручивал страницу с момента её показа. Нужно отложенным
// действиям, которые двигают вид (автофокус контрола ввода). Считаются жесты — колесо, свайп,
// полоса прокрутки, автопрокрутка средней кнопкой, клавиши прокрутки, действие которых никто
// не отменил, — а не событие scroll: его поднимает и программная прокрутка
if (!hasUserScrolled()) { ... }
// забыть прокрутку — показана другая страница. При навигации это делает UiKitMiddleware,
// перед тем как новую страницу нарисуют: её контролы должны видеть уже чистый признак
resetUserScroll();Стили
Подключите стили через импорт в точке входа — они включаются автоматически вместе с пакетом.
box-sizing
Кит объявляет box-sizing: border-box на своих селекторах — поле ввода, кнопка, окно, попап —
и этим ограничивается. Разметка проекта остаётся на том, что в ней уже было: стили приезжают
с обновлением пакета, и переключать box-sizing всей странице значило бы менять размеры блоков,
про которые кит ничего не знает, — блок с width и padding тихо теряет ширину, а сломанное
видно далеко от компонентов кита.
Проекту, которому одно соглашение на всю страницу нужно, оно доступно отдельным файлом — подключается после кита и осознанно:
@import "@brandup/ui-kit/source/border-box.less";Переменные Less
Переопределите значения в файле uikit.vars.less в корне проекта перед сборкой. Файл с переменными по умолчанию: vars.less.
Полный список входов — имя, умолчание, CSS-токен и что задаёт — в TOKENS.md.
Файл темы можно разложить на несколько: @import в нём разворачивается — и рядом лежащий, и адрес в пакет (@import (reference) "@brandup/ui-kit/vars.less";). Имя, похожее на вход кита, но им не являющееся (@fontSize вместо @font-size), сборка называет вслух: молча оно не действовало бы вовсе.
Палитра
Верх vars.less — сырьё, из которого выведено всё остальное. Сайт перекрашивается им, а не перечислением сотни входов:
// цвет
@surface: #fff; // фон страницы, полей и всплывающих поверхностей
@ink: #222; // основной текст
@line: #aaa; // граница в покое
@line-hover: #666; // она же под курсором
@accent: #222222; // фирменный цвет: фокус, главная кнопка, переключатель
@accent-contrast: #fff;
@danger: #d64545;
// пропорции
@space: 8px;
@radius: 0; // поля и кнопки
@radius-overlay: 5px; // попап и модальное окно
@border-width: 1px;
@control-height: 46px; // общая у поля и кнопки — они стоят в одну строку
@control-padding-lr: 15px;Входы компонентов остаются входами: любой из них по-прежнему переопределяется поимённо, когда общего правила не хватает, и такое переопределение сильнее палитры. Оборотная сторона — тема, выраженная через @input-* и @button-*, палитре уже не подчиняется: @main-background: #fff в файле темы победит любой @surface.
Палитра отдаётся и в CSS — --surface, --ink, --accent, --space, --radius и остальные: правилу проекта, которому нужен фирменный цвет, не нужно заводить третье значение рядом.
Перекраска на живой странице
Входы выведены из палитры ссылкой на её CSS-токен, а не вычисленным значением: в :root уезжает --input-border-color: var(--line), а не #aaa. Производные поэтому считает браузер, и переопределение палитры двигает их без пересборки — тёмная тема умещается в семь значений:
:root[data-theme="dark"] {
--surface: #101014;
--ink: #e8e8ea;
--line: #3a3a42;
--line-hover: #6b6b78;
--accent: #4f8cff;
--accent-contrast: #0b0b0f;
--danger: #ff6b6b;
}За ними едет всё, что из них выведено: заливка и рамка полей, главная кнопка, переключатель, кольцо фокуса, попап и модальное окно. То же работает на поддереве — .admin-panel { --accent: #2f6feb } перекрашивает только его.
Тем же способом можно переопределить и отдельный вход — --button-radius: 22px, — но начинать стоит с палитры: вход, заданный поимённо, за ней уже не следует.
Чего в палитре нет и быть не может: границы адаптива (@adaptive-*). CSS-переменную не читает ни медиазапрос, ни less-арифметика, поэтому такие значения остались числами и меняются только пересборкой.
Тема отдельным файлом
Обычная сборка запекает тему в бандл, и сменить её нельзя иначе как пересобрав всё. build/build-theme.cjs собирает из тех же исходников отдельный theme.css — только блоки :root. Он подключается после бандла и перекрывает его умолчания:
const buildTheme = require("@brandup/ui-kit/build/build-theme.cjs");
await buildTheme({
theme: "uikit.vars.less",
variants: [{ selector: ':root[data-theme="dark"]', theme: "dark.vars.less" }],
out: "wwwroot/dist/theme.css",
});Вариант — дельта поверх основной темы: тёмная меняет цвета, а кегль и пропорции берёт у основной, и в файл попадает только то, что действительно отличается. Шести строк палитры хватает на два с лишним десятка токенов.
Чего это не даёт: значения в файле вычислены заранее. Переопределить в браузере одну --accent и ждать, что за ней поедут производные, нельзя — их считает less при сборке этого файла, а не браузер.
Адаптивные брейкпоинты:
@adaptive-desktop-small: 1650px;
@adaptive-notebook: 1550px;
@adaptive-notebook-small: 1370px;
@adaptive-tablet: 1030px;
@adaptive-tablet-small: 850px;
@adaptive-mobile: 500px;
@adaptive-mobile-small: 370px;Общие:
@main-background: #fff;
@font-size: 14px;
@font-family: system-ui, ...;
@font-weight: 400;
@line-height: 130%;
@text-color: #222;Заголовки (h1–h5):
@h-line-height: 130%;
@h1-font-size: 56px; @h1-font-weight: 600;
@h2-font-size: 50px; @h2-font-weight: 600;
@h3-font-size: 28px; @h3-font-weight: 600;
@h4-font-size: 22px; @h4-font-weight: 600;
@h5-font-size: 18px; @h5-font-weight: 600;SVG:
@svg-size: 20px;
@svg-fill: @text-color;
@svg-stroke: none;Блок контента (класс content-width):
@content-max-width: 1280px;
@content-min-width: 320px;
@content-padding-lr: 40px;Попапы:
@popup-fill: @main-background;
@popup-color: @text-color;
@popup-border-radius: 5px;
@popup-box-shadow: 0px 4px 8px 2px rgba(0,0,0,0.12);Поля ввода:
@input-height: 46px;
@input-padding-lr: 20px;
@input-fill: #fff;
@input-color: @text-color;
@input-font-size: 14px;
// Состояния: hover, focus, readonly, disabled, invalid, incorrect
@hover--input-border-color: #666;
@focus--input-border-color: #222;
@readonly--input-fill: #f7f7f7;
@disabled--input-fill: #eee;
@invalid--input-border-color: red;parseLessVars
Утилита для чтения Less-переменных в конфигурации webpack.
const parseLessVars = require("@brandup/ui-kit/build/parse-less-vars.cjs");
// Читает uikit.vars.less из корня проекта
const vars = parseLessVars();
// Или явно указать путь к файлу
const vars = parseLessVars("path/to/variables.less");
// { '@main-color': '#ff0000', '@font-size': '16px', ... }Использование в конфигурации less-loader:
{
loader: "less-loader",
options: {
lessOptions: {
modifyVars: parseLessVars(),
},
},
}