DEV Community

Tsvetan Ganev
Tsvetan Ganev

Posted on • Updated on • Originally published at tsvetan.dev

Tweak Angular components with content projection in Storybook

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
Enter fullscreen mode Exit fullscreen mode

Then we can add the Storybook schematic:

npx sb init
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
// evil-corp-checkbox.component.ts
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;
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

Create the component's story

Let's add a story for the checkbox component:

// evil-corp-checkbox.stories.ts
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> = {
  // ???
};
Enter fullscreen mode Exit fullscreen mode

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:

// evil-corp-checkbox.stories.ts
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.",
  },
};
Enter fullscreen mode Exit fullscreen mode

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 @Inputs 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>
Enter fullscreen mode Exit fullscreen mode

The end result is a story that allows us to edit both the component's @Inputs and its projected content.

Screenshot of the Storybook story for the checkbox component

Knowing this allows us to showcase even the most complex Angular components in Storybook without losing control over all the different component aspects.

Oldest comments (0)