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 aliqua.</h4><p>Consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua, ut enim aden minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea modo consequat. Duis aute irure dolor in reprehenderit 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.</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 reprehenderit 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 perspiciatis 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 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?
h/t Leevi Graham
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