Select Rails Components
Advanced select components with search, multi-select, and custom rendering capabilities. Built with TomSelect and Stimulus.
Installation
1. Stimulus Controller Setup
Add the following controller to your project:
import { Controller } from "@hotwired/stimulus"; import TomSelect from "tom-select"; import { computePosition, flip, shift, offset } from "@floating-ui/dom"; export default class extends Controller { static values = { url: String, // URL to fetch options from valueField: { type: String, default: "value" }, // Field to use for value labelField: { type: String, default: "label" }, // Field to use for label submitOnChange: { type: Boolean, default: false }, // Submit form on change dropdownInput: { type: Boolean, default: true }, // Enable dropdown input plugin dropdownInputPlaceholder: { type: String, default: "Search..." }, // Custom placeholder for dropdown input (if "", it will use the default placeholder) clearButton: { type: Boolean, default: false }, // Show clear button when typing or option selected (onle for single select, this is never shown for multiple select) disableTyping: { type: Boolean, default: false }, // Disable typing allowNew: { type: Boolean, default: false }, // Allow new options scrollButtons: { type: Boolean, default: false }, // Show scroll buttons updateField: { type: Boolean, default: false }, // Update field with selected value updateFieldTarget: String, // Target to update updateFieldSource: { type: String, default: "name" }, // Source to update perPage: { type: Number, default: 60 }, // Number of options per page virtualScroll: { type: Boolean, default: false }, // Use virtual scroll optgroupColumns: { type: Boolean, default: false }, // Use optgroup columns responseDataField: { type: String, default: "data" }, // Field in response containing array of items (auto-detects common patterns) searchParam: { type: String, default: "query" }, // Search parameter name for the API // New flexible rendering options imageField: String, // Field containing image URL subtitleField: String, // Field to show as subtitle metaFields: String, // Comma-separated fields to show as metadata (e.g., "status,species") badgeField: String, // Field to show as a badge/tag renderTemplate: String, // Custom template for option rendering // New count display options showCount: { type: Boolean, default: false }, // Show count instead of individual items countText: { type: String, default: "selected" }, // Text to show after count countTextSingular: { type: String, default: "" }, // Text to show when only one item is selected (optional) // 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>…</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 select functionality. Choose your preferred installation method:
pin "@floating-ui/dom", to: "https://cdn.jsdelivr.net/npm/@floating-ui/dom@1.7.3/+esm" pin "tom-select", to: "https://cdn.jsdelivr.net/npm/tom-select@2.4.3/+esm"
npm install @floating-ui/dom npm install tom-select
yarn add @floating-ui/dom yarn add tom-select
Now add this to your <head>
HTML tag:
<link href="https://cdn.jsdelivr.net/npm/tom-select@2.4.3/dist/css/tom-select.css" rel="stylesheet">
3. CSS Styles
Here is the custom CSS I've used to style the select:
/* Tom Select */ select[multiple][data-controller="select"] { @apply invisible; } .dropdown-input { @apply !border-neutral-300 !bg-white !px-3 !py-2.5 text-sm placeholder:!text-neutral-500 dark:!border-neutral-600 dark:!bg-neutral-700 dark:!placeholder-neutral-300; } .plugin-dropdown_input.focus.dropdown-active .ts-control { @apply !border-none; } .ts-dropdown-content { @apply py-1.5; max-height: 240px; } .ts-dropdown-content { scrollbar-width: thin; scrollbar-color: #a2a2a270 #7878780b; } .ts-dropdown-content::-webkit-scrollbar { width: 6px; } .ts-dropdown-content::-webkit-scrollbar-track { background: #78787879; } .ts-dropdown-content::-webkit-scrollbar-thumb { background-color: #a2a2a270; border-radius: 3px; } .ts-control { @apply flex min-h-[40px] w-full px-3 py-2 cursor-default rounded-lg border-0 text-base leading-6 text-neutral-900 shadow-sm ring-1 placeholder:text-neutral-500 ring-neutral-300 outline-hidden ring-inset focus:ring-neutral-600 dark:bg-neutral-700 dark:text-white dark:placeholder-neutral-300 dark:ring-neutral-600 dark:focus:ring-neutral-500; &[disabled] { @apply cursor-not-allowed bg-neutral-100 dark:bg-neutral-600; } &.error { @apply border-red-400 outline-red-300 focus:outline-red-500 dark:border-red-600 dark:outline-red-500; } } .plugin-dropdown_input .dropdown-input { @apply outline-hidden; } /* Ensure items-placeholder is visible when no items are selected */ .plugin-dropdown_input .items-placeholder { display: block !important; } /* Only hide items-placeholder when items are actually selected */ .plugin-dropdown_input.has-items .items-placeholder { display: none !important; } /* Override the dropdown-active rule to keep placeholder visible when no items selected */ .plugin-dropdown_input.dropdown-active:not(.has-items) .items-placeholder { display: block !important; } .ts-dropdown .active.create { @apply cursor-pointer bg-neutral-100 text-neutral-900 dark:bg-neutral-600 dark:text-white; } .loading-more-results { @apply !cursor-default; } .disabled .ts-control { cursor: not-allowed !important; } @media (min-width: 640px) { .ts-control { font-size: 0.875rem; /* text-sm equivalent (14px) for larger screens */ } } .full .ts-control { @apply dark:bg-neutral-700; } .ts-wrapper.single .ts-control, .ts-wrapper.single .ts-control input, .ts-control, .ts-wrapper.single.input-active .ts-control { @apply cursor-text; } .ts-dropdown [data-selectable] .highlight { @apply bg-orange-500/20 dark:bg-yellow-500/20; } .ts-control, .ts-wrapper.single.input-active .ts-control { @apply bg-white dark:bg-neutral-700; } .input-active { @apply shadow rounded-lg ring-2 ring-inset ring-neutral-600 dark:ring-neutral-500; } .ts-wrapper { @apply bg-white dark:bg-neutral-700 rounded-lg; } .ts-control, .ts-wrapper.single.input-active .ts-control { @apply bg-transparent dark:bg-transparent; } .ts-control input { @apply !m-0 bg-white text-base placeholder:text-neutral-500 read-only:!cursor-pointer dark:bg-neutral-800 dark:text-white dark:placeholder-neutral-300; } @media (min-width: 640px) { .ts-control input { font-size: 0.875rem; /* text-sm equivalent (14px) for larger screens */ } } .ts-wrapper:not(trix-toolbar .trix-input--dialog):not(.form-select).single .ts-control { @apply !pr-8; } .ts-wrapper.plugin-remove_button .item { @apply rounded-md; } .ts-wrapper.plugin-remove_button .item .remove { @apply rounded-r-lg border-none py-1 text-lg leading-none; } .ts-wrapper.plugin-remove_button .item .remove::before { content: ""; background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Ctitle%3Exmark%3C/title%3E%3Cg fill='%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 Select
A simple select component.
<div class="w-full max-w-xs"> <%= select_tag :framework, options_for_select([ ["Ruby on Rails", "rails"], ["Laravel", "laravel"], ["Django", "django"], ["Express.js", "express"], ["Spring Boot", "spring"], ["ASP.NET Core", "aspnet"], ["Phoenix", "phoenix"], ["FastAPI", "fastapi"] ]), include_blank: "Select framework...", class: "w-full [&>*:first-child]:!cursor-pointer", style: "visibility: hidden;", data: { controller: "select", select_dropdown_input_value: false, # Enable this to add a search input to the dropdown select_disable_typing_value: true } %> </div>
Select with Icons, Tags, and Descriptions
Enhanced select with icons, side tags, and descriptions.
<div class="w-full max-w-xs"> <%= select_tag :frameworks_with_icons, options_for_select([ [{ icon: "<svg xmlns='http://www.w3.org/2000/svg' class='size-4' width='24' height='24' viewBox='0 0 24 24'><g stroke-linejoin='round' stroke-linecap='round' stroke-width='2' fill='none' stroke='currentColor'><circle cx='12' cy='12' r='4'></circle><path d='M12 2v2'></path><path d='M12 20v2'></path><path d='m4.93 4.93 1.41 1.41'></path><path d='m17.66 17.66 1.41 1.41'></path><path d='M2 12h2'></path><path d='M20 12h2'></path><path d='m6.34 17.66-1.41 1.41'></path><path d='m19.07 4.93-1.41 1.41'></path></g></svg>", name: "Light", side: "<span class='ml-auto text-xs border border-neutral-200 bg-white px-1 py-0.5 rounded-md dark:bg-neutral-900/50 dark:border-neutral-700'>Optional</span>", description: "Light mode is a theme that uses light colors and a light background." }.to_json, "light"], [{ icon: "<svg xmlns='http://www.w3.org/2000/svg' class='size-4' width='24' height='24' viewBox='0 0 24 24'><g stroke-linejoin='round' stroke-linecap='round' stroke-width='2' fill='none' stroke='currentColor'><path d='M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z'></path></g></svg>", name: "Dark", side: "<span class='ml-auto text-xs border border-neutral-200 bg-white px-1 py-0.5 rounded-md dark:bg-neutral-900/50 dark:border-neutral-700'>Popular</span>", description: "Dark mode is a theme that uses dark colors and a dark background." }.to_json, "dark"], [{ icon: "<svg xmlns='http://www.w3.org/2000/svg' class='size-4' width='24' height='24' viewBox='0 0 24 24'><g stroke-linejoin='round' stroke-linecap='round' stroke-width='2' fill='none' stroke='currentColor'><rect width='20' height='14' x='2' y='3' rx='2'></rect><path d='M8 21L16 21'></path><path d='M12 17L12 21'></path></g></svg>", name: "System", side: "<span class='ml-auto text-xs border border-neutral-200 bg-white px-1 py-0.5 rounded-md dark:bg-neutral-900/50 dark:border-neutral-700'>Default</span>", description: "System mode is a theme that uses the system's default colors and background." }.to_json, "system"] ]), include_blank: "Select theme...", class: "w-full [&>*:first-child]:!cursor-pointer", style: "visibility: hidden;", data: { controller: "select", select_disable_typing_value: true, select_dropdown_input_value: false, autocomplete: "off" } %> </div>
Select with Scroll Arrows
Enhanced select with scroll arrows that appear when the dropdown is too long. Hovering on them will scroll the dropdown.
<div class="w-full max-w-xs"> <%= select_tag :framework, options_for_select([ ["Ruby on Rails", "rails"], ["Laravel", "laravel"], ["Django", "django"], ["Express.js", "express"], ["Spring Boot", "spring"], ["ASP.NET Core", "aspnet"], ["Phoenix", "phoenix"], ["FastAPI", "fastapi"] ]), include_blank: "Select framework...", class: "w-full [&>*:first-child]:!cursor-pointer", style: "visibility: hidden;", data: { controller: "select", select_dropdown_input_value: false, select_disable_typing_value: true, select_scroll_buttons_value: true } %> </div>
Grouped Options
Organize options into groups for better navigation.
<div class="w-full max-w-md space-y-4"> <%= select_tag :cars, grouped_options_for_select({ "European" => [["BMW", "bmw"], ["Mercedes", "mercedes"], ["Audi", "audi"]], "Japanese" => [["Toyota", "toyota"], ["Honda", "honda"], ["Nissan", "nissan"]], "American" => [["Ford", "ford"], ["Chevrolet", "chevy"], ["Dodge", "dodge"]] }), include_blank: "Select car brand...", autocomplete: "off", class: "w-full [&>*:first-child]:!cursor-pointer", data: { controller: "select", select_dropdown_input_value: false, select_disable_typing_value: true } %> <%= select_tag :cars, grouped_options_for_select({ "European" => [["BMW", "bmw"], ["Mercedes", "mercedes"], ["Audi", "audi"], ["Volkswagen", "volkswagen"], ["Seat", "seat"], ["Porsche", "porsche"], ["Alfa Romeo", "alfa-romeo"]], "Japanese" => [["Toyota", "toyota"], ["Honda", "honda"], ["Nissan", "nissan"], ["Mazda", "mazda"], ["Subaru", "subaru"], ["Lexus", "lexus"], ["Mitsubishi", "mitsubishi"]], "American" => [["Ford", "ford"], ["Chevrolet", "chevy"], ["Dodge", "dodge"], ["Chrysler", "chrysler"], ["Jeep", "jeep"], ["GMC", "gmc"], ["Tesla", "tesla"]] }), include_blank: "Select car brand...", autocomplete: "off", class: "w-full [&>*:first-child]:!cursor-pointer", style: "visibility: hidden;", data: { controller: "select", select_dropdown_input_value: false, select_disable_typing_value: true, select_optgroup_columns_value: true } %> </div>
Disabled States
Examples of disabled selects and disabled options.
This combobox is completely disabled
Some options are disabled and cannot be selected
Disabled options appear in different groups
<div class="space-y-6"> <div class="w-full max-w-md"> <label class="block text-sm font-medium mb-1">Fully Disabled Combobox</label> <%= select_tag :disabled_select, options_for_select([ ["Option 1", "1"], ["Option 2", "2"], ["Option 3", "3"] ], "2"), include_blank: "Select option...", class: "w-full [&>*:first-child]:!cursor-pointer", disabled: true, data: { controller: "select", select_dropdown_input_value: false, select_disable_typing_value: true } %> <p class="text-xs text-neutral-500 mt-1">This combobox is completely disabled</p> </div> <div class="w-full max-w-md"> <label class="block text-sm font-medium mb-1">Combobox with Disabled Options</label> <%= select_tag :partial_disabled, options_for_select([ ["Available Option 1", "1"], ["Unavailable Option", "2", { disabled: true }], ["Available Option 2", "3"], ["Out of Stock", "4", { disabled: true }], ["Available Option 3", "5"], ["Coming Soon", "6", { disabled: true }] ]), include_blank: "Select available option...", class: "w-full [&>*:first-child]:!cursor-pointer", data: { controller: "select", select_dropdown_input_value: false, select_disable_typing_value: true } %> <p class="text-xs text-neutral-500 mt-1">Some options are disabled and cannot be selected</p> </div> <div class="w-full max-w-md"> <label class="block text-sm font-medium mb-1">Grouped Options with Disabled Items</label> <%= select_tag :grouped_disabled, grouped_options_for_select({ "In Stock" => [ ["Product A", "a"], ["Product B", "b"], ["Product C", "c"] ], "Out of Stock" => [ ["Product D", "d", { disabled: true }], ["Product E", "e", { disabled: true }] ], "Pre-order" => [ ["Product F (Available)", "f"], ["Product G (Unavailable)", "g", { disabled: true }], ["Product H (Available)", "h"] ] }), include_blank: "Select product...", class: "w-full [&>*:first-child]:!cursor-pointer", style: "visibility: hidden;", data: { controller: "select", select_dropdown_input_value: false, select_disable_typing_value: true } %> <p class="text-xs text-neutral-500 mt-1">Disabled options appear in different groups</p> </div> </div>
Configuration
The select component is powered by TomSelect and a Stimulus controller that provides extensive configuration options.
Configuration Values
Prop | Description | Type | Default |
---|---|---|---|
url | URL for loading options asynchronously. When provided, enables remote data loading with pagination | String | null |
valueField | Field to use for the option value when loading from URL | String | "value" |
labelField | Field to use for the option label when loading from URL | String | "label" |
allowNew | Allow users to create new options that don't exist in the list | Boolean | false |
dropdownInput | Add a search input in the dropdown | Boolean | true |
disableTyping | Make the input read-only while still allowing keyboard navigation | Boolean | false |
submitOnChange | Automatically submit the form when a value is selected | Boolean | false |
updateField | Update another field with data from the selected option | Boolean | false |
updateFieldTarget | CSS selector for the field to update when an option is selected | String | null |
updateFieldSource | Property from the selected option to use when updating the target field | String | "name" |
virtualScroll | Enable virtual scrolling for large datasets (requires URL value) | Boolean | false |
perPage | Number of items to load per page when using async loading | Number | 60 |
scrollButtons | Show scroll buttons at the top and bottom of the dropdown for easier navigation | Boolean | false |
optgroupColumns | Display option groups in columns for better organization | Boolean | false |
dropdownInputPlaceholder | Custom placeholder text for the dropdown search input. If empty, uses the default placeholder | String | "Search..." |
clearButton | Show a clear button when typing or when an option is selected (single select only) | Boolean | false |
Features
- Search & Filter: Built-in search functionality with customizable search fields
- Multi-select: Support for selecting multiple options with checkboxes and tags
- Custom Rendering: Flexible option and item templates with support for icons and descriptions
- Async Loading: Load options from remote APIs with pagination and infinite scroll
- Keyboard Navigation: Full keyboard support including type-ahead search