Lab 10: Content Projection - andreaswissel/design-systems-workshop GitHub Wiki

Hint: if you got lost, you can always check out the corresponding branch for the lab. If you want to start over, you can reset it by typing executing git reset --hard origin/lab-9

Lab

We're nearing the finish line and have almost learned about everything that's necessary to build flexible and robust components. There's one common Angular pattern, that hasn't been used up until now: Content Projection. This pattern proves to be especially useful whenever we want to use a slot or placeholder mechanic. There's two nice examples in our component library already. Since we've worked on implementing the form elements, we will start off by implementing a form layout and finish this lab by implementing the CardComponent.

  • Generate a FormLayoutComponent
ng g c --standalone form-layout

We will use this component as a simple container for our FormElementComponents.

  • Create a slot

The first step is an easy one: we add the <ng-content> element to our template file. That's it! We can now project content to our FormLayoutComponent. However, this doesn't show in Storybook yet, since it will only load the component itself by default.

  • Projecting content in stories

To do so, we first import both the FormLayout and FormElement components:

src/app/form-layout/form-layout.stories.ts

const meta: Meta<FormLayoutComponent> = {
  title: 'Components/FormLayout',
  component: FormLayoutComponent,
  tags: ['autodocs'],
+  decorators: [moduleMetadata({
+    imports: [FormLayoutComponent, FormElementComponent]
+  })]
};

Since Storybook spins up a separate Angular app for all components it renders, we need to make sure this app knows about our component and/or module as well. Usually, this is handled by Storybook. But since we will now set up the component on our own, we need to use the moduleMetadata decorator, to hand over some information. In particular, this means extending the imports part of the NgModule that Storybook is creating for us. What's more, though, we can also hand a custom template to the component rendered in the story. Therefore, it's possible for us specify a template which uses <app-form-layout> and inserts <app-form-element>s to the slot.

To do so, we specify a custom render function in the Default story:

src/app/form-layout/form-layout.stories.ts

export const Default: StoryObj<FormLayoutComponent> = {
  args: {},
+  render: (args: FormLayoutComponent) => ({
+    props: { ...args },
+    template: `
+      <app-form-layout>
+        <app-form-element label="Username"></app-form-element>
+        <app-form-element label="Password" type="${InputType.password}"></app-form-element>
+      </app-form-layout>
+    `,
+  }),
};

Notice that we've implemented the template in the story function itself. Hencefor, we're able to use all the arguments passed to it. For example, we could specify a control, whose value can the be displayed in the template:

export const Default: StoryObj<FormLayoutWithAux> = {
  args: {
+    FirstInputLabel: 'Username',
  },
};

However, there's a catch: The current type for this story function only allows for properties that are already implemented on our component. We can circumvent this by implementing an intersection type:

+type ExternalProps = {
+  FirstInputLabel: string;
+  FirstInputType: InputType;
+  SecondInputLabel: string;
+  SecondInputType: InputType;
+};

+type FormLayoutWithAux = FormLayoutComponent & ExternalProps;

