Combobox Rails Components

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

Installation

1. Stimulus Controller Setup

Add the following controller to your project:

import { Controller } from "@hotwired/stimulus"; import TomSelect from "tom-select"; import { computePosition, flip, shift, offset } from "@floating-ui/dom"; export default class extends Controller { static values = { url: String, // URL to fetch options from valueField: { type: String, default: "value" }, // Field to use for value labelField: { type: String, default: "label" }, // Field to use for label submitOnChange: { type: Boolean, default: false }, // Submit form on change 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) // Tags position options tagsPosition: { type: String, default: "inline" }, // Position of tags: "inline" (inside input), "above", "below", or a custom container ID // Flag toggle options enableFlagToggle: { type: Boolean, default: false }, // Enable flag toggle buttons on tags }; 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 external tags display for multi-select if (this.element.multiple && this.tagsPositionValue !== "inline") { this.#setupExternalTags(); } // 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); } // Add data-flag attribute to items when they're added this.select.on("item_add", (value) => { this.#markFlaggedItem(value); if (this.enableFlagToggleValue) { this.#addFlagButtonToItem(value); } }); // Mark pre-selected items as flagged if needed this.#markExistingFlaggedItems(); // Add flag buttons to existing items if flag toggle is enabled if (this.enableFlagToggleValue) { setTimeout(() => this.#addFlagButtonsToExistingItems(), 50); } // Setup form submission handler to include flag data if (this.enableFlagToggleValue) { this.#setupFormSubmissionHandler(); } } #setupFormSubmissionHandler() { const form = this.element.closest("form"); if (!form) return; // Store reference to the handler so we can remove it later this.formSubmitHandler = (e) => { this.#addFlagDataToForm(form); }; form.addEventListener("submit", this.formSubmitHandler); } #addFlagDataToForm(form) { // Remove any existing flag inputs for this select const existingInputs = form.querySelectorAll(`input[name="${this.element.name}_flags[]"]`); existingInputs.forEach((input) => input.remove()); // Get all selected values const selectedValues = this.select.getValue(); const values = Array.isArray(selectedValues) ? selectedValues : [selectedValues].filter(Boolean); // Add hidden inputs for flagged items values.forEach((value) => { const option = this.select.options[value]; if (option?.$option?.dataset?.flag === "true") { const hiddenInput = document.createElement("input"); hiddenInput.type = "hidden"; hiddenInput.name = `${this.element.name}_flags[]`; hiddenInput.value = value; form.appendChild(hiddenInput); } }); } #markFlaggedItem(value) { const option = this.select.options[value]; if (option?.$option?.dataset?.flag === "true") { // Find the item element and add the data-flag attribute const itemElement = this.select.control.querySelector(`[data-value="${value}"]`); if (itemElement) { itemElement.setAttribute("data-flag", "true"); } } } #markExistingFlaggedItems() { // Check all currently selected items and mark them as flagged if needed const selectedValues = this.select.getValue(); const values = Array.isArray(selectedValues) ? selectedValues : [selectedValues].filter(Boolean); values.forEach((value) => { // Small delay to ensure TomSelect has rendered the items setTimeout(() => this.#markFlaggedItem(value), 0); }); } #addFlagButtonsToExistingItems() { const selectedValues = this.select.getValue(); const values = Array.isArray(selectedValues) ? selectedValues : [selectedValues].filter(Boolean); values.forEach((value) => { this.#addFlagButtonToItem(value); }); } #addFlagButtonToItem(value) { if (!this.element.multiple) return; // Only for multi-select const itemElement = this.select.control.querySelector(`[data-value="${value}"]`); if (!itemElement) return; // Check if flag button already exists if (itemElement.querySelector(".flag-toggle")) return; // Check if item is currently flagged const isFlagged = itemElement.dataset.flag === "true"; // Create flag toggle button const flagButton = document.createElement("button"); flagButton.type = "button"; flagButton.className = "flag-toggle flex size-[18px] items-center justify-center rounded hover:bg-neutral-200 dark:hover:bg-neutral-700 text-neutral-600 dark:text-neutral-400 -mr-0.5"; flagButton.innerHTML = isFlagged ? this.#getFlaggedIcon() : this.#getUnflaggedIcon(); // Add click handler flagButton.addEventListener("click", (e) => { e.preventDefault(); e.stopPropagation(); this.#toggleItemFlag(value, itemElement, flagButton); }); // Insert before the remove button const removeButton = itemElement.querySelector(".remove"); if (removeButton) { removeButton.parentNode.insertBefore(flagButton, removeButton); } } #getUnflaggedIcon() { return ` <svg xmlns="http://www.w3.org/2000/svg" class="size-3" width="12" height="12" viewBox="0 0 12 12"><g fill="currentColor"><rect x=".751" y="5.25" width="10.499" height="1.5" transform="translate(-2.485 6) rotate(-45)" stroke-width="0"></rect><path d="m6,12c-3.309,0-6-2.691-6-6S2.691,0,6,0s6,2.691,6,6-2.691,6-6,6Zm0-10.5C3.519,1.5,1.5,3.519,1.5,6s2.019,4.5,4.5,4.5,4.5-2.019,4.5-4.5S8.481,1.5,6,1.5Z" stroke-width="0"></path></g></svg> `; } #getFlaggedIcon() { return ` <svg xmlns="http://www.w3.org/2000/svg" class="size-3" width="12" height="12" viewBox="0 0 12 12"><g fill="currentColor"><rect x=".751" y="5.25" width="10.499" height="1.5" transform="translate(-2.485 6) rotate(-45)" stroke-width="0"></rect><path d="m6,12c-3.309,0-6-2.691-6-6S2.691,0,6,0s6,2.691,6,6-2.691,6-6,6Zm0-10.5C3.519,1.5,1.5,3.519,1.5,6s2.019,4.5,4.5,4.5,4.5-2.019,4.5-4.5S8.481,1.5,6,1.5Z" stroke-width="0"></path></g></svg> `; } #updateDropdownOptionStyling(value, isFlagged) { // Find the dropdown option element if (!this.select?.dropdown_content) return; const dropdownOption = this.select.dropdown_content.querySelector(`[data-value="${value}"]`); if (!dropdownOption) return; // Find the span element that contains the text (not the checkbox) const textSpan = dropdownOption.querySelector("span:not(.tomselect-checkbox)"); if (!textSpan) return; // Update the text color classes if (isFlagged) { // Add red text classes textSpan.classList.add("text-red-600", "dark:text-red-400"); } else { // Remove red text classes textSpan.classList.remove("text-red-600", "dark:text-red-400"); } } #toggleItemFlag(value, itemElement, flagButton) { const option = this.select.options[value]; const currentFlag = itemElement.dataset.flag === "true"; const newFlag = !currentFlag; // Update item element if (newFlag) { itemElement.setAttribute("data-flag", "true"); itemElement.classList.add("!bg-red-100", "!text-red-900"); itemElement.classList.add("dark:!bg-[#281212]", "dark:!text-red-200"); } else { itemElement.removeAttribute("data-flag"); itemElement.classList.remove("!bg-red-100", "!text-red-900"); itemElement.classList.remove("dark:!bg-[#281212]", "dark:!text-red-200"); } // Update flag button icon if (flagButton) { flagButton.innerHTML = newFlag ? this.#getFlaggedIcon() : this.#getUnflaggedIcon(); } // Update original option element's data-flag attribute // This persists the flag state back to the <option> element in the DOM if (option?.$option) { if (newFlag) { option.$option.setAttribute("data-flag", "true"); option.$option.dataset.flag = "true"; } else { option.$option.removeAttribute("data-flag"); delete option.$option.dataset.flag; } } // Update dropdown option styling if dropdown is open this.#updateDropdownOptionStyling(value, newFlag); // Update external tags if they exist if (this.externalTagsContainer) { this.#updateExternalTags(); } // Dispatch custom event for external listeners this.element.dispatchEvent( new CustomEvent("flag-toggled", { detail: { value, flagged: newFlag }, bubbles: true, }) ); } #toggleExternalTagFlag(value, tagElement) { const option = this.select.options[value]; const currentFlag = tagElement.dataset.flag === "true"; const newFlag = !currentFlag; // Update original option element's data-flag attribute // This persists the flag state back to the <option> element in the DOM if (option?.$option) { if (newFlag) { option.$option.setAttribute("data-flag", "true"); option.$option.dataset.flag = "true"; } else { option.$option.removeAttribute("data-flag"); delete option.$option.dataset.flag; } } // Update dropdown option styling if dropdown is open this.#updateDropdownOptionStyling(value, newFlag); // Find the corresponding internal item and update it const itemElement = this.select.control.querySelector(`[data-value="${value}"]`); if (itemElement) { if (newFlag) { itemElement.setAttribute("data-flag", "true"); itemElement.classList.add("!bg-red-100", "!text-red-900"); itemElement.classList.add("dark:!bg-[#281212]", "dark:!text-red-200"); } else { itemElement.removeAttribute("data-flag"); itemElement.classList.remove("!bg-red-100", "!text-red-900"); itemElement.classList.remove("dark:!bg-[#281212]", "dark:!text-red-200"); } } // Re-render external tags to reflect the change this.#updateExternalTags(); // Dispatch custom event for external listeners this.element.dispatchEvent( new CustomEvent("flag-toggled", { detail: { value, flagged: newFlag }, bubbles: true, }) ); } #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"); } } #setupExternalTags() { // Create external tags container this.externalTagsContainer = document.createElement("div"); const position = this.tagsPositionValue; const isAbove = position === "above"; const isBelow = position === "below"; const isCustomContainer = !isAbove && !isBelow; // Set up classes based on position type if (isCustomContainer) { // Custom container doesn't need margin classes this.externalTagsContainer.className = "external-tags-container flex flex-wrap gap-2"; } else { const positionClass = isAbove ? "mb-2" : "mt-2"; this.externalTagsContainer.className = `external-tags-container ${positionClass} flex flex-wrap gap-2`; } // Insert into custom container or relative to ts-wrapper if (isCustomContainer) { const customContainer = document.getElementById(position); if (!customContainer) { console.warn(`Custom container with id "${position}" not found. Falling back to "below" position.`); // Display error message to user const tsWrapper = this.element.parentElement?.querySelector(".ts-wrapper"); if (!tsWrapper) { setTimeout(() => this.#setupExternalTags(), 100); return; } // Create error message const errorMessage = document.createElement("div"); errorMessage.className = "mt-1 text-sm text-red-600 dark:text-red-400"; errorMessage.textContent = `Error: Container with id "${position}" not found. Using default position.`; tsWrapper.parentElement.insertBefore(errorMessage, tsWrapper.nextSibling); // Store reference for cleanup this.customContainerError = errorMessage; // Fall back to below position tsWrapper.parentElement.insertBefore(this.externalTagsContainer, errorMessage.nextSibling); } else { // Append to custom container customContainer.appendChild(this.externalTagsContainer); } } else { // Standard above/below positioning const tsWrapper = this.element.parentElement?.querySelector(".ts-wrapper"); if (!tsWrapper) { setTimeout(() => this.#setupExternalTags(), 100); return; } if (isAbove) { tsWrapper.parentElement.insertBefore(this.externalTagsContainer, tsWrapper); } else { tsWrapper.parentElement.insertBefore(this.externalTagsContainer, tsWrapper.nextSibling); } } // Hide tags inside the control this.select.control.classList.add("external-tags-active"); // Initial render this.#updateExternalTags(); // Listen for changes this.select.on("item_add", () => this.#updateExternalTags()); this.select.on("item_remove", () => this.#updateExternalTags()); } #updateExternalTags() { if (!this.externalTagsContainer) return; // Clear existing tags this.externalTagsContainer.innerHTML = ""; // Get selected values const values = this.select.getValue(); if (!values || (Array.isArray(values) && values.length === 0)) { // Hide container when empty this.externalTagsContainer.style.display = "none"; return; } // Show container when there are tags this.externalTagsContainer.style.display = "flex"; // Convert to array if needed const valueArray = Array.isArray(values) ? values : [values]; // Render each tag valueArray.forEach((value) => { const option = this.select.options[value]; if (!option) return; const tag = this.#createExternalTag(value, option); this.externalTagsContainer.appendChild(tag); }); } #createExternalTag(value, option) { const tag = document.createElement("div"); const isFlagged = option.$option?.dataset?.flag === "true"; const tagClass = isFlagged ? "inline-flex items-center gap-1 rounded-md bg-red-100 pl-2 pr-1 py-1 text-xs font-medium text-red-900 dark:bg-[#281212] dark:text-red-200 *:text-red-900 dark:*:text-red-200" : "inline-flex items-center gap-1 rounded-md bg-neutral-100 pl-2 pr-1 py-1 text-xs font-medium text-neutral-900 dark:bg-neutral-900 dark:text-neutral-100"; tag.className = tagClass; tag.setAttribute("data-value", value); if (isFlagged) { tag.setAttribute("data-flag", "true"); } // Get label from option const optionData = this.#parseOptionData(option); const label = optionData?.name || option[this.labelFieldValue] || option.text || value; // Add icon if available if (optionData?.icon) { const iconSpan = document.createElement("span"); iconSpan.innerHTML = optionData.icon; tag.appendChild(iconSpan); } // Add label const labelSpan = document.createElement("span"); labelSpan.textContent = label; tag.appendChild(labelSpan); // Add flag toggle button if enabled if (this.enableFlagToggleValue) { const flagBtn = document.createElement("button"); flagBtn.type = "button"; const flagBtnClass = isFlagged ? "flex size-[18px] items-center justify-center rounded hover:bg-red-200 dark:hover:bg-red-100/10 text-red-700 dark:text-red-300" : "flex size-[18px] items-center justify-center rounded hover:bg-neutral-200 dark:hover:bg-neutral-800 text-neutral-600 dark:text-neutral-400"; flagBtn.className = flagBtnClass; flagBtn.innerHTML = isFlagged ? this.#getFlaggedIcon() : this.#getUnflaggedIcon(); flagBtn.addEventListener("click", (e) => { e.preventDefault(); e.stopPropagation(); this.#toggleExternalTagFlag(value, tag); }); tag.appendChild(flagBtn); } // Add remove button const removeBtn = document.createElement("button"); removeBtn.type = "button"; const removeBtnClass = isFlagged ? "flex size-[18px] items-center justify-center rounded hover:bg-red-200 dark:hover:bg-red-100/10 text-red-700 dark:text-red-300" : "flex size-[18px] items-center justify-center rounded hover:bg-neutral-200 dark:hover:bg-neutral-800 text-neutral-600 dark:text-neutral-400"; removeBtn.className = removeBtnClass; removeBtn.innerHTML = ` <svg xmlns="http://www.w3.org/2000/svg" class="size-3" viewBox="0 0 12 12" fill="currentColor"> <path 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"></path> <path 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"></path> </svg> `; removeBtn.addEventListener("click", (e) => { e.preventDefault(); e.stopPropagation(); this.select.removeItem(value); }); tag.appendChild(removeBtn); return tag; } #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 { // Determine placement based on external tags position let placement = "bottom-start"; if (this.tagsPositionValue !== "inline") { const position = this.tagsPositionValue; // Only adjust placement for standard above/below positions, not custom containers if (position === "above") { placement = "bottom-start"; // Tags are above, dropdown below } else if (position === "below") { placement = "top-start"; // Tags are below, dropdown above } // For custom container IDs, keep default "bottom-start" } const { x, y } = await computePosition(reference, floating, { placement, 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); const isFlagged = data.$option?.dataset?.flag === "true"; const textClass = isFlagged ? "text-red-600 dark:text-red-400" : ""; if (optionData) { return `<div class='flex items-center gap-y-[3px] gap-x-1.5 flex-wrap'> ${optionData.icon || ""} <span class='${textClass}'>${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 class='${textClass}'>${escape(data.text)}</span></div>`; } #renderStandardItem(data, escape) { const optionData = this.#parseOptionData(data); const isFlagged = data.$option?.dataset?.flag === "true"; const itemClass = isFlagged ? "!flex items-center gap-1.5 !bg-red-100 !text-red-900 dark:!bg-[#281212] dark:!text-red-200" : "!flex items-center gap-1.5"; if (optionData) { return `<div class='${itemClass}'> ${optionData.icon || ""} <span>${escape(optionData.name)}</span> </div>`; } return `<div class='${itemClass}'><span class='line-clamp-1'>${escape(data.text)}</span></div>`; } #renderImageItem(data, escape) { const label = data[this.labelFieldValue] || data.name || data.text; const isFlagged = data.$option?.dataset?.flag === "true"; const itemClass = isFlagged ? "!flex items-center gap-2 !bg-red-100 !text-red-900 dark:!bg-[#281212] dark:!text-red-200 *:text-red-900 dark:*:text-red-200" : "!flex items-center gap-2"; return `<div class='${itemClass}'> <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; const isFlagged = data.$option?.dataset?.flag === "true"; const labelClass = isFlagged ? "font-medium text-red-600 dark:text-red-400" : "font-medium"; 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='${labelClass}'>${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; } // Clean up external tags container if (this.externalTagsContainer) { this.externalTagsContainer.remove(); this.externalTagsContainer = null; } // Clean up custom container error message if (this.customContainerError) { this.customContainerError.remove(); this.customContainerError = null; } // Clean up form submit handler if (this.formSubmitHandler) { const form = this.element.closest("form"); if (form) { form.removeEventListener("submit", this.formSubmitHandler); } this.formSubmitHandler = 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 combobox 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 combobox:

/* Tom Select */ select[multiple][data-controller="select"] { @apply invisible; } .dropdown-input { @apply !border-neutral-300 !bg-white !px-3 !py-2.5 text-sm placeholder:!text-neutral-500 dark:!border-neutral-600 dark:!bg-neutral-700 dark:!placeholder-neutral-300; } .plugin-dropdown_input.focus.dropdown-active .ts-control { @apply !border-none; } .ts-dropdown-content { @apply py-1.5; max-height: 240px; } .ts-dropdown-content { scrollbar-width: thin; scrollbar-color: #a2a2a270 #7878780b; } .ts-dropdown-content::-webkit-scrollbar { width: 6px; } .ts-dropdown-content::-webkit-scrollbar-track { background: #78787879; } .ts-dropdown-content::-webkit-scrollbar-thumb { background-color: #a2a2a270; border-radius: 3px; } .ts-control { @apply flex min-h-[40px] w-full px-3 py-2 cursor-default rounded-lg border-0 text-base leading-6 text-neutral-900 shadow-sm ring-1 placeholder:text-neutral-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='%23737373'%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; } /* Red remove button for flagged items */ .ts-wrapper.plugin-remove_button .item[data-flag="true"] .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='%23991B1B'%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"); } .dark .ts-wrapper.plugin-remove_button .item[data-flag="true"] .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='%23FCA5A5'%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"); } /* 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 dark:text-neutral-400 dark:hover:bg-neutral-700; } /* Red remove button styling for flagged items inside input */ .ts-wrapper.plugin-remove_button .item[data-flag="true"] .remove { @apply text-red-700 hover:bg-red-200 hover:text-red-900 dark:text-[#FCA5A5] dark:hover:bg-red-100/10 dark:hover:text-red-200; } /* Flag toggle button styling */ .ts-wrapper.plugin-remove_button .item .flag-toggle { @apply text-neutral-400 dark:text-neutral-400; } .ts-wrapper.plugin-remove_button .item[data-flag="true"] .flag-toggle { @apply flex size-[18px] items-center justify-center rounded hover:bg-red-200 dark:hover:bg-red-100/10 text-red-800 dark:text-[#FCA5A5]; } .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]; } /* External tags styles - hide tags inside control */ .ts-control.external-tags-active .item { display: none !important; visibility: hidden !important; width: 0 !important; height: 0 !important; margin: 0 !important; padding: 0 !important; } /* Reset padding when external tags are active */ .ts-wrapper.multi.has-items .ts-control.external-tags-active { @apply !py-2; } /* Keep placeholder visible when external tags are active */ .plugin-dropdown_input.has-items .ts-control.external-tags-active .items-placeholder { display: block !important; } /* Reset input margins when external tags are active */ .ts-wrapper.multi.has-items .ts-control.external-tags-active > input { margin: 0 !important; } 

Examples

Basic Combobox

A simple combobox with search functionality and a clear button.

<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", style: "visibility: hidden;", data: { controller: "select", select_clear_button_value: true, select_dropdown_input_value: false } %> </div>

Combobox with Icons and Descriptions

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

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

Multi-select Combobox

Allow users to select multiple options with checkboxes and tags.

You can use flags when you want to indicate that the option is deprecated, has a warning, an error, or is excluded. Click the icon to toggle flags. Flag data will be submitted as programming_languages_flags_toggles_flags[]

<div class="w-full max-w-md"> <label class="mb-1 block">Tags inside input (default)</label> <%= select_tag :programming_languages, options_for_select([ ["Ruby", "ruby"], ["JavaScript", "javascript"], ["Python", "python"], ["TypeScript", "typescript"], ["Go", "go"], ["Rust", "rust"], ["Java", "java"], ["C#", "csharp"], ["PHP", "php"], ["Swift", "swift"], ["Kotlin", "kotlin"], ["Scala", "scala"] ]), multiple: true, include_blank: "Select languages...", class: "w-full", style: "visibility: hidden;", data: { controller: "select" } %> </div> <div class="w-full max-w-md mt-6"> <label class="mb-1 block">Tags below input</label> <%= select_tag :programming_languages_below, options_for_select([ ["Ruby", "ruby"], ["JavaScript", "javascript"], ["Python", "python"], ["TypeScript", "typescript"], ["Go", "go"], ["Rust", "rust"], ["Java", "java"], ["C#", "csharp"], ["PHP", "php"], ["Swift", "swift"], ["Kotlin", "kotlin"], ["Scala", "scala"] ]), multiple: true, include_blank: "Select languages...", class: "w-full", style: "visibility: hidden;", data: { controller: "select", select_dropdown_input_value: false, select_tags_position_value: "below" } %> </div> <div class="w-full max-w-md mt-6"> <label class="mb-1 block">Tags above input</label> <%= select_tag :programming_languages_above, options_for_select([ ["Ruby", "ruby"], ["JavaScript", "javascript"], ["Python", "python"], ["TypeScript", "typescript"], ["Go", "go"], ["Rust", "rust"], ["Java", "java"], ["C#", "csharp"], ["PHP", "php"], ["Swift", "swift"], ["Kotlin", "kotlin"], ["Scala", "scala"] ]), multiple: true, include_blank: "Select languages...", class: "w-full", style: "visibility: hidden;", data: { controller: "select", select_dropdown_input_value: false, select_tags_position_value: "above" } %> </div> <div class="w-full max-w-md mt-6"> <label class="mb-1 block">Tags in custom container</label> <div id="custom-tags-container" class="mb-2 min-h-[40px] rounded-lg border border-neutral-300 p-1.5 dark:border-neutral-600"></div> <%= select_tag :programming_languages_custom, options_for_select([ ["Ruby", "ruby"], ["JavaScript", "javascript"], ["Python", "python"], ["TypeScript", "typescript"], ["Go", "go"], ["Rust", "rust"], ["Java", "java"], ["C#", "csharp"], ["PHP", "php"], ["Swift", "swift"], ["Kotlin", "kotlin"], ["Scala", "scala"] ]), multiple: true, include_blank: "Select languages...", class: "w-full", style: "visibility: hidden;", data: { controller: "select", select_dropdown_input_value: false, select_tags_position_value: "custom-tags-container" } %> </div> <div class="w-full max-w-md mt-6"> <label class="mb-1 block">With disabled options</label> <%= select_tag :programming_languages_disabled, options_for_select([ ["Ruby", "ruby"], ["JavaScript", "javascript"], ["Python", "python", { disabled: true }], ["TypeScript", "typescript"], ["Go", "go", { disabled: true }], ["Rust", "rust"], ["Java", "java", { disabled: true }], ["C#", "csharp"], ["PHP", "php"], ["Swift", "swift", { disabled: true }], ["Kotlin", "kotlin"], ["Scala", "scala"] ]), multiple: true, include_blank: "Select languages...", class: "w-full", style: "visibility: hidden;", data: { controller: "select" } %> </div> <div class="w-full max-w-md mt-6"> <label class="mb-1 block">With flagged options & toggles</label> <%= select_tag :programming_languages_flags_toggles, options_for_select([ ["Ruby", "ruby"], ["JavaScript", "javascript", { data: { flag: true } }], ["Python", "python"], ["TypeScript", "typescript"], ["Go", "go"], ["Rust", "rust", { data: { flag: true } }], ["Java", "java"], ["C#", "csharp"], ["PHP", "php"], ["Swift", "swift"], ["Kotlin", "kotlin"], ["Scala", "scala"] ], ["ruby", "python", "javascript"]), multiple: true, include_blank: "Select languages...", class: "w-full", style: "visibility: hidden;", data: { controller: "select", select_dropdown_input_value: false, select_tags_position_value: "below", select_enable_flag_toggle_value: true } %> <p class="mt-1 text-xs text-neutral-500 dark:text-neutral-400">You can use flags when you want to indicate that the option is deprecated, has a warning, an error, or is excluded. Click the <span class="code-inline-text"><svg xmlns="http://www.w3.org/2000/svg" class="inline size-2.5" width="12" height="12" viewBox="0 0 12 12"><g fill="currentColor"><rect x=".751" y="5.25" width="10.499" height="1.5" transform="translate(-2.485 6) rotate(-45)" stroke-width="0"></rect><path d="m6,12c-3.309,0-6-2.691-6-6S2.691,0,6,0s6,2.691,6,6-2.691,6-6,6Zm0-10.5C3.519,1.5,1.5,3.519,1.5,6s2.019,4.5,4.5,4.5,4.5-2.019,4.5-4.5S8.481,1.5,6,1.5Z" stroke-width="0"></path></g></svg></span> icon to toggle flags. Flag data will be submitted as <code class="code-inline-text">programming_languages_flags_toggles_flags[]</code></p> </div>

Count Display for Multi-select

Display a count of selected items instead of individual tags. Perfect for filter interfaces where space is limited or when you have many selectable options. Supports singular/plural text variations for better grammar.

Select multiple categories to filter products

This example shows pre-selected items with count display

Works with grouped options too

<div class="space-y-6 w-full max-w-md"> <div> <label for="filter-categories" class="block text-sm font-medium mb-1">Filter by Categories</label> <select id="filter-categories" name="categories[]" multiple class="w-full" style="visibility: hidden;" data-controller="select" data-select-show-count-value="true" data-select-count-text-value="categories selected" data-select-count-text-singular-value="category selected"> <option value="">Select categories...</option> <option value="electronics">Electronics</option> <option value="clothing">Clothing & Apparel</option> <option value="home">Home & Garden</option> <option value="sports">Sports & Outdoors</option> <option value="books">Books & Media</option> <option value="toys">Toys & Games</option> <option value="health">Health & Beauty</option> <option value="automotive">Automotive</option> <option value="food">Food & Grocery</option> <option value="office">Office Supplies</option> </select> <p class="mt-0.5 text-xs text-neutral-600 dark:text-neutral-400">Select multiple categories to filter products</p> </div> <div> <label for="filter-tags" class="block text-sm font-medium mb-1">Filter by Tags (Pre-selected)</label> <select id="filter-tags" name="tags[]" multiple class="w-full" data-controller="select" data-select-show-count-value="true" data-select-count-text-value="tags selected" data-select-count-text-singular-value="tag selected"> <option value="">Select tags...</option> <option value="new" selected>New Arrival</option> <option value="sale" selected>On Sale</option> <option value="featured" selected>Featured</option> <option value="limited">Limited Edition</option> <option value="exclusive">Exclusive</option> <option value="clearance">Clearance</option> <option value="bestseller">Bestseller</option> <option value="eco">Eco-Friendly</option> <option value="premium">Premium</option> <option value="budget">Budget-Friendly</option> </select> <p class="mt-0.5 text-xs text-neutral-600 dark:text-neutral-400">This example shows pre-selected items with count display</p> </div> <div> <label for="filter-locations" class="block text-sm font-medium mb-1">Available Locations</label> <select id="filter-locations" name="locations[]" multiple class="w-full" data-controller="select" data-select-show-count-value="true" data-select-count-text-value="locations" data-select-count-text-singular-value="location"> <option value="">Select locations...</option> <optgroup label="North America"> <option value="us">United States</option> <option value="ca">Canada</option> <option value="mx">Mexico</option> </optgroup> <optgroup label="Europe"> <option value="uk">United Kingdom</option> <option value="de">Germany</option> <option value="fr">France</option> <option value="it">Italy</option> <option value="es">Spain</option> </optgroup> <optgroup label="Asia Pacific"> <option value="jp">Japan</option> <option value="cn">China</option> <option value="au">Australia</option> <option value="in">India</option> </optgroup> </select> <p class="mt-0.5 text-xs text-neutral-600 dark:text-neutral-400">Works with grouped options too</p> </div> </div>

Grouped Options Combobox

Organize options into logical groups for better navigation.

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

Combobox with Create Option

Allow users to create new options on the fly.

Type to search or create a new tag

Select existing skills or type to add new ones

<div class="space-y-6"> <div class="w-full max-w-md"> <label class="block text-sm font-medium mb-1">Single Select with Create</label> <%= select_tag :custom_tags, options_for_select([ ["Bug", "bug"], ["Feature", "feature"], ["Enhancement", "enhancement"], ["Documentation", "documentation"], ["Question", "question"] ]), include_blank: "Select or create tag...", class: "w-full", data: { controller: "select", select_allow_new_value: true } %> <p class="text-xs text-neutral-500 mt-1">Type to search or create a new tag</p> </div> <div class="w-full max-w-md"> <label class="block text-sm font-medium mb-1">Multi-select with Create</label> <%= select_tag :custom_skills, options_for_select([ ["JavaScript", "javascript"], ["Python", "python"], ["Ruby", "ruby"], ["Go", "go"], ["Rust", "rust"] ]), multiple: true, include_blank: "Select or add skills...", class: "w-full", style: "visibility: hidden;", data: { controller: "select", select_allow_new_value: true, select_dropdown_input_placeholder_value: "Search or add skills..." } %> <p class="text-xs text-neutral-500 mt-1">Select existing skills or type to add new ones</p> </div> </div>

Disabled States

Examples of disabled comboboxes and disabled options.

This combobox is completely disabled

Some options are disabled and cannot be selected

Disabled options appear in different groups

<div class="space-y-6"> <div class="w-full max-w-md"> <label class="block text-sm font-medium mb-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", style: "visibility: hidden;", disabled: true, data: { controller: "select" } %> <p class="text-xs text-neutral-500 mt-1">This combobox is completely disabled</p> </div> <div class="w-full max-w-md"> <label class="block text-sm font-medium mb-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", data: { controller: "select" } %> <p class="text-xs text-neutral-500 mt-1">Some options are disabled and cannot be selected</p> </div> <div class="w-full max-w-md"> <label class="block text-sm font-medium mb-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", data: { controller: "select" } %> <p class="text-xs text-neutral-500 mt-1">Disabled options appear in different groups</p> </div> </div>

Async Data Loading

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

⚠️ Important: Most External APIs Need a Backend Proxy

Many external APIs like GitHub (https://api.github.com) cannot be called directly from the browser because:

  • CORS restrictions: Most APIs don't allow browser requests from other domains
  • Authentication: API keys shouldn't be exposed in frontend code
  • Rate limiting: Direct browser requests can quickly hit rate limits

For these APIs, create a backend endpoint to proxy the requests. However, some APIs like the Rick and Morty API (see example below) do support CORS and can be called directly.

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

<div class="space-y-6"> <div class="w-full"> <label class="block text-sm font-medium mb-1">Async GitHub Search</label> <%= select_tag :async_users, nil, include_blank: "Search GitHub users...", class: "w-full", style: "visibility: hidden;", data: { controller: "select", select_url_value: "/api/github-users", select_value_field_value: "login", select_label_field_value: "name", select_per_page_value: 20, select_virtual_scroll_value: true, select_dropdown_input_value: false } %> <p class="text-xs text-neutral-500 mt-1"> Live search with avatars, user/org tags, and profile links </p> </div> </div>
# Github API route for async loading get "/api/github-users", to: "github_api#index"
class GithubApiController < ApplicationController def index query = params[:query] || "" page = params[:page] || 1 per_page = params[:per_page] || 20 return render_empty_response if query.blank? response = fetch_github_users(query, page, per_page) render json: response end private def render_empty_response render json: {data: [], has_more: false} end def fetch_github_users(query, page, per_page) response = call_github_api(query, page, per_page) return {data: [], has_more: false} unless response return {data: [], has_more: false} unless valid_response?(response) users = transform_users(response["items"]) { data: users, has_more: response["total_count"] && response["total_count"] > (page.to_i * per_page.to_i) } rescue => e Rails.logger.error "GitHub API request failed: #{e.message}" Rails.logger.error e.backtrace.join("\n") {data: [], has_more: false} end def call_github_api(query, page, per_page) response = HTTParty.get( "https://api.github.com/search/users", query: {q: query, page: page, per_page: per_page}, timeout: 10 ) unless response.success? Rails.logger.warn "GitHub API error: #{response.code} - #{response.message}" return nil end response end def valid_response?(response) if response.parsed_response.is_a?(Hash) && response.parsed_response["items"].is_a?(Array) true else Rails.logger.warn "GitHub API returned unexpected response structure: #{response.parsed_response}" false end end def transform_users(users_data) users_data.map do |user| { login: user["login"], name: user["login"], text: build_user_text(user).to_json } end end def build_user_text(user) { icon: "<img class='size-6 rounded-full my-0' src='#{user["avatar_url"]}' alt='#{user["login"]}'>", name: user["login"], side: generate_user_tags(user), description: generate_user_description(user) } end def generate_user_tags(user) tags = [] # User type tag tags << if user["type"] == "Organization" "<span class='ml-auto text-xs px-2 py-0.5 rounded-full bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300'>Org</span>" else "<span class='ml-auto text-xs px-2 py-0.5 rounded-full bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300'>User</span>" end # Site admin tag (if applicable) if user["site_admin"] tags << "<span class='text-xs px-2 py-0.5 rounded-full bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300'>Admin</span>" end tags.join(" ") end def generate_user_description(user) parts = [] # Add GitHub profile URL parts << "github.com/#{user["login"]}" # Add user type parts << "GitHub #{user["type"].downcase}" parts.join(" - ") end end 

Direct API Integration (Rick and Morty)

Load data directly from CORS-enabled external APIs without a Rails backend. The Rick and Morty API is one of the few public APIs that allows direct browser requests, making it perfect for this example.

✨ Direct API Support for CORS-enabled APIs

Some APIs like Rick and Morty allow direct browser requests. The select controller intelligently handles these APIs with minimal configuration:

  • Smart auto-detection: Automatically finds data in results, data, or items fields
  • Simple configuration: Often just need to set searchParam if it's not "query"
  • Automatic pagination: When using virtualScroll, pagination is handled seamlessly
  • No backend needed: Works directly from the browser for CORS-enabled APIs

Characters with images, status, species, and type information

Locations with type and dimension information

Episodes with episode codes and air dates

<div class="space-y-6"> <div class="w-full"> <label class="block text-sm font-medium mb-1">Rick and Morty Characters</label> <%= select_tag :rick_morty_characters, nil, include_blank: "Search for characters...", class: "w-full", style: "visibility: hidden;", data: { controller: "select", select_url_value: "https://rickandmortyapi.com/api/character", select_value_field_value: "id", select_label_field_value: "name", select_search_param_value: "name", select_per_page_value: 20, select_virtual_scroll_value: true, select_image_field_value: "image", select_subtitle_field_value: "status", select_badge_field_value: "species", select_meta_fields_value: "type" } %> <p class="text-xs text-neutral-500 mt-1"> Characters with images, status, species, and type information </p> </div> <div class="w-full"> <label class="block text-sm font-medium mb-1">Rick and Morty Locations</label> <%= select_tag :rick_morty_locations, nil, include_blank: "Search for locations...", class: "w-full", data: { controller: "select", select_url_value: "https://rickandmortyapi.com/api/location", select_value_field_value: "id", select_label_field_value: "name", select_search_param_value: "name", select_per_page_value: 20, select_virtual_scroll_value: true, select_subtitle_field_value: "type", select_badge_field_value: "dimension" } %> <p class="text-xs text-neutral-500 mt-1"> Locations with type and dimension information </p> </div> <div class="w-full"> <label class="block text-sm font-medium mb-1">Rick and Morty Episodes</label> <%= select_tag :rick_morty_episodes, nil, include_blank: "Search for episodes...", class: "w-full", data: { controller: "select", select_url_value: "https://rickandmortyapi.com/api/episode", select_value_field_value: "id", select_label_field_value: "name", select_search_param_value: "name", select_per_page_value: 20, select_virtual_scroll_value: true, select_subtitle_field_value: "episode", select_badge_field_value: "air_date" } %> <p class="text-xs text-neutral-500 mt-1"> Episodes with episode codes and air dates </p> </div> </div>

Countries with Flags

Beautiful country selector with flag icons, country codes, and regional information. Uses the countries gem (version 8+) for comprehensive ISO 3166 country data.

Countries with flags & codes

Countries with flags & codes

Countries with phone codes

Countries organized by geographic region

Select your preferred language

<%# Make sure to install the "countries" gem (version 8+) %> <div class="w-full flex flex-col gap-6 items-center"> <div class="w-full max-w-sm"> <label class="block text-sm font-medium mb-1">Select Country</label> <%= select_tag :country, options_for_select( ISO3166::Country.all.map do |country| [{ icon: "<img class='size-5 my-0 rounded-md' src='https://flagicons.lipis.dev/flags/1x1/#{country.alpha2.downcase}.svg' alt='#{country.iso_short_name} flag'>", name: country.common_name, side: "<span class='ml-auto text-xs text-neutral-500 dark:text-neutral-400'>#{country.alpha2}</span>" }.to_json, country.alpha2] end.sort_by { |option| JSON.parse(option[0])["name"] } ), include_blank: "Choose a country...", class: "w-full", style: "visibility: hidden;", data: { controller: "select" } %> <p class="text-xs text-neutral-500 mt-1">Countries with flags & codes</p> </div> <div class="w-full max-w-sm"> <label class="block text-sm font-medium mb-1">Select Multiple Countries</label> <%= select_tag :countries, options_for_select( [["Choose countries...", "", { disabled: true, selected: true }]] + ISO3166::Country.all.map do |country| [{ icon: "<img class='size-5 my-0 rounded-md' src='https://flagicons.lipis.dev/flags/1x1/#{country.alpha2.downcase}.svg' alt='#{country.iso_short_name} flag'>", name: country.common_name, side: "<span class='ml-auto text-xs text-neutral-500 dark:text-neutral-400'>#{country.alpha2}</span>" }.to_json, country.alpha2] end.sort_by { |option| JSON.parse(option[0])["name"] } ), include_blank: false, multiple: true, class: "w-full", data: { controller: "select" } %> <p class="text-xs text-neutral-500 mt-1">Countries with flags & codes</p> </div> <div class="w-full max-w-sm"> <label class="block text-sm font-medium mb-1">Country with Phone Code</label> <%= select_tag :country_phone, options_for_select( ISO3166::Country.all.select { |c| c.country_code.present? }.map do |country| [{ icon: "<img class='size-5 my-0 rounded-md' src='https://flagicons.lipis.dev/flags/1x1/#{country.alpha2.downcase}.svg' alt='#{country.iso_short_name} flag'>", name: country.common_name, side: "<span class='ml-auto text-xs font-medium text-neutral-600 dark:text-neutral-300'>+#{country.country_code}</span>" }.to_json, country.alpha2] end.sort_by { |option| JSON.parse(option[0])["name"] } ), include_blank: "Select country for phone number...", class: "w-full", data: { controller: "select" } %> <p class="text-xs text-neutral-500 mt-1">Countries with phone codes</p> </div> <div class="w-full max-w-sm"> <label class="block text-sm font-medium mb-1">Country by Region</label> <%= select_tag :country_by_region, grouped_options_for_select( ISO3166::Country.all.group_by(&:region).sort_by { |region, _| region.to_s }.map do |region, countries| [ region || "Unknown", countries.map do |country| [{ icon: "<img class='size-5 my-0 rounded-md' src='https://flagicons.lipis.dev/flags/1x1/#{country.alpha2.downcase}.svg' alt='#{country.iso_short_name} flag'>", name: country.common_name, side: "<span class='ml-auto text-xs text-neutral-500 dark:text-neutral-400'>#{country.alpha2}</span>" }.to_json, country.alpha2] end.sort_by { |option| JSON.parse(option[0])["name"] } ] end ), include_blank: "Select country by region...", class: "w-full", data: { controller: "select" } %> <p class="text-xs text-neutral-500 mt-1">Countries organized by geographic region</p> </div> <div class="w-full max-w-sm"> <label class="block text-sm font-medium mb-1">Select Language</label> <%= select_tag :language, options_for_select( [ [{ icon: "<img class='size-5 my-0 rounded-md' src='https://flagicons.lipis.dev/flags/1x1/cn.svg' alt='Chinese flag'>", name: "Chinese", side: "<span class='ml-auto text-xs text-neutral-500 dark:text-neutral-400'>中文</span>" }.to_json, "zh"], [{ icon: "<img class='size-5 my-0 rounded-md' src='https://flagicons.lipis.dev/flags/1x1/gb.svg' alt='English flag'>", name: "English", side: "<span class='ml-auto text-xs text-neutral-500 dark:text-neutral-400'>English</span>" }.to_json, "en"], [{ icon: "<img class='size-5 my-0 rounded-md' src='https://flagicons.lipis.dev/flags/1x1/fr.svg' alt='French flag'>", name: "French", side: "<span class='ml-auto text-xs text-neutral-500 dark:text-neutral-400'>Français</span>" }.to_json, "fr"], [{ icon: "<img class='size-5 my-0 rounded-md' src='https://flagicons.lipis.dev/flags/1x1/de.svg' alt='German flag'>", name: "German", side: "<span class='ml-auto text-xs text-neutral-500 dark:text-neutral-400'>Deutsch</span>" }.to_json, "de"], [{ icon: "<img class='size-5 my-0 rounded-md' src='https://flagicons.lipis.dev/flags/1x1/it.svg' alt='Italian flag'>", name: "Italian", side: "<span class='ml-auto text-xs text-neutral-500 dark:text-neutral-400'>Italiano</span>" }.to_json, "it"], [{ icon: "<img class='size-5 my-0 rounded-md' src='https://flagicons.lipis.dev/flags/1x1/jp.svg' alt='Japanese flag'>", name: "Japanese", side: "<span class='ml-auto text-xs text-neutral-500 dark:text-neutral-400'>日本語</span>" }.to_json, "ja"], [{ icon: "<img class='size-5 my-0 rounded-md' src='https://flagicons.lipis.dev/flags/1x1/kr.svg' alt='Korean flag'>", name: "Korean", side: "<span class='ml-auto text-xs text-neutral-500 dark:text-neutral-400'>한국어</span>" }.to_json, "ko"], [{ icon: "<img class='size-5 my-0 rounded-md' src='https://flagicons.lipis.dev/flags/1x1/ru.svg' alt='Russian flag'>", name: "Russian", side: "<span class='ml-auto text-xs text-neutral-500 dark:text-neutral-400'>Русский</span>" }.to_json, "ru"], [{ icon: "<img class='size-5 my-0 rounded-md' src='https://flagicons.lipis.dev/flags/1x1/es.svg' alt='Spanish flag'>", name: "Spanish", side: "<span class='ml-auto text-xs text-neutral-500 dark:text-neutral-400'>Español</span>" }.to_json, "es"] ] ), include_blank: "Choose a language...", class: "w-full", data: { controller: "select" } %> <p class="text-xs text-neutral-500 mt-1">Select your preferred language</p> </div> </div>

Configuration

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

Configuration Values

Prop Description Type Default
url
URL for loading options asynchronously. When provided, enables remote data loading with pagination String null
valueField
Field to use for the option value when loading from URL String "value"
labelField
Field to use for the option label when loading from URL String "label"
allowNew
Allow users to create new options that don't exist in the list Boolean false
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
responseDataField
Override where to find the array of items (auto-detects: data, results, items) String "data"
searchParam
Search parameter name for the API request String "query"
showCount
Show count of selected items instead of individual tags (multi-select only) Boolean false
countText
Text to display after the count number (e.g., '3 selected') String "selected"
countTextSingular
Text to display when only one item is selected (e.g., '1 item selected'). If not provided, uses countText for all counts String ""
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