Dropdown/Select component using ViewComponent and Hotwire

One possible way of rendering a simple dropdown or selection control

TailwindUI has something they call a Select Menu component which will be the basis for what I am calling a Dropdown component. Here is my Storybook entry for the finished component:

image.png

And here is the component in action for two different types of dropdowns, one for selecting a Locale and another for a Country.

Note that this component is not full featured. At this stage all I am supporting is displaying a fixed set of options, there is no ability to do an autocomplete to another source, a backend server for example. And the only keyboard controls are arrow up, arrow down and enter for selecting an option. In the future I may add support for looking for the first option that matches a given keystroke.

The first thing to do is build a ViewComponent. Here is the Ruby code for a generic dropdown:

app/components/resource_form/dropdown_component.rb

module ResourceForm
  class DropdownComponent < BaseComponent
    # @return [Array<Hash>] the items to display in the dropdown
    attr_reader :items
    # @return [String] the currently selected value
    attr_reader :selected_value
    # @return [Boolean] true if the items list is hidden, false otherwise
    attr_reader :hidden

    # @param [Array<Hash>] items the selectable elements to display in the dropdown.
    #   The hash should contain `text`, `value` and optionally a `css` class list
    #   Example:  `{ text: 'English (GB)', value: 'en-GB', css: 'fi fi-gb' }`
    def initialize(attribute:, label:, resource:, items:, hidden: true, options: {})
      super(attribute: attribute, label: label, resource: resource, options: options)
      @items = items
      @selected_value = resource.send(attribute)
      @hidden = hidden
    end

    private

    def selected?(value:)
      selected_value == value
    end

    def selected_text
      selected_item&.dig(:text)
    end

    def selected_item
      items.find { |o| o[:value] == selected_value }
    end
  end
end

Upon creation the component is expecting to receive a resource and an attribute for that resource. Using this the component can determine if there is a selected value it needs to display. The other important parameter is the items. This should be an array of hashes containing text and value. It may also optionally contain a css fragment for putting an icon next to the item.

An example:

[
        { text: 'English (GB)', value: 'en-GB', css: 'fi fi-gb' },
        { text: 'English (US)', value: 'en-US', css: 'fi fi-us' }
]

This component inherits from a BaseComponent which I use for all of my resource based input components:

app/components/resource_form/base_component.rb

module ResourceForm
  class BaseComponent < ViewComponent::Base
    include IconsHelper

    # @return [String] the model attribute
    attr_reader :attribute
    # @return [String] the label for the field
    attr_reader :label
    # @return [ActiveRecord] the resource to retrieve the attribute from
    attr_reader :resource
    # @return [Hash] the additional configuration options for the field
    attr_reader :options

    # @param [String] attribute model attribute to either display or provide an input for
    # @param [String] label a label for the field, must have i18n already applied
    # @param [ActiveRecord] resource an instance of an ActiveRecord object to read data from
    # @param [Hash] options additional configuration options for the field
    # @option options [String] :help a message to place near the label to help the user understand the attribute
    # @option options [Boolean] :readonly is this attribute currently editable?
    # @option options [Boolean] :editable is this attribute ever editable?
    # @option options [Boolean] :required is this attribute required to have a value?
    # @option options [String] :invalid_message if this field is invalid what message to display
    # @option options [String] :url will convert display to be link to another page
    def initialize(attribute:, label:, resource:, options: {})
      super
      @attribute = attribute
      @label = label
      @resource = resource
      @options = options.reverse_merge!(default_field_options)
    end

    private

    def default_field_options
      { help: nil, readonly: true, editable: true, required: false }
    end

    def field_id
      "#{resource.model_name.singular}_#{resource.id || 'new'}_#{attribute}"
    end

    def field_name
      "#{resource.model_name.singular}[#{attribute}]"
    end

    def display_only?
      @options[:readonly] || !@options[:editable]
    end

    def required?
      @options[:required]
    end

    def invalid_message
      @options[:invalid_message]
    end

    def url
      @options[:url]
    end

    def help
      @options[:help]
    end
  end
