Crafting components with Storybook

👋🏻

I'm Brian Hanson

@brianjhanson

Some confessions

Some confessions

I used this talk as motivation

Some confessions

I'm not an expert on Storybook

Some confessions

This is a "figuring things out" talk not one about well worn paths

What is Storybook?

What is Storybook?

Storybook is an open source tool for building UI components and pages in isolation. 

The dream

The dream

I wanted this on server rendered sites

The dream

I want to be able to add this to a site I'm already working on

The dream

I don't want to regret adding it later (or have to tear it out)

Just a dream

Storybook server

How is it different?

How is it different?

ƒ(x)

How is it different?

Fetch

Let's give it a shot

🤞

We need a project

We need a project

Unfamiliar

We need a project

Not too simple

https://github.com/craftcms/europa-museum​

Europa Demo Site

{% extends "_/layouts/site" %}

{% block inline_css %}
    {{ source ("_/#{entry.type.handle}-critical.min.css", ignore_missing = true) }}
{% endblock %}

{% block content %}
    {% if entry.heroImage|length %}
        {% set heroImage = entry.heroImage.one() %}

        {% do heroImage.setTransform({
            width: 1024,
            quality: 80,
            format: 'jpg',
        }) %}
    {% endif %}

    <article data-router-view="newsArticle" data-handle="{{ entry.type.handle }}">
        <div class="news-article-container" data-scroll-section>
            {% include '_/svg/arrowLeft' %}
            <a class="link-back" href="{{ url('news') }}">{{ 'Back to News'|t }}</a>
            <h5 class="hero-heading">{{ entry.title |typogrify }}</h5>
            <img class="hero-image" src="{{ heroImage.url }}" alt="{{ heroImage.title }}">
        </div>

        {% include "_/components/block-matrix" %}

    </article>
{% endblock %}
{% set contentBlocks = contentBlocks ?? null ? contentBlocks : entry.contentBlocks.all() | default([]) %}

{% if contentBlocks | length %}

    <section class="content-blocks">

        {% for theBlock in contentBlocks %}
            {% set theBlockName =  theBlock.type.handle %}
            {% set isFirst = loop.index == 1 %}

            {% include "_/components/blocks/" ~ theBlockName ignore missing with {
                'theBlock' : theBlock,
                "isFirst" : isFirst
            } %}
        {% endfor %}

    </section>

{% endif %}
{% set richText = theBlock.richText %}
{% set topBorder = theBlock.topBorder ? "topBorder" : "" %}
{% set layout = theBlock.layout %}
{% set narrowWidth = theBlock.narrowWidth ? "narrowWidth" : "" %}

{% if richText %}

    <div class="content-block rich-text-block {{ isFirst }} {{ topBorder }} layout-{{ layout }} {{ narrowWidth }}" data-scroll-section>
        <div class="rich-text">
            {{ richText | typogrify }}
        </div>
    </div>

{% endif %}

Getting set up

The pieces

Storybook

Server

npx storybook init -t server

Everyone loves an npm install

.storybook/main.js
module.exports = {
  "stories": [
    "../src/**/*.stories.mdx",
    "../src/**/*.stories.@(json)"
  ],
  "addons": [
    "@storybook/addon-links",
    "@storybook/addon-essentials"
  ],
  "framework": "@storybook/server"
}
export const parameters = {
  server: {
    url: 'https://europa-museum.ddev.site/storybook/preview',
  },
};
.storybook/preview.js

Config files

package.json
{
  "private": true,
  "scripts": {
    ...
    "storybook": "start-storybook -p 6006",
    "build-storybook": "build-storybook"
  },
  "devDependencies": { ... },
  "dependencies": { ... },
}

Scripts

Storybook

Register the route

use craft\events\RegisterUrlRulesEvent;
use craft\web\UrlManager;
use yii\base\Event;

Event::on(
    UrlManager::class,
    UrlManager::EVENT_REGISTER_SITE_URL_RULES,
    function(RegisterUrlRulesEvent $event) {
    	$event->rules = array_merge($event->rules, [
          'storybook/preview/<component:.+>' => 'storybook/stories/preview'
        ]);
    }
);
plugin.php
<?php

use craft\web\Controller;
use yii\web\Response;

class StoriesController extends Controller
{
    protected int|bool|array $allowAnonymous = ['preview'];

    public function beforeAction($action): bool
    {
        $this->response->getHeaders()
            ->add('Access-Control-Allow-Origin', '*')
            ->add('Access-Control-Allow-Credentials', 'true');

        return parent::beforeAction($action);
    }

