Full Stack Development Series Part 5: Design Systems and Angular Component Development with Storybook
Welcome back! In part 5 of this series we'll cover the "front of the front-end" - the part of an application that an end user actually sees and interacts with. When I start personal projects I usually get hung up on the visuals - I want it to look good while I'm developing the application. I tried to skirt that habit with this project, and for the most part that worked! It's time to give our UI a facelift though, and we'll create a design system from scratch to do so. I'll also show you how to use Storybook to design Angular components without having to run your application, and make components interact with our API.
Other posts in this series:
- An Introduction
- Part 1: Getting Started With Nx, Angular, and NestJS
- Part 2: Creating a REST API
- Part 3: Connecting Angular to a REST API
- Part 4: Data Persistence with TypeORM and NestJS
- Part 5: Design Systems and Angular Component Development with Storybook (this post)
If you want to skip ahead to the code, you can checkout out the repository: wgd3/full-stack-todo@part-05
Creating a Design System
If you've spent any time working on a modern web UI, you've probably heard the term "Design System" more than once. For people like myself who often hit a "writer's block" when designing UIs, it's an invaluable tool. A design system consists of:
- Color palettes
- Font families
- Page layout guidelines
- Spacing
- UI/UX rules
There are many well-established design systems available in the open source world, such as Clarity, Material, or Microsoft's Fluent. For this post however, we'll be creating a library from scratch to learn more about SCSS and how these systems integrate with applications.
Create A Shared Style Library
Time to add another library to our repository! Instead of using an Angular or Nest-based generator, we'll use Nrwl's workspace
generator to create a framework-agnostic library. Since this library will only contain SCSS files we don't need to include tests, modules, or components.
It's not just so there's another node in Nx's pretty dependency graph, nor is it because everything in a monorepo should be a library. By creating a SCSS-only, framework-agnostic style library, we enable any application to take advantage of our designs. For instance, if you wanted to create a Vue or React-based client application in this repository, you could import the same SCSS and have consistency across applications.
One tool that's been a huge help to me when I'm working in SCSS files is Stylelint. Like ESLint, it provides feedback on the structure of SCSS files, naming conventions, and more. There is a Nx plugin available to integrate Stylelint, so let's install that:
$ npm i -D nx-stylelint
extensions.json
file. If you go to the Extensions tab, you'll see it in the recommendations list and can install it from there. Use the newly-available generators to add CSS and SCSS linting to our style library:
$ nx g nx-stylelint:configuration --project client-ui-style
$ nx g nx-stylelint:scss --project client-ui-style
Developers have a lot of opinions on how modular SCSS files should be structured, one of the most popular being the 7-1 configuration. There's not really one correct way of implementing a modular SCSS system; ultimately it comes down to what's best for your project and what you're comfortable with.
Personally, I'm a fan of the 7-1 structure, but we don't quite have a need for all 7 of the modules described in those docs. Let's go with a 5-1 structure instead:
$ mkdir -p libs/client/ui-style/src/lib/scss/{base,components,layout,abstracts,vendors}
$ touch libs/client/ui-style/src/lib/scss/{base,components,layout,abstracts,vendors}/_index.scss
$ tree libs/client/ui-style/src/lib/scss
libs/client/ui-style/src/lib/scss
βββ abstracts # all "partials" - no rules, just mixins, functions, varibles
β βββ _index.scss
βββ base # normalization and typography
β βββ _index.scss
βββ components # individual component styles
β βββ _index.scss
βββ layout # layout styles for sections of pages
β βββ _index.scss
βββ vendors # all 3rd party styles
β βββ _index.scss
βββ style.scss # main entrypoint for library
_index.scss
file in it. This allows the top-level style.scss
to import by directory instead of individual files. More on index files in Sass' documentation.Add Some Style Helpers
Before we start actually writing SCSS, there are a few helpers I want to get installed so we can include them in our vendors/_index.scss
file.
$ npm i --save normalize.css nord
normalize.css
provides a stylesheet which overrides a lot of browser defaults to bring them up to modern standards. It's commonly the first stylesheet included so that our custom SCSS can take priority over it.
Nord is an open-source color palette which provides muted, cool colors for both light and dark modes. As I mentioned earlier, when I start a new project I usually go down a rabbit hole of color palette generators, and lose momentum. Nord is generally regarded as being visually appealing, and it doesn't look like Bootstrap out of the box, so it's a great palette to work with.
Now that the library is set up, there are 2 files that need to be updated in the client
application in order to include our design system.
style
is not the most creative name for the top-level file in our design library, but it made sense at the time. This import can directly reference the style.scss
file since Angular will now look in libs/client/ui-style/src/lib/scss
for any imports. Note that in other components you can now do something like @import 'abstracts/variables'
as well.
Start Defining Your Theme
The first file that gets updated is our vendor directory's index file:
Second stop is a new _variables.scss
file under the abstracts
directory. This is the place where you'll define SCSS variables that are used in all the other stylesheets:
These variables then get used in other SCSS partials. For example, here's some of my typography stylesheet:
There's not room to show everything that I added to these stylesheets, but you can always reference the code in the GitHub repository to see where I started.
Integrating Storybook
An indispensable tool for me when working on web UIs is Storybook. It is a standalone application that provides an isolated, visual representation of components. This allows you to design components without having to run your full application, which greatly speeds up development.
$ npm install -D @nrwl/storybook
-D
flag in the above command saves this dependency to the devDependencies
section of your package.json
file. By storing it there, you prevent the package from being bundled with production builds of your application.Now with Storybook support added to the repository, we need some components to design! This project will use a ui
type library dedicated to our custom components:
$ npx nx generate @nrwl/angular:library ui-components \
--directory=libs/client \
--changeDetection=OnPush \
--importPath=@fst/client/ui-components \
--prefix=fst \
--skipModule \
--standalone \
--style=scss \
--tags=type:ui,scope:client
$ npx nx generate @nrwl/angular:component ToDo \
--project=client-ui-components \
--standalone \
--changeDetection=OnPush \
--path=libs/client/ui-components/src/lib \
--selector=fst-todo
Explaining These Command Options
changeDetection
is a complicated subject, but the short version is that for performance reasons in large applications you want to prevent the browser from constantly re-rendering when tiny changes occur.prefix
forces components in thisui-components
library to have HTML selectors that begin withfst
skipModule
/standalone
this library will not be using a central module through which all the custom components are declared and exported. Instead, we want to be able to import individual pieces from library via paths such as@fst/client/ui-components/to-do
. This pattern enables tree shaking and smaller bundle sizes.- Component name capitalization - this is a fun quirk of the CLI, and I'm not sure if it's Nx or Angular that parse it. Capital letters in names get treated as separated works:
ToDoComponent
becomesto-do.component.ts
inside ato-do
directory. Just something to keep in mind! selector
specifies exactly what I want to use for the HTML tag for this component
Just like we added Stylelint integration to our ui-styles
library, we need to add Storybook integration to our ui-components
library.
$ npx nx g @nrwl/storybook:configuration client-ui-components \
--storybook7betaConfiguration \
--storybook7UiFramework=@storybook/angular \
--configureCypress \
--tsConfiguration \
--configureTestRunner \
--cypressDirectory=./
As you can see here, we're using a beta version of the next Storybook release - I hope this command still works when you read this!
Start Designing
Storybook reads files that it refers to as "stories". These stories usually follow a naming scheme of <component>.stories.ts
and exist in the same directory as te component being designed.
The beta generator for Storybook 7 didn't appear to create stories for my components automatically, so I had to manually run:
$ npx nx generate @nrwl/angular:stories client-ui-components
After that, update build-storybook
target just like the application's project.json
so Storybook is aware of our SCSS:
$ npx nx run client-ui-components:storybook
Using Real Data While Designing
In my local instance of the application, at the time of writing, I only had the gimmicky "write another post!" to-do item in my database. When designing things visually, it helps to use "real" data to see how your component would act in various situations.
My go-to library for generating random data is Falso from the ngneat team.
$ npm i -D @ngneat/falso
Now the ToDoComponent
needs a way to have data passed into it. Angular makes this very simple:
@Input()
decorator is new to you as well! In short, when naming this component in a template you can pass in data ("data binding") via it's HTML tag. I'd like to point out (again!) a place where our shared data structure ITodo
is used. If the underlaying interface ever sees any updates, both client
and server
applications will stay in sync.
The story for ToDoComponent
needs some data, which we can generate on-the-fly in the story file:
The component's template can now refer to the todo
passed into it:
If you left Storybook running in the background then you'll see the page live-reload with the new template, displaying our random data:
Why Not Use Our Own API For Data?
In short, Storybook should be able to run completely isolated. In it's current form, I could run build-storybook
and host the static site somewhere with no external API dependencies.
Changing The Look
We have a design system in a style library, a set of colors to work with (Nord), and a framework for designing and testing in isolation.
You'll see in the caption for the following code block - this code is not going in the stylesheet to-do.component.scss
. Instead, it lives in the components
directory of our design system. Putting these styles in the component's specific stylesheet prevents them from being used in other (potential) applications.
Make The Component Interactive
So far the component just displays the current state - there's no way to alter the state in the UI. Let's add some buttons for interactivity.
@Output()
properties are added for the 3 buttons on the component. This decorator is attached to EventEmitter
objects - a type of RxJs Subject that allows parent components to listen for actions.
And event binding is used in the template to link DOM events with component logic:
Wire up Storybook Actions so we know the interactions are working as expected:
Using The New Component
The ToDoComponent
has now been created with proper inputs and outputs, is fully styled, and ready to be used in a template. Our FeatureDashboardComponent
should be updated to import the standalone component, and the template updated to use it.
Interact With The Component
Our dashboard now contains 2 lists, the completed and incomplete. We know thanks to Storyboard that our buttons work, but currently the @Output()
properties of the ToDoComponent
are not used. The dashboard template needs to be updated to use these properties:
Angular Template Data and Event Binding
In the above code, the ToDoComponent
being created has a lot going on. Here's the breakdown:
*ngIf
in the highest-level element tells the template to render it's child nodes only whentodos$
is defined. In the component, it's defined as soon as the component is initialized, so this will always be true. Theasync as todos
allows us to usetodos
as a regular array instead of requiring multipleasync
pipes within child nodes.[todo]
passes in the item's data to theToDoComponent
*ngIf
filters based on thecompleted
status(toggleComplete)
and(deleteTodo)
are theEventEmitter
properties of theToDoComponent
. They call a method in theFeatureDashboardComponent
with the same name. Since we know the@Output()
fromToDoComponent
containsITodo
-structured data. That's passed to methods with the$event
variable. Technically$event
could be named anything, this is just a common convention.
In the component itself, we define the functions to be called in the template:
export class FeatureDashboardComponent implements OnInit {
private readonly apiService = inject(ApiService);
todos$ = new BehaviorSubject<ITodo[]>([]);
trackTodo(idx: number, todo: ITodo) {
return todo.id;
}
ngOnInit(): void {
this.refreshItems();
}
refreshItems() {
this.apiService
.getAllToDoItems()
.pipe(take(1))
.subscribe((items) => this.todos$.next(items));
}
toggleComplete(todo: ITodo) {
this.apiService
.updateToDo(todo.id, { completed: !todo.completed })
.pipe(take(1))
.subscribe(() => {
this.refreshItems();
});
}
deleteTodo({ id }: ITodo) {
this.apiService
.deleteToDo(id)
.pipe(take(1))
.subscribe(() => {
this.refreshItems();
});
}
}
trackBy
specified in the template! Without it, everytime refreshItems()
is called (which updates the BehaviorSubject and triggers change detection in the template), all ToDoComponent
elements get re-rendered. Providing a way to uniquely identify objects inside of ngFor
loops means that Angular knows which ToDoComponent
was updated, and only re-renders that specific one. For larger arrays this can make a huge performance difference. Summary
If you're still reading, thanks for sticking with me through this post! You now have the basis for a custom design system, an isolated toolset for designing components, and functional components in the client
application!
Here's a look at our dependency graph with the 2 new libraries:
The code for this post can be found in the GitHub repository: wgd3/full-stack-todo@part-05
Speed Bumps
ToDoComponent Outputs Updated
Towards the end of this post once everything was wired up, I realized that to best provide context for the parent component, the toggleComplete
output from the ToDoComponent
should emit an ITodo
just like the other emitters.
Storybook 7 Beta
I found out the hard way that Storybook 6.5.x does not support Angular 15 or the version of zone.js that Angular 15 requires. This situation is discussed in this GitHub issue. I followed Nx's documentation for getting set up with a beta release of Storybook 7, and things seemed to go smoothly after that.
References
- Style library structure inspiration:
Simple SCSS Playbook
A Modern SASS Folder Structure
Official SASS Guidelines - Nord Color Scheme
- Clarity Design System