end

Next is the HTML for the component:

app/components/resource_form/dropdown_component.html.erb

<div class="sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5" data-controller="resource-form--component">
  <div>
    <label id="<%= field_id %>--label" for="<%= field_id %>" class="block text-sm font-medium text-gray-700 sm:mt-px sm:pt-">
      <%= label %>
    </label>
    <% if help.present? %>
      <div id="<%= field_id %>--help" class="mt-2 text-sm text-gray-300 inline-flex items-center">
        <%= icon(name: :question_mark_circle, colour: :blue, options: { size: 5 }) %><%= help %>
      </div>
    <% end %>
  </div>
  <div
    class="mt-1 sm:mt-0 sm:col-span-2"
    data-controller="resource-form--dropdown-component"
    data-resource-form--dropdown-component-item-list-id-value="<%= field_id %>--item-list"
    data-resource-form--dropdown-component-hidden-input-id-value="<%= field_id %>--hidden-input"
    data-resource-form--dropdown-component-input-id-value="<%= field_id %>--input"
  >
    <div class="flex items-center">
      <div class="relative mt-1">
        <input id="<%= field_id %>--input" value="<%= selected_text %>" disabled type="text" class="w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-1 focus:ring-sky-500 sm:text-sm" role="combobox" aria-controls="options" aria-expanded="false">
        <input id="<%= field_id %>--hidden-input" value="<%= selected_value %>" type="hidden" name="<%= field_name %>">
        <% unless display_only? %>
          <button
            id="<%= field_id %>--button"
            type="button"
            class="absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none"
            data-action="click->resource-form--dropdown-component#toggle keydown->resource-form--dropdown-component#handleKeydown"
          >
            <!-- Heroicon name: mini/chevron-up-down -->
            <svg class="h-5 w-5 text-gray-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
              <path fill-rule="evenodd" d="M10 3a.75.75 0 01.55.24l3.25 3.5a.75.75 0 11-1.1 1.02L10 4.852 7.3 7.76a.75.75 0 01-1.1-1.02l3.25-3.5A.75.75 0 0110 3zm-3.76 9.2a.75.75 0 011.06.04l2.7 2.908 2.7-2.908a.75.75 0 111.1 1.02l-3.25 3.5a.75.75 0 01-1.1 0l-3.25-3.5a.75.75 0 01.04-1.06z" clip-rule="evenodd" />
            </svg>
          </button>
        <% end %>

        <ul id="<%= field_id %>--item-list" <%= 'hidden' if hidden %> class="absolute z-10 mt-1 max-h-56 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm" id="options" role="listbox">
          <!--
            Combobox option, manage highlight styles based on mouseenter/mouseleave and keyboard navigation.

            Active: "text-white bg-sky-600", Not Active: "text-gray-900"
          -->
          <% items.each_with_index do |item, index| %>
            <li
              class="relative cursor-default select-none py-2 pl-3 pr-9 text-gray-900 dropdown-component__option"
              id="<%= field_id %>--item-<%= index %>"
              data-resource-form--dropdown-component-item-value-param="<%= item[:value] %>"
              data-resource-form--dropdown-component-item-text-param="<%= item[:text] %>"
              data-resource-form--dropdown-component-checkmark-id-param="<%= field_id %>--<%= item[:value] %>-checkmark"
              data-action="mouseenter->resource-form--dropdown-component#highlight mouseleave->resource-form--dropdown-component#highlight click->resource-form--dropdown-component#select"
              role="option"
              tabindex="-1"
            >
              <div id="<%= field_id %>--<%= item[:value] %>-value" class="flex items-center">
                <% if item[:css] %>
                  <span class="flex-none <%= item[:css] %>"></span>
                <% end %>
                <!-- Selected: "font-semibold" -->
                <span
                  class="ml-3 truncate <%= 'font-semibold' if selected?(value: item[:value]) %>"
                  title="<%= item[:text] %>">
                  <%= item[:text] %>
                </span>
              </div>

              <!--
                Checkmark, only display for selected option.

                Active: "text-white", Not Active: "text-sky-600"
              -->
              <span
                id="<%= field_id %>--<%= item[:value] %>-checkmark"
                data-resource-form--dropdown-component-target="checkmark"
                class="<%= 'hidden' unless selected?(value: item[:value]) %> absolute inset-y-0 right-0 flex items-center pr-4 text-sky-600">
                <!-- Heroicon name: mini/check -->
                <svg class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
                  <path fill-rule="evenodd" d="M16.704 4.153a.75.75 0 01.143 1.052l-8 10.5a.75.75 0 01-1.127.075l-4.5-4.5a.75.75 0 011.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 011.05-.143z" clip-rule="evenodd" />
                </svg>
              </span>
            </li>
          <% end %>
        </ul>
      </div>
    </div>
  </div>
