Exploring Bootstrap 5 with Angular - Creating Dropdown

Zaki Mohammed Zaki Mohammed
Oct 08, 2020 | 8 min read | 7256 Views | Comments

Bootstrap 5 is nothing but love and a dream come true for many developers. With its many features and ability to fight alongside any JS framework makes it insanely powerful. This article focuses on getting along with Bootstrap 5 with super heroic Angular.

Initializing Angular and Bootstrap

Let us start with creating a new Angular application and install the Bootstrap 5 and Bootstrap Icons dependencies. Nothing more, will have a good insight by creating a custom dropdown component with Angular and will check how things are working in the absence of jQuery. By the date this article is written Bootstrap 5.0.0-alpha2 is released.

ng new dropdown-app
npm i bootstrap@next bootstrap-icons

We have made the routing enabled for this Angular application and also made use of SCSS. Let's add Bootstrap and Bootstrap Icons dependencies to angular.json.

{
    ...
    "projects": {
		"dropdown-app": {
			...
			"architect": {
                "build": {
					"options": {
						"assets": [
							{
								"glob": "*.svg",
								"input": "./node_modules/bootstrap-icons/icons/",
								"output": "/assets/icons/"
							},
							"src/favicon.ico",
							"src/assets"
						],
						"styles": [
							"./node_modules/bootstrap/dist/css/bootstrap.min.css",
							"src/styles.scss"
						],
						"scripts": [
							"./node_modules/bootstrap/dist/js/bootstrap.bundle.min.js"
						]
					}
				},
				...
			}
		}
	}
}

We have simply added Bootstrap CSS and JS to our angular.json styles and scripts. Additionally, in order to use Bootstrap icons will make use of .svg as external image usage way as explained here Bootstrap Icons Usage. For icons we need to have bootstrap-icons in our assets folder as Angular doesn't explore anything outside the project's root folder will bring the bootstrap-icons inside our assets by specifying the following:

{
	"glob": "*.svg",
	"input": "./node_modules/bootstrap-icons/icons/",
	"output": "/assets/icons/"
}

Dropdown Component

The default setup of any Angular application remains the same. Let us focus more on the sharable dropdown component.

dropdown
|-- dropdown.component.html
|-- dropdown.component.scss
|-- dropdown.component.spec.ts
|-- dropdown.component.ts
|-- dropdown.model.ts

Below shows the way to make use of our dropdown component in action:

<app-dropdown
    [items]="items"
    [showSearch]="showSearch"
    [showStatus]="showStatus"
    [showError]="showError"
    placeholder="Select Food"
    (itemChange)="onItemClick($event)">
</app-dropdown>

Here most of the Input/Output of the dropdown component is pretty straight forward. We have a typical placeholder for our dropdown which will show the initial message to our user. The items array is and object array of type Item of the dropdown's model interface. The showSearch is to hide or show the search input in the dropdown. The showStatus will show or hide the status section which displays the count, search count of the dropdown. The showError can be used to mark the dropdown field as invalid with Bootstrap validation border colors. Finally the itemChange Output will be emitted when the user selects any value from the dropdown.

dropdown.model.ts

export interface Item {
    id: number;
    name: string;
    visible?: boolean;
}

In most of the typical scenario we get a list of object array and such object generally contains id-name or key-value pair. So our Item interface handles that pretty well with id and name properties. Other than id and name we also have a visible property which we can use to show or hide and element as per our will. For the cases where we only get single value and not a combination of id and name we can simply pass the indexes for id field and a name will hold the values in string format.

A Bootstrap dropdown consist of 2 parts inside the .dropdown class div, first the button which allows to click and expand the dropdown and second dropdown-menu which consist of dropdown-item. Will make use of these to get a tidy dropdown field for our own. With Bootstrap 5 we don't need to add the jQuery to make the dropdown functional the Bootstrap 5 JS handles it on its own.

dropdown.component.html

