Taggable Array

To keep things simple, I have a custom form builder that appends some classes to keep my UX consistent. I looked at how DaisyUI handles dropdowns, menus, and navbars and decided that a dropdown would perfectly serve my purposes for creating a tag-select.

I already had a tag_field that did some horrific stuff with tom-elect, but I never liked its styling. 
lang-ruby
# frozen_string_literal: true

class TailwindFormBuilder < ActionView::Helpers::FormBuilder
  include ActionView::Helpers::TagHelper

  def tag_field(field_name, options = {})
    autocomplete_options = options.delete(:autocomplete_options) || []

    tag.div(class: "tag", data: { controller: "tags" }) do
      tag.div(class: "dropdown dropdown-top w-full") do
        hidden_field(field_name, value: options[:value]) +
          @template.text_field_tag(:tag_input, nil,
            class: "input input-bordered input-primary w-full",
            tabindex: -1,
            placeholder: "Enter tags...",

            data: {
              tags_target: "input",
              action: "keydown.enter->tags#addTag " \
                      "input->tags#filterAutocomplete " \
                      "focus->tags#showDropdown " \
                      "blur->tags#hideDropdownDelayed",
            }
          ) +
          tag.ul(
            class: "dropdown-content bg-base-300 w-full max-h-60 overflow-auto shadow-xl rounded-box z-10 menu p-2",
            data: { tags_target: "dropdown" },
          ) do
            tag.li("", class: "text-sm text-gray-500 p-2", data: { tags_target: "createNew" }) +
              safe_join(autocomplete_options.map do |option|
                tag.li do
                  tag.a(href: "#", data: { action: "mousedown->tags#selectOption" }) { option }
                end
              end,
                       )
          end
      end + tag.div(class: "mt-2", data: { tags_target: "tagList" })
    end
  end

  private

  def combine_options(default_options = {}, options = {})
    original_classes = default_options.delete(:class).to_s.split
    override_classes = options.delete(:class).to_s.split
    returned_classes = (original_classes + override_classes).uniq.join(" ")

    default_options.merge(options).merge(class: returned_classes)
  end
end

ActiveSupport.on_load(:action_view) do
  ActionView::Base.default_form_builder = TailwindFormBuilder
end
It was an iterative process with my partner Claude, who guided me well.

Let's share the stimulus controller

lang-js
import { Controller } from "@hotwired/stimulus"

// Connects to data-controller="tags"
// Connects to data: { controller: "tags" }
export default class extends Controller {
  static targets = ["input", "tagList", "dropdown"]
  static values = {
    tags: { type: Array, default: [] },
  }

  connect() {
    this.loadInitialTags()
    this.updateTagList()
    this.originalOptions = this.getOriginalOptions()
    this.filterAutocomplete()
    this.initializeInputState()
  }

  loadInitialTags() {
    const hiddenInput = this.element.querySelector('input[type="hidden"]')
    const initialTags = hiddenInput.value.split(",").filter(Boolean)
    this.tagsValue = [...new Set(initialTags)]
  }

  getOriginalOptions() {
    return Array.from(this.dropdownTarget.querySelectorAll('li:not([data-tags-target="createNew"]) a')).map((a) =>
      a.textContent.trim(),
    )
  }

  initializeInputState() {
    this.inputTarget.blur()
    this.hideDropdown()
    this.inputIsFocused = false
  }

  addTag(event) {
    if (event.key === "Enter") {
      event.preventDefault()
      this.addTagIfValid(this.inputTarget.value.trim())
    }
  }

  addTagIfValid(tagName) {
    const lowerTagName = tagName.toLowerCase()
    if (tagName && !this.tagsValue.some((tag) => tag.toLowerCase() === lowerTagName)) {
      this.tagsValue = [...this.tagsValue, tagName]
      this.updateTagList()
      this.inputTarget.value = ""
      this.filterAutocomplete()
    }
  }

  removeTag(event) {
    const tagToRemove = event.target.dataset.tag
    this.tagsValue = this.tagsValue.filter((tag) => tag !== tagToRemove)
    this.updateTagList()
    this.filterAutocomplete()
  }

  updateTagList() {
    this.tagListTarget.innerHTML = this.tagsValue.map((tag) => this.createTagElement(tag)).join("")
    const hiddenInput = this.element.querySelector('input[type="hidden"]')
    hiddenInput.value = this.tagsValue.join(",")
  }

  createTagElement(tag) {
    return `
      <span class="bg-primary text-primary-content rounded-md px-2 py-1 m-1 text-sm">
        ${tag}
        <button data-action="click->tags#removeTag" data-tag="${tag}" class="ml-1 text-xs">&times;</button>
      </span>
    `
  }

  filterAutocomplete() {
    const inputValue = this.inputTarget.value.trim().toLowerCase()
    const filteredOptions = this.getFilteredOptions(inputValue)
    this.updateDropdownContent(inputValue, filteredOptions)
  }

  getFilteredOptions(inputValue) {
    return this.originalOptions.filter(
      (option) =>
        option.toLowerCase().includes(inputValue) &&
        !this.tagsValue.some((tag) => tag.toLowerCase() === option.toLowerCase()),
    )
  }

  updateDropdownContent(inputValue, filteredOptions) {
    let dropdownContent = this.createNewTagOption(inputValue)
    dropdownContent += filteredOptions
      .map((option) => `<li><a href="#" data-action="mousedown->tags#selectOption">${option}</a></li>`)
      .join("")

    this.dropdownTarget.innerHTML = dropdownContent
    this.toggleDropdown(!!dropdownContent && this.inputIsFocused)
  }

  createNewTagOption(inputValue) {
    if (
      inputValue &&
      !this.tagsValue.some((tag) => tag.toLowerCase() === inputValue) &&
      !this.originalOptions.some((option) => option.toLowerCase() === inputValue)
    ) {
      return `<li><a href="#" data-action="mousedown->tags#selectOption" class="text-primary">Add: "${inputValue}"</a></li>`
    }
    return ""
  }

  selectOption(event) {
    event.preventDefault()
    const tagName = event.target.textContent.trim().replace(/^Add: "(.+)"$/, "$1")
    this.addTagIfValid(tagName)
    this.hideDropdown()
    this.inputTarget.focus()
  }

  toggleDropdown(show) {
    this.element.querySelector(".dropdown").classList.toggle("dropdown-open", show)
  }

  showDropdown() {
    if (this.inputIsFocused) this.toggleDropdown(true)
  }

  hideDropdown() {
    this.toggleDropdown(false)
  }

  hideDropdownDelayed() {
    setTimeout(() => this.hideDropdown(), 200)
  }

  handleInputFocus() {
    this.inputIsFocused = true
    this.filterAutocomplete()
  }

  handleInputBlur() {
    this.inputIsFocused = false
    this.hideDropdownDelayed()
  }
}
To finish it up, let's share a quick demo:
CleanShot 2024-08-16 at 15.34.58.gif 322 KB