Select 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
    dropdownInput: { type: Boolean, default: true }, // Enable dropdown input plugin
    dropdownInputPlaceholder: { type: String, default: "Search..." }, // Custom placeholder for dropdown input (if "", it will use the default placeholder)
    clearButton: { type: Boolean, default: false }, // Show clear button when typing or option selected (onle for single select, this is never shown for multiple select)
    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
    responseDataField: { type: String, default: "data" }, // Field in response containing array of items (auto-detects common patterns)
    searchParam: { type: String, default: "query" }, // Search parameter name for the API
    // New flexible rendering options
    imageField: String, // Field containing image URL
    subtitleField: String, // Field to show as subtitle
    metaFields: String, // Comma-separated fields to show as metadata (e.g., "status,species")
    badgeField: String, // Field to show as a badge/tag
    renderTemplate: String, // Custom template for option rendering
    // New count display options
    showCount: { type: Boolean, default: false }, // Show count instead of individual items
    countText: { type: String, default: "selected" }, // Text to show after count
    countTextSingular: { type: String, default: "" }, // Text to show when only one item is selected (optional)
  };

  connect() {
    if (this.element.tomselect) return;

    const options = this.#buildOptions();
    this.select = new TomSelect(this.element, options);

    this.#setupEventHandlers();
    this.#setupPositioning();
    this.#handleInitialValue();

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

  disconnect() {
    this.#cleanup();
  }

  // Private methods

  #buildOptions() {
    const plugins = this.#getPlugins();
    const baseOptions = {
      plugins,
      maxOptions: null,
      closeAfterSelect: !this.element.multiple,
      create: this.allowNewValue,
      render: this.#getRenderConfig(),
      onChange: this.#handleChange.bind(this),
      onDropdownOpen: () => this.#updatePosition(),
    };

    if (!this.hasUrlValue) return baseOptions;

    return {
      ...baseOptions,
      preload: true,
      ...(this.virtualScrollValue ? this.#getVirtualScrollConfig() : this.#getCustomScrollConfig()),
    };
  }

  #getPlugins() {
    const plugins = [];
    const isMultiple = this.element.multiple;
    const useVirtualScroll = this.virtualScrollValue && this.hasUrlValue;

    if (useVirtualScroll) {
      plugins.push("virtual_scroll");
      if (isMultiple) plugins.push("remove_button", "checkbox_options", "no_active_items");
    } else if (isMultiple) {
      plugins.push("remove_button", "checkbox_options", "drag_drop", "no_active_items");
    }

    if (this.optgroupColumnsValue) plugins.push("optgroup_columns");
    if (this.dropdownInputValue) plugins.push("dropdown_input");

    return plugins;
  }

  #getRenderConfig() {
    const renderOption = (data, escape) => {
      if (this.renderTemplateValue) return this.#renderWithTemplate(this.renderTemplateValue, data, escape);
      if (this.hasUrlValue && this.#hasCustomFields()) return this.#renderApiOption(data, escape);
      return this.#renderStandardOption(data, escape);
    };

    const renderItem = (data, escape) => {
      if (this.hasUrlValue && this.imageFieldValue && data[this.imageFieldValue]) {
        return this.#renderImageItem(data, escape);
      }
      return this.#renderStandardItem(data, escape);
    };

    return {
      option: renderOption,
      item: renderItem,
      option_create: (data, escape) => `<div class="create">Add <strong>${escape(data.input)}</strong>&hellip;</div>`,
      loading_more: () => this.#renderLoadingMore(),
      no_more_results: () =>
        `<div class="no-more-results hidden py-2 text-center text-sm text-neutral-500 dark:text-neutral-400">No more results</div>`,
    };
  }

  #getVirtualScrollConfig() {
    return {
      valueField: this.valueFieldValue,
      labelField: this.labelFieldValue,
      searchField: this.labelFieldValue,
      firstUrl: (query) => this.#buildApiUrl(this.urlValue, query, 1),
      load: this.#virtualScrollLoad.bind(this),
      shouldLoadMore: this.#shouldLoadMore.bind(this),
    };
  }

  #getCustomScrollConfig() {
    return {
      valueField: this.valueFieldValue,
      labelField: this.labelFieldValue,
      searchField: this.labelFieldValue,
      load: this.#customScrollLoad.bind(this),
    };
  }

  async #virtualScrollLoad(query, callback) {
    // Early return if select is destroyed
    if (!this.select) {
      callback();
      return;
    }

    const url = this.select.getUrl(query);
    const scrollState = this.#captureScrollState(url);

    try {
      const response = await fetch(url);
      if (!response.ok) throw new Error(response.statusText);

      const rawJson = await response.json();
      const json = this.#transformApiResponse(rawJson);

      // Check if select still exists before updating state
      if (this.select) {
        this.#updateVirtualScrollState(url, query, json);
        callback(json.data);

        // For pages after the first, just maintain scroll position
        if (scrollState.currentPage > 1) {
          requestAnimationFrame(() => {
            if (this.select?.dropdown_content && typeof scrollState.scrollTop === "number") {
              this.select.dropdown_content.scrollTop = scrollState.scrollTop;
            }
          });
        } else {
          requestAnimationFrame(() => {
            this.#restoreScrollState(scrollState);
            this.#handlePostLoadFocus(query, scrollState);
          });
        }
      } else {
        callback();
      }
    } catch (error) {
      console.error("Virtual scroll load error:", error);
      this.select?.setNextUrl(query, null);
      callback();
    } finally {
      this.#cleanupScrollState();
    }
  }

  async #customScrollLoad(query, callback) {
    this.#resetPagination();

    try {
      const response = await this.#fetchPage(query, 1);
      const json = this.#transformApiResponse(response);

      callback(json.data);
      this.hasMore = json.has_more;

      if (this.select?.dropdown_content) {
        this.#setupInfiniteScroll();
        setTimeout(() => this.#focusFirstOption(query), 10);
      }
    } catch (error) {
      console.error("Custom scroll load error:", error);
      callback();
      this.hasMore = false;
    }
  }

  #setupEventHandlers() {
    // Override setActiveOption for single active state
    const original = this.select.setActiveOption.bind(this.select);
    this.select.setActiveOption = (option, scroll) => {
      this.#clearAllActiveStates();
      return original(option, scroll);
    };

    // Clear options if URL-based
    if (this.hasUrlValue) this.select.clearOptions();

    // Dropdown open handler
    this.select.on("dropdown_open", () => this.#handleDropdownOpen());

    // Setup additional features
    if (this.scrollButtonsValue && this.select.dropdown_content) this.#addScrollButtons();
    if (this.element.multiple) this.#setupScrollTracking();
    if (this.disableTypingValue) this.#setupReadonlyInput();

    // Setup count display for multi-select
    if (this.element.multiple && this.showCountValue) {
      this.#setupCountDisplay();
    }

    // Setup custom dropdown input placeholder
    this.#setupDropdownInputPlaceholder();

    // Setup clear button visibility (delay to ensure TomSelect is fully initialized)
    if (this.clearButtonValue) {
      setTimeout(() => this.#setupClearButton(), 50);
    }
  }

  #setupPositioning() {
    this.scrollHandler = () => this.#updatePosition();
    window.addEventListener("scroll", this.scrollHandler, true);

    this.resizeObserver = new ResizeObserver(() => this.#updatePosition());
    this.resizeObserver.observe(document.documentElement);

    this.mutationObserver = new MutationObserver(() => {
      if (this.select?.dropdown?.classList.contains("ts-dropdown")) {
        this.#updatePosition();
      }
    });
    this.mutationObserver.observe(document.body, { childList: true, subtree: true });
  }

  #handleDropdownOpen() {
    // Only clear active states if no item was just selected
    if (!this.justSelectedItem) {
      this.#clearAllActiveStates();
      this.select.setActiveOption(null);
    }
    this.justSelectedItem = false; // Reset the flag

    // Update position multiple times to ensure proper placement
    [0, 10, 50, 100].forEach((delay) => {
      setTimeout(() => this.#updatePosition(), delay);
    });

    if (this.hasUrlValue && !this.virtualScrollValue) {
      this.#setupInfiniteScroll();
      this.#resetPagination();
    }
  }

  #setupInfiniteScroll() {
    const content = this.select.dropdown_content;
    if (!content) return;

    const handler = this.#handleScroll.bind(this);
    content.removeEventListener("scroll", handler);
    content.addEventListener("scroll", handler);
  }

  #handleScroll() {
    if (this.virtualScrollValue || !this.select?.dropdown_content) return;

    const { scrollTop, scrollHeight, clientHeight } = this.select.dropdown_content;
    if (scrollTop + clientHeight >= scrollHeight - 50) {
      const query = this.select.control_input?.value || "";
      this.#loadMore(query);
    }
  }

  async #loadMore(query) {
    if (this.virtualScrollValue || this.loadingMore || !this.hasMore) return;

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

    const lastActiveValue = this.#getActiveValue();

    try {
      const response = await this.#fetchPage(query, this.currentPage);
      const newOptions = this.#transformApiResponse(response);

      if (newOptions?.data?.length > 0) {
        this.select.addOptions(newOptions.data);
        this.hasMore = newOptions.has_more;

        setTimeout(() => this.#restoreSelectionAfterLoading(lastActiveValue), 300);
      } else {
        this.hasMore = false;
      }

      this.select.control_input?.focus();
    } catch (error) {
      console.error("Load more error:", error);
      this.hasMore = false;
    } finally {
      this.loadingMore = false;
      this.#updatePosition();
    }
  }

  #setupScrollTracking() {
    const content = this.select.dropdown_content;
    if (!content) return;

    content.addEventListener("scroll", () => {
      this.lastScrollPosition = content.scrollTop;
    });

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

  #setupReadonlyInput() {
    // Only apply readonly to the main control input, not the dropdown input
    const mainInput = this.select.control.querySelector("input:not(.dropdown-input)");
    if (!mainInput) return;

    mainInput.readOnly = true;
    mainInput.setAttribute("readonly", "readonly");

    let buffer = "";
    let timeout;

    mainInput.addEventListener("keydown", (e) => {
      if (!this.#isNavigationKey(e.key)) return;

      if (e.key.length === 1) {
        document.body.requestPointerLock();
        this.#handleTypeAhead(e.key, buffer, timeout);
      }
    });

    document.addEventListener("mousemove", () => {
      if (document.pointerLockElement) document.exitPointerLock();
    });
  }

  #setupCountDisplay() {
    // Create count element
    this.countElement = document.createElement("div");
    this.countElement.className = "ts-count-display";

    // Insert count element into the control
    this.select.control.appendChild(this.countElement);

    // Update count on initial load
    this.#updateCountDisplay();

    // Listen for changes and prevent dropdown from closing
    this.select.on("item_add", () => {
      this.#updateCountDisplay();
      // Force dropdown to stay open after selection
      setTimeout(() => {
        if (!this.select.isOpen) {
          this.select.open();
        }
      }, 0);
    });

    this.select.on("item_remove", () => this.#updateCountDisplay());
  }

  #setupDropdownInputPlaceholder() {
    // Set the dropdown input placeholder after TomSelect is initialized
    const setPlaceholder = () => {
      const dropdownInput = this.select.dropdown?.querySelector(".dropdown-input");
      if (dropdownInput && this.dropdownInputPlaceholderValue) {
        dropdownInput.placeholder = this.dropdownInputPlaceholderValue;
      }
    };

    // Set immediately if dropdown already exists
    setPlaceholder();

    // Also set when dropdown opens (in case it's created dynamically)
    this.select.on("dropdown_open", setPlaceholder);

    // Ensure search icon is present alongside the dropdown input
    const setIcon = () => {
      const dropdownInput = this.select.dropdown?.querySelector(".dropdown-input");
      if (dropdownInput) this.#addSearchIconToDropdownInput(dropdownInput);
    };

    // Add immediately if dropdown already exists
    setIcon();

    // Also add when dropdown opens
    this.select.on("dropdown_open", setIcon);
  }

  #setupClearButton() {
    // Don't show clear button for multiple selects
    if (this.element.multiple) return;

    // Don't show clear button if the select is disabled
    if (this.element.disabled) return;

    // Create the clear button dynamically
    this.#createClearButton();

    // Initial visibility check
    this.#updateClearButtonVisibility();

    // Listen for input changes (typing)
    this.select.on("input", () => {
      this.#updateClearButtonVisibility();
    });

    // Listen for value changes (selection)
    this.select.on("change", () => {
      this.#updateClearButtonVisibility();
    });

    // Listen for item add/remove (for single selects that might have items)
    this.select.on("item_add", () => {
      this.#updateClearButtonVisibility();
    });

    this.select.on("item_remove", () => {
      this.#updateClearButtonVisibility();
    });

    // Listen for dropdown open/close to update visibility
    this.select.on("dropdown_open", () => {
      // Immediate check when dropdown opens
      this.#updateClearButtonVisibility();
      // Also check after a small delay to catch any delayed state changes
      setTimeout(() => {
        this.#updateClearButtonVisibility();
      }, 5);
    });

    this.select.on("dropdown_close", () => {
      // Small delay to ensure TomSelect has finished processing
      setTimeout(() => {
        this.#updateClearButtonVisibility();
      }, 10);
    });

    // Listen for type-ahead and search events
    this.select.on("type", () => {
      this.#updateClearButtonVisibility();
    });

    // Also listen directly to the control input for typing
    if (this.select.control_input) {
      this.select.control_input.addEventListener("input", () => {
        this.#updateClearButtonVisibility();
      });

      // Listen for keydown events to catch ESC key and immediate typing
      this.select.control_input.addEventListener("keydown", (e) => {
        if (e.key === "Escape") {
          // Small delay to let TomSelect process the ESC key first
          setTimeout(() => {
            this.#updateClearButtonVisibility();
          }, 10);
        } else if (e.key.length === 1 && this.select.isOpen) {
          // Only show button immediately if dropdown is open (actual typing)
          this.clearButton.classList.remove("hidden");
          this.clearButton.classList.add("flex");
        }
      });
    }

    // Also listen to the main control for immediate keydown detection
    const mainControl = this.select.control;
    if (mainControl) {
      mainControl.addEventListener("keydown", (e) => {
        if (e.key.length === 1 && this.select.isOpen) {
          // Only show button immediately if dropdown is open (actual typing)
          this.clearButton.classList.remove("hidden");
          this.clearButton.classList.add("flex");
        }
      });
    }
  }

  #createClearButton() {
    // Find the ts-wrapper div
    const tsWrapper = this.element.parentElement?.querySelector(".ts-wrapper");
    if (!tsWrapper) {
      // Retry after a short delay
      setTimeout(() => this.#createClearButton(), 100);
      return;
    }

    // Create the clear button
    this.clearButton = document.createElement("button");
    this.clearButton.type = "button";
    this.clearButton.className =
      "hidden absolute items-center justify-center size-5 right-2 top-2.5 rounded-full text-neutral-500 hover:text-neutral-400 focus-visible:outline-2 focus-visible:outline-offset-0 focus-visible:outline-neutral-600 dark:focus-visible:outline-neutral-200 z-10 bg-white dark:bg-neutral-700 dark:text-neutral-400 dark:hover:text-neutral-500";
    this.clearButton.setAttribute("data-select-target", "clearButton");

    // Add the SVG icon
    this.clearButton.innerHTML = `
      <svg xmlns="http://www.w3.org/2000/svg" class="size-3" width="12" height="12" viewBox="0 0 12 12">
        <g fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" stroke="currentColor">
          <line x1="2.25" y1="9.75" x2="9.75" y2="2.25"></line>
          <line x1="9.75" y1="9.75" x2="2.25" y2="2.25"></line>
        </g>
      </svg>
    `;

    // Add click event listener
    this.clearButton.addEventListener("click", (e) => {
      e.preventDefault();
      e.stopPropagation();
      this.clearInput();
    });

    // Append to ts-wrapper
    tsWrapper.appendChild(this.clearButton);
  }

  #addSearchIconToDropdownInput(dropdownInput) {
    const wrap = dropdownInput.closest(".dropdown-input-wrap") || dropdownInput.parentElement;
    if (!wrap) return;

    // Ensure relative positioning for absolute icon placement
    wrap.classList.add("relative");

    // Avoid duplicating the icon
    if (wrap.querySelector(".dropdown-input-search-icon")) return;

    // Create the icon container
    const icon = document.createElement("span");
    icon.className =
      "dropdown-input-search-icon pointer-events-none absolute left-2.5 top-3 text-neutral-400 dark:text-neutral-300";
    icon.setAttribute("aria-hidden", "true");
    icon.innerHTML = `
      <svg xmlns="http://www.w3.org/2000/svg" class="size-3.5 sm:size-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
        <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
      </svg>
    `;

    // Insert icon before the input
    wrap.insertBefore(icon, dropdownInput);

    // Add left padding to the input so text doesn't overlap the icon
    dropdownInput.classList.add("!pl-8");
  }

  #updateClearButtonVisibility() {
    if (!this.clearButtonValue || this.element.multiple || this.element.disabled || !this.clearButton) return;

    const hasValue = this.select.getValue() && this.select.getValue() !== "";
    const hasInput = this.select.control_input?.value && this.select.control_input.value.trim() !== "";
    const isDropdownOpen = this.select.isOpen;
    const hasActiveSearch = this.select.lastQuery && this.select.lastQuery.trim() !== "";

    // Additional check: if dropdown is closed and no value is selected, hide the button
    const shouldShow = hasValue || (isDropdownOpen && (hasInput || hasActiveSearch));

    if (shouldShow) {
      this.clearButton.classList.remove("hidden");
      this.clearButton.classList.add("flex");
    } else {
      this.clearButton.classList.add("hidden");
      this.clearButton.classList.remove("flex");
    }
  }

  #updateCountDisplay() {
    const count = Object.keys(this.select.getValue()).length;

    if (count > 0) {
      // Use singular text if provided and count is 1, otherwise use regular countText
      const textToUse = count === 1 && this.countTextSingularValue ? this.countTextSingularValue : this.countTextValue;

      this.countElement.textContent = `${count} ${textToUse}`;
      this.select.control.classList.add("count-active");
    } else {
      this.select.control.classList.remove("count-active");
    }
  }

  #handleTypeAhead(key, buffer, timeout) {
    clearTimeout(timeout);
    timeout = setTimeout(() => {
      buffer = "";
    }, 1000);

    buffer += key.toLowerCase();
    const match = this.#findMatchingOption(buffer);

    if (match) {
      const optionEl = this.select.dropdown_content.querySelector(`[data-value="${match[this.valueFieldValue]}"]`);

      if (optionEl) {
        this.select.setActiveOption(optionEl);
        this.select.open();
        this.#scrollToOption(optionEl);
      }
    }
  }

  #addScrollButtons() {
    const createButton = (direction, position) => {
      const btn = document.createElement("div");
      btn.className = `absolute left-0 right-0 ${position} h-5 bg-gradient-to-${
        direction === "up" ? "b" : "t"
      } from-white to-transparent dark:from-neutral-800 z-10 cursor-default flex items-center justify-center transition-opacity duration-150`;
      btn.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="M${
        direction === "up" ? "5 15l7-7 7 7" : "19 9l-7 7-7-7"
      }"></path></svg>`;
      return btn;
    };

    const scrollUpBtn = createButton("up", "top-0");
    const scrollDownBtn = createButton("down", "bottom-0");

    let scrollInterval;
    const scrollSpeed = 80;

    const setupScrollButton = (btn, direction) => {
      const startScroll = () => {
        if (scrollInterval) clearInterval(scrollInterval);
        scrollInterval = setInterval(() => {
          this.select.dropdown_content.scrollTop += direction === "up" ? -scrollSpeed : scrollSpeed;
        }, 100);
        btn.style.opacity = "0.7";
      };

      const stopScroll = () => {
        if (scrollInterval) {
          clearInterval(scrollInterval);
          scrollInterval = null;
        }
        btn.style.opacity = "1";
      };

      // Mouse events
      btn.addEventListener("mouseenter", startScroll);
      btn.addEventListener("mouseleave", stopScroll);

      // Touch events
      ["touchstart", "touchend", "touchcancel"].forEach((event) => {
        btn.addEventListener(
          event,
          (e) => {
            e.preventDefault();
            event === "touchstart" ? startScroll() : stopScroll();
          },
          { passive: false }
        );
      });
    };

    setupScrollButton(scrollUpBtn, "up");
    setupScrollButton(scrollDownBtn, "down");

    this.select.dropdown.insertBefore(scrollUpBtn, this.select.dropdown.firstChild);
    this.select.dropdown.appendChild(scrollDownBtn);

    // Show/hide based on scroll
    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";
    });

    scrollUpBtn.style.display = "none";
  }

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

    // Don't reposition during infinite scroll loading
    if (this.select.dropdown_content?.classList.contains("is-loading-more")) return;

    const reference = this.select.control;
    const floating = this.select.dropdown;

    if (!reference.getBoundingClientRect().height || !floating.getBoundingClientRect().height) {
      if (floating.offsetParent !== null) {
        setTimeout(() => this.#updatePosition(), 50);
      }
      return;
    }

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

      Object.assign(floating.style, {
        position: "absolute",
        left: `${x}px`,
        top: `${y}px`,
        width: `${Math.max(reference.offsetWidth, 160)}px`,
      });
    } catch (error) {
      console.warn("Position update error:", error);
    }
  }

  #handleChange(value) {
    // Set flag to indicate an item was just selected
    this.justSelectedItem = true;

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

  #updateTargetField(value) {
    const form = this.element.closest("form");
    if (!form) return;

    const targetField = this.updateFieldTargetValue
      ? form.querySelector(this.updateFieldTargetValue)
      : form.querySelector('input[name="list_contact[name]"]');

    if (!targetField) return;

    const selectedOption = this.select.options[value];
    if (!selectedOption) return;

    const data = this.#parseOptionData(selectedOption);
    if (data?.[this.updateFieldSourceValue]) {
      targetField.value = data[this.updateFieldSourceValue];
      targetField.dispatchEvent(new Event("input", { bubbles: true }));
    }
  }

  #parseOptionData(option) {
    if (typeof option.text === "string" && option.text.startsWith("{")) {
      try {
        return JSON.parse(option.text);
      } catch (e) {
        console.warn("Parse error:", e);
      }
    }
    return null;
  }

  #addSpinner() {
    const container = this.element.closest(".relative")?.querySelector(".absolute.z-10");
    if (container) {
      container.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>
      `;
    }
  }

  #transformApiResponse(response) {
    const data = this.#extractDataArray(response);
    const transformedData = (data || []).map((item) => ({
      ...item,
      text: item.text || item[this.labelFieldValue] || item.name || "",
      value: item.value || item[this.valueFieldValue],
    }));

    if (!this.virtualScrollValue) {
      const hasMore = this.#detectHasMore(response, data);
      return { data: transformedData, has_more: hasMore };
    }

    return { data: transformedData };
  }

  #extractDataArray(response) {
    if (this.responseDataFieldValue !== "data") {
      return this.#getNestedValue(response, this.responseDataFieldValue);
    }

    if (Array.isArray(response)) return response;

    const fields = ["data", "results", "items"];
    for (const field of fields) {
      if (response[field] && Array.isArray(response[field])) {
        return response[field];
      }
    }

    return null;
  }

  #detectHasMore(response, data) {
    return (
      response.has_more ||
      response.hasMore ||
      !!response.next ||
      !!response.next_page_url ||
      (response.info && !!response.info.next) ||
      (data && data.length === this.perPageValue) ||
      false
    );
  }

  #buildApiUrl(baseUrl, query, page) {
    const url = new URL(baseUrl, window.location.origin);

    if (query) url.searchParams.set(this.searchParamValue, query);
    url.searchParams.set("page", page);

    const isExternalApi = !baseUrl.startsWith("/") && !baseUrl.startsWith(window.location.origin);
    if (!isExternalApi) {
      url.searchParams.set("per_page", this.perPageValue);
    }

    return url.toString();
  }

  async #fetchPage(query, page) {
    const url = this.#buildApiUrl(this.urlValue, query, page);
    const response = await fetch(url);
    if (!response.ok) throw new Error(response.statusText);
    return response.json();
  }

  #renderStandardOption(data, escape) {
    const optionData = this.#parseOptionData(data);

    if (optionData) {
      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>`;
  }

  #renderStandardItem(data, escape) {
    const optionData = this.#parseOptionData(data);

    if (optionData) {
      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>`;
  }

  #renderImageItem(data, escape) {
    const label = data[this.labelFieldValue] || data.name || data.text;
    return `<div class='!flex items-center gap-2'>
      <img class='size-5 rounded-full' src='${escape(data[this.imageFieldValue])}' alt='${escape(label)}'>
      <span class='line-clamp-1'>${escape(label)}</span>
    </div>`;
  }

  #renderApiOption(data, escape) {
    const hasImage = this.imageFieldValue && data[this.imageFieldValue];
    const label = data[this.labelFieldValue] || data.name || data.text;

    let html = `<div class='${hasImage ? "flex items-start gap-3" : ""} py-1'>`;

    if (hasImage) {
      html += `<img class='size-10 rounded-full flex-shrink-0' src='${escape(
        data[this.imageFieldValue]
      )}' alt='${escape(label)}'>`;
      html += `<div class='flex-1 min-w-0'>`;
    }

    html += `<div class='font-medium'>${escape(label)}</div>`;

    if (this.subtitleFieldValue && data[this.subtitleFieldValue]) {
      html += `<div class='text-xs text-neutral-500 dark:text-neutral-400'>${escape(data[this.subtitleFieldValue])}`;
      if (this.badgeFieldValue && data[this.badgeFieldValue]) {
        html += ` • ${escape(data[this.badgeFieldValue])}`;
      }
      html += `</div>`;
    }

    if (this.metaFieldsValue) {
      const metaValues = this.metaFieldsValue
        .split(",")
        .map((f) => f.trim())
        .filter((field) => data[field])
        .map((field) => escape(data[field]));

      if (metaValues.length > 0) {
        html += `<div class='text-xs text-neutral-500 dark:text-neutral-400'>${metaValues.join(" • ")}</div>`;
      }
    }

    if (hasImage) html += `</div>`;
    html += `</div>`;

    return html;
  }

  #renderWithTemplate(template, data, escape) {
    return template.replace(/\{\{(\w+)\}\}/g, (match, field) => (data[field] ? escape(data[field]) : ""));
  }

  #renderLoadingMore() {
    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>`;
  }

  // Helper methods

  #hasCustomFields() {
    return this.imageFieldValue || this.subtitleFieldValue || this.metaFieldsValue;
  }

  #isNavigationKey(key) {
    return (
      (key.length === 1 && key.match(/[a-z0-9]/i)) || ["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(key)
    );
  }

  #getNestedValue(obj, path) {
    return path.split(".").reduce((current, key) => current?.[key], obj);
  }

  #clearAllActiveStates() {
    if (!this.select?.dropdown_content) return;

    // Clear both regular options and create option
    this.select.dropdown_content.querySelectorAll(".option.active, .create.active").forEach((opt) => {
      opt.classList.remove("active");
      opt.setAttribute("aria-selected", "false");
    });

    if (this.select.activeOption) {
      this.select.activeOption = null;
    }
  }

  #captureScrollState(url) {
    const currentUrl = new URL(url, window.location.origin);
    const currentPage = parseInt(currentUrl.searchParams.get("page") || "1");
    let state = { currentPage };

    // Early return if select is destroyed
    if (!this.select?.dropdown_content) return state;

    state.scrollTop = this.select.dropdown_content.scrollTop;
    state.scrollHandler = this.select.dropdown_content.onscroll;

    if (currentPage > 1) {
      const activeItem = this.select.dropdown_content.querySelector(".option.active");
      if (activeItem) {
        state.lastActiveValue = activeItem.getAttribute("data-value");
      }
    }

    this.select.dropdown_content.onscroll = null;
    this.select.dropdown_content.classList.add("is-loading-more");

    return state;
  }

  #restoreScrollState(state) {
    if (!this.select?.dropdown_content || !state) return;

    if (typeof state.scrollTop === "number") {
      this.select.dropdown_content.scrollTop = state.scrollTop;
    }

    this.select.dropdown_content.onscroll = state.scrollHandler;
  }

  #cleanupScrollState() {
    if (this.select?.dropdown_content) {
      this.select.dropdown_content.classList.remove("is-loading-more");
    }
  }

  #updateVirtualScrollState(url, query, json) {
    // Early return if select is destroyed
    if (!this.select) return;

    const currentUrl = new URL(url, window.location.origin);
    const currentPage = parseInt(currentUrl.searchParams.get("page") || "1");

    const hasMore = json.data.length === this.perPageValue || json.info?.next || json.next || json.has_more;

    if (hasMore) {
      const nextUrl = this.#buildApiUrl(this.urlValue, query, currentPage + 1);
      this.select.setNextUrl(query, nextUrl);
    } else {
      this.select.setNextUrl(query, null);
    }
  }

  #handlePostLoadFocus(query, scrollState) {
    if (!this.select?.dropdown_content) return;

    // Don't mess with focus/selection during infinite scroll
    if (scrollState.currentPage > 1) {
      // Just maintain the current scroll position
      return;
    }

    // Only clear active states if no item was just selected
    if (!this.justSelectedItem) {
      this.#clearAllActiveStates();
      this.select.setActiveOption(null);

      if (scrollState.currentPage === 1) {
        this.#focusFirstOption(query);
      }
    }
  }

  #focusFirstOption(query) {
    if (!query?.trim() || !this.select?.dropdown_content) return;

    const currentActive = this.select.dropdown_content.querySelector(".option.active");
    if (currentActive || this.select.activeOption) return;

    const firstOption = this.select.dropdown_content.querySelector(".option:not(.create):not(.no-results)");
    if (firstOption) {
      this.select.setActiveOption(firstOption);
    }
  }

  #restoreSelectionAfterLoading(lastActiveValue) {
    if (!this.select?.dropdown_content || !lastActiveValue) return;

    const currentActive = this.select.dropdown_content.querySelector(".option.active");
    if (currentActive) return;

    const itemToRestore = this.select.dropdown_content.querySelector(`[data-value="${lastActiveValue}"]`);
    if (itemToRestore) {
      this.select.setActiveOption(itemToRestore);
    }
  }

  #getActiveValue() {
    const activeItem = this.select?.dropdown_content?.querySelector(".option.active");
    return activeItem?.getAttribute("data-value");
  }

  #findMatchingOption(buffer) {
    return Object.values(this.select.options).find((option) => {
      const label = this.hasUrlValue
        ? option[this.labelFieldValue]
        : this.#parseOptionData(option)?.name || option.text;
      return label.toLowerCase().startsWith(buffer);
    });
  }

  #scrollToOption(optionEl) {
    const content = this.select.dropdown_content;
    const dropdownHeight = content.offsetHeight;
    const optionTop = optionEl.offsetTop;
    const optionHeight = optionEl.offsetHeight;

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

  #resetPagination() {
    this.currentPage = 1;
    this.hasMore = true;
    this.loadingMore = false;
  }

  #shouldLoadMore() {
    if (!this.select?.dropdown_content) return false;
    const { scrollTop, scrollHeight, clientHeight } = this.select.dropdown_content;
    return scrollTop + clientHeight + 150 >= scrollHeight;
  }

  #handleInitialValue() {
    if (!this.updateFieldValue || !this.hasUrlValue) return;

    try {
      const currentValue = this.getValue(this.urlValue);
      if (currentValue) {
        this.select.setValue(currentValue);
      }
    } catch (error) {
      console.warn("Initial value setting skipped");
    }
  }

  // Public methods

  clearInput() {
    if (!this.select) return;

    // Simply reset the entire combobox to its initial state
    this.select.destroy();

    // Reinitialize the combobox with the same options
    const options = this.#buildOptions();
    this.select = new TomSelect(this.element, options);

    // Re-setup all the event handlers and features
    this.#setupEventHandlers();
    this.#setupPositioning();
    this.#handleInitialValue();

    // Hide the clear button
    this.#updateClearButtonVisibility();

    // Focus the input after a small delay to ensure TomSelect is fully initialized
    setTimeout(() => {
      if (this.dropdownInputValue) {
        // When dropdown input is enabled, focus the main control element
        // This will make it ready for interaction
        this.select.control?.focus();
      } else if (this.select.control_input) {
        // When dropdown input is disabled, focus the control input directly
        this.select.control_input.focus();
      } else {
        // Fallback: focus the main control element
        this.select.control?.focus();
      }
    }, 50);
  }

  // Private methods

  #cleanup() {
    if (this.checkboxObserver) this.checkboxObserver.disconnect();
    if (this.select) {
      this.select.destroy();
      this.select = null;
    }

    window.removeEventListener("scroll", this.scrollHandler, true);
    if (this.resizeObserver) this.resizeObserver.disconnect();
    if (this.mutationObserver) this.mutationObserver.disconnect();
  }
}

2. Dependencies Installation

This component relies on Floating UI & Tom Select for the select functionality. Choose your preferred installation method:

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

Now add this to your <head> HTML tag:

<link href="https://cdn.jsdelivr.net/npm/tom-select@2.4.3/dist/css/tom-select.css" rel="stylesheet">

3. CSS Styles

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

/* 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-500 ring-neutral-300 outline-hidden 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-hidden;
}

/* Ensure items-placeholder is visible when no items are selected */
.plugin-dropdown_input .items-placeholder {
  display: block !important;
}

/* Only hide items-placeholder when items are actually selected */
.plugin-dropdown_input.has-items .items-placeholder {
  display: none !important;
}

/* Override the dropdown-active rule to keep placeholder visible when no items selected */
.plugin-dropdown_input.dropdown-active:not(.has-items) .items-placeholder {
  display: block !important;
}

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

.loading-more-results {
  @apply !cursor-default;
}

.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 shadow rounded-lg ring-2 ring-inset ring-neutral-600 dark:ring-neutral-500;
}

.ts-wrapper {
  @apply bg-white dark:bg-neutral-700 rounded-lg;
}

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

.ts-control input {
  @apply !m-0 bg-white text-base placeholder:text-neutral-500 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 */
.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='%23A1A1A1'%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 */
.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 */
.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-dropdown.plugin-optgroup_columns .optgroup {
  height: fit-content;
  @apply !mt-0;
}

.optgroup {
  @apply mt-1.5 first:mt-0;
}

.dark .ts-dropdown.plugin-optgroup_columns .optgroup {
  border-right: 1px solid #525252;
}

.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;
}

/* Count display for multi-select */
.ts-count-display {
  @apply mr-auto !my-0.5 !bg-transparent !px-0 !text-sm !font-normal pointer-events-none;
  display: none;
}

/* Hide count display when not active (explicit rule) */
.ts-control:not(.count-active) .ts-count-display {
  display: none !important;
}

/* Hide items and input when count is active */
.ts-control.count-active .item {
  display: none !important;
  visibility: hidden !important;
  width: 0 !important;
  height: 0 !important;
  margin: 0 !important;
  padding: 0 !important;
}

/* Keep input technically visible for keyboard navigation but make it invisible */
.ts-control.count-active input {
  position: absolute !important;
  opacity: 0 !important;
  width: 0 !important;
  height: 0 !important;
  padding: 0 !important;
  margin: 0 !important;
}

/* Ensure proper spacing when count is displayed */
.ts-wrapper.multi.has-items .ts-control:has(.ts-count-display) {
  @apply !py-[5px];
}

Examples

Basic Select

A simple select component.

<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 [&>*:first-child]:!cursor-pointer",
      style: "visibility: hidden;",
      data: {
        controller: "select",
        select_dropdown_input_value: false, # Enable this to add a search input to the dropdown
        select_disable_typing_value: true
      } %>
</div>

Select with Icons, Tags, and Descriptions

Enhanced select with icons, side tags, and descriptions.

<div class="w-full max-w-xs">
  <%= select_tag :frameworks_with_icons, options_for_select([
    [{
      icon: "<svg xmlns='http://www.w3.org/2000/svg' class='size-4' width='24' height='24' viewBox='0 0 24 24'><g stroke-linejoin='round' stroke-linecap='round' stroke-width='2' fill='none' stroke='currentColor'><circle cx='12' cy='12' r='4'></circle><path d='M12 2v2'></path><path d='M12 20v2'></path><path d='m4.93 4.93 1.41 1.41'></path><path d='m17.66 17.66 1.41 1.41'></path><path d='M2 12h2'></path><path d='M20 12h2'></path><path d='m6.34 17.66-1.41 1.41'></path><path d='m19.07 4.93-1.41 1.41'></path></g></svg>",
      name: "Light",
      side: "<span class='ml-auto text-xs border border-neutral-200 bg-white px-1 py-0.5 rounded-md dark:bg-neutral-900/50 dark:border-neutral-700'>Optional</span>",
      description: "Light mode is a theme that uses light colors and a light background."
    }.to_json, "light"],
    [{
      icon: "<svg xmlns='http://www.w3.org/2000/svg' class='size-4' width='24' height='24' viewBox='0 0 24 24'><g stroke-linejoin='round' stroke-linecap='round' stroke-width='2' fill='none' stroke='currentColor'><path d='M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z'></path></g></svg>",
      name: "Dark",
      side: "<span class='ml-auto text-xs border border-neutral-200 bg-white px-1 py-0.5 rounded-md dark:bg-neutral-900/50 dark:border-neutral-700'>Popular</span>",
      description: "Dark mode is a theme that uses dark colors and a dark background."
      }.to_json, "dark"],
    [{
      icon: "<svg xmlns='http://www.w3.org/2000/svg' class='size-4' width='24' height='24' viewBox='0 0 24 24'><g stroke-linejoin='round' stroke-linecap='round' stroke-width='2' fill='none' stroke='currentColor'><rect width='20' height='14' x='2' y='3' rx='2'></rect><path d='M8 21L16 21'></path><path d='M12 17L12 21'></path></g></svg>",
      name: "System",
      side: "<span class='ml-auto text-xs border border-neutral-200 bg-white px-1 py-0.5 rounded-md dark:bg-neutral-900/50 dark:border-neutral-700'>Default</span>",
      description: "System mode is a theme that uses the system's default colors and background."
    }.to_json, "system"]
  ]), include_blank: "Select theme...",
    class: "w-full [&>*:first-child]:!cursor-pointer",
    style: "visibility: hidden;",
    data: {
      controller: "select",
      select_disable_typing_value: true,
      select_dropdown_input_value: false,
      autocomplete: "off"
    } %>
</div>

Select with Scroll Arrows

Enhanced select with scroll arrows that appear when the dropdown is too long. Hovering on them will scroll the dropdown.

<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 [&>*:first-child]:!cursor-pointer",
      style: "visibility: hidden;",
      data: {
        controller: "select",
        select_dropdown_input_value: false,
        select_disable_typing_value: true,
        select_scroll_buttons_value: true
      } %>
</div>

Grouped Options

Organize options into groups for better navigation.

<div class="w-full max-w-md space-y-4">
  <%= select_tag :cars, grouped_options_for_select({
      "European" => [["BMW", "bmw"], ["Mercedes", "mercedes"], ["Audi", "audi"]],
      "Japanese" => [["Toyota", "toyota"], ["Honda", "honda"], ["Nissan", "nissan"]],
      "American" => [["Ford", "ford"], ["Chevrolet", "chevy"], ["Dodge", "dodge"]]
    }),
    include_blank: "Select car brand...",
    autocomplete: "off",
    class: "w-full [&>*:first-child]:!cursor-pointer",
    data: {
      controller: "select",
      select_dropdown_input_value: false,
      select_disable_typing_value: true
  } %>



  <%= select_tag :cars, grouped_options_for_select({
      "European" => [["BMW", "bmw"], ["Mercedes", "mercedes"], ["Audi", "audi"], ["Volkswagen", "volkswagen"], ["Seat", "seat"], ["Porsche", "porsche"], ["Alfa Romeo", "alfa-romeo"]],
      "Japanese" => [["Toyota", "toyota"], ["Honda", "honda"], ["Nissan", "nissan"], ["Mazda", "mazda"], ["Subaru", "subaru"], ["Lexus", "lexus"], ["Mitsubishi", "mitsubishi"]],
      "American" => [["Ford", "ford"], ["Chevrolet", "chevy"], ["Dodge", "dodge"], ["Chrysler", "chrysler"], ["Jeep", "jeep"], ["GMC", "gmc"], ["Tesla", "tesla"]]
    }),
    include_blank: "Select car brand...",
    autocomplete: "off",
    class: "w-full [&>*:first-child]:!cursor-pointer",
    style: "visibility: hidden;",
    data: {
      controller: "select",
      select_dropdown_input_value: false,
      select_disable_typing_value: true,
      select_optgroup_columns_value: true
  } %>
</div>

Disabled States

Examples of disabled selects 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-1">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 [&>*:first-child]:!cursor-pointer",
        disabled: true,
        data: {
          controller: "select",
          select_dropdown_input_value: false,
          select_disable_typing_value: true
        } %>
    <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-1">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 [&>*:first-child]:!cursor-pointer",
        data: {
          controller: "select",
          select_dropdown_input_value: false,
          select_disable_typing_value: true
        } %>
    <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-1">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 [&>*:first-child]:!cursor-pointer",
        style: "visibility: hidden;",
        data: {
          controller: "select",
          select_dropdown_input_value: false,
          select_disable_typing_value: true
        } %>
    <p class="text-xs text-neutral-500 mt-1">Disabled options appear in different groups</p>
  </div>
</div>

Configuration

The select 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
dropdownInput
Add a search input in the dropdown Boolean true
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
dropdownInputPlaceholder
Custom placeholder text for the dropdown search input. If empty, uses the default placeholder String "Search..."
clearButton
Show a clear button when typing or when an option is selected (single select only) 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

Get notified when new components come out