    public function actionPreview(string $component = null): Response
    {
        $params = Craft::$app->request->getQueryParams();
        $vars = $this->normalizeParams($params);

        return $this->renderTemplate($component, $vars);
    }
}

Render the template

StoriesController.php

Server

Data

// Button.stories.js|jsx

import React from 'react';

import { Button } from './Button';

export default {
  /* 👇 The title prop is optional.
  * See https://storybook.js.org/docs/react/configure/overview#configure-story-loading
  * to learn how to generate automatic titles
  */
  title: 'Button',
  component: Button,
};

//👇 We create a “template” of how args map to rendering
const Template = (args) => <Button {...args} />;

//👇 Each story then reuses that template
export const Primary = Template.bind({});
Primary.args = {
   primary: true,
   label: 'Button',
};

A regular story file

{
  "title": "Components/Rich Text",
  "parameters": {...},
  "args": {...},
  "argTypes": {...},
  "stories": [...]
}

Server story file

https://craft-storybook-example.ddev.site/storybook/preview/_/components/blocks/richText
{
  "title": "Components/Rich Text",
  "parameters": {
    "server": {
      "id": "_/components/blocks/richText",
      "params": {
        "richText": "Lorem ipsum ..."
      }
    }
  },
  "args": {...},
  "argTypes": {...},
  "stories": [...]
}

Parameters

{
  "title": "Components/Rich Text",
  "parameters": {...},
  "args": {
    "topBorder": false,
    "layout": "imageLeft",
    "isFirst": false,
    "narrowWidth": true
  },
  "argTypes": {...},
  "stories": [...]
}

Arguments

{
  "title": "Components/Rich Text",
  "parameters": {...},
  "args": {...},
  "argTypes": {
    "layout": {
      "control": {
        "type": "select",
        "options": [
          "imageLeft",
          "imageFullWidth",
          "imageRight"
        ]
      }
    }
  },
  "stories": [...]
}

Argument Types

{
  "title": "Components/Rich Text",
  "parameters": {...},
  "args": {...},
  "argTypes": {...},
  "stories": [
    {
      "name": "Default",
      "args": {}
    }
  ]
}

Stories

{
  "title": "Components/Rich Text",
  "parameters": {...},
  "args": {...},
  "argTypes": {...},
  "stories": [
    {
      "name": "Default",
      "args": {}
    },
    {
      "name": "With Top Border",
      "args": {
        "topBorder": true
      }
    },
    {
      "name": "Image Left",
      "args": {
        "layout": "imageLeft"
      }
    }
  ]
}

Stories

{% set richText = theBlock.richText %}
{% set topBorder = theBlock.topBorder ? "topBorder" : "" %}
{% set layout = theBlock.layout %}
{% set narrowWidth = theBlock.narrowWidth ? "narrowWidth" : "" %}

{% if richText %}

    <div class="content-block rich-text-block {{ isFirst }} {{ topBorder }} layout-{{ layout }} {{ narrowWidth }}" data-scroll-section>
        <div class="rich-text">
            {{ richText | typogrify }}
        </div>
    </div>

{% endif %}
{% set richText = richText ?? theBlock.richText %}
{% set topBorder = topBorder ?? theBlock.topBorder ? "topBorder" : "" %}
{% set layout = layout ?? theBlock.layout %}
{% set narrowWidth = narrowWidth ?? theBlock.narrowWidth ? "narrowWidth" : "" %}
{% set isFirst = isFirst ?? false %}

{% if richText %}

    <div class="content-block rich-text-block {{ isFirst }} {{ topBorder }} layout-{{ layout }} {{ narrowWidth }}" data-scroll-section>
        <div class="rich-text">
            {{ richText | typogrify }}
        </div>
    </div>

{% endif %}

🎉

<link
  rel="stylesheet"
  href="https://europa-museum.ddev.site/assets/dist/css/site.css"
>
.storybook/preview-head.html
{
  "title": "Components/Rich Text",
  "parameters": {
    "server": {
      "id": "_/components/blocks/richText",
      "params": {
        "richText": "<h4>Sed eiusmod tempor encodidunt ut labore lore magna&nbsp;aliqua.</h4><p>Consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua, ut enim aden minim veniam, quis nostrud exerci­ta­tion ullamco laboris nisi ut aliquip ex ea modo consequat. Duis aute irure dolor in rep­re­hen­der­it in voluptate velit esse cillum dolore eu fugiat nulla pariatur excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est&nbsp;laborum.</p><figure><img src=\"https://europa-museum.ddev.site/assets/volumes/images/sensory-art-house-QPUao07JBlY-unsplash.jpg\" alt=\"\"></figure><p>Duis aute irure dolor in rep­re­hen­der­it in voluptate velit esse cillum dolore eu fugiat nulla pariatur excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. Sed ut per­spi­ci­atis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Velit esse cillum dolore eu fugiat nulla pariatur excepteur&nbsp;sint</p>"
      }
    }
  },
  "args": {...},
  "argTypes": {...},
  "stories": [...]
}

