Компоненты

sitelo-ui — библиотека компонентов для sitelo. Каждый компонент это функция, возвращающая строку HTML, поэтому он встраивается прямо в дерево javascript-to-html без прослойки: без компилятора, без рантайма, без гидратации. Что вы собрали, то и попадает в dist/.

Она поставляется вместе с sitelo, точка входа — sitelo/ui.

Быстрый старт

npm install sitelo javascript-to-html

Поместите styles() в head и вызывайте компоненты в body. Это вся настройка:

import { body, head, html, meta, title } from 'javascript-to-html'
import { styles, container, stack, heading, text, button } from 'sitelo/ui'

export default () => html({ lang: 'ru' },
  head(
    meta({ charset: 'utf-8' }),
    meta({ name: 'viewport', content: 'width=device-width, initial-scale=1' }),
    title('Мой сайт'),
    styles(),
  ),
  body(
    container({ size: 'md' },
      stack({ gap: 'md' },
        heading({ level: 1 }, 'Привет'),
        text({ variant: 'lead' }, 'Страница, собранная из компонентов.'),
        button({ href: '/docs' }, 'Читать документацию'),
      ),
    ),
  ),
)

Имена компонентов намеренно совпадают с тем, что они рендерят, поэтому некоторые из них — button, input, table, link, code, select, progress — конфликтуют с функциями-элементами javascript-to-html. Импортируйте библиотеку как пространство имён, когда нужны обе:

import * as ui from 'sitelo/ui'

ui.card(
  ui.cardHeader({ title: 'Маршрутизация', subtitle: 'На основе файлов' }),
  ui.cardBody(ui.text('src/about.ht.js становится /about.')),
  ui.cardFooter({ divided: true }, ui.button({ size: 'sm' }, 'Подробнее')),
)

Соглашение о вызове

Каждый компонент принимает необязательный объект пропсов, а за ним — детей, ровно как элемент javascript-to-html. Пропсы, которые компонент понимает, разбираются по имени; всё остальное попадает на отрендеренный элемент как атрибут, поэтому id, data-*, aria-* и атрибуты событий работают без того, чтобы библиотека их перечисляла:

button({ id: 'save', 'data-analytics': 'save-click', onclick: 'save()' }, 'Сохранить')
// <button type="button" id="save" data-analytics="save-click" onclick="save()" class="su-btn …">

Пропс, значение которого компонент не знает — variant: 'nonsense' — откатывается к значению по умолчанию, а не бросает исключение. Косметическая опечатка не должна ронять сборку.

Стили

styles() возвращает <link> на один файл, который браузер кэширует между страницами; плагин sitelo отдаёт его в dev и записывает в сборку под именем с хешем содержимого, которое можно отдавать с immutable. С { inline: true } он вместо этого возвращает <style> со всей таблицей стилей: примерно 11 кБ по сети, ни одного лишнего запроса и ничего, что могло бы потеряться в dist/:

import { styles } from 'sitelo/ui'

head(
  // вся таблица прямо в странице — ни одного запроса
  styles({ inline: true }),
)

Таблица стилей покрывает все компоненты библиотеки, а сайт использует горстку. pruneCss: true в sitelo.config.js записывает только те правила, которые сборка вообще может применить: плагин считывает классы su- со только что записанных страниц — и со скриптов рядом с ними, ради классов, которые тост или шаг добавляют позже, — и отбрасывает каждое правило, чей селектор называет класс, которого нет ни у чего. Решение принимается по результату, а не по импортам: button({ variant: 'soft' }) сохраняет .su-btn--soft, а простой button() — нет. Подключённая таблица урезается по всему сайту и получает новый хеш, каждый <link> переписывается соответственно; встроенная урезается под свою страницу. Дюжина компонентов укладывается в 2–3 кБ gzip. Dev-сервер отдаёт таблицу целиком; разметка, которую сборка никогда не видит, — например, серверного острова, — требует перечислить свои классы в keep:

export default {
  pruneCss: true,
  // или перечислите классы, которых сборка не видит; `*` — префикс
  // pruneCss: { keep: ['su-card', 'su-btn*'] },
}

Темы

Всё держится на пользовательских свойствах CSS в :root: пять палитр, шкала отступов, радиусы, типографика и тени. theme() пишет переопределения для них и принимает имена в camelCase (radiusMd → --su-radius-md), объекты палитр или буквальные пользовательские свойства:

import { styles, theme } from 'sitelo/ui'

head(
  styles(),
  // После styles(), чтобы победили эти значения.
  theme({
    primary: { base: '#5b5bd6', hover: '#4a4ac4', fg: '#ffffff' },
    radiusMd: '2px',
    fontSans: '"Inter", system-ui, sans-serif',
  }, {
    dark: { primary: { base: '#8f8ff0' } },
  }),
)

Тёмная тема определяется сама по prefers-color-scheme. Значение light или dark в data-theme или data-su-theme на любом предке перекрывает её — именно это и делает themeToggle():

import { styles, themeScript, themeToggle } from 'sitelo/ui'

head(
  themeScript(), // применяет сохранённый выбор до первой отрисовки
  styles(),
)

// …где угодно в body
themeToggle()

Или смените весь облик разом: styles({ preset: 'neumorphism' }) подключает таблицу стилей пресета сразу после основной, каждый компонент следует за ней, а theme() по-прежнему работает поверх. Вживую это видно на странице «Неоморфизм».