</div>

They key points of interest from this are:

A div element that attaches a Stimulus controller and also stores ids for various other elements in the component so they can be targeted by the TypeScript code.

  <div
    data-controller="resource-form--dropdown-component"
    data-resource-form--dropdown-component-item-list-id-value="<%= field_id %>--item-list"
    data-resource-form--dropdown-component-hidden-input-id-value="<%= field_id %>--hidden-input"
    data-resource-form--dropdown-component-input-id-value="<%= field_id %>--input"
  >

A text input that will show a selected item's "display" text.

<input id="<%= field_id %>--input" value="<%= selected_text %>" disabled type="text" ...>

A hidden input that will store the value for a selected item.

<input id="<%= field_id %>--hidden-input" value="<%= selected_value %>" type="hidden" name="<%= field_name %>">

A ul element that will start as hidden initially, this contains all the items that can be selected.

<ul id="<%= field_id %>--item-list" <%= 'hidden' if hidden %> ...>

A li element that is something that can be selected. Note that it has a class called "dropdown-component__option". This will be used to help find all the possible options later in the Stimulus controller. It also has some Stimulus parameters and actions attached to it.

<li
    class="relative cursor-default select-none py-2 pl-3 pr-9 text-gray-900 dropdown-component__option"
    id="<%= field_id %>--item-<%= index %>"
    data-resource-form--dropdown-component-item-value-param="<%= item[:value] %>"
    data-resource-form--dropdown-component-item-text-param="<%= item[:text] %>"
    data-resource-form--dropdown-component-checkmark-id-param="<%= field_id %>--<%= item[:value] %>-checkmark"
    data-action="mouseenter->resource-form--dropdown-component#highlight mouseleave->resource-form--dropdown-component#highlight click->resource-form--dropdown-component#select"
    ...
>

A span element used to show a checkmark next to the currently selected item. Note that it has a Stimulus target specified against it.

<span
    id="<%= field_id %>--<%= item[:value] %>-checkmark"
    data-resource-form--dropdown-component-target="checkmark"
    class="<%= 'hidden' unless selected?(value: item[:value]) %> absolute inset-y-0 right-0 flex items-center pr-4 text-sky-600">
    ...
</span>

Now for the Stimulus controller:

app/components/resource_form/dropdown_component_controller.ts

import {ActionEvent, Controller} from "@hotwired/stimulus"
import cash, { Cash } from "cash-dom"

export default class extends Controller {
    static values = {
        inputId: String,
        hiddenInputId: String,
        itemListId: String
    }
    static targets = ["checkmark"]

    declare readonly inputIdValue: string;
    declare readonly hiddenInputIdValue: string;
    declare readonly itemListIdValue: string;
    declare readonly checkmarkTarget: HTMLElement;
    declare readonly checkmarkTargets: HTMLElement[];

    // Toggle visibility of the item list
    toggle(event: ActionEvent) {
        event.preventDefault()
        const dropdownEl: HTMLElement = cash(`#${this.itemListIdValue}`)[0] as HTMLElement
        dropdownEl.toggleAttribute('hidden')
    }

