Summary
For my very first post, I got inspired by an ui element from an application I worked on for my daily job. We are going to build a select component which I found really appealing and which allows to dive into the Angular animations API while caring for accessibility along the way.
You can directly go to the source code if you prefer, and don't hesitate to contribute if you find it perfectible.
A sample demo is also hosted here if you want to play with it.
Step 1: Project initialization
Let's start by bootstraping a new Angular project with scss as the CSS preprocessor, and serving the app:
ng new neat-app --style scss
cd neat-app
ng serve -o
Step 2: The building blocks
The markup consist of two separate components:
- a neat-select component: which encapsulates a native html select element (for accessibility purposes) with enhanced styling.
- a neat-options component: it will be used as a child component inside the neat-select one. It will iterate through the list of options and display them.
Step 2.1: The neat-options component
Here is a peek at the neat-options final look:
And the corresponding html/scss/ts code :
options.component.html
<div class="container">
<label class="option" *ngFor="let option of options">
<span>
{{ option }}
</span>
<neat-radio
[checked]="option === value"
[name]="name"
(valueChange)="onChange(option)"
></neat-radio>
</label>
</div>
The span and the neat-radio elements are contained inside a label element so that a click on the span's text could also trigger the radio valueChange event.
options.component.scss
:host {
display: block;
padding: 1rem;
.container {
max-width: 420px;
margin: auto;
.option {
height: 64px;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 2px solid var(--color-border-bottom);
&:focus-within {
outline: var(--outline);
}
neat-radio {
margin-left: 1rem;
--outline: none;
}
}
}
}
A max-width is set to the container div to make the component responsive. This way it won't stretch accross the entire screen width for large screen sizes.
options.component.ts
import { Component, Input, Output, EventEmitter } from '@angular/core';
@Component({
selector: 'neat-options',
templateUrl: './options.component.html',
styleUrls: ['./options.component.scss']
})
export class OptionsComponent {
@Input() value: string;
@Input() options: string[] = [];
name = Date.now().toString();
@Output() onSelected = new EventEmitter<string>();
onChange(option: string) {
this.value = option;
this.onSelected.emit(this.value);
}
}
The onSelected event emits the selected value whenever the option is changed.
Step 2.2: The neat-select component
This is a screenshot of a page containing 3 neat-select components. The first two have a selected value unlike the third one.
select.component.html
<label>
<div class="left-container">
<span class="selected-option">{{ value }}</span>
<span class="label">{{ label }}</span>
</div>
<select
#select
[value]="value"
[required]="required"
(click)="showOptions($event)"
(keydown.enter)="showOptions($event)"
(change)="change($event)"
>
<option *ngFor="let option of options" [value]="option">{{
option
}}</option>
</select>
<neat-icon [name]="'misc-arrow'" [size]="'xxsmall'"></neat-icon
></label>
<neat-options
[@slideInOut]="opened ? 'visible' : 'hidden'"
[options]="options"
[value]="value"
(onSelected)="onSelected($event)"
></neat-options>
This component encapsulates a native HTML select element.
The options list is iterated through by both the native select through its nested option elements and the neat-options component.
A click or a keydown.enter (for keyboard usage) on the select will show the neat-options component following a smooth transition.
When an option is selected from the then visible neat-options component, the corresponding value is bound to the native select value through the onSelected event listener.
select.component.scss
:host {
font-family: Pluto-Regular;
max-width: 272px;
label {
position: relative;
height: 64px;
overflow: hidden;
display: grid;
grid-gap: 1rem;
align-items: center;
grid-template-columns: auto 64px;
&:focus-within {
outline: var(--outline);
}
.left-container {
display: flex;
height: 100%;
flex-direction: column;
justify-content: space-evenly;
font-size: 1rem;
.selected-option {
transition: font-size 0.5s ease-in-out;
&:empty {
display: none;
}
&:not(:empty) ~ .label {
font-size: 0.9rem;
color: #5a6872;
font-family: SF-Pro-Display-Regular;
}
}
.label {
order: -1;
}
}
select {
opacity: 0;
position: absolute;
width: 100%;
height: 100%;
z-index: -1;
&:focus {
outline: none;
}
option {
display: none;
}
}
neat-icon {
justify-self: flex-end;
}
}
neat-options {
position: fixed;
z-index: 1;
top: 0;
left: 0;
right: 0;
bottom: 0;
overflow-y: auto;
background: var(--color-white);
}
}
The native html select element is hidden with opacity: 0
so that it can still be focused. An outline is added to the label parent element when the select is focused.
select.component.ts
import { trigger, state, style, transition, animate } from '@angular/animations';
@Component({
selector: 'neat-select',
templateUrl: './select.component.html',
styleUrls: ['./select.component.scss'],
animations: [
trigger('slideInOut', [
state(
'visible',
style({
transform: 'translateX(0)',
visibility: 'visible'
})
),
state(
'hidden',
style({
transform: 'translateX(100%)',
visibility: 'hidden'
})
),
transition('hidden => visible', animate('0.25s ease-out')),
transition('visible => hidden', animate('0.25s ease-in'))
])
]
})
export class SelectComponent implements OnInit, ControlValueAccessor {
@Input() label = '';
@Input() required = false;
@Input() options: string[];
@Input() value: string;
opened = false;
@ViewChild('select', { static: false }) select: ElementRef<HTMLSelectElement>;
ngOnInit() {}
onSelected(option: string) {
this.opened = false;
this.select.nativeElement.focus();
}
showOptions(event: any) {
event.preventDefault();
this.opened = true;
}
}
As previously aforementioned, this component is in charge of showing and hiding the neat-options component with smooth transitions.
To use angular's animations API, we start by defining the different states that the animated component can be in, then, we specify the type of transition we want from one state to another.
Here the neat-options component inside this neat-select is either in the visible state or the hidden state. It is hidden by defaut (by applying a tranform: translateX(100%)
) and will become visible (by applying a transform: translateX(0)
) when clicking on the neat-select.
A transform is used here instead of a display: none for performance reasons. Especially when dealing with a large list of options, adding and removing the whole list to and from the DOM would have a negative impact on the perceived performance.
You can notice that we also added a visibility: hidden
to the hidden state, this is important because we don't want the neat-radio buttons inside the neat-options component to be focusable while the latter is offscreen.
One last point not to be forgotten is to give the focus back to the select element when the neat-options goes back in the hidden state following an option selection.
Step 3: Tying it all together
Now that we have our neat-select component implemented, we are going to use it inside a form in our app.component page.
app.component.html
<form [formGroup]="formGroup">
<h2 class="title">Neat and accessible select using Angular with animations</h2>
<neat-select
label="Random takes"
[options]="optionList"
formControlName="option1"
></neat-select>
<neat-select
label="Random takes"
[options]="optionList"
formControlName="option2"
></neat-select>
<neat-select
label="Random takes"
[options]="optionList"
formControlName="option3"
></neat-select>
</form>
app.component.scss
form {
padding: 1rem;
.title {
font-family: SF-Pro-Display-Regular;
text-align: center;
font-size: 2rem;
margin-bottom: 4rem;
}
}
app.component.ts
import { FormGroup, FormBuilder } from '@angular/forms';
@Component({
selector: 'neat-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.scss']
})
export class AppComponent {
formGroup: FormGroup = this.fb.group({
option1: [''],
option2: [''],
option3: ['']
});
constructor(private fb: FormBuilder) {}
optionList = [
'HTML CSS and Javascript',
'Learn the basics',
'They are as important as knowing a framework',
'Angular reactive forms',
'They are really powerfull',
'Got mad love for animations',
'You shoud checkout the Rust language',
'Rust is cool but quite challenging at first',
'It can be compiled to WebAssembly and run on the web',
'WebGL is great for graphics on the web',
'Combine Rust, Wasm and WebGL',
'And you will build great and performant apps',
'Directly avaible in a browser',
'The Cloud humm..',
'It opens a whole category of computing',
'Serverless is king',
'Especially for iterating fast',
'Machine Learning',
'Another intersting topic',
'Can make the difference'
];
}
Nothing fancy here, just a classic Angular FormGroup with three FormControls corresponding to the three neat-select.
Step 4: Furthermore
We used a neat-radio and a neat-icon in this tutorial without mentioning the internals. Also the neat-select component is configured to work nicely with Angular reactive forms by implementing the ControlValueAccessor interface (not shown in the code blocks). Those topics will be covered in the second part of this tutorial wich will be avaible someday during the following week, so stay tuned ;) !
Credits
- UI Design by Pierre Huard
- Review and recommendations by Wassim Chegham
About me
I am a Software Engineer who does FrontEnd work with Angular in my daily job. I am also currently working with Rust compiled to WebAssembly(Wasm) and with WebGL for a side project.
You can follow me on twitter or linkedin if you want to keep in touch. I plan to publish tutorials like this one more often.