Combobox Rails Components

Advanced select components with search, multi-select, and custom rendering capabilities. Built with TomSelect and Stimulus.

Installation

1. Stimulus Controller Setup

Add the following controller to your project:

import { Controller } from "@hotwired/stimulus";
import TomSelect from "tom-select";
import { computePosition, flip, shift, offset } from "@floating-ui/dom";

export default class extends Controller {
  static values = {
    url: String, // URL to fetch options from
    valueField: { type: String, default: "value" }, // Field to use for value
    labelField: { type: String, default: "label" }, // Field to use for label
    submitOnChange: { type: Boolean, default: false }, // Submit form on change
    disableDropdownInput: { type: Boolean, default: false }, // Disable dropdown input
    disableTyping: { type: Boolean, default: false }, // Disable typing
    allowNew: { type: Boolean, default: false }, // Allow new options
    scrollButtons: { type: Boolean, default: false }, // Show scroll buttons
    updateField: { type: Boolean, default: false }, // Update field with selected value
    updateFieldTarget: String, // Target to update
    updateFieldSource: { type: String, default: "name" }, // Source to update
    perPage: { type: Number, default: 60 }, // Number of options per page
    virtualScroll: { type: Boolean, default: false }, // Use virtual scroll
    optgroupColumns: { type: Boolean, default: false }, // Use optgroup columns
  };

