Managing Selections With A Dual-Select Control Experience In Angular 9.1.9
Back in the day, before Angular or AngularJS, when I used to build Form controls with raw JavaScript, one of my favorite type of controls was a dual-select
input, wherein one multi-select
menu represented "unselected items"; and another multi-select
menu represented "selected items". I don't see this type of user experience (UX) that much these days. But, earlier this week, I shared a redesign idea for Contact selection within InVision; and, my design used a dual-select control. As such, I thought it would be fun to try and build a dual-select experience in Angular 9.1.9.
Run this demo in my JavaScript Demos project on GitHub.
View this code in my JavaScript Demos project on GitHub.
It's been a while since I've built a dual-select experience; so, I didn't want to jump right into a dual-select abstraction. Instead, I wanted to take this opportunity to play around with what the mechanics of this type of experience might look like in an Angular context; and then, as a follow-up post, try to take these learnings and wrap them up in a more formal Angular Component.
The idea behind a dual-select input is that you have two select
boxes (or things that look like select
boxes) side-by-side. The list on the left represents "unselected" items; and, the list of the right represents "selected" items. In the past, when I've built controls like this, there were two modes of moving items between the lists:
Double-click an item to automatically move it to the "other" list.
Select multiple items and then click a
button
that moves all of the selected items to the "other" list.
To see this in action, I've put together a demo in which I have a list of Contacts. The contacts can then be moved between the unselected and selected lists:
As you can see, I'm able to move selected Contacts back and forth between the two lists (selected and unselected).
In order to implement this, I have a few data-structures:
An
Array
of unselected contacts. This is used to render the first list.An
Array
of selected contacts. This is used to render the second list.An
Object
of "pending selections". This is just an ID-based look-up that keeps track of which items within the lists have been selected for possible change. This is the object that powers the mutation when I click one of the Add / Remove buttons.
With that said, here's my App component - it's basically just some methods that revolve around Array
manipulation:
// Import the core angular services.
import { Component } from "@angular/core";
// Import the application components and services.
import { Contact } from "./data";
import { contacts as sampleContacts } from "./data";
// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //
interface PendingSelection {
[ key: number ]: boolean;
}
@Component({
selector: "app-root",
styleUrls: [ "./app.component.less" ],
templateUrl: "./app.component.html"
})
export class AppComponent {
public contacts: Contact[];
public pendingSelection: PendingSelection;
public selectedContacts: Contact[];
public unselectedContacts: Contact[];
// I initialize the app component.
constructor() {
this.contacts = sampleContacts;
// To start with, all of the contacts will be unselected. Then, the user will be
// able to move any of the contacts over to the selected collection.
this.unselectedContacts = this.contacts.slice().sort( this.sortContactOperator );
this.selectedContacts = [];
// I am an ID-based look-up index that keeps track of which contacts have been
// selected for pending changes (either adding or removing from the selected
// contacts collection).
this.pendingSelection = Object.create( null );
}
// ---
// PUBLIC METHODS.
// ---
// I add the selected contact or contacts to the selected contacts collection.
public addToSelectedContacts( contact?: Contact ) : void {
var changeContacts = ( contact )
// If a given contact has been provided (via double-click), that's the single
// contact that we want to move.
? [ contact ]
// Otherwise, default to using the pending-selection index as the source of
// contacts to move.
: this.getPendingSelectionFromCollection( this.unselectedContacts )
;
// Now that we know which contacts we want to move, reset the pending-selection.
this.pendingSelection = Object.create( null );
// Remove each pending contact from the unselected list.
this.unselectedContacts = this.removeContactsFromCollection( this.unselectedContacts, changeContacts );
// We always want to move the pending contacts onto the front / top of the
// selected list so that the change is VISUALLY OBVIOUS to the user.
this.selectedContacts = changeContacts.concat( this.selectedContacts );
}
// I remove the selected contact or contacts from the selected contacts collection.
public removeFromSelectedContacts( contact?: Contact ) : void {
var changeContacts = ( contact )
// If a given contact has been provided (via double-click), that's the single
// contact that we want to move.
? [ contact ]
// Otherwise, default to using the pending-selection index as the source of
// contacts to move.
: this.getPendingSelectionFromCollection( this.selectedContacts )
;
// Now that we know which contacts we want to move, reset the pending-selection.
this.pendingSelection = Object.create( null );
// Remove each pending contact from the selected contacts collection.
this.selectedContacts = this.removeContactsFromCollection( this.selectedContacts, changeContacts );
// When moving contacts back to the unselected contacts list, we want to add
// them back in SORT ORDER since this will make it easier for the user to
// navigate the resulting list.
this.unselectedContacts = changeContacts
.concat( this.unselectedContacts )
.sort( this.sortContactOperator )
;
}
// I toggle the pending selection for the given contact.
public togglePendingSelection( contact: Contact ) : void {
this.pendingSelection[ contact.id ] = ! this.pendingSelection[ contact.id ];
}
// ---
// PRIVATE METHODS.
// ---
// I gather the contacts in the given collection that are part of the current pending
// selection.
private getPendingSelectionFromCollection( collection: Contact[] ) : Contact[] {
var selectionFromCollection = collection.filter(
( contact ) => {
return( contact.id in this.pendingSelection );
}
);
return( selectionFromCollection );
}
// I remove the given contacts from the given collection. Returns a new collection.
private removeContactsFromCollection(
collection: Contact[],
contactsToRemove: Contact[]
) : Contact[] {
var collectionWithoutContacts = collection.filter(
( contact ) => {
return( ! contactsToRemove.includes( contact ) );
}
);
return( collectionWithoutContacts );
}
// I provide the sort operator for the contacts collection.
private sortContactOperator( a: Contact, b: Contact ) : number {
return( a.name.localeCompare( b.name ) );
}
}
As you can see, I'm essentially moving items back-and-forth between this.unselectedContacts
and this.selectedContacts
.
Now, here's the HTML. Remember, I didn't want to jump into an abstraction too early; as such, the HTML for the dual-select control is contained entirely within the App component's HTML - cleaner boundaries will come in a follow-up post:
<div class="dual-select">
<div class="dual-select__left">
<ul class="dual-select__items">
<li
*ngFor="let contact of unselectedContacts"
(click)="togglePendingSelection( contact )"
(dblclick)="addToSelectedContacts( contact )"
class="dual-select__item"
[class.dual-select__item--selected]="pendingSelection[ contact.id ]">
<div class="contact">
<div class="contact__name">
{{ contact.name }}
</div>
<div class="contact__email">
{{ contact.email }}
</div>
</div>
</li>
</ul>
</div>
<div class="dual-select__controls">
<button
(click)="addToSelectedContacts()"
class="dual-select__control">
⤇
</button>
<button
(click)="removeFromSelectedContacts()"
class="dual-select__control">
⤆
</button>
</div>
<div class="dual-select__right">
<ul class="dual-select__items">
<li
*ngFor="let contact of selectedContacts"
(click)="togglePendingSelection( contact )"
(dblclick)="removeFromSelectedContacts( contact )"
class="dual-select__item dual-select__item--new"
[class.dual-select__item--selected]="pendingSelection[ contact.id ]">
<div class="contact">
<div class="contact__name">
{{ contact.name }}
</div>
<div class="contact__email">
{{ contact.email }}
</div>
</div>
</li>
</ul>
</div>
</div>
<p class="note">
You have <strong>{{ selectedContacts.length }} of {{ contacts.length }}</strong>
contacts selected.
</p>
This layout uses LESS CSS and flexbox
to render the lists side-by-side:
:host {
display: block ;
font-size: 18px ;
}
.dual-select {
display: flex ;
height: 400px ;
&__left,
&__right {
flex: 0 0 auto ;
width: 307px ;
}
&__controls {
justify-content: center ;
display: flex ;
flex: 0 0 auto ;
flex-direction: column ;
padding: 0px 10px 0px 10px ;
}
&__control {
font-size: 20px ;
height: 40px ;
margin: 10px 0px 10px 0px ;
}
&__items {
border: 1px solid #cccccc ;
height: 100% ;
list-style-type: none ;
margin: 0px 0px 0px 0px ;
overflow: auto ;
overscroll-behavior: contain ;
padding: 0px 0px 0px 0px ;
}
&__item {
border-bottom: 1px solid #cccccc ;
cursor: pointer ;
margin: 0px 0px 0px 0px ;
padding: 0px 0px 0px 0px ;
user-select: none ;
-moz-user-select: none ;
-webkit-user-select: none ;
&:last-child {
border-bottom-width: 0px ;
}
&--selected {
background-color: #bfd5ff ;
}
&--new {
animation-duration: 2s ;
animation-name: dual-select-item-new-fade-in ;
animation-timing-function: ease-out ;
}
}
}
@keyframes dual-select-item-new-fade-in {
0% {
background-color: #ffffff ;
}
25% {
background-color: #bfd5ff ;
}
100% {
background-color: #ffffff ;
}
}
.contact {
font-family: monospace, sans-serif ;
padding: 8px 10px 8px 10px ;
&__name {
font-size: 18px ;
font-weight: bold ;
line-height: 22px ;
overflow: hidden ;
text-overflow: ellipsis ;
white-space: nowrap ;
}
&__email {
font-size: 15px ;
line-height: 19px ;
overflow: hidden ;
text-overflow: ellipsis ;
white-space: nowrap ;
}
}
.note {
font-family: monospace, sans-serif ;
font-size: 16px ;
}
And that's all there is to it. What I really enjoy about this experience is that the selected items are pulled-out and rendered separately, making it extremely obvious to the user what they are doing. In my opinion, this is 10-times better than a native multi-select
box, which is error-prone (due to errant clicking), and is much harder to style in a way that sparks joy in the user's heart.
And, just for reference, here's the UI that I tweeted about the other day - it's a re-imagining of the "Invite to Project" modal window in InVision:
In this case, the left-hand list is the source of potential People; and, the right-hand list is the list of People you want on your project. Again, I find this type of experience very easy to understand since the selected People are singled-out and rendered separately.
As a follow-up post, I want to see if I can create some sort of abstraction around this user experience in Angular 9. It's not immediately obvious to me what data will be "input bindings" and what data will be "projected content". But, I think I'll be able to figure something out. The trick will be figuring out how to off-load the heavy lifting to the abstraction while still allowing the calling context enough wiggle-room to customize the interface (where it makes sense).
Want to use code from this post? Check out the license.
Reader Comments
@All,
As a follow-up to this post, I wanted to see if I could factor this functionality out into a reusable component:
www.bennadel.com/blog/3842-attempting-to-create-a-flexible-dual-select-control-component-in-angular-9-1-9.htm
Ultimately, I actually made three components that work together to provide developer ergonomics that attempt to strike a good balance between complexity and flexibility. The HTML looks like this:
I found the exploration of content projection to be super, super helpful.
thanks a lot. :)
Good job, that help me 🙌
I updated this part because false values do not disappear from list at the time to add to selected items.
@Roos,
Groovy stuff!
how to transfer all from left to right and vice versa
@P,
Ultimately, the left and right sides are just represented as Arrays. So, if you wanted to move all the items to one side you'd have to:
I hope that points you in the right direction.