Angular/TypeScript: how to add an input option in a dropdown list

In this article, I will explain how I implemented an free text field inside a dropdown list, that is an input field as an option of a select box. I did it in an Angular project, but the logic can be reused in any TypeScript or JavaScript project. However, I used Angular, Material and Reactive forms functionalities to improve the code.

I chose to add my input field inside the dropdown list, after all existing options, instead of using the filter/research field approach. Here’s a look at the result I achieved:

I won’t be going through the all creation of a Reactive Form but straight to the addition of an input field in the select box, as an option.

This post was written based on an application developed in Angular 10, some troubles have been fixed by the Angular team in the meantime.

Input field as an option: how and where to add it?

I got some clues from a post on StackOverflow about a similar problem, but it was in plain JavaScript and the solution wouldn’t work properly with Angular. The first issue was to get where to place that input element in the whole Mat form hierarchy. Let’s say it straight: putting an input field in a dropdown list is not a very usual practice, so I had to DIY and use some tricks to achieve the expected result (still, I’ll have some more finetuning to do with CSS for production version).

Here’s the simplified code as a first step.

In a Mat form field, I create a dropdown list with Mat select. I iterate over a list of options to dynamically create my list. So far, nothing new.

Then my input field comes as another option, just as you would create a default option for example. I’ve colorized the corresponding code.

I had to put my whole input field in a Mat form field inside the Mat option, otherwise it wouldn’t work as expected. Then in the field, I put a label for the input (using Mat label instead of a placeholder allows for more customization, as you’ll see later). I used a Mat input field for my text input and I chose to use a Mat icon as a button to validate my input.

'
<form [formGroup]="form">
  
<!-- the input fields were omitted for brevity -->

  <mat-form-field>
    <mat-label>{{categoryLabel}}</mat-label>
    <mat-select formControlName="category">
      <mat-option *ngFor="let category of categories" [value]="category">
        {{category.name}}
      </mat-option>
      <mat-option>
        <mat-form-field>
          <mat-label>Nouvelle catégorie</mat-label>
          <input matInput type="text">
          <mat-icon matSuffix>done_outline</mat-icon>
        </mat-form-field>
      </mat-option>
    </mat-select>
  </mat-form-field>
  
<!-- the toggle, checkbox and simple dropdown fields were omitted for brevity -->
</form>
'

That’s the base, but if you try it so you’ll see it’s kind of messy. Also, so far, we don’t do anything with our input.

Adding logic to work with the input data

So now, we have to work on the logic a bit manually because input fields are not supposed to be part of a select box.

First problem was that if you click an option in a select dropdown list, the element would get selected and the list would close automatically. This is of course annoying in our case, because if we click the input field we don’t want the list to close, we want to be able to write in it and to click the button before it closes.

To achieve this, I put a handle “addOption” on the Mat option containing the input field. Thanks to this handle, I can access to properties such as “disabled” and set its value to false. When I click on the select box, it disables that Mat option, concretely it means when I click that Mat option, it doesn’t get selected, then doesn’t trigger the closing of the list. But I don’t want my list to stay open for ever, so when I click on the Mat icon to save my input, it resets disabled to false.

In my use case, I only allow user to input one option per form, so if you’d like to let your user add multiple values in the select box you should change a bit that part of the logic.

Then up to the saving logic. My use case is that, if the user inputs a new option, that option would be used as an attribute of the object I’ll then communicate to my back-end for saving. I added a click event on my icon, calling a method that passes my input field as an HTMLInputElement. Again, I do that by using a handle “newCat” on my input field.

'
  <mat-form-field>
    <mat-label>{{categoryLabel}}</mat-label>
    <mat-select formControlName="category" (click)="addOption.disabled = true">
      <mat-option *ngFor="let category of categories" [value]="category">
        {{category.name}}
      </mat-option>
      <mat-option #addOption>
        <mat-form-field>
          <mat-label>Nouvelle catégorie</mat-label>
          <input matInput #newCat type="text">
          <mat-icon(click)="addCategory(newCat); addOption.disabled = false;" matSuffix>done_outline</mat-icon>
        </mat-form-field>
      </mat-option>
    </mat-select>
  </mat-form-field>
