Creating Multi-Select Dropdown with Angular and Bootstrap 5

Zaki Mohammed Zaki Mohammed
October 19, 2020 | 10 min read | 1771 Views

The single select dropdown is cool, but what if we have to implement multi-select, search enabled, with toggle all option dropdown? Challenge accepted. This article focuses on creating a cute looking multi-select dropdown in Angular with the help of Bootstrap 5.

The Bootstrap 5 single-select dropdown with Angular is implemented in the Creating Dropdown article. We will follow the similar fashion and implement the multi-select dropdown in the same application itself. We will start with initial setup and then head towards the multi-select dropdown.

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 multi-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/"
}

Multi-Select Dropdown Component

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

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

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

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

We have the same Input/Output properties for multi-select as we have in single-select dropdown. We have a typical placeholder for our multi-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.

multi-dropdown.model.ts

export interface Item {
    uuid?: string;
    id: number | null;
    name: string;
    checked?: boolean;
    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. In 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. Apart from the id, name and visible properties we have additional properties for multi-select dropdown. The uuid will provide each control a unique HTML id attribute value those we can use to make easy distinguish between the checkboxes (this will be helpful in the scenario when the actual id of the item leads in duplication with other components on the page as the Bootstrap checkboxes requires ids to work with the label). The checked property is simply used to change the state of a particular selection.

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.

multi-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]="selected"
        [ngClass]="{ 'border border-danger': showError }">
        <span>
            {{selected ? selected : (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>

        <!-- checkboxes -->
        <div class="dropdown-item-checkboxes">

            <!-- all -->
            <div class="dropdown-item" onclick="event.stopPropagation();" *ngIf="showAll && all.visible"
                [ngClass]="{ 'checked': all.checked }">
                <!-- checkbox -->
                <div class="custom-control custom-checkbox">
                    <input type="checkbox" class="custom-control-input" [id]="'chkItem' + all.id"
                        (change)='onChange($event, all)' [checked]="all.checked">
                    <label class="custom-control-label" [for]="'chkItem' + all.id">
                        <span class="pl-2" [title]="all.name">{{all.name}}</span>
                    </label>
                </div>
            </div>

            <ng-container *ngFor="let item of filtered; let i = index; trackBy: trackByUuid">
                <div class="dropdown-item" onclick="event.stopPropagation();" *ngIf="item.visible"
                    [ngClass]="{ 'checked': item.checked }">
                    <!-- checkbox -->
                    <div class="custom-control custom-checkbox">
                        <input type="checkbox" class="custom-control-input" [id]="'chkItem' + item.id"
                            (change)='onChange($event, item)' [checked]="item.checked">
                        <label class="custom-control-label" [for]="'chkItem' + item.id">
                            <span class="pl-2" [title]="item.name">{{item.name}}</span>
                        </label>
                    </div>
                </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><b>Checked:</b> {{checked}}</span>
            <span *ngIf="search"><b>Search Count:</b> {{filtered.length}}</span>
        </div>
    </div>
</div>

Let's break down our Angular multi-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 multi-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>

For multi-select we have an all checkbox for selecting all of the options and ngFor which contains individual items. So for these we have added a div to club them as a single unit with class "dropdown-item-checkboxes":

<div class="dropdown-item-checkboxes"></div>

Inside "dropdown-item-checkboxes" div we have all option as follows:

<div class="dropdown-item" onclick="event.stopPropagation();" *ngIf="showAll && all.visible"
	[ngClass]="{ 'checked': all.checked }">
	<!-- checkbox -->
	<div class="custom-control custom-checkbox">
		<input type="checkbox" class="custom-control-input" [id]="'chkItem' + all.uuid"
			(change)='onChange($event, all)' [checked]="all.checked">
		<label class="custom-control-label" [for]="'chkItem' + all.uuid">
			<span class="pl-2" [title]="all.name">{{all.name}}</span>
		</label>
	</div>
</div>

We then have the ngFor for all of the items:

<ng-container *ngFor="let item of filtered; let i = index; trackBy: trackByUuid">
	<div class="dropdown-item" onclick="event.stopPropagation();" *ngIf="item.visible"
		[ngClass]="{ 'checked': item.checked }">
		<!-- checkbox -->
		<div class="custom-control custom-checkbox">
			<input type="checkbox" class="custom-control-input" [id]="'chkItem' + item.uuid"
				(change)='onChange($event, item)' [checked]="item.checked">
			<label class="custom-control-label" [for]="'chkItem' + item.uuid">
				<span class="pl-2" [title]="item.name">{{item.name}}</span>
			</label>
		</div>
	</div>
</ng-container>

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. We are showing total item count, checked item count and searched count.

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

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

dropdown.component.ts

import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
import { Item } from './multi-dropdown.model';
import { v4 } from 'uuid';

@Component({
    selector: 'app-multi-dropdown',
    templateUrl: './multi-dropdown.component.html',
    styleUrls: ['./multi-dropdown.component.scss']
})
export class MultiDropdownComponent implements OnInit {

    @Input() items: Item[] = [];
    @Input() placeholder: string;
    @Input() showSearch = true;
    @Input() showAll = true;
    @Input() showStatus = true;
    @Input() showError = false;
    @Output() itemChange = new EventEmitter(null);

    filtered: Item[] = [];
    all: Item = {
        id: null,
        name: 'All',
        uuid: v4(),
        checked: false,
        visible: true
    };

    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];
            this.all.visible = true;
            return;
        }
        this.filtered = this.items.filter(i => i.name.toLowerCase().indexOf(search) !== -1);
        if (this.all.name.toLowerCase().indexOf(search) !== -1) {
            this.all.visible = true;
        } else {
            this.all.visible = false;
        }
    }

    get selected(): string {
        return this.all && this.all.checked ? this.all.name :
            this.items.filter(i => i.checked && i.visible).map(i => i.name).join(', ');
    }

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

    get checked(): number {
        return this.items.filter(i => i.checked).length;
    }

    ngOnInit(): void {
        this.items.map(item => {
            item.uuid = item.uuid || v4();
            item.checked = item.checked || false;
            item.visible = item.visible || true;
        });
        this.filtered = [...this.items];

        if (!this.filtered.length) {
            this.all.visible = false;
        }
    }

    trackByUuid(index: number, item: Item): string {
        return item.uuid;
    }

    onChange($event: any, item: Item): void {
        const checked = $event.target.checked;
        const index = this.items.findIndex(i => i.id === item.id);

        if (item.id === null) {
            this.all.checked = checked;
            for (const iterator of this.items) {
                iterator.checked = checked;
            }
        } else {
            this.items[index].checked = checked;

            /* istanbul ignore else*/
            if (this.all) {
                /* istanbul ignore else*/
                if (this.all.checked) {
                    this.all.checked = false;
                }
                const allChecked = this.items.filter(i => i.id !== null).every(i => i.checked);
                this.all.checked = allChecked;
            }
        }

        this.itemChange.emit(item);
    }

}

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

