Шаблоны
Глава описывает, как компонент строит разметку: встроенный шаблон, внешний шаблон проекта и контракт внешнего модуля. Внешний шаблон позволяет проекту изменить вид компонента, не меняя его поведения.
Шаблон и templateContext
Шаблон — функция, которая получает данные и возвращает разметку Lit. Поведение остаётся в классе; шаблон определяет, что увидит человек.
Данные для шаблона готовит метод templateContext(). Шаблон читает уже
разрешённые значения: если компонент вычисляет значение в геттере (с учётом
имён для совместимости, языка страницы и значений по умолчанию),
templateContext() передаёт именно его, а не сырой атрибут.
Пример нарушения: если шаблон читает сырой атрибут type, а не разрешённое
значение kind, тег с kind="checkbox" рисует неактивный квадрат.
Перерисовка обновляет разметку на месте. Lit сравнивает новый результат шаблона с прежним и меняет только изменившиеся части, поэтому узлы сохраняются: фокус и выделение остаются на месте после перерисовки.
Что лежит в контексте шаблона. Контекст, который получает встроенный и внешний шаблон, состоит из:
| Ключ | Что это |
|---|---|
Ключи, которые возвращает templateContext() компонента |
Разрешённые значения. Ключ с именем геттера компонента равен значению этого геттера: context.size — то же, что component.size |
Объявленные свойства (static props) |
Значения, прочитанные через getProp (см. Как компонент читает свойство) |
component |
Сам элемент |
rootClass |
Дополнительные классы из атрибута root-class тега (по умолчанию пустая строка). Это не вычисленные классы компонента: шаблон строит свои классы сам и добавляет к ним rootClass |
rootStyle |
Стиль из атрибутов style и root-style тега. Сам тег не образует блока, поэтому шаблон ставит этот стиль на свой корень |
custom |
Значения, которые код передал компоненту методами вроде setAttributes() под ключами, которых компонент не объявил; они же лежат в контексте под своими именами |
Полный набор ключей конкретного компонента — его templateContext() и
страница компонента в справочнике.
Свой templateContext() заменяет контекст по умолчанию. Базовый
templateContext() возвращает this.createPropsTemplateContext(): объявленные
свойства, component, rootClass, rootStyle и custom. Если компонент
переопределяет метод и возвращает собственный объект, шаблон получает только
его ключи — и встроенный, и внешний шаблон этого компонента. Чтобы сохранить
стандартные ключи и добавить свои, верните
this.createPropsTemplateContext({ ...свои ключи }): свои ключи добавляются
последними и заменяют свойство с тем же именем. Объект только со
свойствами даёт this.getPropsContext() (см. Как компонент читает свойство).
Контекст кнопки. Шаблон <sf-button> получает такие ключи:
| Ключ | Тип | Что это |
|---|---|---|
component |
элемент | Сам <sf-button>; через него шаблон читает слоты: component.hasSlotContent('text'), component.getSlotContent('text'). Слоты text, icon-start (прежнее имя icon-left) и icon-end (прежнее icon-right) важнее атрибутов: содержимое слота icon-start встроенный шаблон ставит вместо иконки по имени, так же и для icon-end |
rootClass |
строка | Классы из атрибута root-class; шаблон добавляет их к своим |
rootStyle |
строка | Стиль из атрибутов style и root-style; шаблон ставит его на корень |
templateName |
строка | Имя шаблона из атрибута template, по умолчанию default |
size |
строка | Ступень размера из атрибута size, по умолчанию 1 |
appearance, type |
строка | Одно и то же значение вида: appearance или прежний атрибут type, по умолчанию default |
scheme |
строка | Цветовая схема, по умолчанию primary |
status |
строка | error или пустая строка; заданный статус сильнее схемы: цвет корня — sf-button--<status или scheme> |
textPosition |
строка | Выравнивание подписи, по умолчанию center; класс text-<значение> на контейнере подписи sf-button-text-container |
text |
строка | Подпись из атрибута text; содержимое слота text важнее её |
segment |
строка | Положение в группе кнопок или пустая строка |
icon |
строка | Имя иконки; шаблон ставит её по iconPosition на ту сторону, где не задана своя |
iconStart, iconEnd |
строка или null |
Сырые атрибуты icon-start (или icon-left) и icon-end (или icon-right), а не значение, выведенное из icon: null — атрибута нет, сторона берёт icon; пустая строка — атрибут задан пустым, иконки на этой стороне нет |
iconLeft, iconRight |
строка | Прежние имена тех же сторон; без атрибута — пустая строка |
iconPosition |
строка | start (по умолчанию) или end; шаблон принимает и left, right |
spacing |
строка | Плотность: compact, normal, comfortable или spacious, из атрибута spacing или прежнего tightness. Шаблон ставит класс spacing-<значение>, для normal класса нет. Отсутствующий атрибут и явное normal дают одно значение; различить их можно через component.hasAttribute('spacing') |
tightness |
строка | Та же плотность прежним именем: low, пустая строка, high, highest; класс по нему шаблон не ставит, берите spacing |
radius |
строка | default, square, rounded или пустая строка; класс radius-<значение> |
loading |
булево | Идёт загрузка |
disabled |
булево | Кнопка заблокирована |
nativeType |
строка | Значение type у <button>, по умолчанию button |
ariaLabel |
строка | Доступное имя из атрибута aria-label или пустая строка |
diagnostics |
массив строк | Замечания о плотности: invalid-spacing, invalid-tightness, spacing-overrides-tightness |
custom |
объект | Значения под необъявленными ключами |
Атрибуты disabled, aria-busy, type и aria-label корня шаблон рисует
сам: при loading кнопка получает disabled и aria-busy="true". После
отрисовки кнопка переносит на элемент .sf-button только атрибуты тега,
которые не являются её настройками, в том числе data-*. Внешний шаблон
кнопки сохраняет корень с классом sf-button; поведение (обработчики на теге)
остаётся у компонента.
Разметка встроенного шаблона. Встроенный шаблон для
<sf-button text="Save" icon="download" loading> рисует такую разметку; с неё
удобно начинать внешний шаблон:
<button type="button" class="sf-button sf-button--size-1 sf-button--default sf-button--primary flex items-cross-center loading sf-button-state-loading" disabled aria-busy="true">
<sf-icon icon="download" root-class="pointer-event-none" weight="400" aria-hidden="true"></sf-icon>
<span class="sf-button-text-container text-center pointer-event-none">Save</span>
</button>
Без загрузки классов loading sf-button-state-loading и атрибутов disabled,
aria-busy нет. Полосы загрузки рисует любой из двух классов, loading или
sf-button-state-loading. Кнопка без иконок добавляет контейнеру подписи класс
flex-1. Классы segment-…, spacing-…, radius-… и rootClass
добавляются к корню, когда заданы.
Встроенный и внешний шаблон
У каждого компонента есть встроенный шаблон default. Атрибут template не
ограничен этим значением: любое другое имя загружает внешний шаблон с этим
именем. Внешний модуль default/index.js встроенный шаблон не подменяет. Именованный внешний шаблон загружается отдельным модулем:
<script>window.SF_SMART_TEMPLATE_PATH = '/assets/project/smart-templates';</script>
<sf-button template="compact"></sf-button>
Запрашивается /assets/project/smart-templates/button/compact/index.js — имя
берётся из тега без префикса sf-. Для локального project-greeting имя
сохраняется полностью.
Папка шаблонов. Компонент ищет папку в таком порядке и берёт первое заданное значение:
window.SF_SMART_TEMPLATE_PATH;window.SFSmartTemplatePath;- статическое поле класса
static externalTemplateBasePath; у базового класса это/local/smart/templates.
Задавайте папку абсолютным адресом, например
new URL('./assets', document.baseURI).href: проверка файла и загрузка модуля
должны обращаться к одному адресу, а простой относительный путь модуль может
вычислить от файла библиотеки.
Глобальная переменная действует на все Smart-компоненты страницы, в том числе на компоненты проекта: если она задана, статическое поле класса не используется. Переменную читают при каждой отрисовке, поэтому её достаточно задать до отрисовки элемента с внешним шаблоном.
Стили шаблона. Рядом с модулем лежит index.css. Компонент сам
подключает его тегом <link data-sf-smart-css> в <head> и ждёт загрузки
стилей до загрузки модуля; файл подключается один раз на страницу. Такой файл
стоит вне каскадных слоёв, поэтому сильнее любого слоя Framework (см. Каскадные слои).
Чтобы стили шаблона подчинялись порядку слоёв проекта, оберните их в
@layer project { … }. Если index.css нет, шаблон работает без него, но
браузер один раз сообщает в консоли об ошибке 404 для этого файла. Чтобы
консоль оставалась чистой, положите рядом с модулем пустой index.css.
Пример внешнего шаблона
Пример подключает внешний шаблон compact двум компонентам: локальному
project-greeting и библиотечной кнопке <sf-button>. Оба шаблона — настоящие
JavaScript-модули с index.css в папке ./assets примера; папку задаёт
window.SF_SMART_TEMPLATE_PATH до отрисовки. Вторая кнопка рисуется шаблоном
compact: те же классы корня, подпись без внутреннего отступа.
<script>window.SF_SMART_TEMPLATE_PATH = new URL('./assets', document.baseURI).href;</script>
<project-greeting name="Рим" template="compact"></project-greeting>
<div class="flex gap-2 items-cross-center">
<sf-button text="Сохранить" appearance="outline" scheme="on-surface"></sf-button>
<sf-button text="Сохранить" appearance="outline" scheme="on-surface" template="compact"></sf-button>
<sf-button text="Скачать" appearance="outline" scheme="on-surface" template="compact"><i slot="icon-start" class="sf-icon" aria-hidden="true">download</i></sf-button>
</div>
<p id="template-log" role="status">Загружаются шаблоны compact…</p>
async function start() {
if (typeof window.SF?.loadSmartBase !== 'function') {
await new Promise((resolve, reject) => {
const ready = () => { clearTimeout(timeout); resolve(); };
const timeout = setTimeout(() => { window.removeEventListener('sf-loader-init', ready); reject(new Error('Loader не запустился')); }, 15000);
window.addEventListener('sf-loader-init', ready, { once: true });
});
}
await SF.loadSmartBase();
const { SfBaseElement, html } = SF.smart;
class ProjectGreeting extends SfBaseElement {
static externalTemplateBasePath = new URL('./assets', document.baseURI).href;
static get props() { return { name: { default: 'Гость' }, templateName: { attribute: 'template', default: 'default' } }; }
// createPropsTemplateContext keeps component, rootClass, rootStyle and the props.
templateContext() { return this.createPropsTemplateContext(); }
template() { return html`<p>Привет, ${this.getProp('name')}</p>`; }
}
ProjectGreeting.define('project-greeting');
const element = document.querySelector('project-greeting');
await element.whenRendered();
await SF.whenDefined('button');
const button = document.querySelector('sf-button[template="compact"]');
await button.whenRendered();
const greetingOk = Boolean(element.querySelector('strong'));
const buttonOk = Boolean(button.querySelector('.project-button-compact-text'));
document.querySelector('#template-log').textContent = greetingOk && buttonOk
? 'Внешние шаблоны compact подключены: приветствие и кнопка.'
: 'Показан резервный шаблон: проверьте путь к compact.';
}
start().catch(error => { document.querySelector('#template-log').textContent = error.message; });
export default function ({ html, nothing, context }) {
return html`<p class="project-greeting-compact p-2 bg-primary-container radius-2" style=${context.rootStyle || nothing}>Здравствуйте, <strong>${context.name}</strong>!</p>`;
}
// External template "compact" for the library <sf-button>: the same root
// classes and attributes as the built-in template, the text without its inner
// padding. Behaviour (handlers on the tag, attribute forwarding) stays with
// the component.
export default function ({ html, nothing, context, component }) {
const {
size, appearance, status, scheme, text, icon, iconStart, iconEnd, iconPosition,
segment, spacing, radius, textPosition,
loading, disabled, nativeType, ariaLabel, rootClass, rootStyle,
} = context;
// iconStart and iconEnd are the raw attributes: null when absent, '' when
// emptied on purpose. Only an absent side takes `icon`, by iconPosition.
const atEnd = ['end', 'right'].includes(iconPosition);
const start = iconStart ?? (icon && !atEnd ? icon : '');
const end = iconEnd ?? (icon && atEnd ? icon : '');
const label = component.hasSlotContent('text') ? component.getSlotContent('text') : text;
// A slotted icon wins over the icon name, as in the built-in template;
// icon-left and icon-right are the old slot names.
const slotted = (...names) => {
const name = names.find((slot) => component.hasSlotContent(slot));
return name ? component.getSlotContent(name) : null;
};
const startContent = slotted('icon-start', 'icon-left');
const endContent = slotted('icon-end', 'icon-right');
const classes = [
'sf-button', `sf-button--size-${size}`, `sf-button--${appearance}`, `sf-button--${status || scheme}`,
'flex', 'items-cross-center',
segment ? `segment-${segment}` : '',
// An external template writes the density as spacing-*; normal has no class.
spacing && spacing !== 'normal' ? `spacing-${spacing}` : '',
radius ? `radius-${radius}` : '',
loading ? 'loading sf-button-state-loading' : '',
rootClass,
].filter(Boolean).join(' ');
const textClasses = [
'sf-button-text-container', `text-${textPosition || 'center'}`, 'pointer-event-none',
start || end || startContent || endContent ? '' : 'flex-1', 'project-button-compact-text',
].filter(Boolean).join(' ');
const iconOf = (name) => (name ? html`<sf-icon icon=${name} root-class="pointer-event-none" weight="400" aria-hidden="true"></sf-icon>` : nothing);
// disabled, aria-busy, type and aria-label are the template's to render:
// the component forwards only attributes that are not its settings.
return html`<button type=${nativeType} class=${classes} style=${rootStyle || nothing}
?disabled=${disabled || loading} aria-label=${ariaLabel || nothing} aria-busy=${loading ? 'true' : nothing}>
${startContent ?? iconOf(start)}
${label ? html`<span class=${textClasses}>${label}</span>` : nothing}
${endContent ?? iconOf(end)}
</button>`;
}
/* The template's own class. This file sits outside the cascade layers, so it
wins over the Framework's button styles. */
.project-button-compact-text { padding-inline: 0; }
Для кнопки имя папки — имя тега без sf-: <папка шаблонов>/button/compact/index.js.
Для локального компонента имя папки — полное имя тега:
<папка шаблонов>/project-greeting/compact/index.js. Класс задаёт папку полем
static externalTemplateBasePath, а свойство templateName связывает с
атрибутом template; глобальная переменная сильнее этого поля.
Контракт внешнего модуля
export default function ({ html, nothing, context, component }) {
return html`<p aria-label=${context.label || nothing}>Здравствуйте, ${context.name}!</p>`;
}
export function destroy({ component }) { /* снять подписки и таймеры */ }
Модуль экспортирует функцию шаблона по умолчанию и, если нужно, хуки:
| Экспорт | Когда вызывается | Что получает |
|---|---|---|
default |
При каждой отрисовке; возвращает разметку | html, nothing, context, component, changedAttributes |
beforeRender |
Перед каждой отрисовкой, до default |
component, root, context, changedAttributes, html, nothing |
mapContext |
Перед default; возвращает контекст, который целиком заменяет исходный: верните {...context, ключ: значение}, а не только изменённые ключи. Пустой ответ оставляет контекст прежним |
context, component, html, nothing |
afterRender |
После отрисовки и загрузки стилей | component, root, context, changedAttributes, html, nothing |
destroy |
При смене шаблона и при удалении компонента | component, root, context, html, nothing |
Внешний шаблон повторяет разметку встроенного шаблона того выпуска, на котором закреплён проект, и сам за новыми выпусками не следует. При каждом переходе на новый выпуск сравните его с таблицей контекста и разметкой встроенного шаблона на странице компонента и проверьте записи журнала изменений об этом компоненте.
Безопасность и поведение при ошибке
- Интерполяция через
htmlэкранирует текст. Пользовательскую строку нельзя превращать в HTML. - Шаблон — доверенный код проекта. Путь к нему нельзя брать из ввода человека.
- Если именованный модуль не найден или не экспортирует функцию, компонент рисует встроенный шаблон. Проверяйте фактический вид, а не только отсутствие ошибок.
- Загрузка внешнего модуля асинхронна. Первая отрисовка ждёт модуль и его
index.css, поэтомуwhenRendered()выполняется уже после отрисовки проектным шаблоном. Если за время загрузки компонент удалён или выбран другой шаблон, устаревший результат не отображается. - Кеш модулей живёт в пределах страницы. При обновлении шаблонов меняйте версию пути и перезагружайте страницу. Этот кеш не связан с HTTP-кешем сервера.
Смотрите также
- Атрибуты, свойства и состояние — объявление и чтение свойств.
- Содержимое и слоты — содержимое и слоты.
- Поведение при ошибке — поведение при ошибках.