Material Design is a design specification by the Google design team that aims to provide a seamless, cross-platform, beautiful design experience that is backed by technology and science. Angular Material is the implementation of this spec for the Angular framework - built on component-based architecture.
Angular Material is built and maintained by the Angular team to seamlessly integrate into the Angular framework. Adding it to your application - whether using a custom theme, or a prebuilt - is a breeze.
Table of contents
In this blog post we will build an angular app for a simple bank account manager to view bank accounts and the transactions associated to the account. Our app will be built to be cross-platform to look and provide a seamless interaction no matter the device. The goal is to give a preview of the different capabilities and awesomeness that is @angular/material
and the @angular/cdk
libs to provide a beautiful UI/UX that is clean and easy to use. Note: this post assumes you have a working knowledge of the Angular framework; this is also not meant to be a deep-dive into the material-design spec or philosophy.
Generating the Application
First thing, we need to generate an angular application; this is made very easy using the angular cli. If you don’t already have it installed; go ahead and install it really quick by:
[sudo] npm i -g @angular/cli
Once complete, we can validate that the install was successful by simply running a --version
command:
ng --version
Now that the cli has been successfully installed, it is time to create our app; which we will name bank-mgr
. For the sake of this post, I am going to generate a simple app that utilizes routing and the scss
style sheet. There are lots of available options for the command, and I suggest you take a look at them here.
# generate new app
ng new bank-mgr --style=scss --routing
# cd into the app
cd bank-mgr
This command will generate the app and install all of the required dependencies. Now lets start it up with the serve command; again, there are a lot of available options for this command, but for general use, the defaults are fine and we can run as such.
ng serve
Open your web-browser of choice and navigate to http://localhost:4200
(4200 is the default port for angular apps, but you can specify whatever port your heart desires). If all went well, you should see the angular default landing page.
The Angular Console
Recently released is the angular console. You can download the console to your machine or directly into VSCode and utilize it to generate your angular applications visually through the help of a GUI. If that is your preference, by all means; it is a super handy and cool tool as well.
Adding Angular Material
Adding the @angular/material
lib to our angular application can be done in a couple of ways:
- post Angular DevKit 6+: via the
ng add
command - pre Angular DevKit 6+: the class
yarn | npm install
In this post we are going to use the first option
ng add @angular/material
This will not only install the required dependencies (@angular/material
, @angular/cdk
, @angular/animations
[optional], hammerjs
[optional]), it will also wire the dependencies into your application and add the Roboto
font as well as the material icons font registries to your index.html
page. Once ran, it will ask you for some input such as which theme you would like to use (pre-built themese or custom) and if you want to add support for @angular/animations
and hammerjs
. For our app, I chose a custom theme, and yes for animations and hammerjs support.
Afterwards, you will see that some files have changed to wire in the basic support for animations (via importing the BrowserAnimationsModule
into the app.module
), hammerjs (in the main.ts
file via a simple import), and your style.scss
file gets added support for your custom theme. Let’s start with this custom theme support.
Custom theming
Angular material is built on Sass
and comes out of the box with the ability to theme your app however you would like with colors that represent your companies brand - check out the custom theming guide here. It works on a concept of providing color “palettes” that your app components can be themed with. This is incredible as it allows you to change your theming and pallets in one place and the rest of the application picks that change up with 0 code changes required. Material works on the principle of 3 color palettes:
- Primary - the primary color for your application; usually your brands primary color as well
- Accent - accent colors that are used sparingly to provide emphasis onto the accented area
- Warn - errors, warnings, issues, etc. This tells the user that something isn’t right
There is a lot of research that goes into color pallettes; if you would like more information check out the material design spec color docs. In angular/material the input for a color palette is: the palette name (mat-blue, for example) [required], the default hue [optional], a lighter hue [optional], and a darker hue [optional]. For this app, we are going to use these colors (feel free to play around with this and choose whatever colors you would like):
- primary:
mat-blue-grey
, 600, 400, 800 - accent:
mat-teal
, 800, 500, 900 - warn (the default is
mat-red
):mat-red
, 900, 500, A700
Open your src/style.scss
file. You will see some comments and some sass code that is establishing the custom theme with some default values; this was put in there by the ng add
command. Checkout the snippet below for this code with our custom color palettes designed above:
// Custom Theming for Angular Material
// For more information: https://material.angular.io/guide/theming
@import '~@angular/material/theming';
// Plus imports for other components in your app.
// Include the common styles for Angular Material. We include this here so that you only
// have to load a single css file for Angular Material in your app.
// Be sure that you only ever include this mixin once!
@include mat-core();
// Define the palettes for your theme using the Material Design palettes available in palette.scss
// (imported above). For each palette, you can optionally specify a default, lighter, and darker
// hue. Available color palettes: https://material.io/design/color/
$angular-material-v1-primary: mat-palette($mat-blue-grey, 600, 400, 800);
$angular-material-v1-accent: mat-palette($mat-teal, 800, 900, 500);
$angular-material-v1-warn: mat-palette($mat-red, 900, 500, A700);
// Create the theme object (a Sass map containing all of the palettes).
// If you prefer a dark theme, switch to mat-dark-theme and it will switch over to your darker hues
$angular-material-v1-theme: mat-light-theme(
$angular-material-v1-primary,
$angular-material-v1-accent,
$angular-material-v1-warn
);
// Include theme styles for core and each component used in your app.
// Alternatively, you can import and @include the theme mixins for each component
// that you are using.
@include angular-material-theme($angular-material-v1-theme);
Super easy, right? Your app is now custom-themed to better represent your brand. To change the colors, palettes or switch from light to dark theme, it is all centralized to this file.
Custom fonts
The default font for angular material is Roboto
. Let’s go ahead and change the app font to Roboto Mono
because why not use a monospace font for a website. First thing, we need to grab the font files. My preferred way to do this is to use google fonts. From there it will give you options on how you want to import your font of choice; for this post I am just going to grab the stylesheet import and add it to the index.html
like so:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>AngularMaterialV1</title>
<base href="/" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" type="image/x-icon" href="favicon.ico" />
<link
href="https://fonts.googleapis.com/css?family=Roboto+Mono"
rel="stylesheet"
/>
<link
href="https://fonts.googleapis.com/icon?family=Material+Icons"
rel="stylesheet"
/>
</head>
<!-- To have the app use the material typography: add this class here to the body -->
<body class="mat-typography">
<app-root></app-root>
</body>
</html>
And to register the font with angular material, we update the style.scss
sheet to add the custom font:
// Define a custom typography config that overrides the font-family as well as the
// `headlines` and `body-1` levels.
$custom-typography: mat-typography-config(
$font-family: 'Roboto Mono',
);
// Override typography for all Angular Material, including mat-base-typography and all components.
@include angular-material-typography($custom-typography);
// Override the typography in the core CSS.
@include mat-core($custom-typography);
And there you go, now we can use whatever font we would like for our app.
Material Design Module Dependencies
Angular works on the concept of modules; this includes angular material. If there is a @angular/material
component you would like to use in your app, you will need to import that components respective module: for instance the MatButtonModule
grants access to use the angular material button
component and attributes. For ease of use and reuse in this app, we will create a module that will import (and export) a variety of common angular material modules that we can then import into our other app modules. Because schematics are awesome, lets use the cli to generate our material design module that our app will use:
# make sure your present-working-director is the project root
# the defaut generation root is `src/app`.
# I would like this module to exist at the same directory level as `app`,
# that is why the module name is prepended with `../`.
# this is a personal preference
ng g module ../material-design
This will generate a module file called: src/material-design/material-design.module.ts
. In it we will import whatever angular material modules we would like our app to have access to. As of @angular/material
version 8, no longer import the different modules from @angular/material
directly, but from the module directory.
// src/material-design/material-design.module.ts
import { NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { CdkTableModule } from '@angular/cdk/table';
const modules: any[] = [MatButtonModule, MatCheckboxModule, CdkTableModule];
// Declare Module that imports/exports the @angular/material modules needed in the app
@NgModule({
imports: [...modules],
exports: [...modules],
})
export class MaterialDesignModule {}
As the app grows and more components are necessary, we add those modules here and then our other application modules will have access to them. And we then import this into our other app modules (currently we only have the app.module) like so:
// src/app/app,module
import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { MaterialDesignModule } from '../material-design/material-design.module';
import { AppRoutingModule } from './app-routing.module';
import { AppComponent } from './app.component';
@NgModule({
declarations: [AppComponent],
imports: [
BrowserModule,
BrowserAnimationsModule,
MaterialDesignModule,
AppRoutingModule,
],
providers: [],
bootstrap: [AppComponent],
})
export class AppModule {}
Navigation & the Home Page
Now that the angular material setup is complete, our app is ready to build and style. To start with, we are going to build out our apps home/landing page. This is also where we will build our app shell which will contain the app toolbar, navigation, and the router outlet where our app pages will be injected.
Free eBook
Directives, simple right? Wrong! On the outside they look simple, but even skilled Angular devs haven’t grasped every concept in this eBook.
- Observables and Async Pipe
- Identity Checking and Performance
- Web Components <ng-template> syntax
- <ng-container> and Observable Composition
- Advanced Rendering Patterns
- Setters and Getters for Styles and Class Bindings
Open up the src/app/app.component.html
file and go ahead and delete everything that is in there (all of the placeholder content from the app generation) except for the <router-outlet></router-outlet>
. To use side navigation we add the necessary components to specify our app container which contains both the sidenav content and the main app area. For some additional look and feel, an app toolbar and sidenav menu toolbar will be added.
<!-- src/app/app.component.html -->
<!-- this container wraps our entire app in the sidenav content container. this allows the sidenav to take up the entire content area -->
<mat-sidenav-container class="app-content">
<mat-sidenav
#appSideNav
[mode]="viewportMobileQuery.matches ? 'over' : 'side'"
[opened]="!viewportMobileQuery.matches"
[fixedInViewport]="viewportMobileQuery.matches"
>
<mat-toolbar color="primary">
<mat-toolbar-row>
<h1>Menu</h1>
<span class="fill-space"></span>
<button
mat-icon-button
*ngIf="viewportMobileQuery.matches"
(click)="appSideNav.close()"
>
<mat-icon>arrow_back</mat-icon>
</button>
</mat-toolbar-row>
</mat-toolbar>
<mat-nav-list>
<!-- set the `routerLink` on the `mat-list-item` that way it will route if any of the list item is clicked -->
<mat-list-item routerLink="/">
<mat-icon matListIcon>home</mat-icon>
<a matLine>Home</a>
</mat-list-item>
<mat-divider></mat-divider>
<mat-list-item routerLink="/accounts">
<mat-icon matListIcon>account_balance</mat-icon>
<a matLine>Accounts</a>
</mat-list-item>
<mat-list-item routerLink="/accounts/create">
<mat-icon matListIcon>add</mat-icon>
<a matLine>Create Account</a>
</mat-list-item>
</mat-nav-list>
</mat-sidenav>
<mat-sidenav-content class="main-content">
<mat-toolbar color="primary" class="main-toolbar">
<mat-toolbar-row>
<button
mat-icon-button
(click)="appSideNav.toggle()"
*ngIf="viewportMobileQuery.matches"
>
<mat-icon>menu</mat-icon>
</button>
<h1 routerLink="/" class="app-brand">Bank Account Manager</h1>
</mat-toolbar-row>
</mat-toolbar>
<main class="main-content-inner">
<section class="content-area">
<router-outlet></router-outlet>
</section>
</main>
</mat-sidenav-content>
</mat-sidenav-container>
This established our app shell and designates the area for our sidenav and our main app content. The viewportMobileQuery
is a media matcher that uses the size of our app viewport to determine if the viewing is in mobile sizing. Using this we can switch the sidenav from being always open on desktop apps, to being toggleable on smaller screens. It uses the angular ChangeDetectionRef
to pick up the viewport changes and adjust the view accordingly. That work is done in the src/app/app.component.ts
component file.
// src/app/app.component.ts
import { Component, ChangeDetectorRef, OnDestroy } from '@angular/core';
import { MediaMatcher } from '@angular/cdk/layout';
@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.scss'],
})
export class AppComponent implements OnDestroy {
viewportMobileQuery: MediaQueryList;
private _viewportQueryListener: () => void;
constructor(
private changeDetectionRef: ChangeDetectorRef,
private media: MediaMatcher
) {
this.viewportMobileQuery = media.matchMedia('(max-width: 600px)');
this._viewportQueryListener = () => changeDetectionRef.detectChanges();
this.viewportMobileQuery.addEventListener(
'change',
this._viewportQueryListener
);
}
ngOnDestroy(): void {
this.viewportMobileQuery.removeEventListener(
'change',
this._viewportQueryListener
);
}
}
Pretty straightforward, registers our media query based off the max-width (preferrably this would not be a hard-coded pixel width) and registers our query listener with the change dection ref. OnDestroy
we remove this listener.
To get the app to take the entire available content area, even if no content is filling it, we add some style classes in our src/app/app.component.scss
class.
// src/app/app.component.scss
// enforce the app content area container to take the entire available space
.app-content {
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
width: 100vw;
height: 100vh;
// set the width size of the sidenave
mat-sidenav {
width: 350px;
}
// sets up the main content area as a flexbox container with a column direction
.main-content {
display: flex;
flex-direction: column;
// uses flexbox to enforce the app toolbar is always present, even as the user scrolls down through content
.main-toolbar {
flex: 0 0 auto;
z-index: 999;
}
// uses flexbox to push the inner content area, where the router-outlet lives below the toolbar and grants
// it the rest of the available space with the ability to scroll
.main-content-inner {
flex: 1 1 auto;
position: relative; /* need this to position inner content */
overflow-y: auto;
.content-area {
padding: 15px 30px;
}
}
}
}
Cards & Lists
With our app shell and navigation setup, lets add a simple dashboard to show the last records transaction that utilizes the angular material cards and lists. We will add a view (sometimes called dumb or presentation) component that will received the last transaction and display the transaction information in a card. We will also add a container component that will provide the last transaction to the component. Note: This post isn’t about angular architecture, but it is a solid design principle to separate view/presentation/dumb components from smart/container components.
Generate the View Component called: dashboard
using the angular cli. We set the change detection strategy value to be OnPush
so that only new changes will be pushed to the component.
ng g component components/dashboard --changeDetection=OnPush
This will generate a component at src/app/components/dashboard/dashboard.component.[ts | html | scss]
and it will add it as a declaration import into the app.module
.
Lets update the src/app/components/dashboard/dashboard.component.ts component to add an @Input() setter | getter to retrieve the passed in transaction reference. |
// src/app/components/dashboard/dashboard.component.ts
// imports go here
@Component({
selector: 'app-dashboard',
templateUrl: './dashboard.component.html',
styleUrls: ['./dashboard.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class DashboardComponent implements OnInit {
private _lastTransaction: Transaction = null;
@Input() set lastTransaction(txn: Transaction) {
if (
!isNullOrUndefined(txn) &&
(isNullOrUndefined(this._lastTransaction) ||
this._lastTransaction.id !== txn.id)
) {
this._lastTransaction = txn;
}
}
get lastTransaction(): Transaction {
return this._lastTransaction;
}
constructor() {}
ngOnInit() {}
}
Very basic. We just want to display the last transaction info. Now let’s add a mat-card
with mat-list
to display the last transaction data in the component view
<!-- src/app/components/dashboard/dashboard.component.html -->
<mat-card>
<mat-card-header>
<mat-card-title>Last Account Transaction</mat-card-title>
</mat-card-header>
<mat-card-content>
<mat-list>
<mat-list-item>
<mat-icon matListIcon>info</mat-icon>
<h4 matLine>{{ lastTransaction.title }}</h3>
<p matLine>
<span class="text-caption">Transaction Title</span>
</p>
</mat-list-item>
<!-- rest of the transaction props would go here as <mat-list-item> -->
</mat-list>
</mat-card-content>
<mat-card-actions>
<button mat-raised-button color="primary">Edit Transaction</button>
</mat-card-actions>
</mat-card>
Cards are a building block of the material design spec. They are very easy to use and work for a multitude of use-cases. Lists are also great and have a variety of options including the mat-nav-list
in the sidenav on the home page above. Checkout the docs for more examples.
Tables
Tables are an integral part of almost any web application. Material Design utilizes the @angular/cdk
lib to build their table components. Out of the box, the material table is very powerful, easy to use, and full-featured with:
- filtering
- sorting
- pagination
- row selection/action
For our app, let’s implement a page to display a table of our accounts and use the mat-table
component. Our table will implement: filtering, sorting, and pagination.
To start, we will generate a new module (with routing) for our accounts (aptly named: accounts
); as with the material-design
module, we will put this at the src
directory level.
ng g module ../accounts --routing=true
To start, open the src/accounts/accounts.module.ts
file and import our MaterialDesignModule
; same as the AppModule
this gives us access to our imported material design modules. If you did not originally import the MatTableModule
& CdkTableModule
, please import/export those in the MaterialDesignModule
.
// src/accounts/accounts.module.ts
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { MaterialDesignModule } from 'src/material-design/material-design.module';
import { AccountsRoutingModule } from './accounts-routing.module';
@NgModule({
declarations: [],
imports: [CommonModule, MaterialDesignModule, AccountsRoutingModule],
})
export class AccountsModule {}
And now let’s generate a view component for our accounts table to live in. This component will receive a list of Accounts as input and use those to build the datasource for the mat table.
# generates the component in the src/accounts/components directory
# set the accounts module as the owning module
ng g component ../accounts/components/accounts-list --changeDetection=OnPush --module=accounts.module
With the component generated, let’s start with the src/accounts/components/accounts-list/accounts-list.component.ts
component file to set up the input for data and build the data source for our table. There is a bit that goes into this component to set up the table datasource and the filtering. We need to get the input set of accounts, set them as the data value on the instantiate data source. We then use the injected FormBuilder
to build a FormGroup
with a filter FormControl
for the users to use to filter the results. We also add ViewChild
declaration to register the MatSort
and MatPaginator
that are defined in the view component to the component backend and then the data source. If you don’t need sorting or pagination, these can be removed.
import {
Component,
OnInit,
ChangeDetectionStrategy,
Input,
AfterViewInit,
OnDestroy,
ViewChild,
Output,
EventEmitter,
} from '@angular/core';
import { MatPaginator } from '@angular/material/paginator';
import { MatSort } from '@angular/material/sort';
import { MatTableDataSource } from '@angular/material/table';
import { FormBuilder, FormGroup } from '@angular/forms';
import { Subject } from 'rxjs';
import { distinctUntilChanged, debounceTime, takeUntil } from 'rxjs/operators';
import { isNullOrUndefined } from 'util';
import { Account } from 'src/app/models/account.model';
@Component({
selector: 'app-accounts-list',
templateUrl: './accounts-list.component.html',
styleUrls: ['./accounts-list.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AccountsListComponent implements OnInit, AfterViewInit, OnDestroy {
private _accountsDataSource: MatTableDataSource<Account> =
new MatTableDataSource<Account>();
private _unsubscribe = new Subject<void>();
filterTableFormGroup: FormGroup = null;
@Input() set accounts(accounts: Account[]) {
if (!isNullOrUndefined(accounts)) {
// set data on data source to input accounts
this._accountsDataSource.data = accounts;
}
}
get accountsDataSource(): MatTableDataSource<Account> {
return this._accountsDataSource;
}
get columns(): string[] {
// return a string array of the columns in the table
// the order of these values will be the order your columns show up in
return ['id', 'title', 'opened', 'currBalance', 'info'];
}
// add ViewChild support for the table MatPagionator
// allows us to register the paginator with the MatTable
@ViewChild(MatPaginator, { static: true })
paginator: MatPaginator;
// add ViewChild support fot the table column sorting
// allows us to register the table column sorting with the Mat Table
@ViewChild(MatSort, { static: true })
sort: MatSort;
@Output() viewAccountDetails: EventEmitter<Account> =
new EventEmitter<Account>();
constructor(private fb: FormBuilder) {}
ngOnInit() {
// build the filter form group
// add a entry for the user to enter filter text
this.filterTableFormGroup = this.fb.group({
filter: [null, null],
});
// subscribe to changes that occur on the filterTableFormGroup.filter form control
// when these changes occur, filter the results of the table
this.filterTableFormGroup.controls['filter'].valueChanges
.pipe(
debounceTime(1500), // wait 1.5sec for the user to finish entering info before applying filter
distinctUntilChanged(), // only apply the filter if the entered value is distinct
takeUntil(this._unsubscribe) // once _unsubscribe is applied, stop the listener
)
.subscribe((value: string) => {
if (!isNullOrUndefined(value)) {
// apply the filter to the data source
value = value.trim().toLowerCase();
this.accountsDataSource.filter = value;
}
});
}
ngAfterViewInit() {
// register paginator & sort view shildren with the table data source
this.accountsDataSource.paginator = this.paginator;
this.accountsDataSource.sort = this.sort;
}
ngOnDestroy() {
// when the component is destroyed, call to _unsubscribe
// this will stop any active listeners on the component and free up resources
this._unsubscribe.next();
this._unsubscribe.complete();
}
// adds tracking for the data source for faster filtering, and sorting
trackByFn(account: Account) {
return account.id;
}
onViewAccountDetails(account: Account) {
// when clicked, output an event to the parent container to view the account details
// we do this so that the container can be responsible for how it wants to process this event
// i.e. open a dialog or maybe route to a details page
this.viewAccountDetails.emit(account);
}
}
With that built out, lets look at the src/accounts/components/accounts-list/accounts-list.component.html
view to add our filter form group and display our table. A few things to note, the order of the columns in your table is determined by the column order returned in get columns(): string[]
in the component above. It does not matter what order you put the elements inside the table in; you must define a matColumnDef
component for every column defined by the get columns(): string[]
as well.
<mat-card>
<mat-card-header>
<mat-card-title>
<h1>Accounts</h1>
</mat-card-title>
</mat-card-header>
<mat-card-content>
<!-- Form Container for our filter form group for the user to filter the accounts list -->
<form novalidate [formGroup]="filterTableFormGroup">
<mat-form-field appearance="outline" class="full-width-input">
<mat-label>Accounts Filter</mat-label>
<span matPrefix><mat-icon>search</mat-icon></span>
<input
matInput
formControlName="filter"
placeholder="Search by account Title"
/>
</mat-form-field>
</form>
<!-- mat tabe container. assign our data source, add sorting, assign the tracking function -->
<mat-table [dataSource]="accountsDataSource" matSort [trackBy]="trackByFn">
<!-- define our table columns. you must have a column for every column defined in your columns string array -->
<!-- the matColumnDef value needs to be the value of a column you defined -->
<!-- the order of the columns is determined by the order specified in the columns() value -->
<ng-container matColumnDef="id">
<!-- define the header for the id column. add sorting -->
<mat-header-cell *matHeaderCellDef mat-sort-header>
Id
</mat-header-cell>
<!-- define the cell that will contain the data for each record in the data source -->
<!-- row gives you access to the Account record for a given row in the data source -->
<mat-cell *matCellDef="let row">
{{ row.id }}
</mat-cell>
</ng-container>
<ng-container matColumnDef="title">
<mat-header-cell *matHeaderCellDef mat-sort-header>
Title
</mat-header-cell>
<mat-cell *matCellDef="let row">
{{ row.title }}
</mat-cell>
</ng-container>
<ng-container matColumnDef="opened">
<mat-header-cell *matHeaderCellDef mat-sort-header>
Opened
</mat-header-cell>
<mat-cell *matCellDef="let row">
{{ row.opened | date: 'm/d/yy' }}
</mat-cell>
</ng-container>
<ng-container matColumnDef="currBalance">
<mat-header-cell *matHeaderCellDef mat-sort-header>
Balance
</mat-header-cell>
<mat-cell *matCellDef="let row">
{{ row.currBalance | currency: 'USD':'symbol':'2.2-2' }}
</mat-cell>
</ng-container>
<ng-container matColumnDef="info">
<mat-header-cell *matHeaderCellDef mat-sort-header>
Details
</mat-header-cell>
<mat-cell *matCellDef="let row">
<button
mat-icon-button
color="accent"
(click)="onViewAccountDetails(row)"
>
<mat-icon>info</mat-icon>
</button>
</mat-cell>
</ng-container>
<!-- define the header row for the given columns -->
<mat-header-row *matHeaderRowDef="columns"></mat-header-row>
<!-- define the rows and columns for each row in the data source -->
<mat-row *matRowDef="let row; columns: columns"></mat-row>
</mat-table>
<!-- add table pagination -->
<mat-paginator
#paginator
[pageSize]="25"
[pageSizeOptions]="[5, 10, 15, 25, 50, 100]"
[showFirstLastButtons]="true"
>
</mat-paginator>
</mat-card-content>
</mat-card>
And that is it! We now have a table to display our accounts data. Check out the docs for advanced use-cases as well as further information.
Forms
User-entry forms is another key component to any web application. Form feedback and style is incredibly important to make sure the form is user-friendly and communicates to the user any info they may need: what fields are required, what fields are invalid and why, any hints or further information the user might need for the input, etc. The suite of material design form components integrate with both Template-Driven and Reactive Forms provided by angular. This integration makes it very easy to build beautiful form that provide all of the information, validation, and feedback the user will need to make form entry smooth and easy. Check out the docs here.
For our application, let’s add a component that allows users to create a new account. We will have a form group with controls for: title, account type (with a select dropdown), opened (with a date picker), current balance, and active (with a checkbox). Each field will be required and we will show validation messages and a couple hints as well. This will be a good entry into the material design form components. Note: we will be using the ReactiveFormsModule
for this, check out the docs here.
First, let’s build a form-builder provider class that we will inject into our component. It will contain the logic to build the account FormGroup
.
import { Injectable } from '@angular/core';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
@Injectable()
export class AccountBuilder {
constructor(private fb: FormBuilder) {}
/**
* Build and return a `FormGroup` with the required fields and
* validation for the Account creation
*/
public build(): FormGroup {
return this.fb.group({
id: [null, null],
title: [null, Validators.required],
accountType: [null, Validators.required],
opened: [null, Validators.required],
currBalance: [0.0, Validators.required],
active: [true, Validators.required],
});
}
}
This is just my preferred pattern. You do not have to build your form in this way. If you want to build it in the component, go right ahead. If you do decide to use this pattern, make sure to add it tot he src/accounts/accounts.module
file as a provider
to make it available for dependency injection.
Now, let’s generate the view component that will contain for the form group and form controls. Our component class will be pretty light as it should not be responsible for the actual processing of the submitted form; just build the form group and on submit, output to the calling container.
import {
Component,
OnInit,
ChangeDetectionStrategy,
Output,
EventEmitter,
Input,
} from '@angular/core';
import { FormGroup } from '@angular/forms';
import { Account } from 'src/app/models/account.model';
import * as fromBuilders from 'src/accounts/form-builders';
import { isNullOrUndefined } from 'util';
@Component({
selector: 'app-create-account',
templateUrl: './create-account.component.html',
styleUrls: ['./create-account.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class CreateAccountComponent implements OnInit {
private _accountTypes: string[] = null;
accountFormGroup: FormGroup = null;
@Input() set accountTypes(types: string[]) {
if (!isNullOrUndefined(types)) {
this._accountTypes = types;
}
}
get accountTypes(): string[] {
return this._accountTypes;
}
@Output() createAccountEmitter: EventEmitter<Account> =
new EventEmitter<Account>();
constructor(private accountBuilder: fromBuilders.AccountBuilder) {}
ngOnInit() {
// build the account form group using the AccountBuilder
this.accountFormGroup = this.accountBuilder.build();
}
onSubmit(account: Account) {
// emit the user-submitted account to the calling container
this.createAccountEmitter.emit(account);
}
}
And now let’s take a look at the view where we add the components for the mat-form-fields
to build our form. Think of the mat-form-field
component as a container for form entry that allows you to bundle the input, a label, any hints, error messages, etc. Checkout the mat form field docs for more examples and the API.
<mat-card>
<mat-card-header>
<mat-card-title> Create Account </mat-card-title>
</mat-card-header>
<mat-card-content>
<form
novalidate
[formGroup]="accountFormGroup"
(submit)="onSubmit(accountFormGroup.value)"
>
<mat-form-field
appearance="outline"
class="full-width-input"
color="primary"
>
<mat-label>Account Title</mat-label>
<input matInput formControlName="title" required />
<mat-error *ngIf="accountFormGroup.controls['title'].invalid">
Account Title is required
</mat-error>
</mat-form-field>
<mat-form-field
appearance="outline"
class="full-width-input"
color="primary"
>
<mat-label>Account Type</mat-label>
<mat-select required formControlName="accountType">
<mat-option
*ngFor="let accountType of accountTypes"
[value]="accountType"
>
{{ accountType }}
</mat-option>
</mat-select>
<mat-error *ngIf="accountFormGroup.controls['accountType'].invalid">
Account Type is required
</mat-error>
</mat-form-field>
<mat-form-field
appearance="outline"
class="full-width-input give-me-some-space top margin-25"
color="primary"
>
<mat-label>When was the Account Opened</mat-label>
<input
matInput
[matDatepicker]="picker"
placeholder="Choose a date"
formControlName="opened"
required
/>
<mat-datepicker-toggle matSuffix [for]="picker"></mat-datepicker-toggle>
<mat-datepicker #picker></mat-datepicker>
<mat-error *ngIf="accountFormGroup.controls['opened'].invalid">
Account Opened date is required
</mat-error>
</mat-form-field>
<mat-form-field
appearance="outline"
class="full-width-input give-me-some-space top margin-25"
color="primary"
hintLabel="What is the current balance in the account"
>
<mat-label>Account Current Balance</mat-label>
<span matPrefix><mat-icon>attach_money</mat-icon></span>
<input matInput formControlName="currBalance" type="number" required />
<mat-error *ngIf="accountFormGroup.controls['currBalance'].invalid">
Account Current Balance is required
</mat-error>
</mat-form-field>
<section class="full-width-input give-me-some-space top margin-25">
<mat-checkbox formControlName="active">Account is Active</mat-checkbox>
</section>
<section class="full-width-input give-me-some-space top margin-25">
<button
type="submit"
mat-raised-button
color="primary"
[disabled]="accountFormGroup.invalid"
>
Create Account
</button>
</section>
</form>
</mat-card-content>
</mat-card>
This creates a good-looking and clean form component that provides friendly feedback to the user as well as providing some hints and quickly shows what is required. Material design put a lot of thought into forms and the suite contains a lot of other components like autocomplete, radio buttons, etc.
Popups, Modals, Indicators
User feedback is a key to good design principles and the user-experience (UX). This comes in a variety of ways: loading indicators, popups, modals for interactions, etc. It creates an importance and draws the user to it. These components can definitely be overused, so proceed with caution and put yourself in the users mindset. If your avgerage load time is 200ms, is it worth it to have a loading indicator; or is it less jarring to just let the content load. Can you alert the user as to the success/failure of their actions without popups? Should you introduce a modal for a complex user-entry form? These are all questions to consider when designing your application.
That being said, they do have their uses and the angular material implementation of them comes from lots of research and experience to provide the user with the feedback info they need, without creating a jarring experience.
Indicators
To start, we will begin with loading indicators. Assume that our accounts list is massive, or we have a very slow backend serving us requests, and we want the user to know that yes, we are loading their accounts, just give us a second. To do this, we will add a progress bar to our account list container component that will show an indeterminate progress bar until the accounts are “loaded”, and then it will disappear. Open the src/accounts/containers/accounts-list-container/accounts-list-container.component.ts
file and we are going to force a 2sec load time. This requires that our MaterialDesignModule
has imported the MatProgressBarModule
so open the module and validate and add if necessary.
// src/accounts/containers/accounts-list-container/accounts-list-container.component.ts
...
// create a boolean observable value with an initial value of true
loading$: Subject<boolean> = new BehaviorSubject<boolean>(true);
constructor() {}
ngOnInit() {
// wait 2sec then set loading$ to false
setTimeout(() => {
this.loading$.next(false);
}, 2000);
}
...
When the component OnInit
lifecycle hook is hit, wait 2sec (2000ms) and then set the loading$
value to false. Now we need to update our view to remove the app-accounts-list
call if loading$ === true
& show the indeterminate mat-progress-bar
.
<app-accounts-list
[accounts]="accounts$ | async"
(viewAccountDetails)="viewAccountDetails($event)"
*ngIf="!(loading$ | async)"
></app-accounts-list>
<!-- indeterminate progress bar --->
<section *ngIf="loading$ | async">
<h1 class="display-1">Loading Accounts</h1>
<mat-progress-bar mode="indeterminate"></mat-progress-bar>
</section>
And now we have a progress bar loading indicator that informs our users that an action is taking place and their data is loading. Checkout the docs on progress bars here and progress spinners here.
Popups (more specifically, snack bars)
Snack bars are a great way to provide the user with feedback that their action has completed or for things like push notifications. They are non-intrusive and can be manually closed and/or can be closed after a given wait period. This requires the MatSnackbarModule
to be imported. Check our MaterialDesignModule
and validate that we are importing/exporting this module; add if necessary.
We are going to switch our create account console log to instead open a snackbar that informs the user that their account was created successfully. Open the src/accounts/containers/create-account-container/create-account-container.component.ts
file and lets add support for the snackbar.
import { Component, OnInit, ChangeDetectionStrategy } from '@angular/core';
import { MatSnackBar } from '@angular/material/snack-bar';
import { Observable, of } from 'rxjs';
import { Account } from 'src/app/models/account.model';
@Component({
selector: 'app-create-account-container',
templateUrl: './create-account-container.component.html',
styleUrls: ['./create-account-container.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class CreateAccountContainerComponent implements OnInit {
accountTypes$: Observable<string[]> = of([
'CHECKING',
'SAVINGS',
'CREDIT CARD',
'LOAN',
'MISC',
]);
// snackbar is an Injectable component. Use DI to inject an instance of MatSnackBar
// we will use this to open a simple snackbar to display the data
constructor(private snackBar: MatSnackBar) {}
ngOnInit() {}
private openSnackBarForAccountCreate(account: Account) {
// use the inject MatSnackBar instance to open a snack bar
// display the title of the account and append: " was created"
// dispay the snackbar for 2sec (2000ms)
const message = `${account.title} was created!`;
const action = 'Party!';
this.snackBar.open(message, action, {
duration: 2000,
});
}
createAccount(account: Account) {
// open a snackbar that tells the user their account was created
this.openSnackBarForAccountCreate(account);
}
}
Boom. A snackbar. There is a lot of additional configuration you can add, such as having a custom template or component for your snackbar. You can also hook into the dismissal action to do custom things like load a details page of the created account or undo the action, etc. Check out the docs for more info.
Modals/Dialogs
In material design parlance, modals are called Dialogs. Personally, I like dialogs to be quite simple things that display data or ask for a simple user entry. When opened, they get the full focus of the app and darken the app behind it. I also like to treat dialogs like I do view components: any data they need should be passed in and they should not responsible for doing the actual work but instead should return the user response back to the calling container to do the work. Dialogs require importing the MatDialogModule
. Another note, dialog components need to be added to the entryComponents
array in the owning module.
For this app, we will create a dialog component that will take an Account
record and display its details, including any associate transactions.
To start, generate our dialog component; some sweet schematics action.
ng g component ../accounts/components/account-details-dialog --changeDetection=OnPush --module=accounts.module
This will generate the component and add it to the declarations array in the accounts.module
, that is super great; but remember we also need to add it to the entryComponents
array in the NgModule
declaration.
Open up the src/accounts/components/account-details-dialog/account-details-dialog.component.ts
file to set it up as a dialog component and ingest our dialog data of our Account that we want to view the details for.
import { Component, ChangeDetectionStrategy, Inject } from '@angular/core';
import { MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog';
import { Account } from 'src/app/models/account.model';
@Component({
selector: 'app-account-details-dialog',
templateUrl: './account-details-dialog.component.html',
styleUrls: ['./account-details-dialog.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AccountDetailsDialogComponent {
constructor(
// MatDialogRef of this dialog component
// gives us ref access to the dialog so we can close it and return data as necessar
// it contains its own set of lifecycle hooks for this dialog component
private dialogRef: MatDialogRef<AccountDetailsDialogComponent>,
// when the dialog is opened it is passed an account object
// this injects that data so we can view the Account details
// this is an object and can be passed multiple pieces of data
@Inject(MAT_DIALOG_DATA) public account: Account
) {}
onCloseClick() {
// close the dialog
// if you need to pass data back to the calling component,
// you pass it to the close method
this.dialogRef.close();
}
}
As this dialog will just be used to view the Account details, this is pretty light. Open the view and add the account details. The dialog module comes with a mat-dialog-content
component which we will wrap the content in. This does the styling for us and allows us to add actions and theming. Inside our content we will bring in the mat-tab-group
(requires MatTabsModule
) to display the account details in 2 tabs: 1) the details, 2) associated transactions list.
<h1 mat-dialog-title>Account Details</h1>
<section mat-dialog-content>
<mat-tab-group>
<mat-tab label="Account Details">
<mat-list>
<mat-list-item>
<mat-icon matListIcon>info</mat-icon>
<h3 matLine>{{ account.title }}</h3>
<p matLine>
<span class="text-caption">Account Title</span>
</p>
</mat-list-item>
<mat-list-item>
<mat-icon matListIcon>card</mat-icon>
<h3 matLine>{{ account.accountType }}</h3>
<p matLine>
<span class="text-caption">Account Type</span>
</p>
</mat-list-item>
<mat-list-item>
<mat-icon matListIcon>today</mat-icon>
<h3 matLine>
{{ account.opened | date: 'm/d/yy' }}
</h3>
<p matLine>
<span class="text-caption">Account Opened Date</span>
</p>
</mat-list-item>
<mat-list-item>
<mat-icon matListIcon>attach_money</mat-icon>
<h3 matLine>
{{ account.currBalance | currency: 'USD':'symbol':'2.2-2'
}}
</h3>
<p matLine>
<span class="text-caption">Current Balance</span>
</p>
</mat-list-item>
<mat-list-item>
<mat-icon matListIcon>
{{ account.active ? 'check' : 'warning' }}
</mat-icon>
<p matLine>
<span class="text-caption">Account Active</span>
</p>
</mat-list-item>
</mat-list>
</mat-tab>
<mat-tab label="Transactions">
<mat-list>
<mat-list-item *ngFor="let txn of account.transactions">
<mat-icon matListIcon>
{{ txn.transactionType === 'DEBIT' ? 'arrow_upward' :
'arrow_downward' }}
</mat-icon>
<h3 matLine>{{ txn.amount }}</h3>
<h4 matLine>{{ txn.title }}</h4>
<p matLine>
<span class="text-caption">
{{ txn.transactionType + ', ' + txn.paymentType + ', ' +
(txn.transactionDate | date: 'm/d/yy') }}
</span>
</p>
</mat-list-item>
</mat-list>
</mat-tab>
</mat-tab-group>
</section>
<section mat-dialog-actions>
<span class="fill-space"></span>
<button mat-icon-button color="warn" (click)="onCloseClick()">
<mat-icon>close</mat-icon>
</button>
</section>
I would usually recommend building components for the account details and the transactions list and bringing those in. But for brevity I included everything in this component. Notice the mat-dialog-actions
component which separates out any actions (think buttons) that we want to include into our dialog. For this I simply have a button that when clicked will close the dialog. Dialogs have quite a robust API and can be utilized to do a lot of actions. For more info, checkout the API docs.
Conclusion & Closing Remarks
There is a lot more to the Angular Material API; this post provides a good starting point but I highly recommend going through the docs and trying out the different components. Component-based design architecture provides a huge advantage over trying to roll out your own app design or using a pure-css based (like bootstrap or bulma) design pattern, as the css is baked into the components (following angular architecture patterns) and inherits theming as well as consistency. Plus it has the backing and research of the google design team.
Hope you enjoyed the post. Look for more angular goodness in the future.