JavaScript, и как его мало

Большинству компонентов он не нужен. Модальное окно и панель — это элементы <dialog>, которые кнопка открывает через command, так что подложку, фокус, Escape и клик снаружи берёт на себя браузер. Аккордеон — это <details name>. Меню — <details>. Подсказки сделаны на CSS. Вкладки, панели которых переключаются на месте, — это группа радиокнопок: каждая вкладка это <label>, а показывается та панель, которая идёт за отмеченной радиокнопкой.

Скрипт действительно нужен трём вещам — и каждая забирает его сама:

Во входной файл добавлять нечего — импорт и есть атрибут события:

<!-- rendered by alert({ dismissible: true }) -->
<button class="su-alert-dismiss"
        onclick="import('/su/alert.js').then(m=>m.dismiss(this))">
  <svg class="su-icon" …></svg>
</button>

sitelo отдаёт эти модули из /su/ (или из /repo/su/ с --base /repo/) во время разработки и копирует в сборку только те, на которые ваши страницы действительно ссылаются. Каждый заметно меньше килобайта, ни один не загружается до первого взаимодействия, и до этого момента все компоненты рендерятся корректно: меню открываются и закрываются сами, а кнопка закрытия ничего не делает.

Исключение — toast(): на странице нет ничего, что вызвало бы его за вас:

// src/main.js
import { toast } from 'sitelo/ui/client'

Примеры

Формы сами связывают свои подписи, идентификаторы, подсказки и сообщения об ошибках:

import { card, cardBody, cardFooter, button, stack, textField, selectField } from 'sitelo/ui'

card(
  cardBody(
    stack({ gap: 'md' },
      textField({ label: 'Эл. почта', name: 'email', type: 'email', help: 'Никому не передаётся.' }),
      textField({ label: 'Сайт', name: 'site', startAdornment: 'https://', error: 'Это не URL.' }),
      selectField({ label: 'Тариф', name: 'plan', options: ['Бесплатный', 'Pro'], value: 'Pro' }),
    ),
  ),
  cardFooter({ divided: true }, button({ type: 'submit' }, 'Сохранить')),
)

Модальное окно — это <dialog>, а его триггером служит любая кнопка, чей commandfor указывает на его id:

import { button, modal } from 'sitelo/ui'

button({ commandfor: 'confirm', command: 'show-modal' }, 'Удалить…')

modal({
  id: 'confirm',
  title: 'Удалить эту страницу?',
  footer: button({ color: 'danger' }, 'Удалить'),
}, 'Это действие необратимо.')

Вкладки бывают двух видов — ссылки или панели:

// Вкладки-ссылки: по странице на вкладку, без единого скрипта.
tabs({ items: [
  { label: 'Документация', href: '/docs', active: true },
  { label: 'API', href: '/api' },
] })

// Вкладки с панелями: переключаются на месте, на группе радиокнопок и CSS.
tabs({ value: 'use', items: [
  { id: 'install', label: 'Установка', panel: code('npm install sitelo') },
  { id: 'use', label: 'Использование', panel: code("import * as ui from 'sitelo/ui'") },
] })

Таблицы принимают columns и rows, а функция render нужна там, где ячейке мало одного значения:

table({
  striped: true,
  columns: [
    { key: 'page', header: 'Страница' },
    { key: 'size', header: 'Размер', align: 'end' },
    { header: 'Статус', render: (row) => chip({ color: row.ok ? 'success' : 'danger' }, row.ok ? 'ok' : 'ошибка') },
  ],
  rows: pages,
})

Каталог examples/ui в репозитории рендерит все компоненты на одной странице — самый быстрый способ увидеть весь набор.

Справочник компонентов

Все экспорты, по группам. Пропсы типизированы: sitelo/ui поставляется с файлами .d.ts, поэтому редактор подсказывает variant, color и size и в JavaScript, и в TypeScript.

ГруппаКомпоненты
Раскладкаcontainer, stack, grid, divider, card, cardHeader, cardTitle, cardSubtitle, cardMedia, cardBody, cardFooter, aspectRatio
Типографикаtext, heading, link, code, inlineCode, kbd, visuallyHidden, prose
Поля вводаbutton, iconButton, buttonGroup, field, input, textarea, select, textField, textareaField, selectField, checkbox, radio, toggle, choiceGroup, slider, sliderField, toggleButton, toggleGroup
Отображение данныхavatar, avatarGroup, badge, chip, tooltip, table, list, listItem, figure
Обратная связьalert, progress, skeleton, toasts, empty
Навигацияbreadcrumbs, pagination, tabs, appBar, appBarNav, appBarSpacer, appBarActions, navLink, themeToggle
Оверлеиmodal, drawer, closeButton, menu, menuItem, menuSeparator, accordion, accordionItem, collapsible
Секцииhero, footer, siteFooter, footerColumn, footerBottom, stat, statGroup, steps, timeline, timelineItem, mockup
Стилиstyles, stylesheet, stylesUrl, theme, themeScript

Два имени отличаются от ожидаемых: переключатель называется toggle, потому что switch — зарезервированное слово и не может быть импортируемым именем; а стилизованная ссылка экспортируется и как link, и как textLink, чтобы уживаться с link из javascript-to-html. У table, input, select и progress есть тот же запасной выход: dataTable, textInput, selectField, progressBar.