Lab 09: Input And Form Element - 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-8

Lab

In the last lab, the first composite component has crept into our design system - without us even noticing it! The ButtonIcon variant has united the IconComponent with the ButtonComponent to form a new button spice.

In this lab, we will set us up another composite component. Thanks to the magic of standalone components, we don't even need to handle any dependencies in Storybook.

But first, let's start on the atomic level, with the InputComponent

  • Generate the InputComponent
ng g c --standalone input
  • Implement the input component according to the specification in the Figma reference library

Locate the Input component in the Figma reference library and implement the InputComponent. Try to figure out the component API on your own!

Hint: These are the component inputs

src/app/input/input.component.ts

  @Input() inputType: InputType = InputType.text; // You can find the enum definition in the model solution
  @Input() placeholder: string | undefined;
  @Input() value: string | undefined;
  @Input() id?: string;

Once you're done, the InputComponent should be accessible through Storybooks controls and look like this:

  • Generate the FormElementComponent

Next, we want to generate a component that wraps the input and adds a label to it. Since it should augment all the properties of the input, make sure to include the same component inputs.

Hint: This can be checked at compile time by implementing an interface!

ng g c --standalone form-element
  • Implement hover and focus state

Much like the InputComponent, the FormElementComponent has various states. Using the :host selector in our SCSS file, we can react to events on the host component (<app-form-element>) and style the label accordingly:

@import "../../styles/colors";
@import "../../styles/typo";

:host {
  display: flex;
  flex-direction: column;

  label {
    @include body-typo();

    cursor: pointer;
    padding-bottom: 10px;
  }

  &:hover,
  &:focus {
    label {
      color: $blue-400;
    }
  }
}
  • Accessibility: Generate a unique id for each input to make labels clickable

Taking a first step towards accessible components is easy with the FormElementComponent. All we have to do is configure a unique ID that is then used for the id attribute of the InputComponent, as well as the for attribute of the label. The browser will then do the rest of the work for us and link these elements up, so that screenreaders and keyboard users can access this component and it's respective information more easily. In modern browsers, we can use crypto.randomUUID() to generate a UUID:

export class FormElementComponent implements InputApi {
+  uuid: string;

  @Input() inputType: InputType = InputType.text;
  @Input() placeholder?: string;
  @Input() value?: string;
  @Input() label = 'Input Label';

  constructor() {
+    this.uuid = crypto.randomUUID();
  }
}

Once everything is linked up, your :hover and :focus should be toggleable by the label and input alike:

  • Docs: Set specific argTypes to make controls more convenient

Generally speaking, Storybook does a pretty good job of guessing the right control types from the component's inputs. In some cases, however, we need to help out a little and specify the control types manually. This can be achieved by using the argTypes property on the story:

export const Default: StoryObj<InputComponent> = {
  args: {},
  argTypes: {
    placeholder: { control: 'text' },
    value: { control: 'text' },
  },
};

This is helpful, when input values are optional and therefore could be undefined. We can then tell Storybook what control we need most often - in this case a simple text input.

You might have noticed now, that the text in the input labels looks not very much like the one in Figma. That's because Storybook doesn't know anything about our global styles.scss yet. Let's change that!

Self check

  • The InputComponent with it's hover and focus states is implemented in Storybook
  • The InputComponent's @Input's can be controlled via Storybook
  • The FormElementComponent with it's hover and focus states is implemented in Storybook
  • The FormElementComponent's @Input's can be controlled via Storybook

Solutions

InputComponent

Input HTML file

src/app/input/input.component.html

<input
  [type]="inputType"
  [placeholder]="placeholder"
  [value]="value"
  [attr.id]="id"
/>
Input SCSS file

src/app/input/input.component.scss

@import "../../styles/colors";
@import "../../styles/effects";

:host {
  display: flex;
}

input {
  background: $white;

  height: 40px;

  border: 1px solid $gray-200;
  box-sizing: border-box;
  border-radius: 10px;
  color: $text-default;
  padding: 12px;

  &:hover {
    @include hover-shadow();
  }

  &:focus {
    @include hover-shadow();

    border-color: $blue-400;
    outline: none;
  }
}
Input component file

src/app/input/input.component.ts

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

export enum InputType {
  button = 'button',
  checkbox = 'checkbox',
  color = 'color',
  date = 'date',
  datetimelocal = 'datetime-local',
  email = 'email',
  file = 'file',
  hidden = 'hidden',
  image = 'image',
  month = 'month',
  number = 'number',
  password = 'password',
  radio = 'radio',
  range = 'range',
  reset = 'reset',
  search = 'search',
  submit = 'submit',
  tel = 'tel',
  text = 'text',
  time = 'time',
  url = 'url',
  week = 'week',
}

export interface InputApi {
  inputType: InputType;
  placeholder?: string;
  value?: string;
}

@Component({
  selector: 'app-input',
  standalone: true,
  imports: [],
  templateUrl: './input.component.html',
  styleUrl: './input.component.scss',
})
export class InputComponent {
  @Input() inputType: InputType = InputType.text;
  @Input() placeholder: string | undefined;
  @Input() value: string | undefined;
  @Input() id?: string;
}
Input stories file

src/app/input/input.stories.ts

import { InputComponent, InputType } from './input.component';
import { Meta, StoryObj } from '@storybook/angular';

const meta: Meta<InputComponent> = {
  title: 'Components/Input',
  component: InputComponent,
  tags: ['autodocs'],
};

export const Default: StoryObj<InputComponent> = {
  args: {
    inputType: InputType.text,
    placeholder: 'This is a placeholder',
    value: '',
    id: 'uuid',
  },
  argTypes: {
    placeholder: { control: 'text' },
    value: { control: 'text' },
  },
};

export default meta;

FormElementComponent

FormElement HTML file

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

<label [for]="uuid">{{ label }}</label>
<app-input
  [placeholder]="placeholder"
  [inputType]="inputType"
  [value]="value"
  [id]="uuid"
></app-input>
FormElement SCSS file

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

@import "../../styles/colors";
@import "../../styles/typo";

:host {
  display: flex;
  flex-direction: column;

  label {
    @include body-typo();

    cursor: pointer;
    padding-bottom: 10px;
  }

  &:hover,
  &:focus {
    label {
      color: $blue-400;
    }
  }
}
FormElement component file

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

import { Component, Input } from '@angular/core';
import { InputApi, InputComponent, InputType } from '../input/input.component';

@Component({
  selector: 'app-form-element',
  standalone: true,
  imports: [InputComponent],
  templateUrl: './form-element.component.html',
  styleUrl: './form-element.component.scss',
})
export class FormElementComponent implements InputApi {
  uuid: string;

  @Input() inputType: InputType = InputType.text;
  @Input() placeholder: string = 'Form Element Placeholder';
  @Input() value = '';
  @Input() label = 'Input Label';

  constructor() {
    this.uuid = crypto.randomUUID();
  }
}
FormElement stories file

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

import { Meta, StoryObj } from '@storybook/angular';
import { FormElementComponent } from './form-element.component';
import * as InputStories from '../input/input.stories';

const meta: Meta<FormElementComponent> = {
  title: 'Components/FormElement',
  component: FormElementComponent,
  tags: ['autodocs'],
};

export const Default: StoryObj<FormElementComponent> = {
  args: {
    ...InputStories.Default.args,
    label: 'Form Element Label',
  },
};

export default meta;

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

⚠️ **GitHub.com Fallback** ⚠️