Setting up Single Directory Components(SDC), Storybook, TailwindCSS and Alpine JS in Drupal 10
The Drupal's Single Directory Components (SDC) module is the talk of the town these days. Together with Storybook, it can play a vital part in improving the workflow of Drupal developers and themes.
In this tutorial/workshop of two hours (video embedded), we are setting up Storybook's integration with Drupal 10, building a theme build system via Webpack for Tailwind CSS which is a utility-based powerful CSS framework and which can come handy in Drupal's component-based design system, use Alpine JS as a library in our theme.
After everything is setup properly, we will create some components in the storybook and then link them with their corresponding data structure in Drupal using Paragraphs. The components we will create are Accordion, Content Toggler, Button(atom), and Button Group(Molecule). In this blog post, we will write down all the steps we have gone through in the video on Youtube right from the start of Installing Drupal 10 on Lando.
Single Directory Components in Drupal - 9 Easy Steps
Creating a single directory component in Drupal involves several steps. I'll outline a basic guide for you in 9 steps:
Step 1: Installing Drupal via Lando
Create a folder anywhere on your laptop, name it SDC or anything of your liking, and then open it in the Terminal.
lando composer create-project drupal/recommended-project tmp && cp -r tmp/. . && rm -rf tmp
lando init and follow the wizard, choose Drupal 10.
lando start
lando composer require drush/drush
lando drush site:install --db-url=mysql://drupal10:drupal10@database/drupal10 -y
lando info - to check site url
Step 2: Creating New Drupal Theme
Create a custom folder in web/themes folder. Then create a custom theme from the starter kit generator command from inside the web folder (move inside web folder to run this command from your lando root).
php core/scripts/drupal generate-theme drupak_starterkit --path themes/custom
Enable and set of default the new theme from the Drupal admin screen or Drush.
Step 3: Installing Node js in Lando and some other required changes related to CORS
Lando init command above already has created .lando.yml file in the root of the folder We need to replace the contents of our .lando.yml with the following code.
Run lando rebuild after changing this file.
name: sdc
recipe: drupal10
config:
webroot: web
services:
appserver:
build_as_root:
- curl -sL https://deb.nodesource.com/setup_16.x | bash -
- apt-get install -qq -y nodejs
- chown -R www-data /usr/lib/node_modules
- chown -R www-data /usr/bin
- npm install --silent -g npm
run_as_root:
- "a2enmod headers"
- "service apache2 reload"
overrides:
ports:
- 6006:6006
- 6007:6007
tooling:
node:
service: appserver
npm:
service: appserver
npx:
service: appserver
yarn:
service: appserver
Step 4: Install the core sdc module and the contrib cl_server module
drush pm:enable sdc
composer require "drupal/cl_server:^2.0.0@beta"
drush pm:enable --yes cl_server
Step 5: CORS Settings
Use the following cors setting in development.services.yml
cl_server.development: true
cors.config:
enabled: true
allowedHeaders:
["Origin", "X-Requested-With", "Content-Type", "Accept", "*"]
allowedMethods: ["*"]
allowedOrigins: ["*"]
exposedHeaders: false
maxAge: false
supportsCredentials: false
Step 6: Create Package.json file in the Lando root folder for Storybook and install Storybook
In the root of your lando directory run
lando npm init
and go through the wizard with all default answers.
Add the following to the created package.json file. This will install Webpack based storybook and Lullabots storybook addon for the Cl_server module.
"devDependencies": {
"@lullabot/storybook-drupal-addon": "^2.0.1",
"@storybook/addon-essentials": "^7.4.1",
"@storybook/addon-links": "^7.4.1",
"@storybook/blocks": "^7.4.1",
"@storybook/server": "^7.4.1",
"@storybook/server-webpack5": "^7.4.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"storybook": "^7.4.1"
}
And in the script key add the following
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build"
The complete contents of the package.json are as follows after this change.
{
"name": "drupalstorybook",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build"
},
"author": "Drupak",
"license": "ISC",
"devDependencies": {
"@lullabot/storybook-drupal-addon": "^2.0.1",
"@storybook/addon-essentials": "^7.4.1",
"@storybook/addon-links": "^7.4.1",
"@storybook/blocks": "^7.4.1",
"@storybook/server": "^7.4.1",
"@storybook/server-webpack5": "^7.4.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"storybook": "^7.4.1"
}
}
Now when the package.json has been populated as per our wish, run the following command from the Lando root.
lando npm install
Next add a folder .storybook in the root of your Lando folder. In it create two files main.js and preview.js
The contents of main.js are
/** @type { import('@storybook/server-webpack5').StorybookConfig } */
const config = {
stories: [
"../web/themes/**/components/**/*.stories.@(json|yml)",
],
addons: ["@storybook/addon-links", "@storybook/addon-essentials", "@lullabot/storybook-drupal-addon"],
framework: {
name: "@storybook/server-webpack5",
options: {},
},
docs: {
autodocs: "tag",
},
};
export default config;
This line of code
stories: [
"../web/themes/**/components/**/*.stories.@(json|yml)",
],
in main.js informs storybook about the location of stories in the Drupal codebase, here we have specified theme only, but if your sdc components are coming from modules, so you would need to specify that path too.
And the contents of preview.js are
/** @type { import('@storybook/server').Preview } */
const preview = {
parameters: {
actions: { argTypesRegex: "^on[A-Z].*" },
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/,
},
},
server: {
// Replace this with your Drupal site URL, or an environment variable.
url: 'https://sdc.lndo.site/',
},
},
globals: {
drupalTheme: 'drupak_starterkit',
supportedDrupalThemes: {
drupak_starterkit: {title: 'Drupak StarterKit'},
bartik: {title: 'Bartik'},
claro: {title: 'Claro'},
seven: {title: 'Seven'},
},
},
};
export default preview;
Step 7: Create our first component, an atom named Button
We need to create at least one component in our theme or module to run storybook instance. For this create a folder and name it “components” inside the root of our theme. To arrange components into atoms and molecules, create a folder called Atom or atoms and create a button folder inside it. This will be our button component.
When the name of the component is button, it should have button.component.yml which provides metadata for sdc in Drupal, button.twig provides the actual markup, button.stories.json provides metadata and necessary information for the storybook instance, button.css and button.js files provide necessary css or js needed for the component.
Contents of button.components.yml
name: Atoms/Button
props:
type: object
required:
- linkText
properties:
linkText:
type: text
title: The link Text
description: Text to click on.
link:
type: text
title: The URL of the button
description: The button URL.
buttonType:
type: text
title: The button type
description: Button is primary or secondary.
The contents of button.stories.yml are
title: Atoms/Button
argTypes:
linkText:
control: text
stories:
- name: Primary Button
args:
linkText: Welcome
buttonType: primary
link: https://www.google.com
- name: Secondary Button
args:
linkText: Welcome
buttonType: secondary
link: https://www.drupak.com
While button.component.yml is important for Drupal's sdc, button.stories.yml is needed by storybook to display this component alongside its dummy data which is being provided by the stories key in button.stories.yml.
The content of the button.twig is simple one, just this line of code
<a href="{{ link }}" class="sdcbutton {{ buttonType }}">{{ linkText }}</a>
These three files are the most important ones, the rest are .js and .css files, which you can add yourself for custom styling or interactivity. The beauty of this setup is, that you can all your js in behavior.js and storybook will understand and pick tit. After making all this run the following command to spin up storybook instance.
lando npm run storybook.
This will open storybook instance where can see the button component.
Hurrah, we have setup Storybook finally.
Step 8: Setting up the Tailwind CSS build system in our theme
Now we are going to install Tailwind CSS and its related modules. Start with the following command to setup package.json file in the root of our drupak_starterkit theme
lando npm init
Then run
lando npm install tailwindcss@3 --save-dev
Now we will configure Tailwind.
Create a file and name it tailwind.config.js in the root of the theme. Add the following code to this file
module.exports = {
mode: 'jit',
content: [
'./templates/**/*.html.twig',
'./templates/*.html.twig',
'./components/**/*.twig',
'./components/**/*.stories.json',
'./components/**/*.stories.yml'
]
}
Now create a file main.css inside the css directory of the drupak_starterkit theme and add the following
@tailwind base;
@tailwind components;
@tailwind utilities;
Now add postcss and autoprefixer via npm using the following command
Lando npm install postcss autoprefixer --save-dev
Then create a file in the root of the theme and name it postcss.config.js and add the following code to it. It will make tailwindcss compiled for us
let tailwindcss = require('tailwindcss');
module.exports = {
plugins: [
tailwindcss('./tailwind.config.js'),
require('autoprefixer')
] }
Now let us bundle all this via webpack
lando npm install webpack webpack-cli postcss-loader mini-css-extract-plugin css-loader --save-dev
Next add a main.js file inside js folder in the theme which will be used as an entry point for web pack bundle, which is just a css import for now of the main.css in css folder. Add the following code to main.js file
import "../css/main.css";
Next, we will configure webpack by creating a webpack.config.js file in the root of the theme and add the following code.
const path = require("path");
const MiniCssExtractPlugin = require("mini-css-extract-plugin");
module.exports = {
entry: "./js/main.js",
devtool: 'source-map',
mode: 'production',
output: {
filename: "main.js",
path: path.resolve(__dirname, "./build")
},
module: {
rules: [
{
test: /\.css$/,
use: [
MiniCssExtractPlugin.loader,
"css-loader",
"postcss-loader",
] }
] },
plugins: [
new MiniCssExtractPlugin({
filename: "main.css",
}),
] };
Now add the following to the scripts section of the main theme package.json so that we can build our code.
"build": "webpack --config webpack.config.js",
"build:dev": "webpack --config webpack.config.js --mode development",
"start": "webpack --watch",
"start:dev": "webpack --watch --mode development"
Now when we run “lando npm run build:dev” it will build our assets i.e css file containing all our css and tailwind css.
Next let us add this main.js and main.css to a main library of our theme.
Add this to drupak_starterkit.libraries.yml
main:
version: VERSION
js:
build/main.js: {}
css:
theme:
build/main.css: {}
And then the following code to the libraries key in drupak_starterkit.info.yml
- drupak_starterkit/main
Congratulations, we have setup Tailwind CSS in our theme.
Step 9: Setting up Alpine Js in the theme library
We will setup Alpine js, a light weight js library which goes very well with TailwindCss as a theme based library as we may need it all over our site, so lets do it now. Add the following to the libraries.yml file of our theme
alpine:
remote: https://alpinejs.dev
version: 3.13.3
license:
name: MIT
url: https://github.com/alpinejs/alpine/blob/main/LICENSE.md
gpl-compatible: true
js:
https://cdn.jsdelivr.net/npm/alpinejs@3.13.3/dist/cdn.min.js:
{ type: external, minified: true }
then in .info file add the following in the libraries section after the entry for main library created above.
- drupak_starterkit/alpine
That's it. We have successfully added Alpinejs to our theme.
Next we will create some components. Accordions, Button Group, and Toggler. Our main focus is on setting the components from Storybook's perspective and the Drupal one. The respective .js and .css files wont be displayed here as that is plain old stuff for frontend designers. The use of Alpine js and TailwindCSS even further reduces the need of these two files .js and .css as we can directly use css classes and alpine js in the twig file of the component.
Accordion Component:
Add a folder in the components folder and name it accordion.
Contents of the accordion.components.yml
name: Accordion
props:
type: object
required:
- color
properties:
maintitle:
type: text
title: Main Title of the ACCORDION
description: The Main title of the accordion to click on.
color:
type: string
title: Color
description: "Color of the accordion."
slots:
# Content to display in the accordion.
accordion:
title: Content of the accordion.
description: The whole accordion
“maintitle” and the “color” attributes in this yml are the variables to be used in .twig file of the component, and slots has a variable called accordion, which can be used to large amount of render markup from drupal or nested items. In this case, it can be used to render nested paragraphs items from Drupal.
Contents of accordion.stories.json
For storybook, unlike the button component where .stories.yml was used for storybook, we can use .stories.json also
{
"title": "Accordion",
"args": { "label": "Accordion" },
"argTypes": {
"title": { "control": "text" },
"accordion": { "control": "text" }
},
"tags": ["autodocs"],
"stories": [
{
"name": "Dark",
"args": {
"color": "dark",
"maintitle": "This is title 1",
"accordion": "<div class=\"accordion\"><div class=\"accordion__intro\"><h4>Hello Shaukat</h4></div><div class=\"accordion__content\">This is accordion data Azmat</div></div><div class=\"accordion\"><div class=\"accordion__intro\"><h4>Hello Shaukat</h4></div><div class=\"accordion__content\">This is accordion data Azmat</div></div>"
}
},
{
"name": "Light",
"args": {
"color": "light",
"maintitle": "This is title 2"
}
}
]
}
The accordion key inside the stories section provides dummy “slots” input for the accordion variable to the Storybook.
Contents of Accordion.twig
This is the main twig file of the component. Here is its code.
<div class="main--title">
{{ maintitle }}
</div>
<div class="accordion_wrapper {{ color }}">
{% block accordion %}{% endblock %}
</div>
The variables defined in accordion.components.yml namely maintitle, color and accordion are used here. The accordion variable in the slots is printed as a block. From Drupal's template it will then be replaced by the usage of embed in Drupal's twig file, in our case the accordion paragraph type twig file.
Drupal's datastructure for Accordion and the file/s
We have gone ahead and created two paragraph types. One named accordion item, and the other as accordion. Accordion paragraph type refers the accordion item paragraph type, as an accordion can have more than one items.
Accordion item paragraph type fields are.
Accordion Item Description Text (formatted, long)
Accordion title Plain Text
Accordion paragraph type fields are.
Color Mode List Text (dark or light)
Accordion Item Entity reference revisions
Reference type: Paragraph
Paragraph type: Accordion Item
The accordion item field refers to the accordion item paragraph type.
Next we will create a paragraphs/paragraph—accordion.html.twig in the themes template folder
The contents of it are as follows.
{%
set classes = [
'paragraph',
'paragraph--type--' ~ paragraph.bundle|clean_class,
view_mode ? 'paragraph--view-mode--' ~ view_mode|clean_class,
not paragraph.isPublished() ? 'paragraph--unpublished'
]
%}
{% block paragraph %}
<div{{ attributes.addClass(classes) }}>
{% block content %}
{% embed 'drupak_starterkit:accordion' with {'color': paragraph.field_color_mode[0].value,'maintitle': content.field_accordion_main_title.0 } %}
{% block accordion %}
{% for key, item in content.field_accordion_item %}
{% if key|first != '#' %}
<div class="accordion">
<div class="accordion__intro">
<h4>{{ item['#paragraph'].field_accordion_title[0].value|raw }}</h4>
</div>
<div class="accordion__content">
{{ item['#paragraph'].field_accordion_item_description[0].value|raw }}
</div>
</div>
{% endif %}
{% endfor %}
{% endblock accordion %}
{% endembed %}
{% endblock content %}
{% endblock paragraph %}
The way we include sdc component is by specifying the theme or module where the component exists so in this case
“drupak_starterkit:accordion' is used inside the embed tag. We are also passing the color variable from the colormode paragraph field and also the maintitle paragraph field which we later on created as well so that the accordion can have a section title also.
The code {% block accordion %} is used to replace the components “accordion” block from the components twig file. Here we are looping through to get all the inner paragraph “accordion item” values and send as a dump of html as slot defined in the components.yml file. I personally find it a bit strange as the component html depends upon the raw output or dump of the paragraphs output as slot. In the buttongroup component we will do it differently so that the markup of the component is totally isolated from the Drupal's twig templates.
If we now create content with this paragraph type, it will be exactly like the one we see in storybook.
The Toggler Component.
In order to demonstrate how we can use Alpine js in Drupal and storybook, I created this component. We don't need to add our own js inside .js file in the component to make the functionality of clicking a text and revealing the content underneath it.
Create a folder inside the components folder in our theme, name it toggler.
Contents of toggler.component.yml:
name: Toggler
props:
type: object
required:
- linkText
properties:
linkText:
type: text
title: The link Text
description: Text to click on.
slots:
# Content to display in the accordion.
content:
title: Content of the toggle button.
description: Content of the toggle button.
LinkText variable will be used to create the link to reveal the content inside it, and slots content variable will be used to send content from Drupal's paragraph template as rendered markup.
Contents of toggler.stories.yml:
title: Toggler
argTypes:
linkText:
control: text
stories:
- name: Welcome Text
args:
linkText: Welcome
content: Welcome to THE World
- name: Welcome Two Text
args:
linkText: Welcome 2
content: "Welcome to Pakistan"
This will demo the functionality of the component in storybook, so when Welcome is clicked, Welcome to the World will be revealed and so on.
Contents of the toggler.twig file
<div x-data="{ open: false }">
<button class="btn" x-on:click="open = ! open">{{ linkText }}</button>
<div x-show="open">
{% block content %}{% endblock %}
</div>
</div>
This is the twig file responsible for the output of the component. Notice the Alpine js code inside the tags. This is all what we need to make this toggling of the content. The block content will be replaced by the output sent from Paragraph type template in Drupal after embedding this twig file there.. The linkText variable will be passed from the source file to this twig file as normal variable.
Drupal's Datastructure and Paragraph file.
From Drupal's side, we need a paragraph type, let us call it toggler too.
Paragraph type: Toggler
Fields:
We have two fields, one is plain text field which will be used for the link to click on to reveal content, and one is paragraph reference field, named toggle content, so essentially it can have any nested paragraph bundles inside it.
Toggle Link Text Plain Text
Toggle Content Entity reference revisions
Reference type: Paragraph
Paragraph type: Accordion, Image, Button, ButtonGroup
In the themes template folder create a file named paragraph—toggler.html.twig and add the following code.
{%
set classes = [
'paragraph',
'paragraph--type--' ~ paragraph.bundle|clean_class,
view_mode ? 'paragraph--view-mode--' ~ view_mode|clean_class,
not paragraph.isPublished() ? 'paragraph--unpublished'
]
%}
{% block paragraph %}
{% set linkText = paragraph.field_toggle_link_text[0].value|raw %}
{% set content = content.field_accordion_item %}
{% if paragraph.field_button_type is not empty %}
{% set buttonType = paragraph.field_button_type[0].value|raw %}
{% endif %}
<div{{ attributes.addClass(classes) }}>
{% embed 'drupak_starterkit:toggler' with {'linkText': linkText} %}
{% block content %}
{{ content }}
{% endblock content %}
{% endembed %}
{% endblock paragraph %}
Here we are passing the rendered nested paragraph field as the “slots content” by embedding the drupak_starterkit:toggler component.
The Button Component:
The button component briefly discussed while we were still setting up storbybook can serve as “Atom” in our storybook components. It is just the style a link can be displayed in. The files of the button component have already been mentioned above, now let us create its Drupal counterpart.
The Drupal part of Button component.
Paragraph type: Button.
Fields:
Button Type List(Text) (primary or secondary)
Link Link
Contents of button paragraph template:
{% block paragraph %}
{% set link = content.field_link.0['#url'].toString %}
{% set linkText = content.field_link.0['#title'] %}
{% if paragraph.field_button_type is not empty %}
{% set buttonType = paragraph.field_button_type[0].value|raw %}
{% endif %}
<div{{ attributes.addClass(classes) }}>
{% block content %}
{% include 'drupak_starterkit:button' with {'buttonType': buttonType,'linkText': linkText, 'link': link } %}
{% endblock content %}
{% endblock paragraph %}
Here we are including the drupak_starterkit:button component and passing it the variables needed by that template. No need of the embed and slots, as we are not using them here.
The Button Group Component.
This is the most favorite of my components in all of these, because it is totally separate from Drupal the way we have set it up. Let us see how is it working.
Inside the molecules directory in our components folder, we will create a new folder and call it buttongroup.
Contents of the buttongroup.component.yml
name: Molecules/ButtonGroup
props:
type: object
properties:
buttons:
type: text
title: The links
description: Links
Though the buttons variable technically is an array here, but I have used it as type text. I am sure there will some more accurate type for arrays in components.yml files but I have used this one for now.
Contents of buttongroup.stories.yml
title: Molecules/ButtonGroup
argTypes:
buttons:
control: text
stories:
- name: Button Group
args:
buttons:
- buttonType: primary
link: /hello
linkText: Hello
- buttonType: primary
link: "https://www.google.com"
linkText: Google
- buttonType: secondary
link: "http://www.drupak.com"
linkText: Drupak
- buttonType: primary
link: "http://www.cnn.com"
linkText: CNN
Different from other components where we had used slots for nested components, here we are using “buttons” as an array. Storybook renders this sample array for its dummy content while the components twig file uses this array from Drupal's paragraph when it passes it to component.twig. This way there is complete separation of code and markup from Drupal's paragraph and components twig file. We will see it in a moment.
Contents of the buttongroup.twig file
<div class="buttongroup--wrapper">
{% for button in buttons %}
{% include 'drupak_starterkit:button' with {'buttonType': button.buttonType,'linkText': button.linkText, 'link': button.link, 'real': true } %}
{% endfor %}
</div>
Here we are including the atom button component and passing values by looping through the array buttons.
The Drupal part for buttongroup component
Paragraph Type: Buttongroup
Fields:
Button Entity reference revisions
Reference type: Paragraph
Paragraph type: Button
We have just one field here which is paragraph reference field referring the button paragraph type. In other components we have been sending nested paragraphs as slots, but here we will construct an array of buttons, and pass it to the buttongroup.twig file as an array which is accepting it. For storybook we already have used the buttons array for sample content in buttongroup.stories.yml
Contents of buttongroup—paragraph.html.twig
{% block paragraph %}
<div{{ attributes.addClass(classes) }}>
{% block content %}
{% set buttons = [] %}
{% for key, item in content.field_accordion_item %}
{% if key|first != '#' %}
{% set buttonType = item['#paragraph'].field_button_type[0].value|raw %}
{% set link = item['#paragraph'].field_link[0].url|render %}
{% set linkText = item['#paragraph'].field_link.title %}
{% set button = {
"buttonType": buttonType,
"link": link,
"linkText": linkText
} %}
{% set buttons = buttons|merge([button]) %}
{% endif %}
{% endfor %}
{% include 'drupak_starterkit:buttongroup' with {'buttons': buttons} %}
{% endblock content %}
{% endblock paragraph %}
Here we are constructing an array of arrays housing all the nested buttons and then passing it to drupak_starterkit:buttongroup component. We can create this array in paragraphs preprocess function too but for this example I did it here in the twig. Via this array sending mechanism to the relevant component file, we have completely isolated storybook's component markup from Drupal's markup. This is my favourite way of using components in Drupal as my past experience with Patternlab components in Drupal, this method had the best demarcation for me when presenting components to client and then integrating Drupal's markup into it via array variables.
Thanks for reading all this, if there are some technical gotchas in this method and overall this blogpost, please let us know, we will update it accordingly.
S M Azmat Shah
CEO Drupak