In this epic tutorial, we’re going to build some advanced Angular (v2+) components that rebuild Tesla’s battery range calculator and then compile it to AoT and deploy on GitHub pages. We’ll be using the reactive forms API as well and building custom form controls and use some stateful and stateless component practices, as well as change detection strategies.
This is the final project gif
of what we’re about to build:
Check out the live version before we get started
We’ll be building the above app step by step, so you can follow along with the tutorial.
Table of contents
Straight to the source code? Go here!
Setup and Angular CLI
Head over to the Angular CLI website and familiarise yourself with it. We’ll be running our local server and deploying with it.
Versions: this tutorial uses CLI version
1.0.0-beta.22-1
and Angular2.2.3
New GitHub repo
First step, you’ll need a GitHub account if you actually want to deploy this to a GitHub pages instance. Go to GitHub and create your own repo called angular-tesla-range-calculator
.
Tip: It’s a good idea to name your
repo
the same as thecli
project you’re about to create
CLI installation
Let’s assume you’ve just created a repo called angular-tesla-range-calculator
and are available to commit code to it from your machine. If you’ve not got the Angular CLI, you’ll want to run:
npm install -g angular-cli
Then (note the same name as the repo):
cd # e.g. /Users/toddmotto/git
ng new angular-tesla-range-calculator
It’ll take a few moments to download the required dependencies for the project. Then we can add the project to the remote:
cd angular-tesla-range-calculator
git remote add origin https://github.com//angular-tesla-range-calculator.git
git push -u origin master
Now if you check back on GitHub, the project should be there. Voila. Now we’ll get started.
Serving the project
Now we’re ready to roll, so let’s boot up our application:
ng serve # or npm start
Then you’ll be able to hit localhost:4200
and see the app running.
Project images/assets
We’ll make this easy and just drop in all our images before we really get started.
- Download
assets.zip
(all image) - Download the
favicon.ico
(favicon ‘cos why not)
Once you’re done, unzip the assets.zip
folder and replace the downloaded favicon with the one in the project, and locate:
angular-tesla-range-calculator/src/assets/
And then just drop all the images in there (and replace the favicon.ico
in the root).
Root and sub-modules
First thing we’ll do is create our sub-module, a feature specific module for handling our Tesla app.
Directories: Everything we’re going to do with be inside
/src/app/
so any folder references will refer to in there
Root @NgModule
First up, change your app.module.ts
to this (remove comments if you like):
/*
* app.module.ts
*/
import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';
// our feature module
import { TeslaBatteryModule } from './tesla-battery/tesla-battery.module';
// our app component
import { AppComponent } from './app.component';
@NgModule({
declarations: [
AppComponent
],
imports: [
BrowserModule,
// include our TeslaBatteryModule
TeslaBatteryModule
],
providers: [],
// bootstrap the AppComponent
bootstrap: [AppComponent]
})
export class AppModule {}
This will error if we save the file as our module doesn’t exist just yet, so let’s create it.
Tesla Sub-module
From the above code example, you can see we’re importing our tesla-battery
module, so next up we want to create a new folder:
**/src/app/tesla-battery/
Inside here, create two files:
tesla-battery.module.ts
tesla-battery.service.ts
Any time you feel like you’re missing a step or unsure if you’re putting something in the right place, check the full source code as a reference.
Inside your tesla-battery.module.ts
file, paste this in:
/*
* tesla-battery.module.ts
*/
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { ReactiveFormsModule } from '@angular/forms';
// services
import { BatteryService } from './tesla-battery.service';
@NgModule({
declarations: [],
imports: [
CommonModule,
ReactiveFormsModule
],
providers: [
// add the service to our sub-module
BatteryService
],
exports: []
})
export class TeslaBatteryModule {}
We’ll be populating this with new components as we go.
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
Injectable data service
The data from Tesla’s website is actually hard-coded *.json
files that live on the server, I ripped them apart and created a new datastructure that made it easier to access properties once our values change.
IMPORTANT: The data file is hard-coded, and absolutely huge - so go here.
Once you’ve copied the data from the above link, our service will look a little like this:
/*
* tesla-battery.service.ts
*/
import { Injectable } from '@angular/core';
@Injectable()
export class BatteryService {
constructor() {}
getModelData(): Object {
return {...};
}
}
The Injectable
is a decorator from Angular that allows us to inject our service into component, thus inferring they’re “smart” components. Once you’ve copy and pasted the enormous amount of data into your service, move onto the next step. We’ll come back to the datastructure later.
Container and presentational components
This is a new idea I’m currently working with in my Angular apps, separating “container” and “presentational” components, otherwise known as stateful and stateless components which I’ve previously written about, I’d urge you to check that out if you’re up for further reading.
The idea is that stateful components, which we’ll refer to as “container” components in the rest of this tutorial, will live inside our module’s containers
directory. Any stateless components, i.e. presentational components, will just live inside components
.
So, go ahead and create these two directories:
**/src/app/tesla-battery/containers
**/src/app/tesla-battery/components
A container component is in charge of sourcing data and delegating it down into smaller, more focused components. Let’s start with our container component (we only need one in this tutorial), so go ahead and create our first component directory tesla-battery
:
**/src/app/tesla-battery/containers/tesla-battery/
Inside **/containers/tesla-battery/
you should create two files:
tesla-battery.component.ts
tesla-battery.component.scss
Why no tesla-battery.component.html
? At the moment I enjoy using template
instead of a template file, it helps reduce context switching and keeps my thinking contained. With the CLI, you’re welcome to use templateUrl
should you wish to.
Next up, add these styles to your tesla-battery.component.scss
file:
.tesla-battery {
width: 1050px;
margin: 0 auto;
h1 {
font-family: 'RobotoNormal';
font-weight: 100;
font-size: 38px;
text-align: center;
letter-spacing: 3px;
}
&__notice {
margin: 20px 0;
font-size: 15px;
color: #666;
line-height: 20px;
}
}
.tesla-climate {
float: left;
width: 420px;
padding: 0 40px;
margin: 0 40px 0 0;
border-left: 1px solid #ccc;
border-right: 1px solid #ccc;
}
.tesla-controls {
display: block;
width: 100%;
}
FormGroup setup
We’re going to be using a FormGroup
in our component to define the data structure for the view.
Read more here on reactive forms
Inside your tesla-battery.component.ts
file:
/*
* tesla-battery.component.ts
*/
import { Component, OnInit } from '@angular/core';
import { FormBuilder, FormGroup } from '@angular/forms';
@Component({
selector: 'tesla-battery',
template: `
<form class="tesla-battery" [formGroup]="tesla">
<h1>{{ title }}</h1>
<div class="tesla-battery__notice">
<p>
The actual amount of range that you experience will vary based
on your particular use conditions. See how particular use conditions
may affect your range in our simulation model.
</p>
<p>
Vehicle range may vary depending on the vehicle configuration,
battery age and condition, driving style and operating, environmental
and climate conditions.
</p>
</div>
</form>
`,
styleUrls: ['./tesla-battery.component.scss']
})
export class TeslaBatteryComponent implements OnInit {
title: string = 'Range Per Charge';
tesla: FormGroup;
constructor(public fb: FormBuilder) {}
ngOnInit() {
this.tesla = this.fb.group({
config: this.fb.group({
speed: 55,
temperature: 20,
climate: true,
wheels: 19
})
});
}
}
This is pretty much good for now. Go back to tesla-battery.module.ts
and let’s import the new component:
// containers
import { TeslaBatteryComponent } from './containers/tesla-battery/tesla-battery.component';
Our @NgModule()
needs to also look like this:
@NgModule({
declarations: [
// registering our container component
TeslaBatteryComponent
],
imports: [
CommonModule,
ReactiveFormsModule
],
providers: [
// add the service to our sub-module
BatteryService
],
exports: [
// exporting so our root module can access
TeslaBatteryComponent
]
})
export class TeslaBatteryModule {}
We’re using exports
to export that particular component from our module, so we can use it in other modules that our TeslaBatteryModule
is imported into.
Wiring into the app component
Jump across to app.component.ts
and replace the entire file with this:
import { Component } from '@angular/core';
@Component({
selector: 'app-root',
template: `
<header class="header">
<img [src]="logo">
</header>
<div class="wrapper">
<tesla-battery></tesla-battery>
</div>
`,
styleUrls: ['./app.component.scss']
})
export class AppComponent {
logo: string = 'assets/logo.svg';
}
Cannot find module “./app.component.scss” - if you see this, rename your
app.component.css
toapp.component.scss
so we can use Sass
Now open up app.component.scss
and add this:
:host {
display: block;
}
.header {
padding: 25px 0;
text-align: center;
background: #222;
img {
width: 100px;
height: 13px;
}
}
.wrapper {
margin: 100px 0 150px;
}
You should hopefully see some text in the app now as well as the logo header, but we need to add some more styling to our global styles.css
file. Locate that file in the root of your project and replace the contents with this:
@font-face {
font-family: 'RobotoNormal';
src: url('./assets/fonts/Roboto-Regular-webfont.eot');
src: url('./assets/fonts/Roboto-Regular-webfont.eot?#iefix') format('embedded-opentype'),
url('./assets/fonts/Roboto-Regular-webfont.woff') format('woff'),
url('./assets/fonts/Roboto-Regular-webfont.ttf') format('truetype'),
url('./assets/fonts/Roboto-Regular-webfont.svg#RobotoRegular') format('svg');
font-weight: normal;
font-style: normal;
}
*, *:before, *:after {
box-sizing: border-box;
margin: 0;
padding: 0;
font: 300 14px/1.4 'Helvetica Neue', Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
}
.cf:before,
.cf:after {
content: '';
display: table;
}
.cf:after {
clear: both;
}
.cf {
*zoom: 1;
}
Upon saving this file, things will look a lot nicer. Right - back to the components!
Car component
Go ahead and create a /tesla-car/
directory inside a new /components
directory (where we’ll keep our “stateless” components):
**/src/app/tesla-battery/components/tesla-car/
Then inside of there, create these two components:
tesla-car.component.ts
tesla-car.component.scss
This is what will produce our car image and make the wheels spin:
/*
* tesla-car.component.ts
*/
import { Component, Input, ChangeDetectionStrategy } from '@angular/core';
@Component({
selector: 'tesla-car',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="tesla-car">
<div class="tesla-wheels">
<div class="tesla-wheel tesla-wheel--front tesla-wheel--{{ wheelsize }}"></div>
<div class="tesla-wheel tesla-wheel--rear tesla-wheel--{{ wheelsize }}"></div>
</div>
</div>
`,
styleUrls: ['./tesla-car.component.scss']
})
export class TeslaCarComponent {
@Input() wheelsize: number;
constructor() {}
}
We’re also telling Angular not to bother with change detection in this component by using
ChangeDetectionStrategy.OnPush
, which Angular will tell the component to treat props coming down through the@Input()
as immutable.
Now some styles for the tesla-car.component.scss
file:
.tesla-car {
width: 100%;
min-height: 350px;
background: #fff url(assets/tesla.jpg) no-repeat top center;
background-size: contain;
}
.tesla-wheels {
height: 247px;
width: 555px;
position: relative;
margin: 0 auto;
}
.tesla-wheel {
height: 80px;
width: 80px;
bottom: 0;
position: absolute;
background-repeat: no-repeat;
background-position: 0 0;
background-size: cover;
&--front {
left: 53px;
}
&--rear {
right: 72px;
}
&--19 {
background-image: url(assets/wheel-19.png);
-webkit-animation: infinite-spinning 250ms steps(6) infinite;
-moz-animation: infinite-spinning 250ms steps(6) infinite;
-o-animation: infinite-spinning 250ms steps(6) infinite;
animation: infinite-spinning 250ms steps(6) infinite;
}
&--21 {
background-image: url(assets/wheel-21.png);
-webkit-animation: infinite-spinning 480ms steps(12) infinite;
-moz-animation: infinite-spinning 480ms steps(12) infinite;
-o-animation: infinite-spinning 480ms steps(12) infinite;
animation: infinite-spinning 480ms steps(12) infinite;
}
}
@keyframes infinite-spinning {
from {
-webkit-transform: rotate(0deg);
-moz-transform: rotate(0deg);
-ms-transform: rotate(0deg);
-o-transform: rotate(0deg);
transform: rotate(0deg);
}
to {
-webkit-transform: rotate(360deg);
-moz-transform: rotate(360deg);
-ms-transform: rotate(360deg);
-o-transform: rotate(360deg);
transform: rotate(360deg);
}
}
@-webkit-keyframes infinite-spinning {
from {
-webkit-transform: rotate(0deg);
-moz-transform: rotate(0deg);
-ms-transform: rotate(0deg);
-o-transform: rotate(0deg);
transform: rotate(0deg);
}
to {
-webkit-transform: rotate(360deg);
-moz-transform: rotate(360deg);
-ms-transform: rotate(360deg);
-o-transform: rotate(360deg);
transform: rotate(360deg);
}
}
This gives us our animations and the component base for the car, which is displayed as background images. The @Input()
value will be the wheel size that we need to pass in, but first we need to add these components to our module again (back to tesla-battery.module.ts
):
...
/* put this code below the // containers piece */
// components
import { TeslaCarComponent } from './components/tesla-car/tesla-car.component';
...
@NgModule({
declarations: [
TeslaBatteryComponent,
// new addition
TeslaCarComponent
],
...
})
...
We don’t need to export
this component as we’re only using it locally to this module.
Rendering the car
Jump back into tesla-battery.component.ts
and add the component with the [wheelsize]
binding:
...
@Component({
selector: 'tesla-battery',
template: `
<form class="tesla-battery" [formGroup]="tesla">
<h1>{{ title }}</h1>
<tesla-car [wheelsize]="tesla.get('config.wheels').value"></tesla-car>
...
...
</form>
`
})
...
Because we’re using the FormBuilder
, we can access the config.wheels
property (which sets the default wheelsize like Tesla’s website does) through the tesla.get()
method, which returns us the form control. So all we’re doing here is accessing the .value
property and delegating it into the <tesla-car>
component through the @Input()
binding we just setup.
Here’s what you should be seeing:
At this point you could go change the wheels: 19
value in the FormGroup
to 21
to see the wheel size change, however we’ll be building that soon.
Stats component
Now we’re going to render the stats for each Tesla car model.
Go ahead and create a /tesla-stats/
directory inside the /components
directory just like our previous component:
**/src/app/tesla-battery/components/tesla-stats/
Then inside of there, create these two components:
tesla-stats.component.ts
tesla-stats.component.scss
Before we dive in, we need to define an interface
for our “stats”, save this as stat.interface.ts
inside a new /models/
directory in our tesla-battery
root:
// src/app/tesla-battery/models/stat.interface.ts
export interface Stat {
model: string,
miles: number
}
Each stat will contain the name of the Tesla car model
as well as the miles
associated with the model based on the specific calculations we implement (this will become apparent as we continue).
Now we’ll define the stats component:
/*
* tesla-stats.component.ts
*/
import { Component, Input, ChangeDetectionStrategy } from '@angular/core';
import { Stat } from '../../models/stat.interface';
@Component({
selector: 'tesla-stats',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="tesla-stats">
<ul>
<li *ngFor="let stat of stats">
<div class="tesla-stats-icon tesla-stats-icon--{{ stat.model | lowercase }}"></div>
<p>{{ stat.miles }}</p>
</li>
</ul>
</div>
`,
styleUrls: ['./tesla-stats.component.scss']
})
export class TeslaStatsComponent {
@Input() stats: Stat[];
}
This component is purely stateless as well, and takes a single @Input()
of the stats
. You can see we’re expecting a Stat[]
, which means an Array of Stat
Objects that align with our interface.
All this component is doing is iterating with *ngFor
over the stats that are passed in, and will then bind a specific class to the element based on the stat.model
, which will allow us to swap out the background images to display the Tesla models.
Onto the CSS, drop this into tesla-stats.component.scss
:
.tesla-stats {
margin: -70px 0 30px;
ul {
text-align: center;
li {
display: inline-block;
width: 130px;
position: relative;
p {
font-size: 40px;
font-weight: normal;
font-family: 'RobotoNormal';
display: block;
padding: 0 18px 0 0;
position: relative;
color: #008dff;
text-align: right;
&:after {
font-size: 14px;
font-weight: normal;
font-family: 'RobotoNormal';
content: 'MI';
position: absolute;
top: 8px;
right: 0;
}
}
}
}
&-icon {
height: 20px;
background-size: auto 13px;
background-position: top right;
background-repeat: no-repeat;
&--60 {
background-image: url(assets/models/60.svg);
}
&--60d {
background-image: url(assets/models/60d.svg);
}
&--75 {
background-image: url(assets/models/75.svg);
}
&--75d {
background-image: url(assets/models/75d.svg);
}
&--90d {
background-image: url(assets/models/90d.svg);
}
&--p100d {
background-image: url(assets/models/p100d.svg);
}
}
}
You’ll notice at the end that we have values such as &--60
and &--p100d
being extended from the icon
class, where we appropriately swap out the SVG backgrounds. These are the car models we’ll hook up and render momentarily.
Back to our tesla-battery.module.ts
, we need to add:
...
import { TeslaStatsComponent } from './components/tesla-stats/tesla-stats.component';
@NgModule({
declarations: [
TeslaBatteryComponent,
TeslaCarComponent,
// new addition
TeslaStatsComponent
],
...
})
...
Stats and datastructure models
We’ve already implemented the huge amount of data for our tesla-battery.service.ts
, which we did at the beginning of this tutorial. Now it’s time to get the data and start rendering it.
Jump back into your tesla-battery.component.ts
file and add the following imports, to grab our Stat
interface and our BatteryService
:
import { Stat } from '../../models/stat.interface';
import { BatteryService } from '../../tesla-battery.service';
We’ve already dependency injected the FormBuilder
, so now it’s time to add our service, ensure the top of your tesla-battery.component.ts
looks like this:
// tesla-battery.component.ts
@Component({...})
export class TeslaBatteryComponent implements OnInit {
title: string = 'Range Per Charge';
models: any;
stats: Stat[];
tesla: FormGroup;
private results: Array = ['60', '60D', '75', '75D', '90D', 'P100D'];
constructor(public fb: FormBuilder, private batteryService: BatteryService) {}
...
...
}
A few additions here, the models
which I’ve just set to any
, a stats
property which will again be our array of Stat
Objects. The private results
is a list of the Tesla models that will then be passed down into the child component for rendering and switching out with the correct background image - but before they reach the child component they’ll be processed against our data model to return the mileage estimates Tesla provide as well.
Private stats calculation
Drop this method inside your tesla-battery.component.ts
file on the component class, it is our helper function to calculate the current stat that it needs to find in our monolithic Object model returned from our BatteryService
:
// tesla-battery.component.ts
private calculateStats(models, value): Stat[] {
return models.map(model => {
const { speed, temperature, climate, wheels } = value;
const miles = this.models[model][wheels][climate ? 'on' : 'off'].speed[speed][temperature];
return {
model,
miles
};
});
}
Now into the ngOnInit
, ensure yours looks like this:
// tesla-battery.component.ts
ngOnInit() {
this.models = this.batteryService.getModelData();
this.tesla = this.fb.group({
config: this.fb.group({
speed: 55,
temperature: 20,
climate: true,
wheels: 19
})
});
this.stats = this.calculateStats(this.results, this.tesla.controls['config'].value);
}
You can note our models
is now being bound to the synchronous response from our batteryService
we injected, in a real world data-driven application your models may look different and be loaded via routing resolves or an RxJS subscription.
What we’ve just done is taken private results
, and passed it into calculateStats
, with the second argument being the default value of our FormGroup
. This allows us to then run some calculations and render to our stats
, fetching the correct units for each Tesla model.
This bit is complete, but just simply need to bind the tesla-stats
component to our template now:
...
@Component({
selector: 'tesla-battery',
template: `
<form class="tesla-battery" [formGroup]="tesla">
<h1>{{ title }}</h1>
<tesla-car [wheelsize]="tesla.get('config.wheels').value"></tesla-car>
<tesla-stats [stats]="stats"></tesla-stats>
...
...
</form>
`
})
...
Here’s what you should be seeing:
Re-usable counter component
Tesla’s Speed and Outside Temperature controls should be re-usable components, so we’re going to create a generic counter component that accepts a step
, min
value, max
value and some other metadata such as a title
and unit
(mph/degrees) to inject in.
Go ahead and create a /tesla-counter/
directory inside the /components
directory just like our previous component:
**/src/app/tesla-battery/components/tesla-counter/
Then inside of there, create these two components:
tesla-counter.component.ts
tesla-counter.component.scss
Counter and ControlValueAccessor
This bit is the complex bit, where we implement a ControlValueAccessor
to read and write directly to a FormControl
, which we will implement after. I’ve annotated this file (which you need to paste into tesla-counter.component.ts
) so you can understand what’s happening. Essentially it allows our component to directly communicate to the reactive FormControl
we’re binding to it:
// importing forwardRef as an extra here
import { Component, Input, ChangeDetectionStrategy, forwardRef } from '@angular/core';
// importing necessary accessors
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
// NUMBER_CONTROL_ACCESSOR constant to allow us to use the "TeslaCounterComponent" as
// a custom provider to the component and enforce the ControlValueAccessor interface
const NUMBER_CONTROL_ACCESSOR = {
provide: NG_VALUE_ACCESSOR,
// forwardRef allows us to grab the TypeScript class
// at a later (safer) point as classes aren't hoisted
useExisting: forwardRef(() => TeslaCounterComponent),
multi: true
};
@Component({
selector: 'tesla-counter',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="tesla-counter">
<p class="tesla-counter__title">{{ title }}</p>
<div class="tesla-counter__container cf">
<div
class="tesla-counter__item"
(keydown)="onKeyUp($event)"
(blur)="onBlur($event)"
(focus)="onFocus($event)"
tabindex="0">
<p class="tesla-counter__number">
{{ value }}
<span>{{ unit }}</span>
</p>
<div class="tesla-counter__controls" tabindex="-1">
<button tabindex="-1" (click)="increment()" [disabled]="value === max"></button>
<button tabindex="-1" (click)="decrement()" [disabled]="value === min"></button>
</div>
</div>
</div>
</div>
`,
// set the custom accessor as a provider
providers: [NUMBER_CONTROL_ACCESSOR],
styleUrls: ['./tesla-counter.component.scss']
})
export class TeslaCounterComponent implements ControlValueAccessor {
// step count, default of 1
@Input() step: number = 1;
// minimum number allowed before disabling buttons
@Input() min: number;
// maximum number allowed before disabling buttons
@Input() max: number;
// title to be passed to the control
@Input() title: string = '';
// unit to be used alongside the title (mph/degrees/anything)
@Input() unit: string = '';
value: number;
focused: boolean;
// internal functions to call when ControlValueAccessor
// gets called
private onTouch: Function;
private onModelChange: Function;
// our custom onChange method
private onChange(value: number) {
this.value = value;
this.onModelChange(value);
}
// called by the reactive form control
registerOnChange(fn: Function) {
// assigns to our internal model change method
this.onModelChange = fn;
}
// called by the reactive form control
registerOnTouched(fn: Function) {
// assigns our own "touched" method
this.onTouch = fn;
}
// writes the value to the local component
// that binds to the "value"
writeValue(value: number) {
this.value = value;
}
// increment function
increment() {
if (this.value this.min) {
this.onChange(this.value - this.step);
}
this.onTouch();
}
// our onBlur event, has effect on template
private onBlur(event: FocusEvent) {
this.focused = false;
event.preventDefault();
event.stopPropagation();
}
// our onKeyup event, will respond to user
// ArrowDown and ArrowUp keys and call
// the relevant functions we need
private onKeyUp(event: KeyboardEvent) {
let handlers = {
ArrowDown: () => this.decrement(),
ArrowUp: () => this.increment()
};
// events here stop the browser scrolling up
// when using the keys, as well as preventing
// event bubbling
if (handlers[event.code]) {
handlers[event.code]();
event.preventDefault();
event.stopPropagation();
}
}
// when we focus on our counter control
private onFocus(event: FocusEvent) {
this.focused = true;
event.preventDefault();
event.stopPropagation();
}
}
Once you’re done here, time for the styles for tesla-counter.component.scss
:
.tesla-counter {
float: left;
width: 230px;
&__title {
letter-spacing: 2px;
font-size: 16px;
}
&__container {
margin: 10px 0 0;
padding-right: 40px;
input[type=number] {
border: 0;
clip: rect(0 0 0 0);
height: 1px;
margin: -1px;
overflow: hidden;
padding: 0;
position: absolute;
width: 1px;
}
}
&__number {
font-family: 'RobotoNormal';
font-size: 25px;
line-height: 25px;
font-weight: 400;
position: relative;
span {
position: absolute;
top: 0;
left: 35px;
font-size: 15px;
text-transform: uppercase;
}
}
&__item {
position: relative;
width: 100%;
height: 65px;
border: 1px solid #ccc;
display: inline-block;
padding: 18px 0 0 30px;
margin: 0 8px 0 0;
background-color: #f7f7f7;
background-position: 24.21053% 9px;
background-repeat: no-repeat;
background-size: 44px;
&:focus {
background-color: #f2f2f2;
outline: none;
}
}
&__controls {
position: absolute;
right: 10px;
top: 7px;
button {
outline: 0;
width: 30px;
color: #008dff;
cursor: pointer;
display: block;
padding: 11px 0;
vertical-align: middle;
border: 0;
background-size: 60%;
background-position: center;
background-repeat: no-repeat;
background-color: transparent;
&[disabled] {
opacity: 0.4;
cursor: not-allowed;
}
&:first-child {
border-bottom: 1px solid #fff;
background-image: url(assets/counter/up.svg);
}
&:last-child {
border-top: 1px solid #ccc;
background-image: url(assets/counter/down.svg);
}
}
}
}
That was a bigger more complex implementation, but once you view it in the browser you’ll see the power behind it.
Back to our tesla-battery.module.ts
, we need to add:
...
import { TeslaCounterComponent } from './components/tesla-counter/tesla-counter.component';
@NgModule({
declarations: [
TeslaBatteryComponent,
TeslaCarComponent,
TeslaStatsComponent,
// new addition
TeslaCounterComponent
],
...
})
...
Now we have a generic counter component that we can pass our FormGroup
values into.
Displaying the counters
Let’s jump back into our tesla-battery.component.ts
and add our custom form controls, as well as the formGroupName
:
...
@Component({
selector: 'tesla-battery',
template: `
<form class="tesla-battery" [formGroup]="tesla">
<h1>{{ title }}</h1>
<tesla-car [wheelsize]="tesla.get('config.wheels').value"></tesla-car>
<tesla-stats [stats]="stats"></tesla-stats>
<div class="tesla-controls cf" formGroupName="config">
<tesla-counter
[title]="'Speed'"
[unit]="'mph'"
[step]="5"
[min]="45"
[max]="70"
formControlName="speed">
</tesla-counter>
<div class="tesla-climate cf">
<tesla-counter
[title]="'Outside Temperature'"
[unit]="'°'"
[step]="10"
[min]="-10"
[max]="40"
formControlName="temperature">
</tesla-counter>
</div>
</div>
...
...
</form>
`
})
...
Here we’re using formGroupName="config"
to target the config
scope in our initial FormBuilder
setup, then delegating the speed
and temperature
controls down to our custom <tesla-counter>
components.
At this point, you should see this:
Aircon and Heating controls
This is a fun one. We have to monitor the value of the temperature
control, and once it hits 20
degrees, we switch “heating” to “aircon”. When it’s below 20
degrees we switch it back to heating. Let’s do it!
Go ahead and create a /tesla-climate/
directory inside the /components
directory just like our previous component:
**/src/app/tesla-battery/components/tesla-climate/
Then inside of there, create these two components:
tesla-climate.component.ts
tesla-climate.component.scss
Once you’re done, populate your tesla-climate.component.ts
component with this, which should look a little familiar:
import { Component, Input, ChangeDetectionStrategy, forwardRef } from '@angular/core';
import { FormControl, ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
const CHECKBOX_VALUE_ACCESSOR = {
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => TeslaClimateComponent),
multi: true
};
@Component({
selector: 'tesla-climate',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="tesla-climate">
<label
class="tesla-climate__item"
[class.tesla-heat]="!limit"
[class.tesla-climate__item--active]="value"
[class.tesla-climate__item--focused]="focused === value">
<p>{{ (limit ? 'ac' : 'heat') }} {{ value ? 'on' : 'off' }}</p>
<i class="tesla-climate__icon"></i>
<input
type="checkbox"
name="climate"
[checked]="value"
(change)="onChange(value)"
(blur)="onBlur($event)"
(focus)="onFocus($event)">
</label>
</div>
`,
providers: [CHECKBOX_VALUE_ACCESSOR],
styleUrls: ['./tesla-climate.component.scss']
})
export class TeslaClimateComponent implements ControlValueAccessor {
@Input() limit: boolean;
value: boolean;
focused: boolean;
private onTouch: Function;
private onModelChange: Function;
private onChange(value: boolean) {
this.value = !value;
this.onModelChange(this.value);
}
registerOnChange(fn: Function) {
this.onModelChange = fn;
}
registerOnTouched(fn: Function) {
this.onTouch = fn;
}
writeValue(value: boolean) {
this.value = value;
}
private onBlur(value: boolean) {
this.focused = false;
}
private onFocus(value: boolean) {
this.focused = value;
this.onTouch();
}
}
We’re pretty much doing the same thing as the previous component, however we’re directly writing the value
property to a checkbox
as seen here:
<input
type="checkbox"
name="climate"
[checked]="value"
(change)="onChange(value)"
(blur)="onBlur($event)"
(focus)="onFocus($event)">
So when value === true
, the checkbox is ticked. Pretty simple, and we can monitor those changes with our custom form control, switch out some text and class names when the value changes.
Our @Input() limit
is when the temperature reaches a specific limit (20
degrees) we need to tell the component from the outside as we’ll be monitoring changes, which we’ll complete once we add the component to the tesla-battery
template shortly.
Let’s add some styles to tesla-climate.component.scss
:
.tesla-climate {
float: left;
&__item {
cursor: pointer;
display: block;
width: 100px;
height: 100px;
border: 6px solid #f7f7f7;
border-radius: 50%;
box-shadow: 0px 1px 3px rgba(0, 0, 0, 0.3);
color: #666;
background: #fff;
&--active {
color: #fff;
background: #33a0ff;
background: -moz-linear-gradient(top, #33a0ff 0%, #388bff 100%);
background: -webkit-linear-gradient(top, #33a0ff 0%,#388bff 100%);
background: linear-gradient(to bottom, #33a0ff 0%,#388bff 100%);
&.tesla-heat {
background: #d64800;
background: -moz-linear-gradient(top, #d64800 0%, #d20200 100%);
background: -webkit-linear-gradient(top, #d64800 0%,#d20200 100%);
background: linear-gradient(to bottom, #d64800 0%,#d20200 100%);
}
}
}
&__icon {
display: block;
width: 22px;
height: 22px;
margin: 8px auto 0;
background-repeat: no-repeat;
background-position: center;
background-image: url(assets/climate/ac-off.svg);
.tesla-heat & {
background-image: url(assets/climate/heat-off.svg);
}
.tesla-climate__item--active & {
background-image: url(assets/climate/ac-on.svg);
}
.tesla-climate__item--active.tesla-heat & {
background-image: url(assets/climate/heat-on.svg);
}
}
p {
margin: 14px 0 0;
text-align: center;
font-size: 10px;
text-transform: uppercase;
}
input[type=checkbox] {
border: 0;
clip: rect(0 0 0 0);
height: 1px;
margin: -1px;
overflow: hidden;
padding: 0;
position: absolute;
width: 1px;
}
}
Back to our tesla-battery.module.ts
, we need to add:
...
import { TeslaClimateComponent } from './components/tesla-climate/tesla-climate.component';
@NgModule({
declarations: [
TeslaBatteryComponent,
TeslaCarComponent,
TeslaStatsComponent,
TeslaCounterComponent,
// new addition
TeslaClimateComponent
],
...
})
...
Now for the fun part, we need to implement that limit
!
Conditional aircon/heating limits
Let’s jump back into our tesla-battery.component.ts
and add our custom form tesla-climate
control (make sure it sits exactly as shown here as the styling keeps it looking jazzy):
...
@Component({
selector: 'tesla-battery',
template: `
<form class="tesla-battery" [formGroup]="tesla">
<h1>{{ title }}</h1>
<tesla-car [wheelsize]="tesla.get('config.wheels').value"></tesla-car>
<tesla-stats [stats]="stats"></tesla-stats>
<div class="tesla-controls cf" formGroupName="config">
<tesla-counter
[title]="'Speed'"
[unit]="'mph'"
[step]="5"
[min]="45"
[max]="70"
formControlName="speed">
</tesla-counter>
<div class="tesla-climate cf">
<tesla-counter
[title]="'Outside Temperature'"
[unit]="'°'"
[step]="10"
[min]="-10"
[max]="40"
formControlName="temperature">
</tesla-counter>
<tesla-climate
[limit]="tesla.get('config.temperature').value > 10"
formControlName="climate">
</tesla-climate>
</div>
</div>
...
...
</form>
`
})
...
The magic piece here is simply tesla.get('config.temperature').value > 10
and passing that expression down as a binding to [limit]
. This will be re-evaluated when Angular runs change detection on our component, and the boolean
result of the expression down into the component. You can check the styling to see how it works internally with particular class name swapping.
Wheel size component
This one’s my favourite (and the final component) just because I love the animation on the wheels.
Go ahead and create a /tesla-wheels/
directory inside the /components
directory just like our previous component:
**/src/app/tesla-battery/components/tesla-wheels/
Then inside of there, create these two components:
tesla-wheels.component.ts
tesla-wheels.component.scss
Once you’re done, populate your tesla-wheels.component.ts
component with this, another custom form control that accesses radio
inputs:
import { Component, Input, ChangeDetectionStrategy, forwardRef } from '@angular/core';
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
const RADIO_CONTROL_ACCESSOR = {
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => TeslaWheelsComponent),
multi: true
};
@Component({
selector: 'tesla-wheels',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="tesla-wheels">
<p class="tesla-wheels__title">Wheels</p>
<div class="tesla-wheels__container cf">
<label
*ngFor="let size of sizes;"
class="tesla-wheels__item tesla-wheels__item--{{ size }}"
[class.tesla-wheels__item--active]="value === size"
[class.tesla-wheels__item--focused]="focused === size">
<input
type="radio"
name="wheelsize"
[attr.value]="size"
(blur)="onBlur(size)"
(change)="onChange(size)"
(focus)="onFocus(size)"
[checked]="value === size">
<p>
{{ size }}
</p>
</label>
</div>
</div>
`,
providers: [RADIO_CONTROL_ACCESSOR],
styleUrls: ['./tesla-wheels.component.scss']
})
export class TeslaWheelsComponent implements ControlValueAccessor {
constructor() {}
private onModelChange: Function;
private onTouch: Function;
private value: string;
private focused: string;
private sizes: number[] = [19, 21];
registerOnChange(fn: Function) {
this.onModelChange = fn;
}
registerOnTouched(fn: Function) {
this.onTouch = fn;
}
writeValue(value: string) {
this.value = value;
}
private onChange(value: string) {
this.value = value;
this.onModelChange(value);
}
private onBlur(value: string) {
this.focused = '';
}
private onFocus(value: string) {
this.focused = value;
this.onTouch();
}
}
The only real thing to note here is that we’re using private sizes
to dynamically generate the wheel sizes and then assign the correct class names to the elements. As it’s a radio
button, only one can be selected at a time, you’ll also be able to use keyboard left/right/up/down arrows to flick through the sizes once we’ve implemented it!
As always, the styles. Jump into tesla-wheels.component.scss
:
.tesla-wheels {
float: left;
width: 355px;
&__title {
letter-spacing: 2px;
font-size: 16px;
}
&__container {
margin: 10px 0 0;
}
&__item {
cursor: pointer;
width: 47%;
height: 65px;
border: 1px solid #ccc;
display: inline-block;
padding: 20px 0 0 90px;
margin: 0 8px 0 0;
background-color: #f7f7f7;
background-position: 24.21053% 9px;
background-repeat: no-repeat;
background-size: 44px;
&--19 {
background-image: url(assets/wheels/19.svg);
}
&--21 {
background-image: url(assets/wheels/21.svg);
}
&--focused {
background-color: #f2f2f2;
}
&--active {
border-color: #39f;
box-shadow: inset 0px 0px 0px 1px #39f;
}
p {
font-family: 'RobotoNormal';
font-size: 16px;
font-weight: 400;
color: #333;
}
input[type=radio] {
border: 0;
clip: rect(0 0 0 0);
height: 1px;
margin: -1px;
overflow: hidden;
padding: 0;
position: absolute;
width: 1px;
}
}
}
Back to our tesla-battery.module.ts
, we need to add:
...
import { TeslaWheelsComponent } from './components/tesla-wheels/tesla-wheels.component';
@NgModule({
declarations: [
TeslaBatteryComponent,
TeslaCarComponent,
TeslaStatsComponent,
TeslaCounterComponent,
TeslaClimateComponent,
// new addition
TeslaWheelsComponent
],
...
})
...
This one’s an easy addition to our tesla-battery.component.ts
(ensure it’s outside the <div>
containing the counters for styling purposes):
...
@Component({
selector: 'tesla-battery',
template: `
<form class="tesla-battery" [formGroup]="tesla">
<h1>{{ title }}</h1>
<tesla-car [wheelsize]="tesla.get('config.wheels').value"></tesla-car>
<tesla-stats [stats]="stats"></tesla-stats>
<div class="tesla-controls cf" formGroupName="config">
<tesla-counter
[title]="'Speed'"
[unit]="'mph'"
[step]="5"
[min]="45"
[max]="70"
formControlName="speed">
</tesla-counter>
<div class="tesla-climate cf">
<tesla-counter
[title]="'Outside Temperature'"
[unit]="'°'"
[step]="10"
[min]="-10"
[max]="40"
formControlName="temperature">
</tesla-counter>
<tesla-climate
[limit]="tesla.get('config.temperature').value > 10"
formControlName="climate">
</tesla-climate>
</div>
<tesla-wheels formControlName="wheels"></tesla-wheels>
</div>
...
...
</form>
`
})
...
Now we’re done! Or are we? Nothing actually changes when we change our form controls.
FormGroup valueChange subscription
Now to implement the final feature, then we’ll deploy it to GitHub pages with Ahead-of-Time compilation.
Jump inside your tesla-battery.component.ts
again, inside ngOnInit
add this:
this.tesla.controls['config'].valueChanges.subscribe(data => {
this.stats = this.calculateStats(this.results, data);
});
All we’re doing here is accessing the controls.config
Object (square bracket notation as TypeScript enjoys moaning) and subscribing to value changes. Once a value is changed, we can simply run the calculateStats
method again with our existing results that we set at runtime, as well as the new data Object being passed as the second argument instead of the initial form value. The Objects are the same as the initial form value, so we can reuse the function, they just have different values.
Your ngOnInit
should look like this:
ngOnInit() {
this.models = this.batteryService.getModelData();
this.tesla = this.fb.group({
config: this.fb.group({
speed: 55,
temperature: 20,
climate: true,
wheels: 19
})
});
this.stats = this.calculateStats(this.results, this.tesla.controls['config'].value);
this.tesla.controls['config'].valueChanges.subscribe(data => {
this.stats = this.calculateStats(this.results, data);
});
}
You should have a fully working Tesla range calculator.
Deploying with Ahead-of-Time compilation
AoT means Angular will precompile everything (including our templates) and give us the bare minimum Angular needs for our application. I’m getting around 313 KB
for this entire project, including images, fonts. 184 KB
of that is Angular code!
Deploying to GitHub pages
Angular CLI to the rescue. Ready to deploy what you’ve just built?
Make sure you’ve pushed all your changes to master
, then run it:
ng github-pages:deploy
It should give you something like this:
Child html-webpack-plugin for "index.html":
Asset Size Chunks Chunk Names
index.html 2.75 kB 0
chunk {0} index.html 286 bytes [entry] [rendered]
Deployed! Visit https://ultimateangular.github.io/angular-tesla-range-calculator/
Github pages might take a few minutes to show the deployed site.
Visit the URL the CLI gives you and enjoy.
Check out my live version if you’d like instead
Source code
Grab it all on GitHub.