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

Comments

No comments yet. Be the first to comment!
Your email address will be verified before your first comment is posted. It will not be displayed publicly.
Legal Information
By commenting, you agree to our Privacy Policy and Terms of Service.