    // Highlight an individual item from the list
    highlight(event: ActionEvent) {
        const targetEl: HTMLElement = event.target as HTMLElement
        const listItemEl = cash(`#${targetEl.id}`)
        const checkmarkEl = cash(`#${event.params.checkmarkId}`)
        listItemEl.toggleClass('text-gray-900 text-white bg-gray-900 bg-sky-600 dropdown-component__option--highlighted')
        checkmarkEl.toggleClass('text-white text-sky-600')
    }

    // Set the value of the associated inputs to the item a user has selected
    select(event: ActionEvent) {
        const checkmarkEl: HTMLElement | null = document.querySelector(`#${event.params.checkmarkId}`)
        const inputEl: HTMLInputElement | null = document.querySelector(`#${this.inputIdValue}`)
        const hiddenInputEl: HTMLInputElement | null = document.querySelector(`#${this.hiddenInputIdValue}`)
        const value: string = event.params.itemValue
        const text: string = event.params.itemText

        if (inputEl && hiddenInputEl) {
            // Set the input values
            inputEl.value = text
            hiddenInputEl.value = value

            // Deselect any currently selected item
            this.checkmarkTargets.forEach(function (checkmark) {
                checkmark.classList.add('hidden')
            })

            // Select this item
            if (checkmarkEl) {
                checkmarkEl.classList.remove('hidden')
            }

            // Hide the dropdown
            this.toggle(event)
        }
    }

    // Handle supported keydown events
    handleKeydown(event: ActionEvent & KeyboardEvent) {
        event.preventDefault()
        const dropdownEl = cash(`#${this.itemListIdValue}`)
        const highlightedItems = dropdownEl.children('li.dropdown-component__option--highlighted')
        const currentItem = highlightedItems[0]
        let nextItem: HTMLElement | undefined

        if (event.key === 'Enter') {
            this.handleEnter(currentItem)
            return
        }
        if (event.key === 'ArrowDown') {
            nextItem = this.getNextItem(highlightedItems, dropdownEl)
        }
        if (event.key === 'ArrowUp') {
            nextItem = this.getPreviousItem(highlightedItems, dropdownEl)
        }
        // In future handle other keys

        this.triggerHighlight(nextItem, currentItem)
    }

    // Get the next item that can be highlighted
    getNextItem(highlightedItems: Cash, dropdownEl: Cash): HTMLElement {
        if (highlightedItems.length > 0) {
            return highlightedItems.next('li.dropdown-component__option')[0] as HTMLElement
        } else {
            return dropdownEl.find('li.dropdown-component__option')[0] as HTMLElement
        }
    }

    // Get the previous item that can be highlighted
    getPreviousItem(highlightedItems: Cash, dropdownEl: Cash) {
        if (highlightedItems.length > 0) {
            return highlightedItems.prev('li.dropdown-component__option')[0] as HTMLElement
        } else {
            return dropdownEl.find('li.dropdown-component__option')[0] as HTMLElement
        }
    }

    // Select the element the user has highlighted (if there is one)
    handleEnter(currentItem: HTMLElement | undefined) {
        if (currentItem) {
            currentItem.dispatchEvent(new Event('click'))    
        }
    }

    // Trigger highlighting on the next item and remove it from the current item
    triggerHighlight(nextItem: HTMLElement | undefined, currentItem: HTMLElement | undefined) {
        if (nextItem) {
            // mouseenter to trigger highlighting
            nextItem.dispatchEvent(new Event('mouseenter'))
        }
        if (nextItem && currentItem) {
            // mouseleave to trigger highlight removal (but only if the user has not reached the last option)
            currentItem.dispatchEvent(new Event('mouseleave'))
        } 
    }
}

The key points of interest from this controller then are:

Values here are ids of specific elements in the control, for example the hiddenInputId is used to find the input element when selecting a value. The checkmark targets are used when adding and removing highlights on items in the list.

static values = {
        inputId: String,
        hiddenInputId: String,
        itemListId: String
}
static targets = ["checkmark"]

Using the next and prev functionality of cash-dom to find a list item to highlight. The previously mentioned css class dropdown-component_option is used to mark what are valid targets for this.

return highlightedItems.next('li.dropdown-component__option')[0] as HTMLElement
return highlightedItems.prev('li.dropdown-component__option')[0] as HTMLElement

When a user presses enter on a highlighted item I dispatch a click event so it has the same behaviour as a user clicking on it with a mouse.

handleEnter(currentItem: HTMLElement | undefined) {
    if (currentItem) {
        currentItem.dispatchEvent(new Event('click'))    
    }
}

So how can I use one of these new Dropdown components? Because I am using polymorphic ViewComponent slots I need to add my new Dropdown component to the list of possibilities:

app/components/resource_form/field_component.rb

module ResourceForm
  class FieldComponent < ViewComponent::Base
    include IconsHelper

    renders_one :attribute, types: {
      text: ResourceForm::TextComponent,
      toggle: ResourceForm::ToggleComponent,
      timestamp: ResourceForm::TimestampComponent,
      hidden: ResourceForm::HiddenComponent,
      image: ResourceForm::ImageComponent,
      dropdown: ResourceForm::DropdownComponent,
    }
  end
end

And then on a form that I want to present a Dropdown for options on I can add one of these new field component types for my Dropdown:

    <% component.with_field do |c| %>
      <% c.with_attribute_dropdown(
             attribute: :option,
             label: resource_class.human_attribute_name(:option),
             resource: resource,
             items: [{text: 'Option A', value: 'A'}, {text: 'Option B', value: 'B'}],
             options: { readonly: readonly, help: t('.help.option') }
         ) %>
    <% end %>

However, another possibility exists that encapsulates things a bit better. Let's assume I want a Dropdown for locales, such as en-GB, th, zh etc. Instead of using the generic Dropdown component and passing in the items each time I can build a new ViewComponent specifically for handling locales:

app/components/resource_form/locale_dropdown_component.rb

module ResourceForm
  class LocaleDropdownComponent < BaseComponent
    # @return [Boolean] true if the items list is hidden, false otherwise
    attr_reader :hidden

    def initialize(attribute:, label:, resource:, hidden: true, options: {})
      super(attribute: attribute, label: label, resource: resource, options: options)
      @hidden = hidden
    end

    private

    def locales
      supported_locales.map { |l| { text: l[:name], value: l[:alpha2], css: "fi fi-#{l[:flag]}" } }
    end

    def supported_locales
      [
        { name: 'English (GB)', alpha2: 'en-GB', flag: 'gb' },
        { name: 'English (US)', alpha2: 'en-US', flag: 'us' },
        { name: 'Thai', alpha2: 'th', flag: 'th' },
        { name: 'Chinese', alpha2: 'zh', flag: 'cn' }
      ]
    end
  end
end

Note that I'm not inheriting from the original Dropdown component. GitHub recommends against this approach (see here), so instead I'm using composition. The Locale component renders a regular Dropdown component but has its own method for producing the items array.

app/components/resource_form/locale_dropdown_component.html.erb

<%= render ResourceForm::DropdownComponent.new(
    attribute: attribute, label: label, resource: resource, items: locales, hidden: hidden, options: options) %>

Now I can use this fully encapsulated Locale dropdown anywhere I need it, and it already knows how to generate all the locales I support:

    <% component.with_field do |c| %>
      <% c.with_attribute_locale_dropdown(
             attribute: :locale,
             label: resource_class.human_attribute_name(:locale),
             resource: resource,
             options: { readonly: readonly, help: t('.help.locale') }
         ) %>
    <% end %>

Here's what it looks like:

image.png

And that wraps it up for this Dropdown component (for now). If I add future functionality to it I will update this page.