Storybook is one of my favorite front-end tools nowadays and I'm advocating for its usage across all projects that I work on. While the Angular support is getting more refined with each new version, there are still tricky parts that require diving deep into the tools's documentation. One such scenario is creating stories for complex Angular components that support content projection (ng-content
) and wiring them up with Storybook's control knobs.
Set-up
You can skip this section if you already have Angular and Storybook integrated with each other.
Let's create a new Angular 13 project:
npx @angular/cli@13.3.0 new ng-content-storybook --style=scss --prefix=evil-corp --strict=true --routing=true
Then we can add the Storybook schematic:
npx sb init
This installs the latest Storybook version (at the time of writing 6.4). To check if the installation was successful, you can start the Storybook UI:
npm run storybook
If everything works you should see a bunch of demo components in the Storybook UI. From this point onward we don't need the auto-generated demo stories and components anymore - delete them with rm -rf src/stories
.
Creating a sample component with content projection
Let's create a simple checkbox component which can take advantage of content projection.
npx ng g c components/evil-corp-checkbox --prefix='' --inline-template --inline-style --skip-tests
import { Component, Input } from '@angular/core';
@Component({
selector: 'evil-corp-checkbox',
template: `
<label>
<input type="checkbox" [(ngModel)]="checked" />
<span class="label">
<ng-content></ng-content>
</span>
</label>
`,
styles: [
`
:host {
.label {
margin-left: 1ch;
}
}
`,
],
})
export class EvilCorpCheckboxComponent {
@Input()
checked: boolean = false;
}
This is a simplified wrapper for the native HTML checkbox input with the ability to provide a label with ng-content
. The component API looks like:
<evil-corp-checkbox [checked]="true">
Check me!
</evil-corp-checkbox>
Create the component's story
Let's add a story for the checkbox component:
import { FormsModule } from '@angular/forms';
import { Meta, moduleMetadata, StoryObj } from '@storybook/angular';
import { EvilCorpCheckboxComponent } from './evil-corp-checkbox.component';
export default {
title: 'Checkbox',
component: EvilCorpCheckboxComponent,
decorators: [
moduleMetadata({
imports: [FormsModule],
}),
],
argTypes: {
checked: {
control: 'boolean',
},
},
args: {
checked: false,
},
} as Meta<EvilCorpCheckboxComponent>;
export const Checked: StoryObj<EvilCorpCheckboxComponent> = {
args: {
checked: true,
},
};
export const Unchecked: StoryObj<EvilCorpCheckboxComponent> = {
args: {
checked: false,
},
};
export const WithLabel: StoryObj<EvilCorpCheckboxComponent> = {
// ???
};
The story UI renders a boolean control knob for the checked
@Input
property of the component but we can't change the label yet. It's passed to the template as projected content and that requires special handling on our side.
A quick note: we are using the new Storybook story format CSF 3 in the examples since CSF 2 is going to be deprecated in Storybook 7.0 (the next major version at the time of writing). The main difference is that the story configuration is an object (StoryObj
) instead of a function (Story
/StoryFn
). This makes creating and combining stories way easier. You can read more about the new format here. We can achieve the same result demonstrated in the article with CSF 2 as well, so don't get discouraged if you're using that format in your existing project.
Make the story support projected content
By default Storybook automatically maps the current values of the control knobs to the displayed component's properties. This works for the trivial cases where we don't use ng-content
and we have all control knobs named the same way as our component's properties. To allow content projection for our checkbox, we must go a step further and manually render an Angular template in our story. This will offer us more flexibility in regard how Storybook's UI manipulates our component but will also mean we have to take care of some new caveats.
Let's update the story by adding - our own render
method and a control knob (text field) for changing the checkbox label:
import { FormsModule } from '@angular/forms';
import { Meta, moduleMetadata, StoryObj } from '@storybook/angular';
import { EvilCorpCheckboxComponent } from './evil-corp-checkbox.component';
type StoryType = EvilCorpCheckboxComponent & { label?: string };
export default {
title: 'Checkbox',
component: EvilCorpCheckboxComponent,
decorators: [
moduleMetadata({
imports: [FormsModule],
}),
],
render: (args) => {
const { label, ...props } = args;
return {
props,
template: `
<evil-corp-checkbox [checked]="checked">
${label}
</evil-corp-checkbox>
`
};
},
argTypes: {
checked: {
control: 'boolean',
},
label: {
control: 'text',
},
},
args: {
checked: false,
label: '',
},
} as Meta<StoryType>;
export const Checked: StoryObj<StoryType> = {
args: {
checked: true,
},
};
export const Unchecked: StoryObj<StoryType> = {
args: {
checked: false,
},
};
export const WithLabel: StoryObj<StoryType> = {
args: {
label: 'I have read the terms and conditions.',
},
};
The first change is the addition of a new custom type called StoryType
which appends the label
string field to the EvilCorpCheckboxComponent
interface. We also use that new type as the generic argument for the Meta
and StoryObj
interfaces. This trick is useful when we have more Storybook UI controls than a component's @Input
properties. In this case, the label
is not part of the Angular component's interface but still we want to edit it via the Storybook control knobs.
We've also added the label
property to the argTypes
and args
metadata fields. This allows us to edit the label's value and the component will automatically update itself with the current one.
The most notable change is the render
method added to the default export of type Meta
. It takes the values from the Storybook control knobs as a parameter and allows us to dynamically build a story metadata object. With these new powers we can render an Angular template that receives all its properties from the Storybook knobs. One gotcha is that we must provide all component @Input
s by hand. Storybook magically took care of this before, but now that we've requested complete control over the rendering, we have to do it by ourselves. The template
field contains an Angular template string which evaluates each time the function's arguments change. The props
field in the return value of the render
function binds its contents to the template's variables, for example:
// passing these props as argument to render()
{ checked: true, theAnswer: 42 }
// results in
component.checked = true
component.theAnswer = 42
// and now we can use them in an Angular template
<my-comp [checked]="checked" [theAnswer]="theAnswer"></my-comp>
The end result is a story that allows us to edit both the component's @Input
s and its projected content.
Knowing this allows us to showcase even the most complex Angular components in Storybook without losing control over all the different component aspects.