@Input() items: Item[] = [];
@Input() placeholder: string;
@Input() showSearch = true;
@Input() showAll = true;
@Input() showStatus = true;
@Input() showError = false;
@Output() itemChange = new EventEmitter(null);

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

filtered: Item[] = [];
all: Item = {
	id: null,
	name: 'All',
	uuid: v4(),
	checked: false,
	visible: true
};

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];
		this.all.visible = true;
		return;
	}
	this.filtered = this.items.filter(i => i.name.toLowerCase().indexOf(search) !== -1);
	if (this.all.name.toLowerCase().indexOf(search) !== -1) {
		this.all.visible = true;
	} else {
		this.all.visible = false;
	}
}

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;
}

The checked getter will return number of selected items count.

get checked(): number {
	return this.items.filter(i => i.checked).length;
}

On initialize we are mapping the items array to filtered array along with setting the uuid, checked and visible properties. Making the visible property default to true and checked as false in case not provided from the parent. The uuid is either supplied by the parent or generated using v4() method of uuid package. If the no items present in the array will make the all item instance visible to false.

ngOnInit(): void {
	this.items.map(item => {
		item.uuid = item.uuid || v4();
		item.checked = item.checked || false;
		item.visible = item.visible || true;
	});
	this.filtered = [...this.items];

	if (!this.filtered.length) {
		this.all.visible = false;
	}
}

Just to optimize a bit we have added trackByUuid function:

trackByUuid(index: number, item: Item): string {
	return item.uuid;
}

Finally, the on item click event will call our event listener which will further emit the Output. If the id of the current item is null then it is for all item selection; for that we will mark all item checked, otherwise mark currently selected item as checked.

onChange($event: any, item: Item): void {
	const checked = $event.target.checked;
	const index = this.items.findIndex(i => i.id === item.id);

	if (item.id === null) {
		this.all.checked = checked;
		for (const iterator of this.items) {
			iterator.checked = checked;
		}
	} else {
		this.items[index].checked = checked;

		/* istanbul ignore else*/
		if (this.all) {
			/* istanbul ignore else*/
			if (this.all.checked) {
				this.all.checked = false;
			}
			const allChecked = this.items.filter(i => i.id !== null).every(i => i.checked);
			this.all.checked = allChecked;
		}
	}

	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-item-checkboxes {
            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;
            }

            .custom-control {
                label {
                    width: 100%;
                }
            }
        }
    }
}

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