<div class="dropdown">
    <button class="btn btn-block text-left dropdown-toggle" type="button" data-toggle="dropdown" aria-haspopup="true"
        aria-expanded="false" data-display="static" [title]="item?.name || ''"
        [ngClass]="{ 'border border-danger': showError }">
        <span>
            {{item ? item.name : (placeholder ? placeholder : 'Select')}}
        </span>
    </button>
    <div class="dropdown-menu">

        <!-- search -->
        <div *ngIf="showSearch" class="dropdown-item dropdown-item-search" onclick="event.stopPropagation();">
            <div class="input-group">
                <input type="text" class="form-control" placeholder="Search" [(ngModel)]="search">
                <div class="input-group-append">
                    <span class="input-group-text h-100">
                        <img src="/assets/icons/search.svg" alt="" title="Bootstrap">
                    </span>
                </div>
            </div>
        </div>

        <!-- items -->
        <div class="dropdown-items">
            <ng-container *ngFor="let item of filtered; let i = index; trackBy: trackById">
                <div class="dropdown-item" *ngIf="item.visible" (click)='onChange(item)'>
                    {{item.name}}
                </div>
            </ng-container>
        </div>

        <!-- not found -->
        <div class="dropdown-item" *ngIf="isEmpty">
            No item found ??
        </div>

        <!-- status -->
        <div *ngIf="showStatus" class="dropdown-count text-dark">
            <span><b>Count:</b> {{_items.length}}</span>
            <span *ngIf="search"><b>Search Count:</b> {{filtered.length}}</span>
        </div>
    </div>
</div>

Let's break down our Angular dropdown component and study individual components.

Firstly, we have a button inside dropdown div where we are showing the placeholder; also we are applying error class in order to show a red border as shown below:

<button class="btn btn-block text-left dropdown-toggle" type="button" data-toggle="dropdown" aria-haspopup="true"
	aria-expanded="false" data-display="static" [title]="item?.name || ''"
	[ngClass]="{ 'border border-danger': showError }">
	<span>
		{{item ? item.name : (placeholder ? placeholder : 'Select')}}
	</span>
</button>

Next we added search input text in the dropdown-menu as shown:

<div *ngIf="showSearch" class="dropdown-item dropdown-item-search" onclick="event.stopPropagation();">
	<div class="input-group">
		<input type="text" class="form-control" placeholder="Search" [(ngModel)]="search">
		<div class="input-group-append">
			<span class="input-group-text h-100">
				<img src="/assets/icons/search.svg" alt="" title="Bootstrap">
			</span>
		</div>
	</div>
</div>

After that item's ngFor is added for populating the items array:

<div class="dropdown-items">
	<ng-container *ngFor="let item of filtered; let i = index; trackBy: trackById">
		<div class="dropdown-item" *ngIf="item.visible" (click)='onChange(item)'>
			{{item.name}}
		</div>
	</ng-container>
</div>

Then for showing no item found message in case of search or empty items array:

<div class="dropdown-item" *ngIf="isEmpty">
	No item found ??
</div>

Finally the dropdown status is shown below:

<div *ngIf="showStatus" class="dropdown-count text-dark">
	<span><b>Count:</b> {{items.length}}</span>
	<span *ngIf="search"><b>Search Count:</b> {{filtered.length}}</span>
</div>

Now Let's switch our focus to the component's class of dropdown where the functionality part exists.

dropdown.component.ts

import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
import { Item } from './dropdown.model';

@Component({
    selector: 'app-dropdown',
    templateUrl: './dropdown.component.html',
    styleUrls: ['./dropdown.component.scss'],
    changeDetection: ChangeDetectionStrategy.OnPush
})
export class DropdownComponent {

    _items: Item[] = [];

    @Input() placeholder: string;
    @Input() showSearch = true;
    @Input() showStatus = true;
    @Input() showError = false;
    @Output() itemChange = new EventEmitter(null);

    @Input('items')
    set items(items: Item[]) {
        this._items = items;
        this._items.map(item => {
            item.visible = item.visible || true;
        });
        this.filtered = [...this._items];
    }

    filtered: Item[] = [];
    item: Item = null;

    private searchText = '';

    get search(): string {
        return this.searchText;
    }

    set search(searchText: string) {
        this.searchText = searchText;

        const search = this.searchText.toLowerCase();
        if (!search) {
            this.filtered = [...this._items];
            return;
        }
        this.filtered = this._items.filter(i => i.name.toLowerCase().indexOf(search) !== -1);
    }

    get isEmpty(): boolean {
        return this.filtered.filter(i => i.visible).length === 0;
    }

    trackById(item: Item): number {
        return item.id;
    }

    onChange(item: Item): void {
        this.item = item;
        this.itemChange.emit(item);
    }
}

