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.
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
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">×</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: