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
    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
    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");

    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) {
    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);

      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);
        });
      }
    } 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();
    }
  }

  #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() {
    this.#clearAllActiveStates();
    this.select.setActiveOption(null);

    // 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() {
    const input = this.select.control_input;
    if (!input) return;

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

    let buffer = "";
    let timeout;

    input.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());
  }

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

    if (this.select?.dropdown_content) {
      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) {
    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;
    }

    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");
    }

    // Re-init with dropdown_input if needed
    if (!this.disableDropdownInputValue && this.select.getValue()) {
      const options = this.#buildOptions();
      options.plugins.push("dropdown_input");

      this.select.destroy();
      this.select = new TomSelect(this.element, options);
      this.#setupEventHandlers();
    }
  }

  #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.0/+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-400 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;
}

.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 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 */
.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 */
.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;
  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 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 [&>*:first-child]:!cursor-pointer",
      data: {
        controller: "select",
        select_disable_dropdown_input_value: true,
        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",
    data: {
      controller: "select",
      select_disable_typing_value: true,
      select_disable_dropdown_input_value: true,
      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",
      data: {
        controller: "select",
        select_disable_dropdown_input_value: true,
        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_disable_dropdown_input_value: true,
      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",
    data: {
      controller: "select",
      select_disable_dropdown_input_value: true,
      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_disable_dropdown_input_value: true,
          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_disable_dropdown_input_value: true,
          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",
        data: {
          controller: "select",
          select_disable_dropdown_input_value: true,
          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
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

Get notified when new components come out