-const meta: Meta<FormLayoutComponent> = {
+const meta: Meta<FormLayoutWithAux> = {
  title: 'Components/FormLayout',
  component: FormLayoutComponent,
  tags: ['autodocs'],
  decorators: [
    moduleMetadata({
      imports: [FormLayoutComponent, FormElementComponent],
    }),
  ],
-  render: (args: FormLayoutComponent) => ({
+  render: (args: FormLayoutWithAux) => ({
    props: { ...args },
    template: `
      <app-form-layout>
+        <app-form-element label="${args.FirstInputLabel}" inputType="${args.FirstInputType}"></app-form-element>
+        <app-form-element label="${args.SecondInputLabel}" inputType="${args.SecondInputType}"></app-form-element>
      </app-form-layout>
    `,
  }),
};

This way, TypeScript now knows about our special properties in ExternalProps, too. Now, the story will render properly and show properly setup FormElement controls in the canvas:

  • Layouting the projected content with display: grid;

Now we know how compose components through content projection and how to hand over properties to our custom template. Let's make use of this new found knowledge and implement a simple CSS grid, that will create some columns based on the user input in Storybook.

First of all, we need to have an input for the columns property, as well as a method that returns the css property for the grid columns:

export class FormLayoutComponent{
+  @Input() columns = 1;

  constructor() {}

+  get templateColumnsString() {
+    return `repeat(${this.columns}, 1fr)`;
+  }
}

Then we create a small helper CSS class:

.grid-layout {
  display: grid;
}

Then, we wrap the content in a CSS grid container and use ngStyle to add the columns specification:

+<div
+  class="grid-layout"
+  [ngStyle]="{ 'grid-template-columns': templateColumnsString }"
+>
  <ng-content></ng-content>
+</div>

And finally, we update our story file again:

const meta: Meta<FormLayoutWithAux> = {
  title: 'Components/FormLayout',
  component: FormLayoutComponent,
  tags: ['autodocs'],
  decorators: [
    moduleMetadata({
      imports: [FormLayoutComponent, FormElementComponent],
    }),
  ],
  render: (args: FormLayoutWithAux) => ({
    props: { ...args },
    template: `
-    <app-form-layout>    
+    <app-form-layout [columns]="${args.columns}">
        <app-form-element label="${args.FirstInputLabel}" inputType="${args.FirstInputType}"></app-form-element>
        <app-form-element label="${args.SecondInputLabel}" inputType="${args.SecondInputType}"></app-form-element>
      </app-form-layout>
    `,
  }),
};


export const Default: StoryObj<FormLayoutWithAux> = {
  args: {
    FirstInputLabel: 'Username',
    FirstInputType: InputType.text,
    SecondInputLabel: 'Password',
    SecondInputType: InputType.password,
+    columns: 2,
  },
};

If everything went right, your story should now look like this:

Remember: you can implement additional controls that don't belong directly to the story's component!

  • Bonus level: Try to implement a form heading through a <legend> tag

Self check

  • The FormLayoutComponent should show up with two projected inputs: Username, Password
  • The columns property should be accessible as a control in Storybook

Solution

FormLayout Story

src/app/form-layout/form-layout.stories.ts

import { Meta, StoryObj, moduleMetadata } from '@storybook/angular';
import { FormLayoutComponent } from './form-layout.component';
import { FormElementComponent } from '../form-element/form-element.component';
import { InputType } from '../input/input.component';

type ExternalProps = {
  FirstInputLabel: string;
  FirstInputType: InputType;
  SecondInputLabel: string;
  SecondInputType: InputType;
};

type FormLayoutWithAux = FormLayoutComponent & ExternalProps;

const meta: Meta<FormLayoutWithAux> = {
  title: 'Components/FormLayout',
  component: FormLayoutComponent,
  tags: ['autodocs'],
  decorators: [
    moduleMetadata({
      imports: [FormLayoutComponent, FormElementComponent],
    }),
  ],
  render: (args: FormLayoutWithAux) => ({
    props: { ...args },
    template: `
    <app-form-layout [columns]="${args.columns}" heading="${args.heading}">
        <app-form-element label="${args.FirstInputLabel}" inputType="${args.FirstInputType}"></app-form-element>
        <app-form-element label="${args.SecondInputLabel}" inputType="${args.SecondInputType}"></app-form-element>
      </app-form-layout>
    `,
  }),
};

export const Default: StoryObj<FormLayoutWithAux> = {
  args: {
    FirstInputLabel: 'Username',
    FirstInputType: InputType.text,
    SecondInputLabel: 'Password',
    SecondInputType: InputType.password,
    columns: 2,
    heading: 'Login Form',
  },
};

export default meta;
FormLayout Component

src/app/form-layout/form-layout.component.ts

import { CommonModule } from '@angular/common';
import { Component, Input } from '@angular/core';

@Component({
  selector: 'app-form-layout',
  standalone: true,
  imports: [CommonModule],
  templateUrl: './form-layout.component.html',
  styleUrl: './form-layout.component.scss',
})
export class FormLayoutComponent {
  @Input() columns = 1;
  @Input() heading: string = 'Form Heading';

  get templateColumnsString() {
    return `repeat(${this.columns}, 1fr)`;
  }
}
FormLayout HTML

src/app/form-layout/form-layout.component.html

<legend>{{ heading }}</legend>
<div
  class="grid-layout"
  [ngStyle]="{ 'grid-template-columns': templateColumnsString }"
>
  <ng-content></ng-content>
</div>
FormLayout SCSS

src/app/form-layout/form-layout.component.scss

legend {
  font-size: 20px;

  margin-bottom: 15px;
}

.grid-layout {
  display: grid;
}
⚠️ **GitHub.com Fallback** ⚠️