Here we have following set of Input/Output members of the class:

@Input() placeholder: string;
@Input() showSearch = true;
@Input() showStatus = true;
@Input() showError = false;
@Output() itemChange = new EventEmitter(null);

@Input('items')
set items(items: Item[]) {
	this._items = items;
	this._items.map(item => {
		item.visible = item.visible || true;
	});
	this.filtered = [...this._items];
}

For dealing with dynamic changes of items from the parent component, instead of making items as an @Input() array we will make it a setter. This setter will update the actual _items array of the component as shown below. Additionally, we are mapping the items array to filered array along with setting the visible property to true in case not provided from the parent.

@Input('items')
set items(items: Item[]) {
	this._items = items;
	this._items.map(item => {
		item.visible = item.visible || true;
	});
	this.filtered = [...this._items];
}

Then we have an array named filtered which hold up the filtered items based on search and an item object which hold up current clicked item's instance:

_items: Item[] = [];
filtered: Item[] = [];
item: Item = null;

Then we have implemented search functionality using search field and getter/setter as shown below:

private searchText = '';

get search(): string {
	return this.searchText;
}

set search(searchText: string) {
	this.searchText = searchText;

	const search = this.searchText.toLowerCase();
	if (!search) {
		this.filtered = [...this._items];
		return;
	}
	this.filtered = this._items.filter(i => i.name.toLowerCase().indexOf(search) !== -1);
}

Here the setter is mostly holding the logic where we are filtering the Input items based on search text.

Next we have isEmpty getter which is pretty handy to show/hide the not found message to the user in case no item found by search or no item present in item array.

get isEmpty(): boolean {
	return this.filtered.filter(i => i.visible).length === 0;
}

Just to optimize a bit we have added trackById function:

trackById(item: Item): number {
	return item.id;
}

Finally, the on item click event will call our event listener which will further emit the Output:

onChange(item: Item): void {
	this.item = item;
	this.itemChange.emit(item);
}

Of course not to forget the SCSS changes are as shown below:

dropdown.component.scss

.dropdown {
    button {
        border: 1px solid #ced4da;
        background-color: #fff;

        span {
            display: inline-block;
            width: 95%;
            text-overflow: clip;
            overflow: hidden;
            vertical-align: middle;
        }

        &::after {
            margin-top: 12px;
            position: absolute;
            right: 12px;
        }
        &.border-danger:focus {
            box-shadow: 0 0 0 0.25rem rgba(220,53,69,.25);
        }
    }

    .dropdown-menu {
        width: 100%;

        .dropdown-items {
            max-height: 300px;
            overflow: hidden;
            overflow-y: auto;
            margin: .25rem 1rem;

            .dropdown-item {
                padding: .25rem 0;
            }
        }

        .dropdown-count {
            border-top: 1px solid #ced4da;
            font-size: 12px;
            padding: .50rem 1rem 0;
            margin-top: .25rem;

            span {
                margin-right: 5px;
            }
        }

        & .dropdown-item {
            &:active {
                background-color: inherit !important;
                color: #212529 !important;
            }
        }
    }
}

Dropdown Component - In Action

It's time to launch our rocket (dropdown) and check how it is orbiting in space. For testing the dropdown component we have added 2 scenarios, one is with static data for which we have created a component named "single-select" and the other one is with dynamic (async) data from the server (right now we are considering JSONPlaceholder as a server to provide some placeholder data) for which we have created a component named "single-select-async". There is no change in the .html file for both the scenarios.

single-select.component.html/single-select-async.component.html

<app-dropdown 
	[items]="items" 
	[showSearch]="showSearch" 
	[showStatus]="showStatus" 
	[showError]="showError" 
	(itemChange)="onItemChange($event)">
</app-dropdown>

Loading the data for both the scenarios as follows:

single-select.component.ts

ngOnInit(): void {
	this.items = this.appService.getFoods().map(fruit => ({
		id: fruit.id,
		name: fruit.name
	} as Item));
}

single-select-async.component.ts

ngOnInit(): void {
	this.appService.getFoodsAsync().subscribe(response => {
		this.items = response;
	})
}

So either sync or async the dropdown seems to working and a happy go running code is what we needed in our lives.


Zaki Mohammed
Zaki Mohammed
Learner, developer, coder and an exceptional omelet lover. Knows how to flip arrays or omelet or arrays of omelet.