More components

{
  "title": "Components/Featured Entry",
  "parameters": {
    "server": {
      "id": "_/components/blocks/featuredEntry"
    }
  },
  "args": {
    "textColor": "#000",
    "title": "Featured entry title",
    "isFirst": false
  },
  "argTypes": {},
  "stories": [
    {
      "name": "Default",
      "args": {}
    }
  ]
}
_/components/blocks/featuredEntry.twig
{% set featuredEntry = theBlock.entry.one() ?? null %}
{% set textColor = theBlock.textColor | default('#000') %}
{% set title = featuredEntry.title %}
{% set heroImage = featuredEntry.heroImage.one() ?? null %}

{% if featuredEntry %}
    <div class="content-block featured-entry-block {{ isFirst }}" data-scroll-section>
        <div class="featured-entry-container">
            <a class="link no-underline" href="{{ featuredEntry.url }}" data-scroll data-scroll-speed="-3">
                <div class="title-container" data-scroll data-scroll-speed="2">
                    <h3 class="title {{ textColor }} underline">
                        {{ title }}
                    </h3>
                </div>
                {% if heroImage %}
                    <div class="hero-image" data-scroll data-scroll-speed="1">
                        {# with, classes: 'example' #}
                        {% include "_/components/picture" with {
                            asset: heroImage,
                            transform: 'base',
                            sizes: sizes ?? '100vw',
                            lazytransition: 'none'
                        } %}
                    </div>
                {% endif %}
            </a>
        </div>
    </div>
{% endif %}
{% set featuredEntry = featuredEntry ?? theBlock.entry.one() ?? null %}
{% set textColor = textColor ?? theBlock.textColor | default('#000') %}
{% set title = title ?? featuredEntry.title %}
{% set heroImage = heroImage ?? featuredEntry.heroImage.one() ?? null %}

{% if featuredEntry %}
    <div class="content-block featured-entry-block {{ isFirst }}" data-scroll-section>
        <div class="featured-entry-container">
            <a class="link no-underline" href="{{ featuredEntry.url }}" data-scroll data-scroll-speed="-3">
                <div class="title-container" data-scroll data-scroll-speed="2">
                    <h3 class="title {{ textColor }} underline">
                        {{ title }}
                    </h3>
                </div>
                {% if heroImage %}
                    <div class="hero-image" data-scroll data-scroll-speed="1">
                        {# with, classes: 'example' #}
                        {% include "_/components/picture" with {
                            asset: heroImage,
                            transform: 'base',
                            sizes: sizes ?? '100vw',
                            lazytransition: 'none'
                        } %}
                    </div>
                {% endif %}
            </a>
        </div>
    </div>
{% endif %}
{% set featuredEntry = featuredEntry ?? theBlock.entry.one() ?? null %}
{% set textColor = textColor ?? theBlock.textColor | default('#000') %}
{% set title = title ?? featuredEntry.title %}
{% set heroImage = heroImage ?? featuredEntry.heroImage.one() ?? null %}

{% if featuredEntry %}
    <div class="content-block featured-entry-block {{ isFirst }}" data-scroll-section>
        <div class="featured-entry-container">
            <a class="link no-underline" href="{{ featuredEntry.url }}" data-scroll data-scroll-speed="-3">
                <div class="title-container" data-scroll data-scroll-speed="2">
                    <h3 class="title {{ textColor }} underline">
                        {{ title }}
                    </h3>
                </div>
                {% if heroImage %}
                    <div class="hero-image" data-scroll data-scroll-speed="1">
                        {# with, classes: 'example' #}
                        {% include "_/components/picture" with {
                            asset: heroImage,
                            transform: 'base',
                            sizes: sizes ?? '100vw',
                            lazytransition: 'none'
                        } %}
                    </div>
                {% endif %}
            </a>
        </div>
    </div>
{% endif %}
{
  "title": "Components/Featured Entry",
  "parameters": {
    "server": {
      "id": "_/components/blocks/featuredEntry"
    }
  },
  "args": {
    "textColor": "#000",
    "title": "Featured entry title",
    "isFirst": false
  },
  "argTypes": {},
  "stories": [
    {
      "name": "Default",
      "args": {}
    }
  ]
}

Elements

{entry:107}

{%entry:107}

<?php

namespace brianjhanson\storybook\services;

use Craft;
use craft\base\Component;
use craft\base\ElementInterface;
use craft\helpers\StringHelper;

class StoriesService extends Component
{
    public function parseStoryRefs(string $str): ElementInterface|null
    {
        $elementService = Craft::$app->getElements();
        $core = StringHelper::trim($str, '{%}');
        $parts = array_pad(explode(':', $core), 2, null);

        $refHandle = $parts[0];
        $ref = $parts[1];
        $elementType = $elementService->getElementTypeByRefHandle($refHandle);

        $elementQuery = $elementService->createElementQuery($elementType)
            ->status(null);

        if ($ref) {
            $elementQuery->id($ref);
        }

        return $elementQuery->one();
    }
}
<?php

namespace brianjhanson\storybook\controllers;

use brianjhanson\storybook\Storybook;


/**
 * @author    Brian Hanson
 * @package   craft-storybook-example
 * @since     1.0.0
 */
class StoriesController extends Controller
{
    // ... additional functions

    /**
     * @param string $value
     * @return bool|string|ElementInterface|null
     */
    private function normalizeValue(string $value): mixed
    {
        if (StringHelper::startsWith($value, '{%')) {
            return Storybook::getInstance()->stories->parseStoryRefs($value);
        }
		
        // Other normalization
    }
}
{
  "title": "Components/Featured Entry",
  "parameters": {
    "server": {
      "id": "_/components/blocks/featuredEntry"
    }
  },
  "args": {
    "textColor": "#000",
    "title": "Featured entry title",
    "isFirst": false
  },
  "argTypes": {},
  "stories": [
    {
      "name": "Default",
      "args": {}
    }
  ]
}
{
  "title": "Components/Featured Entry",
  "parameters": {
    "server": {
      "id": "_/components/blocks/featuredEntry"
    }
  },
  "args": {
    "featuredEntry": "{%entry:95}"
    "textColor": "#000",
    "title": "Featured entry title",
    "isFirst": false
  },
  "argTypes": {},
  "stories": [
    {
      "name": "Default",
      "args": {}
    }
  ]
}

We're close

We're close

Inject new styles into preview

We're close

Reload when markup changes

Handling styles

Styles

<link
  rel="stylesheet"
  href="https://europa-museum.ddev.site/assets/dist/css/site.css"
>
.storybook/preview-head.html

Styles

<link
  rel="stylesheet"
  href="http://localhost:3002/assets/dist/css/site.css
>
.storybook/preview-head.html

Styles

The markup

The markup

😭

The problem

Potential Solutions

🔄

Potential Solutions

Browsersync

{% do story({...}) %}

{% set theBlock = theBlock ?? craft.matrixBlocks.type('richText').one() %}
{% set richText = richText ?? theBlock.richText %}
{% set topBorder = topBorder ?? theBlock.topBorder ? "topBorder" : "" %}
{% set layout = layout ?? theBlock.layout %}
{% set narrowWidth = narrowWidth ?? theBlock.narrowWidth ? "narrowWidth" : "" %}
{% set isFirst = isFirst ?? false %}

{% if richText %}

    <div class="content-block rich-text-block {{ isFirst }} {{ topBorder }} layout-{{ layout }} {{ narrowWidth }}" data-scroll-section>
        <div class="rich-text">
            {{ richText | typogrify }}
        </div>
    </div>

{% endif %}

Potential Solutions

Stories directly in twig

Potential Solutions

Copy story files

Move stories into a `src` directory

Point Storybook at a `dist` directory

Add node task that watches for changes to twig files, and copies the JSON files over

Potential Solutions

Twig UX Components?

Other Considerations

Other Considerations

CORs will be CORs

Other Considerations

Promising but needs more work

Recap

Recap


Uses Craft to
render templates

Recap


Uses Craft to
render templates


Uses Craft to
render templates


Minimal template modifications


Minimal template modifications

Recap


Uses Craft to
render templates


Uses Craft to
render templates


Minimal template modifications


Minimal template modifications


Easy to use and maintain

Thank you!

Pip

Millie

Footnotes

Crafting Components with Storybook

By brianjhanson

Crafting Components with Storybook

Storybook is a fantastic tool for UI development. It gives you a comprehensive suite of tools for developing and viewing your components in isolation. However, it can be very framework focused. This talk will explore how we can bring the ideas and tools of Storybook into the more traditional technology stack alongside Craft.

  • 174