Select Rails Components
Advanced select components with search, multi-select, and custom rendering capabilities. Built with TomSelect and Stimulus.
Installation
1. Stimulus Controller Setup
Add the following controller to your project:
import { Controller } from "@hotwired/stimulus";
import TomSelect from "tom-select";
import { computePosition, flip, shift, offset } from "@floating-ui/dom";
export default class extends Controller {
static values = {
url: String, // URL to fetch options from
valueField: { type: String, default: "value" }, // Field to use for value
labelField: { type: String, default: "label" }, // Field to use for label
submitOnChange: { type: Boolean, default: false }, // Submit form on change
disableDropdownInput: { type: Boolean, default: false }, // Disable dropdown input
disableTyping: { type: Boolean, default: false }, // Disable typing
allowNew: { type: Boolean, default: false }, // Allow new options
scrollButtons: { type: Boolean, default: false }, // Show scroll buttons
updateField: { type: Boolean, default: false }, // Update field with selected value
updateFieldTarget: String, // Target to update
updateFieldSource: { type: String, default: "name" }, // Source to update
perPage: { type: Number, default: 60 }, // Number of options per page
virtualScroll: { type: Boolean, default: false }, // Use virtual scroll
optgroupColumns: { type: Boolean, default: false }, // Use optgroup columns
};
connect() {
// Prevent re-initialization if TomSelect is already initialized
if (this.element.tomselect) {
return;
}
let plugins = [];
if (this.virtualScrollValue && this.hasUrlValue) {
plugins.push("virtual_scroll");
// Keep these plugins even when virtual scrolling is enabled
if (this.element.multiple) {
plugins.push("remove_button", "checkbox_options", "no_active_items");
}
} else if (this.element.multiple) {
// Keep existing multi-select plugins if not virtual scrolling
plugins.push("remove_button", "checkbox_options", "drag_drop", "no_active_items");
}
// Add optgroup columns plugin if enabled
if (this.hasOptgroupColumnsValue && this.optgroupColumnsValue) {
plugins.push("optgroup_columns");
}
let tomSelectOptions = {
plugins,
maxOptions: null, // Let virtual scroll or custom scroll handle limits
closeAfterSelect: !this.element.multiple,
create: this.allowNewValue,
render: {
option: (data, escape) => {
if (typeof data.text === "string" && data.text.startsWith("{")) {
let optionData = JSON.parse(data.text);
return `<div class='flex items-center gap-y-[3px] gap-x-1.5 flex-wrap'>
${optionData.icon || ""}
<span>${escape(optionData.name)}</span>
${optionData.side || ""}
${
optionData.description
? `<p class='text-neutral-500 dark:text-neutral-300 text-xs my-0 w-full'>${escape(
optionData.description
)}</p>`
: ""
}
</div>`;
}
return `<div class='flex items-center gap-1.5'>
<span>${escape(data.text)}</span>
</div>`;
},
item: (data, escape) => {
if (typeof data.text === "string" && data.text.startsWith("{")) {
let optionData = JSON.parse(data.text);
return `<div class='!flex items-center gap-1.5'>
${optionData.icon || ""}
<span>${escape(optionData.name)}</span>
</div>`;
}
return `<div class='!flex items-center gap-1.5'>
<span class='line-clamp-1'>${escape(data.text)}</span>
</div>`;
},
option_create: (data, escape) => {
return `<div class="create">Add <strong>${escape(data.input)}</strong>…</div>`;
},
},
onDropdownOpen: (dropdown) => {
// Immediately set position when dropdown opens and then again after a brief delay
this.#updatePosition();
setTimeout(() => this.#updatePosition(), 10);
},
};
if (this.hasUrlValue) {
tomSelectOptions.preload = true; // Add preload if URL is present
if (this.virtualScrollValue) {
// Setup for TomSelect's virtual_scroll plugin
tomSelectOptions = {
...tomSelectOptions, // Includes preload:true
valueField: this.valueFieldValue,
labelField: this.labelFieldValue,
searchField: this.labelFieldValue,
firstUrl: (query) => {
return `${this.urlValue}?query=${encodeURIComponent(query)}&page=1&per_page=${this.perPageValue}`;
},
load: async (query, callback) => {
// This is the specific load for virtual_scroll
const url = this.select.getUrl(query);
let originalScrollHandler = null;
let currentScrollTop = null;
if (this.select && this.select.dropdown_content) {
currentScrollTop = this.select.dropdown_content.scrollTop;
originalScrollHandler = this.select.dropdown_content.onscroll;
this.select.dropdown_content.onscroll = null;
this.select.dropdown_content.classList.add("is-loading-more");
}
try {
const response = await fetch(url);
if (response.ok) {
const json = await response.json();
if (json.has_more) {
const currentUrl = new URL(url, window.location.origin);
const currentPage = parseInt(currentUrl.searchParams.get("page") || "1");
const nextPage = currentPage + 1;
const nextQueryUrl = `${this.urlValue}?query=${encodeURIComponent(query)}&page=${nextPage}&per_page=${
this.perPageValue
}`;
this.select.setNextUrl(query, nextQueryUrl);
} else {
this.select.setNextUrl(query, null);
}
callback(json.data);
requestAnimationFrame(() => {
if (this.select && this.select.dropdown_content) {
if (typeof currentScrollTop === "number") {
this.select.dropdown_content.scrollTop = currentScrollTop;
}
this.select.dropdown_content.onscroll = originalScrollHandler;
this.select.setActiveOption(null);
this.select.dropdown_content
.querySelectorAll(".option.active")
.forEach((opt) => opt.classList.remove("active"));
this.select.dropdown_content.classList.remove("is-loading-more");
}
});
} else {
console.error("Failed to load data for virtual scroll:", response.statusText);
if (this.select) this.select.setNextUrl(query, null);
callback();
if (this.select && this.select.dropdown_content) {
this.select.dropdown_content.onscroll = originalScrollHandler;
this.select.dropdown_content.classList.remove("is-loading-more");
}
}
} catch (error) {
console.error("Error in virtual scroll load function:", error);
if (this.select) this.select.setNextUrl(query, null);
callback();
if (this.select && this.select.dropdown_content) {
this.select.dropdown_content.onscroll = originalScrollHandler;
this.select.dropdown_content.classList.remove("is-loading-more");
}
}
},
render: {
...tomSelectOptions.render,
loading_more: function (data, escape) {
return `<div class="loading-more-results py-2 flex items-center justify-center text-sm text-neutral-500 dark:text-neutral-400"><svg class="animate-spin -ml-1 mr-3 h-4 w-4 text-neutral-500 dark:text-neutral-400" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path></svg>Loading...</div>`;
},
no_more_results: function (data, escape) {
return `<div class="no-more-results hidden py-2 text-center text-sm text-neutral-500 dark:text-neutral-400">No more results</div>`;
},
},
shouldLoadMore: function () {
if (!this.dropdown_content) return false;
const scrollTop = this.dropdown_content.scrollTop;
const scrollHeight = this.dropdown_content.scrollHeight;
const clientHeight = this.dropdown_content.clientHeight;
return scrollTop + clientHeight + 150 >= scrollHeight;
},
};
} else {
// Setup for custom infinite scroll (non-virtual scroll)
tomSelectOptions = {
...tomSelectOptions, // Includes preload:true
valueField: this.valueFieldValue,
labelField: this.labelFieldValue,
searchField: this.labelFieldValue,
load: this.load.bind(this), // Uses the controller's .load() method for pagination
};
}
}
// Consolidate onChange logic
const originalOnChange = (value) => {
if (value === "none") {
this.element.value = "";
} else if (this.submitOnChangeValue) {
this.submitOnChange(value);
}
if (this.hasUpdateFieldValue && this.updateFieldValue && value) {
this.updateFieldWithSelectedValue(value);
}
};
tomSelectOptions.onChange = originalOnChange;
this.select = new TomSelect(this.element, tomSelectOptions);
if (this.hasUrlValue) {
// If a URL is provided, clear any options from HTML,
// as preload:true + load function will fetch initial options.
this.select.clearOptions();
}
// Add position updater when dropdown opens
this.select.on("dropdown_open", () => {
// Clear any lingering active states when dropdown opens
this.select.setActiveOption(null);
if (this.select.dropdown_content) {
this.select.dropdown_content
.querySelectorAll(".option.active")
.forEach((opt) => opt.classList.remove("active"));
}
this.#updatePosition();
setTimeout(() => this.#updatePosition(), 10);
setTimeout(() => this.#updatePosition(), 50);
setTimeout(() => this.#updatePosition(), 100);
// For custom infinite scroll, setup listeners and state
if (this.hasUrlValue && !this.virtualScrollValue) {
// Ensure listener is not added multiple times if dropdown_open fires again for some reason
const boundHandleScroll = this.#handleScroll.bind(this);
this.select.dropdown_content.removeEventListener("scroll", boundHandleScroll);
this.select.dropdown_content.addEventListener("scroll", boundHandleScroll);
this.currentPage = 1; // Reset page count for custom scroll
this.loadingMore = false;
this.hasMore = true;
}
});
// Add listeners for window events
this.scrollHandler = () => this.#updatePosition();
window.addEventListener("scroll", this.scrollHandler, true);
this.resizeObserver = new ResizeObserver(() => this.#updatePosition());
this.resizeObserver.observe(document.documentElement);
// Additional position updater for layout shifts
this.mutationObserver = new MutationObserver((mutations) => {
if (this.select.dropdown_content && this.select.dropdown.classList.contains("ts-dropdown")) {
this.#updatePosition();
}
});
this.mutationObserver.observe(document.body, { childList: true, subtree: true });
// Add scroll buttons if there are many options and scrollButtons is enabled
if (this.select.dropdown_content && this.hasScrollButtonsValue && this.scrollButtonsValue) {
this.addScrollButtons();
}
// Only track and restore scroll position for multiple selects
if (this.element.multiple) {
this.select.dropdown_content.addEventListener("scroll", () => {
this.lastScrollPosition = this.select.dropdown_content.scrollTop;
});
["item_add", "item_remove"].forEach((eventName) => {
this.select.on(eventName, () => {
if (this.lastScrollPosition) {
setTimeout(() => {
this.select.dropdown_content.scrollTop = this.lastScrollPosition;
}, 0);
}
});
});
}
// Handle initial preselected value
if (this.hasUpdateFieldValue && this.updateFieldValue) {
try {
const currentValue = this.urlValue ? this.getValue(this.urlValue) : null;
if (currentValue) {
this.select.setValue(currentValue);
}
} catch (error) {
console.warn("Initial value setting skipped");
}
}
// If there's a preselected value and we're not disabling dropdown input, enable typing
if (!this.disableDropdownInputValue && this.select.getValue()) {
this.select.destroy();
plugins.push("dropdown_input");
this.select = new TomSelect(this.element, { ...tomSelectOptions, plugins });
}
// Disable typing if specified
if (this.hasDisableTypingValue && this.disableTypingValue) {
const tomInput = this.select.control_input;
if (tomInput) {
tomInput.readOnly = true;
tomInput.setAttribute("readonly", "readonly");
// Add keyboard navigation for readonly input
let buffer = "";
let timeout;
tomInput.addEventListener("keydown", (e) => {
// Handle alphanumeric keys and arrow keys
if (
(e.key.length === 1 && e.key.match(/[a-z0-9]/i)) ||
["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(e.key)
) {
// Hide cursor when using keyboard
document.body.requestPointerLock();
if (e.key.length === 1) {
// Only add to buffer for alphanumeric keys
// Clear buffer timeout
clearTimeout(timeout);
timeout = setTimeout(() => {
buffer = "";
}, 1000);
buffer += e.key.toLowerCase();
const options = this.select.options;
// Find first option that starts with the buffer
const match = Object.values(options).find((option) => {
const label = this.hasUrlValue
? option[this.labelFieldValue]
: option.text.startsWith("{")
? JSON.parse(option.text).name
: option.text;
return label.toLowerCase().startsWith(buffer);
});
if (match) {
// Find the matching option in the dropdown list
const optionEl = this.select.dropdown_content.querySelector(
`[data-value="${match[this.valueFieldValue]}"]`
);
if (optionEl) {
// Highlight the option without selecting it
this.select.setActiveOption(optionEl);
this.select.open();
// Scroll to the option if needed
const dropdownHeight = this.select.dropdown_content.offsetHeight;
const optionTop = optionEl.offsetTop;
const optionHeight = optionEl.offsetHeight;
if (optionTop < this.select.dropdown_content.scrollTop) {
this.select.dropdown_content.scrollTop = optionTop;
} else if (optionTop + optionHeight > this.select.dropdown_content.scrollTop + dropdownHeight) {
this.select.dropdown_content.scrollTop = optionTop + optionHeight - dropdownHeight;
}
}
}
}
}
});
// Show cursor again when moving the mouse
document.addEventListener("mousemove", () => {
if (document.pointerLockElement) {
document.exitPointerLock();
}
});
}
}
this.element.style.visibility = "visible";
}
disconnect() {
if (this.checkboxObserver) {
this.checkboxObserver.disconnect();
}
if (this.select) {
this.select.destroy();
this.select = null; // Nullify to prevent errors if disconnect is called multiple times or accessed later
}
window.removeEventListener("scroll", this.scrollHandler, true);
if (this.resizeObserver) this.resizeObserver.disconnect();
if (this.mutationObserver) this.mutationObserver.disconnect();
// Custom scroll listener is on this.select.dropdown_content, which is destroyed with this.select.
// So, no explicit removal of that specific listener is strictly needed here if TomSelect cleans up its DOM.
}
async load(query, callback) {
// Reset for new searches/initial loads
this.currentPage = 1;
this.hasMore = true;
this.loadingMore = false;
try {
const response = await fetch(
`${this.urlValue}?query=${query}&page=${this.currentPage}&per_page=${this.perPageValue}`
);
if (response.ok) {
const json = await response.json();
callback(json.data); // Pass only the data array to TomSelect
this.hasMore = json.has_more;
// Ensure dropdown content exists before trying to add/remove scroll listener
if (this.select && this.select.dropdown_content) {
// Remove existing listener before adding, to prevent duplicates if load is called multiple times
this.select.dropdown_content.removeEventListener("scroll", this.#handleScroll.bind(this));
this.select.dropdown_content.addEventListener("scroll", this.#handleScroll.bind(this));
}
} else {
callback();
this.hasMore = false;
}
} catch (error) {
console.error("Error during initial load:", error);
callback();
this.hasMore = false;
}
}
async #loadMore(query) {
if (this.virtualScrollValue) return; // Do not run for virtual scroll
if (this.loadingMore || !this.hasMore) return;
this.loadingMore = true;
this.currentPage += 1;
try {
const response = await fetch(
`${this.urlValue}?query=${query || ""}&page=${this.currentPage}&per_page=${this.perPageValue}`
);
if (response.ok) {
const newOptions = await response.json();
if (newOptions && newOptions.data && newOptions.data.length > 0) {
this.select.addOptions(newOptions.data);
this.hasMore = newOptions.has_more;
} else {
this.hasMore = false;
}
// Restore focus to the input if it exists, to allow continued typing/searching
if (this.select.control_input) {
this.select.control_input.focus();
}
} else {
this.hasMore = false; // Stop trying if there's an error
console.error("Failed to load more options:", response.statusText);
}
} catch (error) {
console.error("Error loading more options:", error);
this.hasMore = false; // Stop trying if there's an error
} finally {
this.loadingMore = false;
// Re-check position after loading more items, in case the dropdown size changed significantly
this.#updatePosition();
}
}
#handleScroll() {
if (this.virtualScrollValue) return; // Do not run for virtual scroll
const dropdownContent = this.select.dropdown_content;
// Check if near bottom (e.g., 50px from bottom)
if (dropdownContent.scrollTop + dropdownContent.clientHeight >= dropdownContent.scrollHeight - 50) {
const currentQuery = this.select.control_input ? this.select.control_input.value : "";
this.#loadMore(currentQuery);
}
}
submitOnChange(value) {
if (value) {
if (value === "none") {
const url = new URL(window.location.href);
const paramName = this.element.name;
url.searchParams.delete(paramName);
window.location.href = url.toString();
} else {
this.element.form.requestSubmit();
}
this.element.value = value;
this.addSpinner();
}
}
updateFieldWithSelectedValue(value) {
// Get the form containing this select
const form = this.element.closest("form");
if (!form) return;
// Determine the target field to update
let targetField;
if (this.hasUpdateFieldTargetValue) {
// Use the specified selector to find the target field
targetField = form.querySelector(this.updateFieldTargetValue);
} else {
// Default to finding a field named "name" within the same form
targetField = form.querySelector('input[name="list_contact[name]"]');
}
if (!targetField) return;
// Get the selected option from the TomSelect instance
const selectedOption = this.select.options[value];
if (!selectedOption) return;
// Extract the data from the selected option
if (typeof selectedOption.text === "string" && selectedOption.text.startsWith("{")) {
try {
const optionData = JSON.parse(selectedOption.text);
const sourceProperty = this.hasUpdateFieldSourceValue ? this.updateFieldSourceValue : "name";
if (optionData[sourceProperty] !== undefined) {
targetField.value = optionData[sourceProperty];
// Dispatch an input event to trigger any listeners
targetField.dispatchEvent(new Event("input", { bubbles: true }));
}
} catch (e) {
console.warn("Could not parse option data:", e);
}
}
}
addSpinner() {
const logoContainer = this.element.closest(".relative").querySelector(".absolute.z-10");
if (logoContainer) {
logoContainer.innerHTML = `
<svg xmlns="http://www.w3.org/2000/svg" class="animate-spin size-7 mr-[5px] text-neutral-500 p-1 rounded-full bg-white dark:bg-neutral-700" width="24" height="24" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
`;
}
}
addScrollButtons() {
const scrollSpeed = 80; // Pixels per frame
let scrollInterval;
// Create scroll up button
const scrollUpBtn = document.createElement("div");
scrollUpBtn.className =
"absolute left-0 right-0 top-0 h-5 bg-gradient-to-b from-white to-transparent dark:from-neutral-800 z-10 cursor-default flex items-center justify-center transition-opacity duration-150";
scrollUpBtn.innerHTML =
'<svg class="size-3 text-neutral-600 dark:text-neutral-300" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7"></path></svg>';
// Create scroll down button
const scrollDownBtn = document.createElement("div");
scrollDownBtn.className =
"absolute left-0 right-0 bottom-0 h-5 bg-gradient-to-t from-white to-transparent dark:from-neutral-800 z-10 cursor-default flex items-center justify-center transition-opacity duration-150";
scrollDownBtn.innerHTML =
'<svg class="size-3 text-neutral-600 dark:text-neutral-300" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path></svg>';
// Helper function to start scrolling
const startScrolling = (direction) => {
// Clear any existing interval
if (scrollInterval) {
clearInterval(scrollInterval);
}
scrollInterval = setInterval(() => {
if (direction === "up") {
this.select.dropdown_content.scrollTop -= scrollSpeed;
} else {
this.select.dropdown_content.scrollTop += scrollSpeed;
}
}, 100); // ~60fps
};
// Helper function to stop scrolling
const stopScrolling = () => {
if (scrollInterval) {
clearInterval(scrollInterval);
scrollInterval = null;
}
};
// Helper function to add visual feedback
const addPressedState = (btn) => {
btn.style.opacity = "0.7";
};
// Helper function to remove visual feedback
const removePressedState = (btn) => {
btn.style.opacity = "1";
};
// Mouse events (for desktop)
scrollUpBtn.addEventListener("mouseenter", () => {
startScrolling("up");
addPressedState(scrollUpBtn);
});
scrollDownBtn.addEventListener("mouseenter", () => {
startScrolling("down");
addPressedState(scrollDownBtn);
});
// Clear interval when mouse leaves
[scrollUpBtn, scrollDownBtn].forEach((btn) => {
btn.addEventListener("mouseleave", () => {
stopScrolling();
removePressedState(btn);
});
});
// Touch events (for mobile)
scrollUpBtn.addEventListener(
"touchstart",
(e) => {
e.preventDefault(); // Prevent scrolling and other touch behaviors
startScrolling("up");
addPressedState(scrollUpBtn);
},
{ passive: false }
);
scrollDownBtn.addEventListener(
"touchstart",
(e) => {
e.preventDefault(); // Prevent scrolling and other touch behaviors
startScrolling("down");
addPressedState(scrollDownBtn);
},
{ passive: false }
);
// Stop scrolling on touch end/cancel for both buttons
[scrollUpBtn, scrollDownBtn].forEach((btn) => {
btn.addEventListener(
"touchend",
(e) => {
e.preventDefault();
stopScrolling();
removePressedState(btn);
},
{ passive: false }
);
btn.addEventListener(
"touchcancel",
(e) => {
e.preventDefault();
stopScrolling();
removePressedState(btn);
},
{ passive: false }
);
});
// Add buttons to dropdown
this.select.dropdown.insertBefore(scrollUpBtn, this.select.dropdown.firstChild);
this.select.dropdown.appendChild(scrollDownBtn);
// Show/hide buttons based on scroll position
this.select.dropdown_content.addEventListener("scroll", () => {
const { scrollTop, scrollHeight, clientHeight } = this.select.dropdown_content;
scrollUpBtn.style.display = scrollTop > 0 ? "flex" : "none";
scrollDownBtn.style.display = scrollTop + clientHeight < scrollHeight ? "flex" : "none";
});
// Initial visibility
scrollUpBtn.style.display = "none";
}
async #updatePosition() {
if (!this.select || !this.select.dropdown) return;
const referenceEl = this.select.control;
const floatingEl = this.select.dropdown;
// Ensure both elements are fully rendered before calculating position
if (!referenceEl.getBoundingClientRect().height || !floatingEl.getBoundingClientRect().height) {
// If dropdown is not visible or not fully rendered yet, try again shortly.
// This might happen if #updatePosition is called before TomSelect fully opens the dropdown.
if (floatingEl.offsetParent !== null) {
// Check if it's in the DOM and potentially visible
setTimeout(() => this.#updatePosition(), 50); // Increased delay for rendering
}
return;
}
try {
const { x, y } = await computePosition(referenceEl, floatingEl, {
placement: "bottom-start",
middleware: [offset(6), flip(), shift({ padding: 8 })],
});
// Apply correct position
Object.assign(floatingEl.style, {
position: "absolute",
left: `${x}px`,
top: `${y}px`,
width: `${Math.max(referenceEl.offsetWidth, 160)}px`,
});
} catch (error) {
console.warn("Error positioning dropdown:", error);
}
}
// Override the original TomSelect behavior for checkbox_options when virtual scroll is used
afterConnect() {
if (this.virtualScrollValue && this.element.multiple) {
// Watch for newly rendered options and add checkboxes to them if needed
const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
if (mutation.type === "childList" && mutation.addedNodes.length) {
// Iterate through added nodes to find options that need checkboxes
for (const node of mutation.addedNodes) {
if (
node.nodeType === Node.ELEMENT_NODE &&
node.classList.contains("option") &&
!node.querySelector(".checkbox")
) {
// Create a checkbox element
const checkbox = document.createElement("span");
checkbox.className = "checkbox me-2";
checkbox.innerHTML =
'<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="4" stroke-linecap="round" stroke-linejoin="round" class="size-3"><polyline points="20 6 9 17 4 12"></polyline></svg>';
// Insert at the beginning of the option
if (node.firstChild) {
node.insertBefore(checkbox, node.firstChild);
} else {
node.appendChild(checkbox);
}
}
}
}
}
});
if (this.select && this.select.dropdown_content) {
observer.observe(this.select.dropdown_content, {
childList: true,
subtree: true,
});
// Store the observer so we can disconnect it later
this.checkboxObserver = observer;
}
}
}
}
2. Dependencies Installation
The context menu component relies on Floating UI for intelligent tooltip positioning. Choose your preferred installation method:
pin "@floating-ui/dom", to: "https://cdn.jsdelivr.net/npm/@floating-ui/[email protected]/+esm"
pin "tom-select", to: "https://cdn.jsdelivr.net/npm/[email protected]/+esm"
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/[email protected]/dist/css/tom-select.css" rel="stylesheet">
3. CSS Styles
Here is the custom CSS I've used to style the select:
/* Tom Select */
select[multiple][data-controller="select"] {
@apply invisible;
}
.dropdown-input {
@apply !border-neutral-300 !bg-white !px-3 !py-2.5 text-sm placeholder:!text-neutral-500 dark:!border-neutral-600 dark:!bg-neutral-700 dark:!placeholder-neutral-300;
}
.plugin-dropdown_input.focus.dropdown-active .ts-control {
@apply !border-none;
}
.ts-dropdown-content {
@apply py-1.5;
max-height: 240px;
}
.ts-dropdown-content {
scrollbar-width: thin;
scrollbar-color: #a2a2a270 #7878780b;
}
.ts-dropdown-content::-webkit-scrollbar {
width: 6px;
}
.ts-dropdown-content::-webkit-scrollbar-track {
background: #78787879;
}
.ts-dropdown-content::-webkit-scrollbar-thumb {
background-color: #a2a2a270;
border-radius: 3px;
}
.ts-control {
@apply flex min-h-[40px] w-full px-3 py-2 cursor-default rounded-lg border-0 text-base leading-6 text-neutral-900 shadow-sm ring-1 placeholder:text-neutral-400 ring-neutral-300 outline-none ring-inset focus:ring-neutral-600 dark:bg-neutral-700 dark:text-white dark:placeholder-neutral-300 dark:ring-neutral-600 dark:focus:ring-neutral-500;
&[disabled] {
@apply cursor-not-allowed bg-neutral-100 dark:bg-neutral-600;
}
&.error {
@apply border-red-400 outline-red-300 focus:outline-red-500 dark:border-red-600 dark:outline-red-500;
}
}
.plugin-dropdown_input .dropdown-input {
@apply outline-none;
}
.ts-dropdown .active.create {
@apply bg-neutral-100 text-neutral-900 dark:bg-neutral-600 dark:text-white;
}
.disabled .ts-control {
cursor: not-allowed !important;
}
@media (min-width: 640px) {
.ts-control {
font-size: 0.875rem; /* text-sm equivalent (14px) for larger screens */
}
}
.full .ts-control {
@apply dark:bg-neutral-700;
}
.ts-wrapper.single .ts-control,
.ts-wrapper.single .ts-control input,
.ts-control,
.ts-wrapper.single.input-active .ts-control {
@apply cursor-text;
}
.ts-dropdown [data-selectable] .highlight {
@apply bg-orange-500/20 dark:bg-yellow-500/20;
}
.ts-control,
.ts-wrapper.single.input-active .ts-control {
@apply bg-white dark:bg-neutral-700;
}
.input-active {
@apply rounded-lg bg-transparent outline-2 outline-neutral-600 dark:bg-neutral-600 dark:outline-neutral-500;
}
.ts-control input {
@apply !m-0 bg-white text-base placeholder:text-neutral-400 read-only:!cursor-pointer dark:bg-neutral-800 dark:text-white dark:placeholder-neutral-300;
}
@media (min-width: 640px) {
.ts-control input {
font-size: 0.875rem; /* text-sm equivalent (14px) for larger screens */
}
}
.ts-wrapper:not(trix-toolbar .trix-input--dialog):not(.form-select).single .ts-control {
@apply !pr-8;
}
.ts-wrapper.plugin-remove_button .item {
@apply rounded-md;
}
.ts-wrapper.plugin-remove_button .item .remove {
@apply rounded-r-lg border-none py-1 text-lg leading-none;
}
.ts-wrapper.plugin-remove_button .item .remove::before {
content: "";
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Ctitle%3Exmark%3C/title%3E%3Cg fill='%236B7280'%3E%3Cpath d='m2.25,10.5c-.192,0-.384-.073-.53-.22-.293-.293-.293-.768,0-1.061L9.22,1.72c.293-.293.768-.293,1.061,0s.293.768,0,1.061l-7.5,7.5c-.146.146-.338.22-.53.22Z' stroke-width='0'%3E%3C/path%3E%3Cpath d='m9.75,10.5c-.192,0-.384-.073-.53-.22L1.72,2.78c-.293-.293-.293-.768,0-1.061s.768-.293,1.061,0l7.5,7.5c.293.293.293.768,0,1.061-.146.146-.338.22-.53.22Z' stroke-width='0'%3E%3C/path%3E%3C/g%3E%3C/svg%3E");
@apply block size-4 bg-center bg-no-repeat;
}
/* Add separate dark mode version */
@media (prefers-color-scheme: dark) {
.ts-wrapper.plugin-remove_button .item .remove::before {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Ctitle%3Exmark%3C/title%3E%3Cg fill='%239CA3AF'%3E%3Cpath d='m2.25,10.5c-.192,0-.384-.073-.53-.22-.293-.293-.293-.768,0-1.061L9.22,1.72c.293-.293.768-.293,1.061,0s.293.768,0,1.061l-7.5,7.5c-.146.146-.338.22-.53.22Z' stroke-width='0'%3E%3C/path%3E%3Cpath d='m9.75,10.5c-.192,0-.384-.073-.53-.22L1.72,2.78c-.293-.293-.293-.768,0-1.061s.768-.293,1.061,0l7.5,7.5c.293.293.293.768,0,1.061-.146.146-.338.22-.53.22Z' stroke-width='0'%3E%3C/path%3E%3C/g%3E%3C/svg%3E");
}
}
.ts-wrapper.plugin-remove_button .item .remove {
font-size: 0 !important;
@apply my-0.5 mr-1 !ml-0.5 flex size-[18px] items-center justify-center rounded !border-0 !p-1 !leading-none text-neutral-500 hover:text-neutral-700 dark:text-neutral-400 dark:hover:bg-neutral-700 dark:hover:text-neutral-200;
}
.ts-dropdown {
@apply z-40 m-0 overflow-hidden rounded-lg border border-t border-solid border-neutral-300 shadow-sm dark:border-neutral-600 dark:bg-neutral-800 dark:text-white;
}
.ts-dropdown .create {
@apply mx-1.5 cursor-default rounded-md px-2.5 py-2 text-sm dark:text-neutral-400;
}
.ts-dropdown [data-selectable].option,
.ts-dropdown .no-results {
@apply mx-1.5 cursor-default rounded-md px-2.5 py-2 text-sm;
}
.ts-dropdown .option,
.ts-dropdown [data-disabled],
.ts-dropdown [data-disabled] [data-selectable].option {
@apply mx-1.5 cursor-not-allowed rounded-md px-2.5 py-2 text-sm;
}
.ts-dropdown [data-selectable].option,
.ts-dropdown .ts-dropdown .create {
@apply cursor-pointer;
}
.ts-dropdown .active {
@apply bg-neutral-100 text-neutral-900 dark:bg-neutral-600 dark:text-white;
}
.ts-dropdown .spinner {
@apply h-auto w-auto;
}
.ts-dropdown .spinner:after {
@apply mt-1 mb-0 inline-block size-4 border-2 p-0;
}
.ts-wrapper:not(.form-control):not(.form-select).single .ts-control {
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%23737373' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e");
background-position: right 0.5rem center;
background-repeat: no-repeat;
background-size: 1.5em 1.5em;
print-color-adjust: exact;
}
/* Dark mode arrow for single select */
@media (prefers-color-scheme: dark) {
.ts-wrapper:not(.form-control):not(.form-select).single .ts-control {
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%23A1A1AA' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e");
}
}
/* Add dropdown arrow to multiselect elements */
.ts-wrapper:not(.form-control):not(.form-select).multi .ts-control {
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%23737373' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 6l4-4 4 4M6 14l4 4 4-4'/%3e%3c/svg%3e");
background-position: right 0.6rem center;
background-repeat: no-repeat;
background-size: 1.25em 1.25em;
print-color-adjust: exact;
padding-right: 2rem !important;
}
/* Dark mode arrow for multiselect */
@media (prefers-color-scheme: dark) {
.ts-wrapper:not(.form-control):not(.form-select).multi .ts-control {
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%23A1A1AA' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 6l4-4 4 4M6 14l4 4 4-4'/%3e%3c/svg%3e");
}
}
.ts-wrapper.multi .ts-control > div {
@apply mr-1 inline-flex items-center justify-center rounded-md bg-neutral-100 px-2 text-xs leading-none font-medium text-neutral-900 dark:bg-neutral-900 dark:text-neutral-100;
}
/* Ensure items don't overlap with the dropdown arrow */
.ts-wrapper.multi.has-items .ts-control {
@apply !pt-[7px] !pr-8 !pb-[4px];
}
.ts-wrapper.plugin-remove_button:not(.rtl) .item {
@apply cursor-grab;
}
.ts-wrapper.plugin-remove_button:not(.rtl) .item .remove {
@apply !-ml-0.5 cursor-pointer border-none;
}
.ts-wrapper.plugin-remove_button .item .remove {
@apply my-0.5 mr-1 !ml-0.5 flex size-[18px] items-center justify-center rounded border-0 text-lg leading-none text-neutral-900/60 hover:text-neutral-900 dark:text-neutral-100/60 dark:hover:bg-neutral-700 dark:hover:text-neutral-100;
}
.ts-dropdown .optgroup-header {
@apply border-t border-neutral-300 bg-white font-semibold text-neutral-900 dark:border-neutral-600 dark:bg-neutral-800 dark:text-neutral-100;
}
.ts-wrapper.multi.has-items .ts-control > input {
@apply !mb-[3px];
}
.tomselect-checkbox {
@apply !mr-0;
}
.input-hidden.focus {
@apply !rounded-lg border border-neutral-300 dark:border-neutral-600;
}
/* Replace the previous attempt with this updated selector */
select[data-select-disable-typing-value="true"] + .ts-wrapper .ts-control,
select[data-select-disable-typing-value="true"] + .ts-wrapper.single .ts-control,
select[data-select-disable-typing-value="true"] + .ts-wrapper.single .ts-control input,
select[data-select-disable-typing-value="true"] + .ts-wrapper.single.input-active .ts-control {
@apply cursor-default;
}
.ts-dropdown-content.is-loading-more .option {
pointer-events: none !important;
}
Examples
Basic Select
A simple select with search functionality.
<div class="w-full max-w-xs">
<%= select_tag :framework,
options_for_select([
["Ruby on Rails", "rails"],
["Laravel", "laravel"],
["Django", "django"],
["Express.js", "express"],
["Spring Boot", "spring"],
["ASP.NET Core", "aspnet"],
["Phoenix", "phoenix"],
["FastAPI", "fastapi"]
]),
include_blank: "Select framework...",
class: "w-full [&>*:first-child]:!cursor-pointer",
data: {
controller: "select",
select_disable_dropdown_input_value: true,
select_disable_typing_value: true
} %>
</div>
Select with Icons, Tags, and Descriptions
Enhanced select with icons, side tags, and descriptions.
<div class="w-full max-w-xs">
<%= select_tag :frameworks_with_icons, options_for_select([
[{
icon: "<svg xmlns='http://www.w3.org/2000/svg' class='size-4' width='24' height='24' viewBox='0 0 24 24'><g stroke-linejoin='round' stroke-linecap='round' stroke-width='2' fill='none' stroke='currentColor'><circle cx='12' cy='12' r='4'></circle><path d='M12 2v2'></path><path d='M12 20v2'></path><path d='m4.93 4.93 1.41 1.41'></path><path d='m17.66 17.66 1.41 1.41'></path><path d='M2 12h2'></path><path d='M20 12h2'></path><path d='m6.34 17.66-1.41 1.41'></path><path d='m19.07 4.93-1.41 1.41'></path></g></svg>",
name: "Light",
side: "<span class='ml-auto text-xs border border-neutral-200 bg-white px-1 py-0.5 rounded-md dark:bg-neutral-900/50 dark:border-neutral-700'>Optional</span>",
description: "Light mode is a theme that uses light colors and a light background."
}.to_json, "light"],
[{
icon: "<svg xmlns='http://www.w3.org/2000/svg' class='size-4' width='24' height='24' viewBox='0 0 24 24'><g stroke-linejoin='round' stroke-linecap='round' stroke-width='2' fill='none' stroke='currentColor'><path d='M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z'></path></g></svg>",
name: "Dark",
side: "<span class='ml-auto text-xs border border-neutral-200 bg-white px-1 py-0.5 rounded-md dark:bg-neutral-900/50 dark:border-neutral-700'>Popular</span>",
description: "Dark mode is a theme that uses dark colors and a dark background."
}.to_json, "dark"],
[{
icon: "<svg xmlns='http://www.w3.org/2000/svg' class='size-4' width='24' height='24' viewBox='0 0 24 24'><g stroke-linejoin='round' stroke-linecap='round' stroke-width='2' fill='none' stroke='currentColor'><rect width='20' height='14' x='2' y='3' rx='2'></rect><path d='M8 21L16 21'></path><path d='M12 17L12 21'></path></g></svg>",
name: "System",
side: "<span class='ml-auto text-xs border border-neutral-200 bg-white px-1 py-0.5 rounded-md dark:bg-neutral-900/50 dark:border-neutral-700'>Default</span>",
description: "System mode is a theme that uses the system's default colors and background."
}.to_json, "system"]
]), include_blank: "Select theme...",
class: "w-full [&>*:first-child]:!cursor-pointer",
data: {
controller: "select",
select_disable_typing_value: true,
select_disable_dropdown_input_value: true,
autocomplete: "off"
} %>
</div>
Select with Scroll Arrows
Enhanced select with scroll arrows that appear when the dropdown is too long. Hovering on them will scroll the dropdown.
<div class="w-full max-w-xs">
<%= select_tag :framework,
options_for_select([
["Ruby on Rails", "rails"],
["Laravel", "laravel"],
["Django", "django"],
["Express.js", "express"],
["Spring Boot", "spring"],
["ASP.NET Core", "aspnet"],
["Phoenix", "phoenix"],
["FastAPI", "fastapi"]
]),
include_blank: "Select framework...",
class: "w-full [&>*:first-child]:!cursor-pointer",
data: {
controller: "select",
select_disable_dropdown_input_value: true,
select_disable_typing_value: true,
select_scroll_buttons_value: true
} %>
</div>
Grouped Options
Organize options into groups for better navigation.
<div class="w-full max-w-md space-y-4">
<%= select_tag :cars, grouped_options_for_select({
"European" => [["BMW", "bmw"], ["Mercedes", "mercedes"], ["Audi", "audi"]],
"Japanese" => [["Toyota", "toyota"], ["Honda", "honda"], ["Nissan", "nissan"]],
"American" => [["Ford", "ford"], ["Chevrolet", "chevy"], ["Dodge", "dodge"]]
}),
include_blank: "Select car brand...",
autocomplete: "off",
class: "w-full [&>*:first-child]:!cursor-pointer",
data: {
controller: "select",
select_disable_dropdown_input_value: true,
select_disable_typing_value: true
} %>
<%= select_tag :cars, grouped_options_for_select({
"European" => [["BMW", "bmw"], ["Mercedes", "mercedes"], ["Audi", "audi"], ["Volkswagen", "volkswagen"], ["Seat", "seat"], ["Porsche", "porsche"], ["Alfa Romeo", "alfa-romeo"]],
"Japanese" => [["Toyota", "toyota"], ["Honda", "honda"], ["Nissan", "nissan"], ["Mazda", "mazda"], ["Subaru", "subaru"], ["Lexus", "lexus"], ["Mitsubishi", "mitsubishi"]],
"American" => [["Ford", "ford"], ["Chevrolet", "chevy"], ["Dodge", "dodge"], ["Chrysler", "chrysler"], ["Jeep", "jeep"], ["GMC", "gmc"], ["Tesla", "tesla"]]
}),
include_blank: "Select car brand...",
autocomplete: "off",
class: "w-full [&>*:first-child]:!cursor-pointer",
data: {
controller: "select",
select_disable_dropdown_input_value: true,
select_disable_typing_value: true,
select_optgroup_columns_value: true
} %>
</div>
Disabled States
Examples of disabled selects and disabled options.
This combobox is completely disabled
Some options are disabled and cannot be selected
Disabled options appear in different groups
<div class="space-y-6">
<div class="w-full max-w-md">
<label class="block text-sm font-medium mb-2">Fully Disabled Combobox</label>
<%= select_tag :disabled_select,
options_for_select([
["Option 1", "1"],
["Option 2", "2"],
["Option 3", "3"]
], "2"),
include_blank: "Select option...",
class: "w-full [&>*:first-child]:!cursor-pointer",
disabled: true,
data: {
controller: "select",
select_disable_dropdown_input_value: true,
select_disable_typing_value: true
} %>
<p class="text-xs text-neutral-500 mt-1">This combobox is completely disabled</p>
</div>
<div class="w-full max-w-md">
<label class="block text-sm font-medium mb-2">Combobox with Disabled Options</label>
<%= select_tag :partial_disabled,
options_for_select([
["Available Option 1", "1"],
["Unavailable Option", "2", { disabled: true }],
["Available Option 2", "3"],
["Out of Stock", "4", { disabled: true }],
["Available Option 3", "5"],
["Coming Soon", "6", { disabled: true }]
]),
include_blank: "Select available option...",
class: "w-full [&>*:first-child]:!cursor-pointer",
data: {
controller: "select",
select_disable_dropdown_input_value: true,
select_disable_typing_value: true
} %>
<p class="text-xs text-neutral-500 mt-1">Some options are disabled and cannot be selected</p>
</div>
<div class="w-full max-w-md">
<label class="block text-sm font-medium mb-2">Grouped Options with Disabled Items</label>
<%= select_tag :grouped_disabled,
grouped_options_for_select({
"In Stock" => [
["Product A", "a"],
["Product B", "b"],
["Product C", "c"]
],
"Out of Stock" => [
["Product D", "d", { disabled: true }],
["Product E", "e", { disabled: true }]
],
"Pre-order" => [
["Product F (Available)", "f"],
["Product G (Unavailable)", "g", { disabled: true }],
["Product H (Available)", "h"]
]
}),
include_blank: "Select product...",
class: "w-full [&>*:first-child]:!cursor-pointer",
data: {
controller: "select",
select_disable_dropdown_input_value: true,
select_disable_typing_value: true
} %>
<p class="text-xs text-neutral-500 mt-1">Disabled options appear in different groups</p>
</div>
</div>
Configuration
The select component is powered by TomSelect and a Stimulus controller that provides extensive configuration options.
Configuration Values
Prop | Description | Type | Default |
---|---|---|---|
url
|
URL for loading options asynchronously. When provided, enables remote data loading with pagination |
String
|
null
|
valueField
|
Field to use for the option value when loading from URL |
String
|
"value"
|
labelField
|
Field to use for the option label when loading from URL |
String
|
"label"
|
allowNew
|
Allow users to create new options that don't exist in the list |
Boolean
|
false
|
disableDropdownInput
|
Disable the search input in the dropdown |
Boolean
|
false
|
disableTyping
|
Make the input read-only while still allowing keyboard navigation |
Boolean
|
false
|
submitOnChange
|
Automatically submit the form when a value is selected |
Boolean
|
false
|
updateField
|
Update another field with data from the selected option |
Boolean
|
false
|
updateFieldTarget
|
CSS selector for the field to update when an option is selected |
String
|
null
|
updateFieldSource
|
Property from the selected option to use when updating the target field |
String
|
"name"
|
virtualScroll
|
Enable virtual scrolling for large datasets (requires URL value) |
Boolean
|
false
|
perPage
|
Number of items to load per page when using async loading |
Number
|
60
|
scrollButtons
|
Show scroll buttons at the top and bottom of the dropdown for easier navigation |
Boolean
|
false
|
optgroupColumns
|
Display option groups in columns for better organization |
Boolean
|
false
|
Features
- Search & Filter: Built-in search functionality with customizable search fields
- Multi-select: Support for selecting multiple options with checkboxes and tags
- Custom Rendering: Flexible option and item templates with support for icons and descriptions
- Async Loading: Load options from remote APIs with pagination and infinite scroll
- Keyboard Navigation: Full keyboard support including type-ahead search