npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@brandup/ui-kit

v1.1.1

Published

Base UI kit: reset, typography, form field styles, PopupManager and app middleware.

Readme

@brandup/ui-kit

Build Status

Базовый пакет 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 | null

open() только показывает: повторный вызов по уже открытому попапу ничего не меняет — это нужно тому, кто показывает попап по внешнему событию или перерисовывает его содержимое. Закрытие повторным нажатием по кнопке — работа 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;

Заголовки (h1h5):

@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(),
        },
    },
}