Воробьёв и Икс, Игрек, Зед

Cover Image for Книжка для ванили

Книжка для ванили

Издано в

О любви

Признаюсь в любви к бездушной вещице. Я люблю Сторибук. Пожалуй, у меня есть чувство, что я им пользовался всегда, с детства сего инструмента. Всегда ценил банальную возможность быстро и системно презентовать свою работу. В первую очередь самому себе же. Бывали у меня попытки попробовать и иные подобные инструменты, оных отчасти уже и нет, и попытки сотворить в стол похожее (в далёких нулевых, как вспомню). Единственный инструмент, помимо Сторибука, исполняющий подобную роль и хоть сколько-то задержавшийся в моей работе — это Лукбук, но это ограниченно-культурная рельсовая история.

В этой же заметке я сделаю малое, замечу для себя простую краткую историю подъёма Сторибука для ванильного фронтенда. Обычно Сторибук используется для более артефактно-сложных вещей — дизайн-система с Реакт-компонентами или Вью, что там ещё популярно в сию секунду. Тем не менее мы можем использовать инструмент и для работы с обычной "вёрсткой", ванильными Джаваскриптом и стилями. Отчасти, это будет не совсем чистым решением с точки зрения ванильности, так как Сторибук требует некоего сборщика кода (Вебпак, Вит, …). Но мы сыграем в эту игру не замечая сего недоразумения.

Сторибук явись

Для начала создадим директорию и проинициализируем проект

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',
    },
};

Кратко пропишем, что здесь происходит:

  1. Сторибук подхватывает историю stories/Button.stories.js;
  2. В истории:
    1. Импортируем фабрику компонента import {createButton} from './Button';;
    2. Перечисляем варианты порождения
      export const Primary = {
          args: {
              primary: true,
              label: 'Button',
          },
      };
      
    3. Собственно порождаем для отображения в книжке return createButton({label, ...args});;
  3. В компоненте stories\Button.js:
    1. Импортируем стили import './button.css';. И вот здесь замечу, что в ванильном окружении так не может работать, то есть конкретно этот import должен быть чем-то транспилирован. Ниже мы рассмотрим иной способ делать то же самое;
    2. В функции-объекте createButton собирается, исходя из аргументов, объект компонента класса HTMLButtonElement;
  4. В стилях компонент стилизуется с селектором по классу элемента. Соответсвующий класс устанавливается в фабрике.

Что-то похожее на стиль функциональных компонентов Реакта со своими особенностям без особых заморочек.

Собственно запускаем Сторибук командой, если ранее не запустился при генерации

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-файлы, не нужно анализировать реакцию браузеров на вещи стандартные. Почти. А Сторибук нам помощник в деле сбора ванили.