'

Ok, let’s dive into the TypeScript file now! This is my addCategory method with its HTMLInputElement parameter. In order to get the actual value that was input in my input field, I’ll use the property “value”. But be aware that an HTMLInputElement holds a lot of useful information and handlers, console.log it to see it all!

I’m not pasting my whole class here, but basically: if the user chooses an element in the dropdown list, I would get the object and use its ID to save it as an attribute of the form created object (in back-end). But if my user inputs a new option, I will send the input text in my DTO in order to create that attribute (as an object of its own kind) and be able to set it as an attribute of my whole object (again, in back-end). Your use case is probably different, so all you have to understand for now is how to retrieve information from the input, then the logic is up to you!

To shortly explain, here I have a newCat attribute of type string to which I assign the input value. I assign that same value to a field categoryLabel which allows me to display the category as label of the placeholder (therefore the {{categoryLabel}} in the template file). Finally, I add a control to my form with the input value so it would be sent altogether with the other information to my back-end server when I make an AJAX post or put request.

addCategory(newCat: HTMLInputElement) {
  this.newCat = newCat.value;
  this.categoryLabel = newCat.value;
  this.form.setControl('category', new FormControl(this.newCat));
}

A bit of styling to hide the DIY…

Now, some more tricks to make it look natural…

First I chose to make a even/odd different background to separate the different options. That’s a bit off topics but since we’re here… I used CSS binding to achieve this. I just had to set a conditional styling based on the fact that the number is even or odd. I used an index in the *ngFor directive. All pretty built-in Angular possibilities. For my additional option, I would only need to check if the length of the previously iterated-over list is even or odd, because the length would be the last index + 1, so it means the length will always be odd if last index was even, and vice versa.

My “categoryOption” class only adds the grey border around each option.

I set the Mat form field appearance to “none” so it wouldn’t put a line under the input field, because it felt too crowded inside my dropdown list.

Then, some more DIY to make the label completely disappear as soon as I entered the input field. The natural behavior of the label is to appear as a superscript when the field is clicked, but again it felt too crowded in this case, and the superscript would be half-hidden by above option. I didn’t manage to find a built-in event that would allow me to cancel the label automatically, so I had to use some tricks. That is, setting the size of the text of the label to 0, again with CSS binding on the Mat label this time. To do so, I created an attribute “hiddenLabel = false” in the TypeScript file, and I just basically set it to true when clicking in the input field.

Doing so, my label doesn’t reappear if I delete the whole text of my input field, because I didn’t code the backward way, but that’s pretty easy to do (something like checking if the value of the input field is empty on blur and if so resetting normal font size basically…).

'
  <mat-form-field>
    <mat-label>{{categoryLabel}}</mat-label>
    <mat-select formControlName="category" (click)="addOption.disabled = true">
      <mat-option [style.background-color]="i % 2 == 0 ? '#e6e6ff' : ''" class="categoryOption" *ngFor="let category of categories, let i = index" [value]="category">
        {{category.name}}
      </mat-option>
      <mat-option [style.background-color]="categories.length % 2 == 0 ? '#e6e6ff' : ''" class="categoryOption" #addOption>
        <mat-form-field appearance="none">
          <mat-label style="color: grey;" [style.font-size]="hiddenLabel ? 0 : 12">Nouvelle catégorie</mat-label>
          <input matInput #newCat (click)="hiddenLabel = true" type="text">
          <mat-icon style="color: black" (click)="addCategory(newCat); addOption.disabled = false;" matSuffix>done_outline</mat-icon>
        </mat-form-field>
      </mat-option>
    </mat-select>
  </mat-form-field>
'

That’s it folks! I hope it helped, and if you have other tricks please share with us in comments!

Leave a Reply

Your email address will not be published. Required fields are marked *

Don’t miss out!

Receive every new article in your mailbox automatically.


Skip to content