
Книжка для ванили
О любви
Признаюсь в любви к бездушной вещице. Я люблю Сторибук. Пожалуй, у меня есть чувство, что я им пользовался всегда, с детства сего инструмента. Всегда ценил банальную возможность быстро и системно презентовать свою работу. В первую очередь самому себе же. Бывали у меня попытки попробовать и иные подобные инструменты, оных отчасти уже и нет, и попытки сотворить в стол похожее (в далёких нулевых, как вспомню). Единственный инструмент, помимо Сторибука, исполняющий подобную роль и хоть сколько-то задержавшийся в моей работе — это Лукбук, но это ограниченно-культурная рельсовая история.
В этой же заметке я сделаю малое, замечу для себя простую краткую историю подъёма Сторибука для ванильного фронтенда. Обычно Сторибук используется для более артефактно-сложных вещей — дизайн-система с Реакт-компонентами или Вью, что там ещё популярно в сию секунду. Тем не менее мы можем использовать инструмент и для работы с обычной "вёрсткой", ванильными Джаваскриптом и стилями. Отчасти, это будет не совсем чистым решением с точки зрения ванильности, так как Сторибук требует некоего сборщика кода (Вебпак, Вит, …). Но мы сыграем в эту игру не замечая сего недоразумения.
Сторибук явись
Для начала создадим директорию и проинициализируем проект
mkdir storybook-for-vanilla
cd storybook-for-vanilla
pnpm init
О pnpm
— это конечно же дело личных предпочтений, а могли бы быть npm
, yarn
.
В результате на данном шаге имеем в package.json
что-то вроде такого
{
"name": "storybook-for-vanilla",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"packageManager": "pnpm@10.13.1"
}
Далее интегрируем-генерируем инфраструктуру Сторибука
pnpm dlx storybook@latest init --type html
Я в процессе выбираю
— What configuration should we install?
— Minimal: Component dev only
Нам в нашем пруф-оф-концепт'е не нужно многое. И далее
— We were not able to detect the right builder for your project. Please select one:
— Vite
Не суть важно, Вит или Вебпак, задача решается в обоих случаях. Важен общий ход мысли.
Сторибучная ваниль
В этом варианте генерации Сторибук нам предложит своё видения ванильных компонентов.
А именно stories/Button.js
, наш компонент, а вернее фабрика компонента кнопки
import './button.css';
export const createButton = ({
primary = false,
size = 'medium',
backgroundColor,
label,
onClick,
}) => {
const btn = document.createElement('button');
btn.type = 'button';
btn.innerText = label;
btn.addEventListener('click', onClick);
const mode = primary ? 'storybook-button--primary' : 'storybook-button--secondary';
btn.className = ['storybook-button', `storybook-button--${size}`, mode].join(' ');
btn.style.backgroundColor = backgroundColor;
return btn;
};
Стили компонента stories/button.css
.storybook-button {
display: inline-block;
cursor: pointer;
border: 0;
border-radius: 3em;
font-weight: 700;
line-height: 1;
font-family: 'Nunito Sans', 'Helvetica Neue', Helvetica, Arial, sans-serif;
}
.storybook-button--primary {
background-color: #555ab9;
color: white;
}
.storybook-button--secondary {
box-shadow: rgba(0, 0, 0, 0.15) 0px 0px 0px 1px inset;
background-color: transparent;
color: #333;
}
.storybook-button--small {
padding: 10px 16px;
font-size: 12px;
}
.storybook-button--medium {
padding: 11px 20px;
font-size: 14px;
}
.storybook-button--large {
padding: 12px 24px;
font-size: 16px;
}
И истории использования stories/Button.stories.js
import {fn} from 'storybook/test';
import {createButton} from './Button';
// More on how to set up stories at: https://storybook.js.org/docs/writing-stories
export default {
title: 'Example/Button',
tags: ['autodocs'],
render: ({label, ...args}) => {
// You can either use a function to create DOM elements or use a plain html string!
// return `<div>${label}</div>`;
return createButton({label, ...args});
},
argTypes: {
backgroundColor: {control: 'color'},
label: {control: 'text'},
onClick: {action: 'onClick'},
primary: {control: 'boolean'},
size: {
control: {type: 'select'},
options: ['small', 'medium', 'large'],
},
},
// Use `fn` to spy on the onClick arg, which will appear in the actions panel once invoked: https://storybook.js.org/docs/essentials/actions#action-args
args: {onClick: fn()},
};
// More on writing stories with args: https://storybook.js.org/docs/writing-stories/args
export const Primary = {
args: {
primary: true,
label: 'Button',
},
};
export const Secondary = {
args: {
label: 'Button',
},
};
export const Large = {
args: {
size: 'large',
label: 'Button',
},
};
export const Small = {
args: {
size: 'small',
label: 'Button',
},
};
Кратко пропишем, что здесь происходит:
- Сторибук подхватывает историю
stories/Button.stories.js
; - В истории:
- Импортируем фабрику компонента
import {createButton} from './Button';
; - Перечисляем варианты порождения
export const Primary = { args: { primary: true, label: 'Button', }, };
- Собственно порождаем для отображения в книжке
return createButton({label, ...args});
;
- Импортируем фабрику компонента
- В компоненте
stories\Button.js
:- Импортируем стили
import './button.css';
. И вот здесь замечу, что в ванильном окружении так не может работать, то есть конкретно этотimport
должен быть чем-то транспилирован. Ниже мы рассмотрим иной способ делать то же самое; - В функции-объекте
createButton
собирается, исходя из аргументов, объект компонента классаHTMLButtonElement
;
- Импортируем стили
- В стилях компонент стилизуется с селектором по классу элемента. Соответсвующий класс устанавливается в фабрике.
Что-то похожее на стиль функциональных компонентов Реакта со своими особенностям без особых заморочек.
Собственно запускаем Сторибук командой, если ранее не запустился при генерации
pnpm run storybook
и обозреваем демонстрацию наших свежеиспеченных компонентов с их историями.
Ванильная ваниль
Но нас это не должно устроить, и мы пофантазируем сами. Для полноты картины нарисуем компонент на классах.
Командуем
mkdir -p src/Select
touch src/Select/Select.{js,css,stories.js}
где:
src/Select
— директория кастомизированного компонента селекта;src/Select/Select.js
— его класс;src/Select/Select.css
— его же стили;src/Select/Select.stories.js
— и история компонента.
Я предпочитаю "фрактальное" расположение исходного кода и посему кладу истории, тесты и прочее в
семантически-ограниченный контейнер-директорию src/Select
.
Поправим настройки Сторибука в файле .storybook/main.js
"stories": [
"../stories/**/*.mdx",
- "../stories/**/*.stories.@(js|jsx|mjs|ts|tsx)",
+ "../@(stories|src)/**/*.stories.@(js|jsx|mjs|ts|tsx)",
],
Истории из директории компонента будут благополучно подхватываться.
Поработаем над поведением компонента в src/Select/Select.js
. Наполним его следующим содержимым
import stylesheet from './CustomSelect.css' with {type: 'css'};
document.adoptedStyleSheets.push(stylesheet);
export class CustomSelect extends HTMLSelectElement {}
if (customElements.get('custom-select') === undefined) {
customElements.define('custom-select', CustomSelect, {extends: 'select'});
}
Что здесь происходит? Вспомним из варианта предложенного Сторибуком, фабрика-компонент Button.js
имел следующую строку
import './button.css';
В пункте 3.1 я описал это следующим образом
Импортируем стили
import './button.css';
. И вот здесь замечу, что в ванильном окружении так не может работать, то есть конкретно этотimport
должен быть чем-то транспилирован. Ниже мы рассмотрим иной способ делать то же самое;
Мы же импорт релевантных стилей обыграли строками
import stylesheet from './CustomSelect.css' with {type: 'css'};
document.adoptedStyleSheets.push(stylesheet);
Конструкция Import attributes относится к "Baseline 2025" и на данный момент (июль 25-го) поддерживается не всеми движками, но, можно сказать, большинством. Например, в Хроме работает из коробки. И, следовательно, нет необходимости в транспилировании импортов модулей со стилями. Для запаздывающих инструментов и браузеров мы подставим плечо плагинов, полифилов, шимов.
Для того чтобы Сторибук с включённым в него, в нашем случае, Витом могли обыграть эту историю мы добавим плагин. Прервём процесс Сторибука и выполним
pnpm add -D vite
pnpm add -D vite-plugin-standard-css-modules
Здесь мы добавили в зависимости Вит, что теоретически и не нужно, но практически так стоит сделать.
Уж поверьте на слово, и не будем вникать в неидеальность мира программного обеспечения. А что
касается vite-plugin-standard-css-modules
(подробнее
vite-plugin-...), то этот
плагин Вита нам необходим для корректной обработки случая импорта стилей "по-ванильному" именно
в процессе работы Сторибука для Сторибука. Об отстающих браузерах поговорим чуть позже, за рамками нашего
повествования о Сторибуке.
Также здесь мы поправим настройки этого плагина в .storybook\main.js
"framework": {
"name": "@storybook/html-vite",
"options": {}
},
+async viteFinal(config) {
+ const {mergeConfig} = await import('vite');
+
+ return mergeConfig(config, {
+ plugins: [standardCssModules({})]
+
+ });
+}
Про импорт обговорили. Поговорим о
document.adoptedStyleSheets.push(stylesheet);
Поскольку мы пытаемся расширять нативный элемент, то ограничены в работе с теневым DOM. Для упрощения демонстрационных манипуляций со стилями мы их сконструировали в стили документа.
В строке
export class CustomSelect extends HTMLSelectElement {}
мы расширили встроенный нативный элемент селекта и заявили о нашем компоненте CustomSelect
.
Далее в строках
if (customElements.get('custom-select') === undefined) {
customElements.define('custom-select', CustomSelect, {extends: 'select'});
}
мы зарегистрировали наш компонент-элемент-тег в реестре кастомизированных элементов.
Обозрим историю компонента src/Select/Select.stories.js
import {CustomSelect} from './CustomSelect';
export default {
title: 'CustomSelect',
render: () => `
<select name="pets" id="pet-select" is="custom-select" required>
<option selected disabled value hidden>Please choose an option</option>
<option value="dog">Dog</option>
<option value="cat">Cat</option>
<option value="hamster">Hamster</option>
<option value="parrot">Parrot</option>
<option value="spider">Spider</option>
<option value="goldfish">Goldfish</option>
</select>
`,
};
export const Default = {};
Достаточно простая история простого селекта, почти как на MDN. Из наших вебкомпонентных особенностей
присутствует глобальный атрибут is
. Конструкция is="custom-select"
заявляет браузеру, что текущий
нативный встроенный компонент-элемент-тег select
на самом деле кастомизирован, и его поведение
диктуется компонентом-элементом-тегом custom-element
.
С таким способом расширения компонентов-элементов-тегов и пользования атрибутом is
есть нюансы,
о которых я расскажу также позже.
Поглядим на стили
[is="custom-select"] {
--box-shadow: none;
--border-color: oklch(0.65 0 270);
--background-color: oklch(1 0 285);
--padding-length: .75rem;
--icon: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="size-5"><path fill-rule="evenodd" d="M5.22 8.22a.75.75 0 0 1 1.06 0L10 11.94l3.72-3.72a.75.75 0 1 1 1.06 1.06l-4.25 4.25a.75.75 0 0 1-1.06 0L5.22 9.28a.75.75 0 0 1 0-1.06Z" clip-rule="evenodd" /></svg>');
appearance: none;
font-size: 1rem;
line-height: 1.5;
font-family: system-ui, sans-serif;
color: oklch(0.27 0 260);
background-color: var(--background-color);
border: .05rem solid var(--border-color);
border-radius: .25rem;
outline: 0;
box-shadow: var(--box-shadow);
padding-top: var(--padding-length);
padding-bottom: var(--padding-length);
padding-left: var(--padding-length);
padding-inline-start: var(--padding-length);
padding-right: calc(var(--padding-length) + 1.5rem);
padding-inline-end: calc(var(--padding-length) + 1.5rem);
background-image: var(--icon);
background-position: center right var(--padding-length);
background-size: 1rem auto;
background-repeat: no-repeat;
}
[is="custom-select"]:invalid {
color: oklch(0.7 0 260);
}
[is="custom-select"] > option {
color: oklch(0.27 0 260);
}
[is="custom-select"] > option:disabled {
cursor: not-allowed;
}
[is="custom-select"]:focus {
--border-color: oklch(0.6 0.15 260);
--box-shadow: 0 0 0 .05rem oklch(0.6 0.15 260);
--background-color: oklch(1 0 90);
}
Очевидно, что мы привязываем правила по селектору [is="custom-select"]
. В стилях формируем
приятный компонент селекта. Немного обыгрываем микроповедение селекта в купе с предустановленными
нами значениями атрибутов в самой разметке, учитывая и нюансы Сафари.
Запускаем Сторибук
pnpm run storybook
и любуемся в Хроме
chrome.webm
А также в Сафари
safari.webm
Импорт с атрибутами в Нехромоидах
Выше мы обыграли импорт
import stylesheet from './CustomSelect.css' with {type: 'css'};
в Сторибуке, но заметили, что часть браузеров, в случае безучастия сборщиков а-ля Вит в процессе, необходимо поддержать полифилами.
Для чистоты эксперимента создадим файл index.html
в корне рабочей директории нашего проекта
touch index.html
с содержимым
<!doctype html>
<html lang="">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Storybook for Vanilla</title>
<meta name="description" content="Example of Storybook setup for vanilla HTML, CSS and JavaScript">
<link rel="icon" href="favicon.ico" sizes="any">
<link rel="icon" href="icon.svg" type="image/svg+xml">
<link rel="apple-touch-icon" href="icon.png">
<link rel="manifest" href="site.webmanifest">
<meta name="theme-color" content="#ffffff">
</head>
<body>
<!--suppress HtmlFormInputWithoutLabel -->
<select name="pets" id="pet-select" is="custom-select" required>
<option selected disabled hidden value>Please choose an option</option>
<option value="dog">Dog</option>
<option value="cat">Cat</option>
<option value="hamster">Hamster</option>
<option value="parrot">Parrot</option>
<option value="spider">Spider</option>
<option value="goldfish">Goldfish</option>
</select>
<script type="module" async src="src/CustomSelect/CustomSelect.js"></script>
</body>
</html>
Установим простой HTTP-сервер
pnpm add -D http-server
Добавим в package.json
строку скрипта запуска сервера
"scripts": {
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build",
+ "start": "http-server -c-1"
},
Запустим скрипт
pnpm run start
В браузере откроем вероятно http://localhost:8080/
.
Если ваш браузер из новых версий Хрома или его производных (на июль 25-го), то наш селект примет облагороженный вид. Для иных случаев дополним
pnpm add es-module-shims
где es-module-shims
— библиотека с полифилом, реализующим импорт с атрибутами, детальнее
es-module-shims.
Подключим полифил в index.html
<meta name="theme-color" content="#ffffff">
+ <script async src="node_modules/es-module-shims/dist/es-module-shims.js"></script>
</head>
Вуаля!.. Наблюдаем в браузер (например, Фаерфокс), и стили импортируются в компонент без транспиляции. Как и завещают стандартописатели.
Встроенные элементы в Сафари и глобальный атрибут is
Второй момент отложенный на потом — проблема расширения встроенных элементов в Сафари.
Разработчики Сафари и его движка не видят смысла в расширении стандартных встроенных элементов
— таких как select
и прочих. Желаете свои элементы — расширяйте
HTMLElement
. В принципе, доводы сафаристов-вебкитеров понятны, но нам же нужно больше свободы, и
мы любим брать ответственность на себя. А посему
pnpm add @ungap/custom-elements
и в index.html
<meta name="theme-color" content="#ffffff">
+ <script async src="node_modules/@ungap/custom-elements/es.js"></script>
<script async src="node_modules/es-module-shims/dist/es-module-shims.js"></script>
</head>
Подробнее о полифиле @ungap/custom-elements.
После этих действия атрибут is
в
<select name="pets" id="pet-select" is="custom-select" required>
и class CustomSelect extends HTMLSelectElement
в файле src/Select/Select.js
удобоваримы для
Сафари.
Ну вот и всё, ну вот и всё…
В качестве заключения похвалим себя: "Мы — молодцы! Приблизили светлое доброе будущее!..". Ванильные компоненты прекрасны. Ванильный код функционален и работоспособен. Современная веб-платформа позволяет нам многое, и не нужно штудировать DTD-файлы, не нужно анализировать реакцию браузеров на вещи стандартные. Почти. А Сторибук нам помощник в деле сбора ванили.