  connect() {
    // Prevent re-initialization if TomSelect is already initialized
    if (this.element.tomselect) {
      return;
    }

    let plugins = [];
    if (this.virtualScrollValue && this.hasUrlValue) {
      plugins.push("virtual_scroll");
      // Keep these plugins even when virtual scrolling is enabled
      if (this.element.multiple) {
        plugins.push("remove_button", "checkbox_options", "no_active_items");
      }
    } else if (this.element.multiple) {
      // Keep existing multi-select plugins if not virtual scrolling
      plugins.push("remove_button", "checkbox_options", "drag_drop", "no_active_items");
    }

    // Add optgroup columns plugin if enabled
    if (this.hasOptgroupColumnsValue && this.optgroupColumnsValue) {
      plugins.push("optgroup_columns");
    }

    let tomSelectOptions = {
      plugins,
      maxOptions: null, // Let virtual scroll or custom scroll handle limits
      closeAfterSelect: !this.element.multiple,
      create: this.allowNewValue,
      render: {
        option: (data, escape) => {
          if (typeof data.text === "string" && data.text.startsWith("{")) {
            let optionData = JSON.parse(data.text);
            return `<div class='flex items-center gap-y-[3px] gap-x-1.5 flex-wrap'>
              ${optionData.icon || ""}
              <span>${escape(optionData.name)}</span>
              ${optionData.side || ""}
              ${
                optionData.description
                  ? `<p class='text-neutral-500 dark:text-neutral-300 text-xs my-0 w-full'>${escape(
                      optionData.description
                    )}</p>`
                  : ""
              }
            </div>`;
          }
          return `<div class='flex items-center gap-1.5'>
            <span>${escape(data.text)}</span>
          </div>`;
        },
        item: (data, escape) => {
          if (typeof data.text === "string" && data.text.startsWith("{")) {
            let optionData = JSON.parse(data.text);
            return `<div class='!flex items-center gap-1.5'>
              ${optionData.icon || ""}
              <span>${escape(optionData.name)}</span>
            </div>`;
          }
          return `<div class='!flex items-center gap-1.5'>
            <span class='line-clamp-1'>${escape(data.text)}</span>
          </div>`;
        },
        option_create: (data, escape) => {
          return `<div class="create">Add <strong>${escape(data.input)}</strong>&hellip;</div>`;
        },
      },
      onDropdownOpen: (dropdown) => {
        // Immediately set position when dropdown opens and then again after a brief delay
        this.#updatePosition();
        setTimeout(() => this.#updatePosition(), 10);
      },
    };

    if (this.hasUrlValue) {
      tomSelectOptions.preload = true; // Add preload if URL is present

      if (this.virtualScrollValue) {
        // Setup for TomSelect's virtual_scroll plugin
        tomSelectOptions = {
          ...tomSelectOptions, // Includes preload:true
          valueField: this.valueFieldValue,
          labelField: this.labelFieldValue,
          searchField: this.labelFieldValue,
          firstUrl: (query) => {
            return `${this.urlValue}?query=${encodeURIComponent(query)}&page=1&per_page=${this.perPageValue}`;
          },
          load: async (query, callback) => {
            // This is the specific load for virtual_scroll
            const url = this.select.getUrl(query);
            let originalScrollHandler = null;
            let currentScrollTop = null;

            if (this.select && this.select.dropdown_content) {
              currentScrollTop = this.select.dropdown_content.scrollTop;
              originalScrollHandler = this.select.dropdown_content.onscroll;
              this.select.dropdown_content.onscroll = null;
              this.select.dropdown_content.classList.add("is-loading-more");
            }

            try {
              const response = await fetch(url);
              if (response.ok) {
                const json = await response.json();
                if (json.has_more) {
                  const currentUrl = new URL(url, window.location.origin);
                  const currentPage = parseInt(currentUrl.searchParams.get("page") || "1");
                  const nextPage = currentPage + 1;
                  const nextQueryUrl = `${this.urlValue}?query=${encodeURIComponent(query)}&page=${nextPage}&per_page=${
                    this.perPageValue
                  }`;
                  this.select.setNextUrl(query, nextQueryUrl);
                } else {
                  this.select.setNextUrl(query, null);
                }
                callback(json.data);
                requestAnimationFrame(() => {
                  if (this.select && this.select.dropdown_content) {
                    if (typeof currentScrollTop === "number") {
                      this.select.dropdown_content.scrollTop = currentScrollTop;
                    }
                    this.select.dropdown_content.onscroll = originalScrollHandler;
                    this.select.setActiveOption(null);
                    this.select.dropdown_content
                      .querySelectorAll(".option.active")
                      .forEach((opt) => opt.classList.remove("active"));
                    this.select.dropdown_content.classList.remove("is-loading-more");
                  }
                });
              } else {
                console.error("Failed to load data for virtual scroll:", response.statusText);
                if (this.select) this.select.setNextUrl(query, null);
                callback();
                if (this.select && this.select.dropdown_content) {
                  this.select.dropdown_content.onscroll = originalScrollHandler;
                  this.select.dropdown_content.classList.remove("is-loading-more");
                }
              }
            } catch (error) {
              console.error("Error in virtual scroll load function:", error);
              if (this.select) this.select.setNextUrl(query, null);
              callback();
              if (this.select && this.select.dropdown_content) {
                this.select.dropdown_content.onscroll = originalScrollHandler;
                this.select.dropdown_content.classList.remove("is-loading-more");
              }
            }
          },
          render: {
            ...tomSelectOptions.render,
            loading_more: function (data, escape) {
              return `<div class="loading-more-results py-2 flex items-center justify-center text-sm text-neutral-500 dark:text-neutral-400"><svg class="animate-spin -ml-1 mr-3 h-4 w-4 text-neutral-500 dark:text-neutral-400" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path></svg>Loading...</div>`;
            },
            no_more_results: function (data, escape) {
              return `<div class="no-more-results hidden py-2 text-center text-sm text-neutral-500 dark:text-neutral-400">No more results</div>`;
            },
          },
          shouldLoadMore: function () {
            if (!this.dropdown_content) return false;
            const scrollTop = this.dropdown_content.scrollTop;
            const scrollHeight = this.dropdown_content.scrollHeight;
            const clientHeight = this.dropdown_content.clientHeight;
            return scrollTop + clientHeight + 150 >= scrollHeight;
          },
        };
      } else {
        // Setup for custom infinite scroll (non-virtual scroll)
        tomSelectOptions = {
          ...tomSelectOptions, // Includes preload:true
          valueField: this.valueFieldValue,
          labelField: this.labelFieldValue,
          searchField: this.labelFieldValue,
          load: this.load.bind(this), // Uses the controller's .load() method for pagination
        };
      }
    }

    // Consolidate onChange logic
    const originalOnChange = (value) => {
      if (value === "none") {
        this.element.value = "";
      } else if (this.submitOnChangeValue) {
        this.submitOnChange(value);
      }

      if (this.hasUpdateFieldValue && this.updateFieldValue && value) {
        this.updateFieldWithSelectedValue(value);
      }
    };
    tomSelectOptions.onChange = originalOnChange;

    this.select = new TomSelect(this.element, tomSelectOptions);

    if (this.hasUrlValue) {
      // If a URL is provided, clear any options from HTML,
      // as preload:true + load function will fetch initial options.
      this.select.clearOptions();
    }

    // Add position updater when dropdown opens
    this.select.on("dropdown_open", () => {
      // Clear any lingering active states when dropdown opens
      this.select.setActiveOption(null);
      if (this.select.dropdown_content) {
        this.select.dropdown_content
          .querySelectorAll(".option.active")
          .forEach((opt) => opt.classList.remove("active"));
      }

      this.#updatePosition();
      setTimeout(() => this.#updatePosition(), 10);
      setTimeout(() => this.#updatePosition(), 50);
      setTimeout(() => this.#updatePosition(), 100);

      // For custom infinite scroll, setup listeners and state
      if (this.hasUrlValue && !this.virtualScrollValue) {
        // Ensure listener is not added multiple times if dropdown_open fires again for some reason
        const boundHandleScroll = this.#handleScroll.bind(this);
        this.select.dropdown_content.removeEventListener("scroll", boundHandleScroll);
        this.select.dropdown_content.addEventListener("scroll", boundHandleScroll);

        this.currentPage = 1; // Reset page count for custom scroll
        this.loadingMore = false;
        this.hasMore = true;
      }
    });

    // Add listeners for window events
    this.scrollHandler = () => this.#updatePosition();
    window.addEventListener("scroll", this.scrollHandler, true);
    this.resizeObserver = new ResizeObserver(() => this.#updatePosition());
    this.resizeObserver.observe(document.documentElement);

    // Additional position updater for layout shifts
    this.mutationObserver = new MutationObserver((mutations) => {
      if (this.select.dropdown_content && this.select.dropdown.classList.contains("ts-dropdown")) {
        this.#updatePosition();
      }
    });
    this.mutationObserver.observe(document.body, { childList: true, subtree: true });

    // Add scroll buttons if there are many options and scrollButtons is enabled
    if (this.select.dropdown_content && this.hasScrollButtonsValue && this.scrollButtonsValue) {
      this.addScrollButtons();
    }

    // Only track and restore scroll position for multiple selects
    if (this.element.multiple) {
      this.select.dropdown_content.addEventListener("scroll", () => {
        this.lastScrollPosition = this.select.dropdown_content.scrollTop;
      });

      ["item_add", "item_remove"].forEach((eventName) => {
        this.select.on(eventName, () => {
          if (this.lastScrollPosition) {
            setTimeout(() => {
              this.select.dropdown_content.scrollTop = this.lastScrollPosition;
            }, 0);
          }
        });
      });
    }

    // Handle initial preselected value
    if (this.hasUpdateFieldValue && this.updateFieldValue) {
      try {
        const currentValue = this.urlValue ? this.getValue(this.urlValue) : null;
        if (currentValue) {
          this.select.setValue(currentValue);
        }
      } catch (error) {
        console.warn("Initial value setting skipped");
      }
    }

    // If there's a preselected value and we're not disabling dropdown input, enable typing
    if (!this.disableDropdownInputValue && this.select.getValue()) {
      this.select.destroy();
      plugins.push("dropdown_input");
      this.select = new TomSelect(this.element, { ...tomSelectOptions, plugins });
    }

    // Disable typing if specified
    if (this.hasDisableTypingValue && this.disableTypingValue) {
      const tomInput = this.select.control_input;
      if (tomInput) {
        tomInput.readOnly = true;
        tomInput.setAttribute("readonly", "readonly");

        // Add keyboard navigation for readonly input
        let buffer = "";
        let timeout;

        tomInput.addEventListener("keydown", (e) => {
          // Handle alphanumeric keys and arrow keys
          if (
            (e.key.length === 1 && e.key.match(/[a-z0-9]/i)) ||
            ["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(e.key)
          ) {
            // Hide cursor when using keyboard
            document.body.requestPointerLock();

            if (e.key.length === 1) {
              // Only add to buffer for alphanumeric keys
              // Clear buffer timeout
              clearTimeout(timeout);
              timeout = setTimeout(() => {
                buffer = "";
              }, 1000);

              buffer += e.key.toLowerCase();
              const options = this.select.options;

              // Find first option that starts with the buffer
              const match = Object.values(options).find((option) => {
                const label = this.hasUrlValue
                  ? option[this.labelFieldValue]
                  : option.text.startsWith("{")
                  ? JSON.parse(option.text).name
                  : option.text;
                return label.toLowerCase().startsWith(buffer);
              });

              if (match) {
                // Find the matching option in the dropdown list
                const optionEl = this.select.dropdown_content.querySelector(
                  `[data-value="${match[this.valueFieldValue]}"]`
                );

                if (optionEl) {
                  // Highlight the option without selecting it
                  this.select.setActiveOption(optionEl);
                  this.select.open();

                  // Scroll to the option if needed
                  const dropdownHeight = this.select.dropdown_content.offsetHeight;
                  const optionTop = optionEl.offsetTop;
                  const optionHeight = optionEl.offsetHeight;

                  if (optionTop < this.select.dropdown_content.scrollTop) {
                    this.select.dropdown_content.scrollTop = optionTop;
                  } else if (optionTop + optionHeight > this.select.dropdown_content.scrollTop + dropdownHeight) {
                    this.select.dropdown_content.scrollTop = optionTop + optionHeight - dropdownHeight;
                  }
                }
              }
            }
          }
        });

        // Show cursor again when moving the mouse
        document.addEventListener("mousemove", () => {
          if (document.pointerLockElement) {
            document.exitPointerLock();
          }
        });
      }
    }

    this.element.style.visibility = "visible";
  }

  disconnect() {
    if (this.checkboxObserver) {
      this.checkboxObserver.disconnect();
    }

    if (this.select) {
      this.select.destroy();
      this.select = null; // Nullify to prevent errors if disconnect is called multiple times or accessed later
    }
    window.removeEventListener("scroll", this.scrollHandler, true);
    if (this.resizeObserver) this.resizeObserver.disconnect();
    if (this.mutationObserver) this.mutationObserver.disconnect();
    // Custom scroll listener is on this.select.dropdown_content, which is destroyed with this.select.
    // So, no explicit removal of that specific listener is strictly needed here if TomSelect cleans up its DOM.
  }

  async load(query, callback) {
    // Reset for new searches/initial loads
    this.currentPage = 1;
    this.hasMore = true;
    this.loadingMore = false;

    try {
      const response = await fetch(
        `${this.urlValue}?query=${query}&page=${this.currentPage}&per_page=${this.perPageValue}`
      );
      if (response.ok) {
        const json = await response.json();
        callback(json.data); // Pass only the data array to TomSelect
        this.hasMore = json.has_more;
        // Ensure dropdown content exists before trying to add/remove scroll listener
        if (this.select && this.select.dropdown_content) {
          // Remove existing listener before adding, to prevent duplicates if load is called multiple times
          this.select.dropdown_content.removeEventListener("scroll", this.#handleScroll.bind(this));
          this.select.dropdown_content.addEventListener("scroll", this.#handleScroll.bind(this));
        }
      } else {
        callback();
        this.hasMore = false;
      }
    } catch (error) {
      console.error("Error during initial load:", error);
      callback();
      this.hasMore = false;
    }
  }

  async #loadMore(query) {
    if (this.virtualScrollValue) return; // Do not run for virtual scroll
    if (this.loadingMore || !this.hasMore) return;

    this.loadingMore = true;
    this.currentPage += 1;

    try {
      const response = await fetch(
        `${this.urlValue}?query=${query || ""}&page=${this.currentPage}&per_page=${this.perPageValue}`
      );
      if (response.ok) {
        const newOptions = await response.json();
        if (newOptions && newOptions.data && newOptions.data.length > 0) {
          this.select.addOptions(newOptions.data);
          this.hasMore = newOptions.has_more;
        } else {
          this.hasMore = false;
        }
        // Restore focus to the input if it exists, to allow continued typing/searching
        if (this.select.control_input) {
          this.select.control_input.focus();
        }
      } else {
        this.hasMore = false; // Stop trying if there's an error
        console.error("Failed to load more options:", response.statusText);
      }
    } catch (error) {
      console.error("Error loading more options:", error);
      this.hasMore = false; // Stop trying if there's an error
    } finally {
      this.loadingMore = false;
      // Re-check position after loading more items, in case the dropdown size changed significantly
      this.#updatePosition();
    }
  }

  #handleScroll() {
    if (this.virtualScrollValue) return; // Do not run for virtual scroll
    const dropdownContent = this.select.dropdown_content;
    // Check if near bottom (e.g., 50px from bottom)
    if (dropdownContent.scrollTop + dropdownContent.clientHeight >= dropdownContent.scrollHeight - 50) {
      const currentQuery = this.select.control_input ? this.select.control_input.value : "";
      this.#loadMore(currentQuery);
    }
  }

  submitOnChange(value) {
    if (value) {
      if (value === "none") {
        const url = new URL(window.location.href);
        const paramName = this.element.name;
        url.searchParams.delete(paramName);
        window.location.href = url.toString();
      } else {
        this.element.form.requestSubmit();
      }
      this.element.value = value;
      this.addSpinner();
    }
  }

  updateFieldWithSelectedValue(value) {
    // Get the form containing this select
    const form = this.element.closest("form");
    if (!form) return;

    // Determine the target field to update
    let targetField;

    if (this.hasUpdateFieldTargetValue) {
      // Use the specified selector to find the target field
      targetField = form.querySelector(this.updateFieldTargetValue);
    } else {
      // Default to finding a field named "name" within the same form
      targetField = form.querySelector('input[name="list_contact[name]"]');
    }

    if (!targetField) return;

    // Get the selected option from the TomSelect instance
    const selectedOption = this.select.options[value];
    if (!selectedOption) return;

    // Extract the data from the selected option
    if (typeof selectedOption.text === "string" && selectedOption.text.startsWith("{")) {
      try {
        const optionData = JSON.parse(selectedOption.text);
        const sourceProperty = this.hasUpdateFieldSourceValue ? this.updateFieldSourceValue : "name";

        if (optionData[sourceProperty] !== undefined) {
          targetField.value = optionData[sourceProperty];

          // Dispatch an input event to trigger any listeners
          targetField.dispatchEvent(new Event("input", { bubbles: true }));
        }
      } catch (e) {
        console.warn("Could not parse option data:", e);
      }
    }
  }

  addSpinner() {
    const logoContainer = this.element.closest(".relative").querySelector(".absolute.z-10");
    if (logoContainer) {
      logoContainer.innerHTML = `
        <svg xmlns="http://www.w3.org/2000/svg" class="animate-spin size-7 mr-[5px] text-neutral-500 p-1 rounded-full bg-white dark:bg-neutral-700" width="24" height="24" fill="none" viewBox="0 0 24 24">
          <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
          <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
        </svg>
      `;
    }
  }

  addScrollButtons() {
    const scrollSpeed = 80; // Pixels per frame
    let scrollInterval;

    // Create scroll up button
    const scrollUpBtn = document.createElement("div");
    scrollUpBtn.className =
      "absolute left-0 right-0 top-0 h-5 bg-gradient-to-b from-white to-transparent dark:from-neutral-800 z-10 cursor-default flex items-center justify-center transition-opacity duration-150";
    scrollUpBtn.innerHTML =
      '<svg class="size-3 text-neutral-600 dark:text-neutral-300" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7"></path></svg>';

    // Create scroll down button
    const scrollDownBtn = document.createElement("div");
    scrollDownBtn.className =
      "absolute left-0 right-0 bottom-0 h-5 bg-gradient-to-t from-white to-transparent dark:from-neutral-800 z-10 cursor-default flex items-center justify-center transition-opacity duration-150";
    scrollDownBtn.innerHTML =
      '<svg class="size-3 text-neutral-600 dark:text-neutral-300" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path></svg>';

    // Helper function to start scrolling
    const startScrolling = (direction) => {
      // Clear any existing interval
      if (scrollInterval) {
        clearInterval(scrollInterval);
      }

      scrollInterval = setInterval(() => {
        if (direction === "up") {
          this.select.dropdown_content.scrollTop -= scrollSpeed;
        } else {
          this.select.dropdown_content.scrollTop += scrollSpeed;
        }
      }, 100); // ~60fps
    };

    // Helper function to stop scrolling
    const stopScrolling = () => {
      if (scrollInterval) {
        clearInterval(scrollInterval);
        scrollInterval = null;
      }
    };

    // Helper function to add visual feedback
    const addPressedState = (btn) => {
      btn.style.opacity = "0.7";
    };

    // Helper function to remove visual feedback
    const removePressedState = (btn) => {
      btn.style.opacity = "1";
    };

    // Mouse events (for desktop)
    scrollUpBtn.addEventListener("mouseenter", () => {
      startScrolling("up");
      addPressedState(scrollUpBtn);
    });

    scrollDownBtn.addEventListener("mouseenter", () => {
      startScrolling("down");
      addPressedState(scrollDownBtn);
    });

    // Clear interval when mouse leaves
    [scrollUpBtn, scrollDownBtn].forEach((btn) => {
      btn.addEventListener("mouseleave", () => {
        stopScrolling();
        removePressedState(btn);
      });
    });

    // Touch events (for mobile)
    scrollUpBtn.addEventListener(
      "touchstart",
      (e) => {
        e.preventDefault(); // Prevent scrolling and other touch behaviors
        startScrolling("up");
        addPressedState(scrollUpBtn);
      },
      { passive: false }
    );

    scrollDownBtn.addEventListener(
      "touchstart",
      (e) => {
        e.preventDefault(); // Prevent scrolling and other touch behaviors
        startScrolling("down");
        addPressedState(scrollDownBtn);
      },
      { passive: false }
    );

    // Stop scrolling on touch end/cancel for both buttons
    [scrollUpBtn, scrollDownBtn].forEach((btn) => {
      btn.addEventListener(
        "touchend",
        (e) => {
          e.preventDefault();
          stopScrolling();
          removePressedState(btn);
        },
        { passive: false }
      );

      btn.addEventListener(
        "touchcancel",
        (e) => {
          e.preventDefault();
          stopScrolling();
          removePressedState(btn);
        },
        { passive: false }
      );
    });

    // Add buttons to dropdown
    this.select.dropdown.insertBefore(scrollUpBtn, this.select.dropdown.firstChild);
    this.select.dropdown.appendChild(scrollDownBtn);

    // Show/hide buttons based on scroll position
    this.select.dropdown_content.addEventListener("scroll", () => {
      const { scrollTop, scrollHeight, clientHeight } = this.select.dropdown_content;
      scrollUpBtn.style.display = scrollTop > 0 ? "flex" : "none";
      scrollDownBtn.style.display = scrollTop + clientHeight < scrollHeight ? "flex" : "none";
    });

    // Initial visibility
    scrollUpBtn.style.display = "none";
  }

  async #updatePosition() {
    if (!this.select || !this.select.dropdown) return;

    const referenceEl = this.select.control;
    const floatingEl = this.select.dropdown;

    // Ensure both elements are fully rendered before calculating position
    if (!referenceEl.getBoundingClientRect().height || !floatingEl.getBoundingClientRect().height) {
      // If dropdown is not visible or not fully rendered yet, try again shortly.
      // This might happen if #updatePosition is called before TomSelect fully opens the dropdown.
      if (floatingEl.offsetParent !== null) {
        // Check if it's in the DOM and potentially visible
        setTimeout(() => this.#updatePosition(), 50); // Increased delay for rendering
      }
      return;
    }

    try {
      const { x, y } = await computePosition(referenceEl, floatingEl, {
        placement: "bottom-start",
        middleware: [offset(6), flip(), shift({ padding: 8 })],
      });

      // Apply correct position
      Object.assign(floatingEl.style, {
        position: "absolute",
        left: `${x}px`,
        top: `${y}px`,
        width: `${Math.max(referenceEl.offsetWidth, 160)}px`,
      });
    } catch (error) {
      console.warn("Error positioning dropdown:", error);
    }
  }

  // Override the original TomSelect behavior for checkbox_options when virtual scroll is used
  afterConnect() {
    if (this.virtualScrollValue && this.element.multiple) {
      // Watch for newly rendered options and add checkboxes to them if needed
      const observer = new MutationObserver((mutations) => {
        for (const mutation of mutations) {
          if (mutation.type === "childList" && mutation.addedNodes.length) {
            // Iterate through added nodes to find options that need checkboxes
            for (const node of mutation.addedNodes) {
              if (
                node.nodeType === Node.ELEMENT_NODE &&
                node.classList.contains("option") &&
                !node.querySelector(".checkbox")
              ) {
                // Create a checkbox element
                const checkbox = document.createElement("span");
                checkbox.className = "checkbox me-2";
                checkbox.innerHTML =
                  '<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="4" stroke-linecap="round" stroke-linejoin="round" class="size-3"><polyline points="20 6 9 17 4 12"></polyline></svg>';

                // Insert at the beginning of the option
                if (node.firstChild) {
                  node.insertBefore(checkbox, node.firstChild);
                } else {
                  node.appendChild(checkbox);
                }
              }
            }
          }
        }
      });

      if (this.select && this.select.dropdown_content) {
        observer.observe(this.select.dropdown_content, {
          childList: true,
          subtree: true,
        });

        // Store the observer so we can disconnect it later
        this.checkboxObserver = observer;
      }
    }
  }
}

2. Dependencies Installation

The context menu component relies on Floating UI for intelligent tooltip positioning. Choose your preferred installation method:

pin "@floating-ui/dom", to: "https://cdn.jsdelivr.net/npm/@floating-ui/[email protected]/+esm"
pin "tom-select", to: "https://cdn.jsdelivr.net/npm/[email protected]/+esm"
Terminal
npm install @floating-ui/dom
npm install tom-select
Terminal
yarn add @floating-ui/dom
yarn add tom-select

3. CSS Styles

Here is the custom CSS I've used to style the combobox:

/* Tom Select */

select[multiple][data-controller="select"] {
  @apply invisible;
}

.dropdown-input {
  @apply !border-neutral-300 !bg-white !px-3 !py-2.5 text-sm placeholder:!text-neutral-500 dark:!border-neutral-600 dark:!bg-neutral-700 dark:!placeholder-neutral-300;
}

.plugin-dropdown_input.focus.dropdown-active .ts-control {
  @apply !border-none;
}

.ts-dropdown-content {
  @apply py-1.5;
  max-height: 240px;
}

.ts-dropdown-content {
  scrollbar-width: thin;
  scrollbar-color: #a2a2a270 #7878780b;
}

.ts-dropdown-content::-webkit-scrollbar {
  width: 6px;
}

.ts-dropdown-content::-webkit-scrollbar-track {
  background: #78787879;
}

.ts-dropdown-content::-webkit-scrollbar-thumb {
  background-color: #a2a2a270;
  border-radius: 3px;
}

.ts-control {
  @apply flex min-h-[40px] w-full px-3 py-2 cursor-default rounded-lg border-0 text-base leading-6 text-neutral-900 shadow-sm ring-1 placeholder:text-neutral-400 ring-neutral-300 outline-none ring-inset focus:ring-neutral-600 dark:bg-neutral-700 dark:text-white dark:placeholder-neutral-300 dark:ring-neutral-600 dark:focus:ring-neutral-500;

  &[disabled] {
    @apply cursor-not-allowed bg-neutral-100 dark:bg-neutral-600;
  }

  &.error {
    @apply border-red-400 outline-red-300 focus:outline-red-500 dark:border-red-600 dark:outline-red-500;
  }
}

.plugin-dropdown_input .dropdown-input {
  @apply outline-none;
}

.ts-dropdown .active.create {
  @apply bg-neutral-100 text-neutral-900 dark:bg-neutral-600 dark:text-white;
}

.disabled .ts-control {
  cursor: not-allowed !important;
}

@media (min-width: 640px) {
  .ts-control {
    font-size: 0.875rem; /* text-sm equivalent (14px) for larger screens */
  }
}

.full .ts-control {
  @apply dark:bg-neutral-700;
}

.ts-wrapper.single .ts-control,
.ts-wrapper.single .ts-control input,
.ts-control,
.ts-wrapper.single.input-active .ts-control {
  @apply cursor-text;
}

.ts-dropdown [data-selectable] .highlight {
  @apply bg-orange-500/20 dark:bg-yellow-500/20;
}

.ts-control,
.ts-wrapper.single.input-active .ts-control {
  @apply bg-white dark:bg-neutral-700;
}

.input-active {
  @apply rounded-lg bg-transparent outline-2 outline-neutral-600 dark:bg-neutral-600 dark:outline-neutral-500;
}

.ts-control input {
  @apply !m-0 bg-white text-base placeholder:text-neutral-400 read-only:!cursor-pointer dark:bg-neutral-800 dark:text-white dark:placeholder-neutral-300;
}

@media (min-width: 640px) {
  .ts-control input {
    font-size: 0.875rem; /* text-sm equivalent (14px) for larger screens */
  }
}

.ts-wrapper:not(trix-toolbar .trix-input--dialog):not(.form-select).single .ts-control {
  @apply !pr-8;
}

.ts-wrapper.plugin-remove_button .item {
  @apply rounded-md;
}

.ts-wrapper.plugin-remove_button .item .remove {
  @apply rounded-r-lg border-none py-1 text-lg leading-none;
}

.ts-wrapper.plugin-remove_button .item .remove::before {
  content: "";
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Ctitle%3Exmark%3C/title%3E%3Cg fill='%236B7280'%3E%3Cpath d='m2.25,10.5c-.192,0-.384-.073-.53-.22-.293-.293-.293-.768,0-1.061L9.22,1.72c.293-.293.768-.293,1.061,0s.293.768,0,1.061l-7.5,7.5c-.146.146-.338.22-.53.22Z' stroke-width='0'%3E%3C/path%3E%3Cpath d='m9.75,10.5c-.192,0-.384-.073-.53-.22L1.72,2.78c-.293-.293-.293-.768,0-1.061s.768-.293,1.061,0l7.5,7.5c.293.293.293.768,0,1.061-.146.146-.338.22-.53.22Z' stroke-width='0'%3E%3C/path%3E%3C/g%3E%3C/svg%3E");
  @apply block size-4 bg-center bg-no-repeat;
}

/* Add separate dark mode version */
@media (prefers-color-scheme: dark) {
  .ts-wrapper.plugin-remove_button .item .remove::before {
    background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Ctitle%3Exmark%3C/title%3E%3Cg fill='%239CA3AF'%3E%3Cpath d='m2.25,10.5c-.192,0-.384-.073-.53-.22-.293-.293-.293-.768,0-1.061L9.22,1.72c.293-.293.768-.293,1.061,0s.293.768,0,1.061l-7.5,7.5c-.146.146-.338.22-.53.22Z' stroke-width='0'%3E%3C/path%3E%3Cpath d='m9.75,10.5c-.192,0-.384-.073-.53-.22L1.72,2.78c-.293-.293-.293-.768,0-1.061s.768-.293,1.061,0l7.5,7.5c.293.293.293.768,0,1.061-.146.146-.338.22-.53.22Z' stroke-width='0'%3E%3C/path%3E%3C/g%3E%3C/svg%3E");
  }
}

.ts-wrapper.plugin-remove_button .item .remove {
  font-size: 0 !important;
  @apply my-0.5 mr-1 !ml-0.5 flex size-[18px] items-center justify-center rounded !border-0 !p-1 !leading-none text-neutral-500 hover:text-neutral-700 dark:text-neutral-400 dark:hover:bg-neutral-700 dark:hover:text-neutral-200;
}

.ts-dropdown {
  @apply z-40 m-0 overflow-hidden rounded-lg border border-t border-solid border-neutral-300 shadow-sm dark:border-neutral-600 dark:bg-neutral-800 dark:text-white;
}

.ts-dropdown .create {
  @apply mx-1.5 cursor-default rounded-md px-2.5 py-2 text-sm dark:text-neutral-400;
}

.ts-dropdown [data-selectable].option,
.ts-dropdown .no-results {
  @apply mx-1.5 cursor-default rounded-md px-2.5 py-2 text-sm;
}

.ts-dropdown .option,
.ts-dropdown [data-disabled],
.ts-dropdown [data-disabled] [data-selectable].option {
  @apply mx-1.5 cursor-not-allowed rounded-md px-2.5 py-2 text-sm;
}

.ts-dropdown [data-selectable].option,
.ts-dropdown .ts-dropdown .create {
  @apply cursor-pointer;
}

.ts-dropdown .active {
  @apply bg-neutral-100 text-neutral-900 dark:bg-neutral-600 dark:text-white;
}

.ts-dropdown .spinner {
  @apply h-auto w-auto;
}

.ts-dropdown .spinner:after {
  @apply mt-1 mb-0 inline-block size-4 border-2 p-0;
}

.ts-wrapper:not(.form-control):not(.form-select).single .ts-control {
  background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%23737373' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e");
  background-position: right 0.5rem center;
  background-repeat: no-repeat;
  background-size: 1.5em 1.5em;
  print-color-adjust: exact;
}

/* Dark mode arrow for single select */
@media (prefers-color-scheme: dark) {
  .ts-wrapper:not(.form-control):not(.form-select).single .ts-control {
    background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%23A1A1AA' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e");
  }
}

/* Add dropdown arrow to multiselect elements */
.ts-wrapper:not(.form-control):not(.form-select).multi .ts-control {
  background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%23737373' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 6l4-4 4 4M6 14l4 4 4-4'/%3e%3c/svg%3e");
  background-position: right 0.6rem center;
  background-repeat: no-repeat;
  background-size: 1.25em 1.25em;
  print-color-adjust: exact;
  padding-right: 2rem !important;
}

/* Dark mode arrow for multiselect */
@media (prefers-color-scheme: dark) {
  .ts-wrapper:not(.form-control):not(.form-select).multi .ts-control {
    background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%23A1A1AA' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 6l4-4 4 4M6 14l4 4 4-4'/%3e%3c/svg%3e");
  }
}
.ts-wrapper.multi .ts-control > div {
  @apply mr-1 inline-flex items-center justify-center rounded-md bg-neutral-100 px-2 text-xs leading-none font-medium text-neutral-900 dark:bg-neutral-900 dark:text-neutral-100;
}

/* Ensure items don't overlap with the dropdown arrow */
.ts-wrapper.multi.has-items .ts-control {
  @apply !pt-[7px] !pr-8 !pb-[4px];
}

.ts-wrapper.plugin-remove_button:not(.rtl) .item {
  @apply cursor-grab;
}

.ts-wrapper.plugin-remove_button:not(.rtl) .item .remove {
  @apply !-ml-0.5 cursor-pointer border-none;
}

.ts-wrapper.plugin-remove_button .item .remove {
  @apply my-0.5 mr-1 !ml-0.5 flex size-[18px] items-center justify-center rounded border-0 text-lg leading-none text-neutral-900/60 hover:text-neutral-900 dark:text-neutral-100/60 dark:hover:bg-neutral-700 dark:hover:text-neutral-100;
}

.ts-dropdown .optgroup-header {
  @apply border-t border-neutral-300 bg-white font-semibold text-neutral-900 dark:border-neutral-600 dark:bg-neutral-800 dark:text-neutral-100;
}

.ts-wrapper.multi.has-items .ts-control > input {
  @apply !mb-[3px];
}

.tomselect-checkbox {
  @apply !mr-0;
}

.input-hidden.focus {
  @apply !rounded-lg border  border-neutral-300 dark:border-neutral-600;
}

/* Replace the previous attempt with this updated selector */
select[data-select-disable-typing-value="true"] + .ts-wrapper .ts-control,
select[data-select-disable-typing-value="true"] + .ts-wrapper.single .ts-control,
select[data-select-disable-typing-value="true"] + .ts-wrapper.single .ts-control input,
select[data-select-disable-typing-value="true"] + .ts-wrapper.single.input-active .ts-control {
  @apply cursor-default;
}

.ts-dropdown-content.is-loading-more .option {
  pointer-events: none !important;
}

Examples

Basic Combobox

A simple combobox with search functionality.

<div class="w-full max-w-xs">
  <%= select_tag :framework,
      options_for_select([
        ["Ruby on Rails", "rails"],
        ["Laravel", "laravel"],
        ["Django", "django"],
        ["Express.js", "express"],
        ["Spring Boot", "spring"],
        ["ASP.NET Core", "aspnet"],
        ["Phoenix", "phoenix"],
        ["FastAPI", "fastapi"]
      ]),
      include_blank: "Select framework...",
      class: "w-full",
      data: {
        controller: "select"
      } %>
</div>

Combobox with Icons and Descriptions

Enhanced combobox with icons, badges, and descriptions for each option.

<div class="w-full max-w-md">
  <%= select_tag :framework_with_details,
      options_for_select([
        [{
          icon: "<img class='size-5 my-0 dark:invert' src='https://cdn.prod.website-files.com/61d14fa88c31332eb4187c19/62af9b63d93f763db776358d_Tech-Rails-Black.png'>",
          name: "Rails",
          side: "<span class='ml-auto text-xs text-red-600 dark:text-red-400'>Ruby</span>",
          description: "Full-stack web application framework with convention over configuration"
        }.to_json, "rails"],
        [{
          icon: "<img class='size-5 my-0 dark:invert' src='https://cdn.prod.website-files.com/61d14fa88c31332eb4187c19/62af928c450de106001471fd_Tech-Laravel-Black.png'>",
          name: "Laravel",
          side: "<span class='ml-auto text-xs text-green-600 dark:text-green-400'>PHP</span>",
          description: "Elegant PHP framework with expressive, beautiful syntax"
        }.to_json, "laravel"],
        [{
          icon: "<img class='size-5 my-0 dark:invert' src='https://cdn.prod.website-files.com/61d14fa88c31332eb4187c19/62af943503138202b88a1ef6_Tech-Django-Black.png'>",
          name: "Django",
          side: "<span class='ml-auto text-xs text-blue-600 dark:text-blue-400'>Python</span>",
          description: "High-level Python web framework for rapid development"
        }.to_json, "django"],
        [{
          icon: "<svg class='size-5 my-0' viewBox='0 0 24 24' fill='currentColor'><path d='M24 18.588a1.529 1.529 0 01-1.895-.72l-3.45-4.771-.5-.667-4.003 5.444a1.466 1.466 0 01-1.802.708l5.158-6.92-4.798-6.251a1.595 1.595 0 011.9.666l3.576 4.83 3.596-4.81a1.435 1.435 0 011.788-.668L21.708 7.9l-2.522 3.283a.666.666 0 000 .994l4.804 6.412zM.002 11.576l.42-2.075c1.154-4.103 5.858-5.81 9.094-3.27 1.895 1.489 2.368 3.597 2.275 5.973H1.116C.943 16.447 4.005 19.009 7.92 17.7a4.078 4.078 0 002.582-2.876c.207-.666.548-.78 1.174-.588a5.417 5.417 0 01-2.589 3.957 6.272 6.272 0 01-7.306-.933 6.575 6.575 0 01-1.64-3.858c0-.235-.08-.455-.134-.666A88.33 88.33 0 010 11.577zm1.127-.286h9.654c-.06-3.076-2.001-5.258-4.59-5.278-2.882-.04-4.944 2.094-5.071 5.264z'/></svg>",
          name: "Express.js",
          side: "<span class='ml-auto text-xs text-yellow-600 dark:text-yellow-400'>Node.js</span>",
          description: "Fast, unopinionated, minimalist web framework for Node.js"
        }.to_json, "express"]
      ]),
      include_blank: "Select framework...",
      class: "w-full",
      data: {
        controller: "select"
      } %>
</div>

Multi-select Combobox

Allow users to select multiple options with checkboxes and tags.

<div class="w-full max-w-md">
  <%= select_tag :programming_languages,
      options_for_select([
        ["Ruby", "ruby"],
        ["JavaScript", "javascript"],
        ["Python", "python"],
        ["TypeScript", "typescript"],
        ["Go", "go"],
        ["Rust", "rust"],
        ["Java", "java"],
        ["C#", "csharp"],
        ["PHP", "php"],
        ["Swift", "swift"],
        ["Kotlin", "kotlin"],
        ["Scala", "scala"]
      ]),
      multiple: true,
      include_blank: "Select languages...",
      class: "w-full",
      data: {
        controller: "select"
      } %>
</div>

Grouped Options Combobox

Organize options into logical groups for better navigation.

<div class="w-full max-w-md">
  <%= select_tag :technology_stack,
      grouped_options_for_select({
        "Frontend Frameworks" => [
          ["React", "react"],
          ["Vue.js", "vue"],
          ["Angular", "angular"],
          ["Svelte", "svelte"],
          ["Alpine.js", "alpine"]
        ],
        "Backend Frameworks" => [
          ["Ruby on Rails", "rails"],
          ["Django", "django"],
          ["Express.js", "express"],
          ["Laravel", "laravel"],
          ["Spring Boot", "spring"]
        ],
        "Mobile Development" => [
          ["React Native", "react-native"],
          ["Flutter", "flutter"],
          ["Swift", "swift"],
          ["Kotlin", "kotlin"],
          ["Ionic", "ionic"]
        ],
        "Database Systems" => [
          ["PostgreSQL", "postgresql"],
          ["MySQL", "mysql"],
          ["MongoDB", "mongodb"],
          ["Redis", "redis"],
          ["SQLite", "sqlite"]
        ]
      }),
      include_blank: "Select technology...",
      class: "w-full",
      data: {
        controller: "select"
      } %>
</div>

Combobox with Create Option

Allow users to create new options on the fly.

Type to search or create a new tag

Select existing skills or type to add new ones

<div class="space-y-6">
  <div class="w-full max-w-md">
    <label class="block text-sm font-medium mb-2">Single Select with Create</label>
    <%= select_tag :custom_tags,
        options_for_select([
          ["Bug", "bug"],
          ["Feature", "feature"],
          ["Enhancement", "enhancement"],
          ["Documentation", "documentation"],
          ["Question", "question"]
        ]),
        include_blank: "Select or create tag...",
        class: "w-full",
        data: {
          controller: "select",
          select_allow_new_value: true
        } %>
    <p class="text-xs text-neutral-500 mt-1">Type to search or create a new tag</p>
  </div>

  <div class="w-full max-w-md">
    <label class="block text-sm font-medium mb-2">Multi-select with Create</label>
    <%= select_tag :custom_skills,
        options_for_select([
          ["JavaScript", "javascript"],
          ["Python", "python"],
          ["Ruby", "ruby"],
          ["Go", "go"],
          ["Rust", "rust"]
        ]),
        multiple: true,
        include_blank: "Select or add skills...",
        class: "w-full",
        data: {
          controller: "select",
          select_allow_new_value: true
        } %>
    <p class="text-xs text-neutral-500 mt-1">Select existing skills or type to add new ones</p>
  </div>
</div>

Disabled States

Examples of disabled comboboxes and disabled options.

This combobox is completely disabled

Some options are disabled and cannot be selected

Disabled options appear in different groups

<div class="space-y-6">
  <div class="w-full max-w-md">
    <label class="block text-sm font-medium mb-2">Fully Disabled Combobox</label>
    <%= select_tag :disabled_select,
        options_for_select([
          ["Option 1", "1"],
          ["Option 2", "2"],
          ["Option 3", "3"]
        ], "2"),
        include_blank: "Select option...",
        class: "w-full",
        disabled: true,
        data: {
          controller: "select"
        } %>
    <p class="text-xs text-neutral-500 mt-1">This combobox is completely disabled</p>
  </div>

  <div class="w-full max-w-md">
    <label class="block text-sm font-medium mb-2">Combobox with Disabled Options</label>
    <%= select_tag :partial_disabled,
        options_for_select([
          ["Available Option 1", "1"],
          ["Unavailable Option", "2", { disabled: true }],
          ["Available Option 2", "3"],
          ["Out of Stock", "4", { disabled: true }],
          ["Available Option 3", "5"],
          ["Coming Soon", "6", { disabled: true }]
        ]),
        include_blank: "Select available option...",
        class: "w-full",
        data: {
          controller: "select"
        } %>
    <p class="text-xs text-neutral-500 mt-1">Some options are disabled and cannot be selected</p>
  </div>

  <div class="w-full max-w-md">
    <label class="block text-sm font-medium mb-2">Grouped Options with Disabled Items</label>
    <%= select_tag :grouped_disabled,
        grouped_options_for_select({
          "In Stock" => [
            ["Product A", "a"],
            ["Product B", "b"],
            ["Product C", "c"]
          ],
          "Out of Stock" => [
            ["Product D", "d", { disabled: true }],
            ["Product E", "e", { disabled: true }]
          ],
          "Pre-order" => [
            ["Product F (Available)", "f"],
            ["Product G (Unavailable)", "g", { disabled: true }],
            ["Product H (Available)", "h"]
          ]
        }),
        include_blank: "Select product...",
        class: "w-full",
        data: {
          controller: "select"
        } %>
    <p class="text-xs text-neutral-500 mt-1">Disabled options appear in different groups</p>
  </div>
</div>

Async Data Loading

Load options from a remote API with infinite scroll support. In this example, we'll be using the GitHub API to search for users.

⚠️ Important: External APIs Won't Work Directly

You cannot use external API URLs like https://api.github.com/search/users directly because:

  • Browser CORS policies block cross-origin requests
  • External APIs use different parameter names and response formats
  • Rate limiting and authentication requirements

Instead, you need to create a backend endpoint that fetches the data from the external API and formats it to match the expected format.

# Add this to your routes.rb file
get "/api/github-users", to: "github_api#index"
class GithubApiController < ApplicationController
  def index
    query = params[:query] || ""
    page = params[:page] || 1
    per_page = params[:per_page] || 20

    # Return empty results if query is blank
    if query.blank?
      render json: {
        data: [],
        has_more: false
      }
      return
    end

    # Call GitHub API with correct parameters
    response = HTTParty.get(
      "https://api.github.com/search/users",
      query: {q: query, page: page, per_page: per_page}
    )

    # Transform to expected format
    users = response["items"].map do |user|
      {
        login: user["login"],
        name: user["login"],
        text: {
          icon: "<img class='size-6 rounded-full my-0' src='#{user["avatar_url"]}' alt='#{user["login"]}'>",
          name: user["login"],
          side: generate_user_tags(user),
          description: generate_user_description(user)
        }.to_json
      }
    end

    render json: {
      data: users,
      has_more: response["total_count"] > (page.to_i * per_page.to_i)
    }
  end

  private

  def generate_user_tags(user)
    tags = []

    # User type tag
    tags << if user["type"] == "Organization"
      "<span class='ml-auto text-xs px-2 py-0.5 rounded-full bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300'>Org</span>"
    else
      "<span class='ml-auto text-xs px-2 py-0.5 rounded-full bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300'>User</span>"
    end

    # Site admin tag (if applicable)
    if user["site_admin"]
      tags << "<span class='text-xs px-2 py-0.5 rounded-full bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300'>Admin</span>"
    end

    tags.join(" ")
  end

  def generate_user_description(user)
    parts = []

    # Add GitHub profile URL
    parts << "github.com/#{user["login"]}"

    # Add user type
    parts << "GitHub #{user["type"].downcase}"

    parts.join(" - ")
  end
end

Live search with avatars, user/org tags, and profile links

<div class="space-y-6">
  <div class="w-full">
    <label class="block text-sm font-medium mb-2">Async GitHub Search</label>
    <%= select_tag :async_users,
        nil,
        include_blank: "Search GitHub users...",
        class: "w-full",
        data: {
          controller: "select",
          select_url_value: "/api/github-users",
          select_value_field_value: "login",
          select_label_field_value: "name",
          select_per_page_value: 20,
          select_virtual_scroll_value: true
        } %>
    <p class="text-xs text-neutral-500 mt-1">
      Live search with avatars, user/org tags, and profile links
    </p>
  </div>
</div>

Configuration

The combobox component is powered by TomSelect and a Stimulus controller that provides extensive configuration options.

Configuration Values

Prop Description Type Default
url
URL for loading options asynchronously. When provided, enables remote data loading with pagination String null
valueField
Field to use for the option value when loading from URL String "value"
labelField
Field to use for the option label when loading from URL String "label"
allowNew
Allow users to create new options that don't exist in the list Boolean false
disableDropdownInput
Disable the search input in the dropdown Boolean false
disableTyping
Make the input read-only while still allowing keyboard navigation Boolean false
submitOnChange
Automatically submit the form when a value is selected Boolean false
updateField
Update another field with data from the selected option Boolean false
updateFieldTarget
CSS selector for the field to update when an option is selected String null
updateFieldSource
Property from the selected option to use when updating the target field String "name"
virtualScroll
Enable virtual scrolling for large datasets (requires URL value) Boolean false
perPage
Number of items to load per page when using async loading Number 60
scrollButtons
Show scroll buttons at the top and bottom of the dropdown for easier navigation Boolean false
optgroupColumns
Display option groups in columns for better organization Boolean false

Features

  • Search & Filter: Built-in search functionality with customizable search fields
  • Multi-select: Support for selecting multiple options with checkboxes and tags
  • Custom Rendering: Flexible option and item templates with support for icons and descriptions
  • Async Loading: Load options from remote APIs with pagination and infinite scroll
  • Keyboard Navigation: Full keyboard support including type-ahead search

Table of contents