Creating a dynamic typeahead multi-select component with Rails, Alpine.js, and Tailwind

post cover picture

Multiselects are an essential component in nearly every web application, providing users with the ability to select multiple options from a list. While traditional HTML offers a basic multi-select feature, modern web apps demand a more intuitive and visually appealing solution. In this blog post, we'll explore how to create a dynamic Multiselect component using Rails, Alpine.js, and Tailwind CSS to enhance the user experience.

For those eager to dive into the code right away, feel free to skip ahead by clicking here.

 

Traditional Multiselect with Rails Form Helpers

Before diving into the implementation, let's briefly review how a basic multiselect is created using Rails form helpers.

As of today, here’s what HTML, unfortunately, provides us with:

Building this Multiselect component is extremely easy with Rails Form Helpers.


<%= form.label :leader_ids, 'Leaders' %>
<%= form.select :leader_ids,
  options_from_collection_for_select(@team_members, "id", "name", @project.leader_ids),
  { },
  multiple: true
%>

While functional, this standard HTML multiselect is not good enough for modern apps. From a UX perspective, it lacks features and user experience expected in today's web applications. For instance, if there is a vast array of options, it is challenging to rapidly find the one we’re actually looking for.

 

Introducing Alpine.js and Tailwind

Thus, we picked an already-provided multiselect and enhanced it so as to adapt it to our needs. Leveraging Alpine.js for interactivity and Tailwind CSS for styling, we customized the multiselect offered by talwindcomponent.com to suit it to our requirements.  

 

Crafting a Stylish Multiselect Component with Alpine.js: a step-by-step guide

Follow along as we break down the process of creating a nice-looking Multiselect Component into simple steps.

First, let’s develop a javascript/components/multiselect.js to import our Alpine.js component. Focusing entirely on the look-and-feel, we will initialize the component with a predefined set of options, enhancing its functionality and aesthetics.


export const multiselect = () => {
 return {
   options: [
     {
       value: 1,
       text: 'Ignacio Grondona',
       selected: false
     },
     {
       value: 2,
       text: 'Santiago Calvo',
       selected: false
     }
   ],
   selected: [],
   show: false,
   open() {
     this.show = true
   },
   close() {
     this.show = false
   },
   select(optionIndex) {
       if (!this.options[optionIndex].selected) {
         this.options[optionIndex].selected = true;
         this.selected.push(optionIndex);
       } else {
         this.remove(optionIndex);
       }
   },
   remove(optionIndex) {
     this.selected.splice(this.selected.lastIndexOf(optionIndex), 1);
     this.options[optionIndex].selected = false;
   }
 }
}

Next, we'll import the component into our JavaScript application.js and run it:


window.Alpine = Alpine
import { multiselect } from "./components/multiselect"

Alpine.data('multiselect', multiselect)
Alpine.start()

Now, let's proceed by creating a fresh partial at views/shared/multiselect.html.erb to define the HTML structure and styling:


<div x-data="multiselect()">
 <div class="form-group">
   <label class="block text-sm font-medium leading-6 text-gray-900 mb-2">Leaders</label>


   <div class="flex flex-col items-center relative">
     <div @click="open" class="w-full">
       <div class="p-1 flex shadow-sm ring-1 ring-inset ring-gray-300 rounded-md focus:ring-inset focus:ring-primary-600">
         <div class="flex flex-auto flex-wrap">
           <template x-for="optionIndex in selected" :key="options[optionIndex].value">
             <div class="flex justify-center items-center m-1 font-medium py-1 px-2 bg-white rounded-full text-indigo-700 bg-indigo-100 border border-indigo-300 ">
               <div class="text-xs font-normal leading-none max-w-full flex-initial" x-model="options[optionIndex]" x-text="options[optionIndex].text">
               </div>


               <div class="flex flex-auto flex-row-reverse">
                 <div class="cursor-pointer" @click="remove(optionIndex)">
                   <%= render_svg "icons/x-mark", styles: "fill-current h-4 w-4 ml-1" %>
                 </div>
               </div>
             </div>
           </template>


           <div x-show="selected.length == 0" class="flex-1">
             <input placeholder="Select a option" class="bg-transparent p-1 px-2 appearance-none outline-none h-full w-full text-gray-800 border-0 text-sm">
           </div>
         </div>


         <div class="text-gray-300 w-8 py-1 pl-2 pr-1 border-l flex items-center border-gray-200">
           <button type="button" x-show="show" @click="open" class="w-6 h-6 text-gray-600 outline-none focus:outline-none">
               <%= render_svg "icons/cheveron-up", styles: "fill-current h-5 w-5" %>
           </button>


           <button type="button" x-show="!show" @click="close" class="w-6 h-6 text-gray-600 outline-none focus:outline-none">
               <%= render_svg "icons/cheveron-down", styles: "fill-current h-5 w-5" %>
           </button>
         </div>
       </div>
     </div>


     <div class="w-full px-4">
       <div x-show.transition.origin.top="show"
         class="absolute shadow top-100 bg-white z-40 w-full left-0 rounded max-h-select overflow-y-auto"
         @click.away="close">
         <div class="flex flex-col w-full">
           <template x-for="(option, index) in options" :key="option.value">
             <div>
               <div class="cursor-pointer w-full border-gray-100 rounded-t border-b hover:bg-indigo-100"
                 @click="select(index)">
                 <div :class="option.selected ? 'border-indigo-700' : 'border-transparent'"
                   class="flex w-full items-center p-2 pl-2 border-l-2 relative">
                   <div class="w-full items-center flex">
                     <div class="mx-2 leading-6 text-sm" x-model="option" x-text="option.text"></div>
                   </div>
                 </div>
               </div>
             </div>
           </template>
         </div>
       </div>
     </div>
   </div>
 </div>
</div>

Upon inspecting the HTML code, you'll notice the presence of two <template></template> sections. The first one showcases selected options with a remove button, triggering the remove(optionIndex) function on click. The latter renders all available options, calling the select(index) function upon selection.

To integrate our custom multiselect with a Rails Form, we’ll add a hidden select element—mirroring the one added at the beginning—and ensure that selections made in our custom component reflect in the hidden select:


<div x-data="multiselect()" x-init="loadOptions()">
 <%= form.select field,
   options_for_select(@team_members.map.with_index { |member, index|
     [member.name, member.id, { ':selected' => "options[#{index}].selected" }]
   }, form.object.send(field)),
   { },
   multiple: true,
   class: "hidden",
   "x-ref": "hiddenSelect"
 %>


 ...
</div>

The main difference with the previous select is the :selected attribute on our options. This attribute binds the selection status of each option to our JavaScript collection. Consequently, when we select an option in our custom select, the corresponding option in the hidden select will also be selected. We’ll also add an x-ref attribute to facilitate seamless usage of our select within our JavaScript code.

To load the options, we run the loadOptions function upon initialization using x-init="loadOptions()". We need to define this function within our Alpine component:


 loadOptions() {
     const options = this.$refs.hiddenSelect.options;
     for (let i = 0; i < options.length; i++) {
       let selected = options[i].getAttribute("selected") != null;

       this.options.push({
         value: options[i].value,
         text: options[i].innerText,
         selected: selected
       });

       if (selected) { this.selected.push(i); }
     }
   }

 

Lastly, we include our partial on our form and pass the correct local variables:


<%= render partial: "shared/multiselect", locals: { form: form, label: 'Leaders', field: :leader_ids, options: @team_members } %>

 

Conclusion

By combining the power of Rails, Alpine.js, and Tailwind CSS, we've created a dynamic multiselect component that enhances the user experience in web applications. This customizable solution offers improved usability and visual appeal compared to traditional multiselects.

With this article, we aim to help you understand how to build and integrate a dynamic multiselect component into your Rails application. Feel free to experiment with additional features and customization to meet your specific project requirements, and if you happen to come across a more efficient way to implement it, we’d love to know! 

Check out our other blog posts in this link, to learn more about the latest trends, insights and updates on the tech world.

Want to know more about us?

LEARN MORE
post creator picture
Santiago Bertinat
February 05, 2024

Would you like to stay updated?