Select Rails Components
Advanced select components with search, multi-select, and custom rendering capabilities. Built with TomSelect and Stimulus.
Installation
1. Stimulus Controller Setup
Add the following controller to your project:
import { Controller } from "@hotwired/stimulus";
import TomSelect from "tom-select";
import { computePosition, flip, shift, offset } from "@floating-ui/dom";
export default class extends Controller {
static values = {
url: String, // URL to fetch options from
valueField: { type: String, default: "value" }, // Field to use for value
labelField: { type: String, default: "label" }, // Field to use for label
submitOnChange: { type: Boolean, default: false }, // Submit form on change
disableDropdownInput: { type: Boolean, default: false }, // Disable dropdown input
disableTyping: { type: Boolean, default: false }, // Disable typing
allowNew: { type: Boolean, default: false }, // Allow new options
scrollButtons: { type: Boolean, default: false }, // Show scroll buttons
updateField: { type: Boolean, default: false }, // Update field with selected value
updateFieldTarget: String, // Target to update
updateFieldSource: { type: String, default: "name" }, // Source to update
perPage: { type: Number, default: 60 }, // Number of options per page
virtualScroll: { type: Boolean, default: false }, // Use virtual scroll
optgroupColumns: { type: Boolean, default: false }, // Use optgroup columns
responseDataField: { type: String, default: "data" }, // Field in response containing array of items (auto-detects common patterns)
searchParam: { type: String, default: "query" }, // Search parameter name for the API
// New flexible rendering options
imageField: String, // Field containing image URL
subtitleField: String, // Field to show as subtitle
metaFields: String, // Comma-separated fields to show as metadata (e.g., "status,species")
badgeField: String, // Field to show as a badge/tag
renderTemplate: String, // Custom template for option rendering
// New count display options
showCount: { type: Boolean, default: false }, // Show count instead of individual items
countText: { type: String, default: "selected" }, // Text to show after count
countTextSingular: { type: String, default: "" }, // Text to show when only one item is selected (optional)
};
connect() {
if (this.element.tomselect) return;
const options = this.#buildOptions();
this.select = new TomSelect(this.element, options);
this.#setupEventHandlers();
this.#setupPositioning();
this.#handleInitialValue();
this.element.style.visibility = "visible";
}
disconnect() {
this.#cleanup();
}
// Private methods
#buildOptions() {
const plugins = this.#getPlugins();
const baseOptions = {
plugins,
maxOptions: null,
closeAfterSelect: !this.element.multiple,
create: this.allowNewValue,
render: this.#getRenderConfig(),
onChange: this.#handleChange.bind(this),
onDropdownOpen: () => this.#updatePosition(),
};
if (!this.hasUrlValue) return baseOptions;
return {
...baseOptions,
preload: true,
...(this.virtualScrollValue ? this.#getVirtualScrollConfig() : this.#getCustomScrollConfig()),
};
}
#getPlugins() {
const plugins = [];
const isMultiple = this.element.multiple;
const useVirtualScroll = this.virtualScrollValue && this.hasUrlValue;
if (useVirtualScroll) {
plugins.push("virtual_scroll");
if (isMultiple) plugins.push("remove_button", "checkbox_options", "no_active_items");
} else if (isMultiple) {
plugins.push("remove_button", "checkbox_options", "drag_drop", "no_active_items");
}
if (this.optgroupColumnsValue) plugins.push("optgroup_columns");
return plugins;
}
#getRenderConfig() {
const renderOption = (data, escape) => {
if (this.renderTemplateValue) return this.#renderWithTemplate(this.renderTemplateValue, data, escape);
if (this.hasUrlValue && this.#hasCustomFields()) return this.#renderApiOption(data, escape);
return this.#renderStandardOption(data, escape);
};
const renderItem = (data, escape) => {
if (this.hasUrlValue && this.imageFieldValue && data[this.imageFieldValue]) {
return this.#renderImageItem(data, escape);
}
return this.#renderStandardItem(data, escape);
};
return {
option: renderOption,
item: renderItem,
option_create: (data, escape) => `<div class="create">Add <strong>${escape(data.input)}</strong>…</div>`,
loading_more: () => this.#renderLoadingMore(),
no_more_results: () =>
`<div class="no-more-results hidden py-2 text-center text-sm text-neutral-500 dark:text-neutral-400">No more results</div>`,
};
}
#getVirtualScrollConfig() {
return {
valueField: this.valueFieldValue,
labelField: this.labelFieldValue,
searchField: this.labelFieldValue,
firstUrl: (query) => this.#buildApiUrl(this.urlValue, query, 1),
load: this.#virtualScrollLoad.bind(this),
shouldLoadMore: this.#shouldLoadMore.bind(this),
};
}
#getCustomScrollConfig() {
return {
valueField: this.valueFieldValue,
labelField: this.labelFieldValue,
searchField: this.labelFieldValue,
load: this.#customScrollLoad.bind(this),
};
}
async #virtualScrollLoad(query, callback) {
const url = this.select.getUrl(query);
const scrollState = this.#captureScrollState(url);
try {
const response = await fetch(url);
if (!response.ok) throw new Error(response.statusText);
const rawJson = await response.json();
const json = this.#transformApiResponse(rawJson);
this.#updateVirtualScrollState(url, query, json);
callback(json.data);
// For pages after the first, just maintain scroll position
if (scrollState.currentPage > 1) {
requestAnimationFrame(() => {
if (this.select?.dropdown_content && typeof scrollState.scrollTop === "number") {
this.select.dropdown_content.scrollTop = scrollState.scrollTop;
}
});
} else {
requestAnimationFrame(() => {
this.#restoreScrollState(scrollState);
this.#handlePostLoadFocus(query, scrollState);
});
}
} catch (error) {
console.error("Virtual scroll load error:", error);
this.select?.setNextUrl(query, null);
callback();
} finally {
this.#cleanupScrollState();
}
}
async #customScrollLoad(query, callback) {
this.#resetPagination();
try {
const response = await this.#fetchPage(query, 1);
const json = this.#transformApiResponse(response);
callback(json.data);
this.hasMore = json.has_more;
if (this.select?.dropdown_content) {
this.#setupInfiniteScroll();
setTimeout(() => this.#focusFirstOption(query), 10);
}
} catch (error) {
console.error("Custom scroll load error:", error);
callback();
this.hasMore = false;
}
}
#setupEventHandlers() {
// Override setActiveOption for single active state
const original = this.select.setActiveOption.bind(this.select);
this.select.setActiveOption = (option, scroll) => {
this.#clearAllActiveStates();
return original(option, scroll);
};
// Clear options if URL-based
if (this.hasUrlValue) this.select.clearOptions();
// Dropdown open handler
this.select.on("dropdown_open", () => this.#handleDropdownOpen());
// Setup additional features
if (this.scrollButtonsValue && this.select.dropdown_content) this.#addScrollButtons();
if (this.element.multiple) this.#setupScrollTracking();
if (this.disableTypingValue) this.#setupReadonlyInput();
// Setup count display for multi-select
if (this.element.multiple && this.showCountValue) {
this.#setupCountDisplay();
}
}
#setupPositioning() {
this.scrollHandler = () => this.#updatePosition();
window.addEventListener("scroll", this.scrollHandler, true);
this.resizeObserver = new ResizeObserver(() => this.#updatePosition());
this.resizeObserver.observe(document.documentElement);
this.mutationObserver = new MutationObserver(() => {
if (this.select?.dropdown?.classList.contains("ts-dropdown")) {
this.#updatePosition();
}
});
this.mutationObserver.observe(document.body, { childList: true, subtree: true });
}
#handleDropdownOpen() {
this.#clearAllActiveStates();
this.select.setActiveOption(null);
// Update position multiple times to ensure proper placement
[0, 10, 50, 100].forEach((delay) => {
setTimeout(() => this.#updatePosition(), delay);
});
if (this.hasUrlValue && !this.virtualScrollValue) {
this.#setupInfiniteScroll();
this.#resetPagination();
}
}
#setupInfiniteScroll() {
const content = this.select.dropdown_content;
if (!content) return;
const handler = this.#handleScroll.bind(this);
content.removeEventListener("scroll", handler);
content.addEventListener("scroll", handler);
}
#handleScroll() {
if (this.virtualScrollValue || !this.select?.dropdown_content) return;
const { scrollTop, scrollHeight, clientHeight } = this.select.dropdown_content;
if (scrollTop + clientHeight >= scrollHeight - 50) {
const query = this.select.control_input?.value || "";
this.#loadMore(query);
}
}
async #loadMore(query) {
if (this.virtualScrollValue || this.loadingMore || !this.hasMore) return;
this.loadingMore = true;
this.currentPage += 1;
const lastActiveValue = this.#getActiveValue();
try {
const response = await this.#fetchPage(query, this.currentPage);
const newOptions = this.#transformApiResponse(response);
if (newOptions?.data?.length > 0) {
this.select.addOptions(newOptions.data);
this.hasMore = newOptions.has_more;
setTimeout(() => this.#restoreSelectionAfterLoading(lastActiveValue), 300);
} else {
this.hasMore = false;
}
this.select.control_input?.focus();
} catch (error) {
console.error("Load more error:", error);
this.hasMore = false;
} finally {
this.loadingMore = false;
this.#updatePosition();
}
}
#setupScrollTracking() {
const content = this.select.dropdown_content;
if (!content) return;
content.addEventListener("scroll", () => {
this.lastScrollPosition = content.scrollTop;
});
["item_add", "item_remove"].forEach((event) => {
this.select.on(event, () => {
if (this.lastScrollPosition) {
setTimeout(() => {
content.scrollTop = this.lastScrollPosition;
}, 0);
}
});
});
}
#setupReadonlyInput() {
const input = this.select.control_input;
if (!input) return;
input.readOnly = true;
input.setAttribute("readonly", "readonly");
let buffer = "";
let timeout;
input.addEventListener("keydown", (e) => {
if (!this.#isNavigationKey(e.key)) return;
if (e.key.length === 1) {
document.body.requestPointerLock();
this.#handleTypeAhead(e.key, buffer, timeout);
}
});
document.addEventListener("mousemove", () => {
if (document.pointerLockElement) document.exitPointerLock();
});
}
#setupCountDisplay() {
// Create count element
this.countElement = document.createElement("div");
this.countElement.className = "ts-count-display";
// Insert count element into the control
this.select.control.appendChild(this.countElement);
// Update count on initial load
this.#updateCountDisplay();
// Listen for changes and prevent dropdown from closing
this.select.on("item_add", () => {
this.#updateCountDisplay();
// Force dropdown to stay open after selection
setTimeout(() => {
if (!this.select.isOpen) {
this.select.open();
}
}, 0);
});
this.select.on("item_remove", () => this.#updateCountDisplay());
}
#updateCountDisplay() {
const count = Object.keys(this.select.getValue()).length;
if (count > 0) {
// Use singular text if provided and count is 1, otherwise use regular countText
const textToUse = count === 1 && this.countTextSingularValue ? this.countTextSingularValue : this.countTextValue;
this.countElement.textContent = `${count} ${textToUse}`;
this.select.control.classList.add("count-active");
} else {
this.select.control.classList.remove("count-active");
}
}
#handleTypeAhead(key, buffer, timeout) {
clearTimeout(timeout);
timeout = setTimeout(() => {
buffer = "";
}, 1000);
buffer += key.toLowerCase();
const match = this.#findMatchingOption(buffer);
if (match) {
const optionEl = this.select.dropdown_content.querySelector(`[data-value="${match[this.valueFieldValue]}"]`);
if (optionEl) {
this.select.setActiveOption(optionEl);
this.select.open();
this.#scrollToOption(optionEl);
}
}
}
#addScrollButtons() {
const createButton = (direction, position) => {
const btn = document.createElement("div");
btn.className = `absolute left-0 right-0 ${position} h-5 bg-gradient-to-${
direction === "up" ? "b" : "t"
} from-white to-transparent dark:from-neutral-800 z-10 cursor-default flex items-center justify-center transition-opacity duration-150`;
btn.innerHTML = `<svg class="size-3 text-neutral-600 dark:text-neutral-300" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M${
direction === "up" ? "5 15l7-7 7 7" : "19 9l-7 7-7-7"
}"></path></svg>`;
return btn;
};
const scrollUpBtn = createButton("up", "top-0");
const scrollDownBtn = createButton("down", "bottom-0");
let scrollInterval;
const scrollSpeed = 80;
const setupScrollButton = (btn, direction) => {
const startScroll = () => {
if (scrollInterval) clearInterval(scrollInterval);
scrollInterval = setInterval(() => {
this.select.dropdown_content.scrollTop += direction === "up" ? -scrollSpeed : scrollSpeed;
}, 100);
btn.style.opacity = "0.7";
};
const stopScroll = () => {
if (scrollInterval) {
clearInterval(scrollInterval);
scrollInterval = null;
}
btn.style.opacity = "1";
};
// Mouse events
btn.addEventListener("mouseenter", startScroll);
btn.addEventListener("mouseleave", stopScroll);
// Touch events
["touchstart", "touchend", "touchcancel"].forEach((event) => {
btn.addEventListener(
event,
(e) => {
e.preventDefault();
event === "touchstart" ? startScroll() : stopScroll();
},
{ passive: false }
);
});
};
setupScrollButton(scrollUpBtn, "up");
setupScrollButton(scrollDownBtn, "down");
this.select.dropdown.insertBefore(scrollUpBtn, this.select.dropdown.firstChild);
this.select.dropdown.appendChild(scrollDownBtn);
// Show/hide based on scroll
this.select.dropdown_content.addEventListener("scroll", () => {
const { scrollTop, scrollHeight, clientHeight } = this.select.dropdown_content;
scrollUpBtn.style.display = scrollTop > 0 ? "flex" : "none";
scrollDownBtn.style.display = scrollTop + clientHeight < scrollHeight ? "flex" : "none";
});
scrollUpBtn.style.display = "none";
}
async #updatePosition() {
if (!this.select?.dropdown) return;
// Don't reposition during infinite scroll loading
if (this.select.dropdown_content?.classList.contains("is-loading-more")) return;
const reference = this.select.control;
const floating = this.select.dropdown;
if (!reference.getBoundingClientRect().height || !floating.getBoundingClientRect().height) {
if (floating.offsetParent !== null) {
setTimeout(() => this.#updatePosition(), 50);
}
return;
}
try {
const { x, y } = await computePosition(reference, floating, {
placement: "bottom-start",
middleware: [offset(6), flip(), shift({ padding: 8 })],
});
Object.assign(floating.style, {
position: "absolute",
left: `${x}px`,
top: `${y}px`,
width: `${Math.max(reference.offsetWidth, 160)}px`,
});
} catch (error) {
console.warn("Position update error:", error);
}
}
#handleChange(value) {
if (value === "none") {
this.element.value = "";
if (this.submitOnChangeValue) {
const url = new URL(window.location.href);
url.searchParams.delete(this.element.name);
window.location.href = url.toString();
}
} else {
if (this.submitOnChangeValue) {
this.element.form.requestSubmit();
this.element.value = value;
this.#addSpinner();
}
if (this.updateFieldValue) {
this.#updateTargetField(value);
}
}
}
#updateTargetField(value) {
const form = this.element.closest("form");
if (!form) return;
const targetField = this.updateFieldTargetValue
? form.querySelector(this.updateFieldTargetValue)
: form.querySelector('input[name="list_contact[name]"]');
if (!targetField) return;
const selectedOption = this.select.options[value];
if (!selectedOption) return;
const data = this.#parseOptionData(selectedOption);
if (data?.[this.updateFieldSourceValue]) {
targetField.value = data[this.updateFieldSourceValue];
targetField.dispatchEvent(new Event("input", { bubbles: true }));
}
}
#parseOptionData(option) {
if (typeof option.text === "string" && option.text.startsWith("{")) {
try {
return JSON.parse(option.text);
} catch (e) {
console.warn("Parse error:", e);
}
}
return null;
}
#addSpinner() {
const container = this.element.closest(".relative")?.querySelector(".absolute.z-10");
if (container) {
container.innerHTML = `
<svg xmlns="http://www.w3.org/2000/svg" class="animate-spin size-7 mr-[5px] text-neutral-500 p-1 rounded-full bg-white dark:bg-neutral-700" width="24" height="24" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
`;
}
}
#transformApiResponse(response) {
const data = this.#extractDataArray(response);
const transformedData = (data || []).map((item) => ({
...item,
text: item.text || item[this.labelFieldValue] || item.name || "",
value: item.value || item[this.valueFieldValue],
}));
if (!this.virtualScrollValue) {
const hasMore = this.#detectHasMore(response, data);
return { data: transformedData, has_more: hasMore };
}
return { data: transformedData };
}
#extractDataArray(response) {
if (this.responseDataFieldValue !== "data") {
return this.#getNestedValue(response, this.responseDataFieldValue);
}
if (Array.isArray(response)) return response;
const fields = ["data", "results", "items"];
for (const field of fields) {
if (response[field] && Array.isArray(response[field])) {
return response[field];
}
}
return null;
}
#detectHasMore(response, data) {
return (
response.has_more ||
response.hasMore ||
!!response.next ||
!!response.next_page_url ||
(response.info && !!response.info.next) ||
(data && data.length === this.perPageValue) ||
false
);
}
#buildApiUrl(baseUrl, query, page) {
const url = new URL(baseUrl, window.location.origin);
if (query) url.searchParams.set(this.searchParamValue, query);
url.searchParams.set("page", page);
const isExternalApi = !baseUrl.startsWith("/") && !baseUrl.startsWith(window.location.origin);
if (!isExternalApi) {
url.searchParams.set("per_page", this.perPageValue);
}
return url.toString();
}
async #fetchPage(query, page) {
const url = this.#buildApiUrl(this.urlValue, query, page);
const response = await fetch(url);
if (!response.ok) throw new Error(response.statusText);
return response.json();
}
#renderStandardOption(data, escape) {
const optionData = this.#parseOptionData(data);
if (optionData) {
return `<div class='flex items-center gap-y-[3px] gap-x-1.5 flex-wrap'>
${optionData.icon || ""}
<span>${escape(optionData.name)}</span>
${optionData.side || ""}
${
optionData.description
? `<p class='text-neutral-500 dark:text-neutral-300 text-xs my-0 w-full'>${escape(
optionData.description
)}</p>`
: ""
}
</div>`;
}
return `<div class='flex items-center gap-1.5'><span>${escape(data.text)}</span></div>`;
}
#renderStandardItem(data, escape) {
const optionData = this.#parseOptionData(data);
if (optionData) {
return `<div class='!flex items-center gap-1.5'>
${optionData.icon || ""}
<span>${escape(optionData.name)}</span>
</div>`;
}
return `<div class='!flex items-center gap-1.5'><span class='line-clamp-1'>${escape(data.text)}</span></div>`;
}
#renderImageItem(data, escape) {
const label = data[this.labelFieldValue] || data.name || data.text;
return `<div class='!flex items-center gap-2'>
<img class='size-5 rounded-full' src='${escape(data[this.imageFieldValue])}' alt='${escape(label)}'>
<span class='line-clamp-1'>${escape(label)}</span>
</div>`;
}
#renderApiOption(data, escape) {
const hasImage = this.imageFieldValue && data[this.imageFieldValue];
const label = data[this.labelFieldValue] || data.name || data.text;
let html = `<div class='${hasImage ? "flex items-start gap-3" : ""} py-1'>`;
if (hasImage) {
html += `<img class='size-10 rounded-full flex-shrink-0' src='${escape(
data[this.imageFieldValue]
)}' alt='${escape(label)}'>`;
html += `<div class='flex-1 min-w-0'>`;
}
html += `<div class='font-medium'>${escape(label)}</div>`;
if (this.subtitleFieldValue && data[this.subtitleFieldValue]) {
html += `<div class='text-xs text-neutral-500 dark:text-neutral-400'>${escape(data[this.subtitleFieldValue])}`;
if (this.badgeFieldValue && data[this.badgeFieldValue]) {
html += ` • ${escape(data[this.badgeFieldValue])}`;
}
html += `</div>`;
}
if (this.metaFieldsValue) {
const metaValues = this.metaFieldsValue
.split(",")
.map((f) => f.trim())
.filter((field) => data[field])
.map((field) => escape(data[field]));
if (metaValues.length > 0) {
html += `<div class='text-xs text-neutral-500 dark:text-neutral-400'>${metaValues.join(" • ")}</div>`;
}
}
if (hasImage) html += `</div>`;
html += `</div>`;
return html;
}
#renderWithTemplate(template, data, escape) {
return template.replace(/\{\{(\w+)\}\}/g, (match, field) => (data[field] ? escape(data[field]) : ""));
}
#renderLoadingMore() {
return `<div class="loading-more-results py-2 flex items-center justify-center text-sm text-neutral-500 dark:text-neutral-400">
<svg class="animate-spin -ml-1 mr-3 h-4 w-4 text-neutral-500 dark:text-neutral-400" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>Loading...
</div>`;
}
// Helper methods
#hasCustomFields() {
return this.imageFieldValue || this.subtitleFieldValue || this.metaFieldsValue;
}
#isNavigationKey(key) {
return (
(key.length === 1 && key.match(/[a-z0-9]/i)) || ["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(key)
);
}
#getNestedValue(obj, path) {
return path.split(".").reduce((current, key) => current?.[key], obj);
}
#clearAllActiveStates() {
if (!this.select?.dropdown_content) return;
// Clear both regular options and create option
this.select.dropdown_content.querySelectorAll(".option.active, .create.active").forEach((opt) => {
opt.classList.remove("active");
opt.setAttribute("aria-selected", "false");
});
if (this.select.activeOption) {
this.select.activeOption = null;
}
}
#captureScrollState(url) {
const currentUrl = new URL(url, window.location.origin);
const currentPage = parseInt(currentUrl.searchParams.get("page") || "1");
let state = { currentPage };
if (this.select?.dropdown_content) {
state.scrollTop = this.select.dropdown_content.scrollTop;
state.scrollHandler = this.select.dropdown_content.onscroll;
if (currentPage > 1) {
const activeItem = this.select.dropdown_content.querySelector(".option.active");
if (activeItem) {
state.lastActiveValue = activeItem.getAttribute("data-value");
}
}
this.select.dropdown_content.onscroll = null;
this.select.dropdown_content.classList.add("is-loading-more");
}
return state;
}
#restoreScrollState(state) {
if (!this.select?.dropdown_content || !state) return;
if (typeof state.scrollTop === "number") {
this.select.dropdown_content.scrollTop = state.scrollTop;
}
this.select.dropdown_content.onscroll = state.scrollHandler;
}
#cleanupScrollState() {
if (this.select?.dropdown_content) {
this.select.dropdown_content.classList.remove("is-loading-more");
}
}
#updateVirtualScrollState(url, query, json) {
const currentUrl = new URL(url, window.location.origin);
const currentPage = parseInt(currentUrl.searchParams.get("page") || "1");
const hasMore = json.data.length === this.perPageValue || json.info?.next || json.next || json.has_more;
if (hasMore) {
const nextUrl = this.#buildApiUrl(this.urlValue, query, currentPage + 1);
this.select.setNextUrl(query, nextUrl);
} else {
this.select.setNextUrl(query, null);
}
}
#handlePostLoadFocus(query, scrollState) {
if (!this.select?.dropdown_content) return;
// Don't mess with focus/selection during infinite scroll
if (scrollState.currentPage > 1) {
// Just maintain the current scroll position
return;
}
this.#clearAllActiveStates();
this.select.setActiveOption(null);
if (scrollState.currentPage === 1) {
this.#focusFirstOption(query);
}
}
#focusFirstOption(query) {
if (!query?.trim() || !this.select?.dropdown_content) return;
const currentActive = this.select.dropdown_content.querySelector(".option.active");
if (currentActive || this.select.activeOption) return;
const firstOption = this.select.dropdown_content.querySelector(".option:not(.create):not(.no-results)");
if (firstOption) {
this.select.setActiveOption(firstOption);
}
}
#restoreSelectionAfterLoading(lastActiveValue) {
if (!this.select?.dropdown_content || !lastActiveValue) return;
const currentActive = this.select.dropdown_content.querySelector(".option.active");
if (currentActive) return;
const itemToRestore = this.select.dropdown_content.querySelector(`[data-value="${lastActiveValue}"]`);
if (itemToRestore) {
this.select.setActiveOption(itemToRestore);
}
}
#getActiveValue() {
const activeItem = this.select?.dropdown_content?.querySelector(".option.active");
return activeItem?.getAttribute("data-value");
}
#findMatchingOption(buffer) {
return Object.values(this.select.options).find((option) => {
const label = this.hasUrlValue
? option[this.labelFieldValue]
: this.#parseOptionData(option)?.name || option.text;
return label.toLowerCase().startsWith(buffer);
});
}
#scrollToOption(optionEl) {
const content = this.select.dropdown_content;
const dropdownHeight = content.offsetHeight;
const optionTop = optionEl.offsetTop;
const optionHeight = optionEl.offsetHeight;
if (optionTop < content.scrollTop) {
content.scrollTop = optionTop;
} else if (optionTop + optionHeight > content.scrollTop + dropdownHeight) {
content.scrollTop = optionTop + optionHeight - dropdownHeight;
}
}
#resetPagination() {
this.currentPage = 1;
this.hasMore = true;
this.loadingMore = false;
}
#shouldLoadMore() {
if (!this.select?.dropdown_content) return false;
const { scrollTop, scrollHeight, clientHeight } = this.select.dropdown_content;
return scrollTop + clientHeight + 150 >= scrollHeight;
}
#handleInitialValue() {
if (!this.updateFieldValue || !this.hasUrlValue) return;
try {
const currentValue = this.getValue(this.urlValue);
if (currentValue) {
this.select.setValue(currentValue);
}
} catch (error) {
console.warn("Initial value setting skipped");
}
// Re-init with dropdown_input if needed
if (!this.disableDropdownInputValue && this.select.getValue()) {
const options = this.#buildOptions();
options.plugins.push("dropdown_input");
this.select.destroy();
this.select = new TomSelect(this.element, options);
this.#setupEventHandlers();
}
}
#cleanup() {
if (this.checkboxObserver) this.checkboxObserver.disconnect();
if (this.select) {
this.select.destroy();
this.select = null;
}
window.removeEventListener("scroll", this.scrollHandler, true);
if (this.resizeObserver) this.resizeObserver.disconnect();
if (this.mutationObserver) this.mutationObserver.disconnect();
}
}
2. Dependencies Installation
This component relies on Floating UI & Tom Select for the select functionality. Choose your preferred installation method:
pin "@floating-ui/dom", to: "https://cdn.jsdelivr.net/npm/@floating-ui/dom@1.7.0/+esm"
pin "tom-select", to: "https://cdn.jsdelivr.net/npm/tom-select@2.4.3/+esm"
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-400 ring-neutral-300 outline-hidden ring-inset focus:ring-neutral-600 dark:bg-neutral-700 dark:text-white dark:placeholder-neutral-300 dark:ring-neutral-600 dark:focus:ring-neutral-500;
&[disabled] {
@apply cursor-not-allowed bg-neutral-100 dark:bg-neutral-600;
}
&.error {
@apply border-red-400 outline-red-300 focus:outline-red-500 dark:border-red-600 dark:outline-red-500;
}
}
.plugin-dropdown_input .dropdown-input {
@apply outline-hidden;
}
.ts-dropdown .active.create {
@apply cursor-pointer bg-neutral-100 text-neutral-900 dark:bg-neutral-600 dark:text-white;
}
.loading-more-results {
@apply !cursor-default;
}
.disabled .ts-control {
cursor: not-allowed !important;
}
@media (min-width: 640px) {
.ts-control {
font-size: 0.875rem; /* text-sm equivalent (14px) for larger screens */
}
}
.full .ts-control {
@apply dark:bg-neutral-700;
}
.ts-wrapper.single .ts-control,
.ts-wrapper.single .ts-control input,
.ts-control,
.ts-wrapper.single.input-active .ts-control {
@apply cursor-text;
}
.ts-dropdown [data-selectable] .highlight {
@apply bg-orange-500/20 dark:bg-yellow-500/20;
}
.ts-control,
.ts-wrapper.single.input-active .ts-control {
@apply bg-white dark:bg-neutral-700;
}
.input-active {
@apply rounded-lg bg-transparent outline-2 outline-neutral-600 dark:bg-neutral-600 dark:outline-neutral-500;
}
.ts-control input {
@apply !m-0 bg-white text-base placeholder:text-neutral-400 read-only:!cursor-pointer dark:bg-neutral-800 dark:text-white dark:placeholder-neutral-300;
}
@media (min-width: 640px) {
.ts-control input {
font-size: 0.875rem; /* text-sm equivalent (14px) for larger screens */
}
}
.ts-wrapper:not(trix-toolbar .trix-input--dialog):not(.form-select).single .ts-control {
@apply !pr-8;
}
.ts-wrapper.plugin-remove_button .item {
@apply rounded-md;
}
.ts-wrapper.plugin-remove_button .item .remove {
@apply rounded-r-lg border-none py-1 text-lg leading-none;
}
.ts-wrapper.plugin-remove_button .item .remove::before {
content: "";
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Ctitle%3Exmark%3C/title%3E%3Cg fill='%236B7280'%3E%3Cpath d='m2.25,10.5c-.192,0-.384-.073-.53-.22-.293-.293-.293-.768,0-1.061L9.22,1.72c.293-.293.768-.293,1.061,0s.293.768,0,1.061l-7.5,7.5c-.146.146-.338.22-.53.22Z' stroke-width='0'%3E%3C/path%3E%3Cpath d='m9.75,10.5c-.192,0-.384-.073-.53-.22L1.72,2.78c-.293-.293-.293-.768,0-1.061s.768-.293,1.061,0l7.5,7.5c.293.293.293.768,0,1.061-.146.146-.338.22-.53.22Z' stroke-width='0'%3E%3C/path%3E%3C/g%3E%3C/svg%3E");
@apply block size-4 bg-center bg-no-repeat;
}
/* Add separate dark mode version */
.dark {
.ts-wrapper.plugin-remove_button .item .remove::before {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Ctitle%3Exmark%3C/title%3E%3Cg fill='%239CA3AF'%3E%3Cpath d='m2.25,10.5c-.192,0-.384-.073-.53-.22-.293-.293-.293-.768,0-1.061L9.22,1.72c.293-.293.768-.293,1.061,0s.293.768,0,1.061l-7.5,7.5c-.146.146-.338.22-.53.22Z' stroke-width='0'%3E%3C/path%3E%3Cpath d='m9.75,10.5c-.192,0-.384-.073-.53-.22L1.72,2.78c-.293-.293-.293-.768,0-1.061s.768-.293,1.061,0l7.5,7.5c.293.293.293.768,0,1.061-.146.146-.338.22-.53.22Z' stroke-width='0'%3E%3C/path%3E%3C/g%3E%3C/svg%3E");
}
}
.ts-wrapper.plugin-remove_button .item .remove {
font-size: 0 !important;
@apply my-0.5 mr-1 !ml-0.5 flex size-[18px] items-center justify-center rounded !border-0 !p-1 !leading-none text-neutral-500 hover:text-neutral-700 dark:text-neutral-400 dark:hover:bg-neutral-700 dark:hover:text-neutral-200;
}
.ts-dropdown {
@apply z-40 m-0 overflow-hidden rounded-lg border border-t border-solid border-neutral-300 shadow-sm dark:border-neutral-600 dark:bg-neutral-800 dark:text-white;
}
.ts-dropdown .create {
@apply mx-1.5 cursor-default rounded-md px-2.5 py-2 text-sm dark:text-neutral-400;
}
.ts-dropdown [data-selectable].option,
.ts-dropdown .no-results {
@apply mx-1.5 cursor-default rounded-md px-2.5 py-2 text-sm;
}
.ts-dropdown .option,
.ts-dropdown [data-disabled],
.ts-dropdown [data-disabled] [data-selectable].option {
@apply mx-1.5 cursor-not-allowed rounded-md px-2.5 py-2 text-sm;
}
.ts-dropdown [data-selectable].option,
.ts-dropdown .ts-dropdown .create {
@apply cursor-pointer;
}
.ts-dropdown .active {
@apply bg-neutral-100 text-neutral-900 dark:bg-neutral-600 dark:text-white;
}
.ts-dropdown .spinner {
@apply h-auto w-auto;
}
.ts-dropdown .spinner:after {
@apply mt-1 mb-0 inline-block size-4 border-2 p-0;
}
.ts-wrapper:not(.form-control):not(.form-select).single .ts-control {
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%23737373' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e");
background-position: right 0.5rem center;
background-repeat: no-repeat;
background-size: 1.5em 1.5em;
print-color-adjust: exact;
}
/* Dark mode arrow for single select */
.dark {
.ts-wrapper:not(.form-control):not(.form-select).single .ts-control {
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%23A1A1AA' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e");
}
}
/* Add dropdown arrow to multiselect elements */
.ts-wrapper:not(.form-control):not(.form-select).multi .ts-control {
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%23737373' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 6l4-4 4 4M6 14l4 4 4-4'/%3e%3c/svg%3e");
background-position: right 0.6rem center;
background-repeat: no-repeat;
background-size: 1.25em 1.25em;
print-color-adjust: exact;
padding-right: 2rem !important;
}
/* Dark mode arrow for multiselect */
.dark {
.ts-wrapper:not(.form-control):not(.form-select).multi .ts-control {
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%23A1A1AA' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 6l4-4 4 4M6 14l4 4 4-4'/%3e%3c/svg%3e");
}
}
.ts-wrapper.multi .ts-control > div {
@apply mr-1 inline-flex items-center justify-center rounded-md bg-neutral-100 px-2 text-xs leading-none font-medium text-neutral-900 dark:bg-neutral-900 dark:text-neutral-100;
}
/* Ensure items don't overlap with the dropdown arrow */
.ts-wrapper.multi.has-items .ts-control {
@apply !pt-[7px] !pr-8 !pb-[4px];
}
.ts-wrapper.plugin-remove_button:not(.rtl) .item {
@apply cursor-grab;
}
.ts-wrapper.plugin-remove_button:not(.rtl) .item .remove {
@apply !-ml-0.5 cursor-pointer border-none;
}
.ts-wrapper.plugin-remove_button .item .remove {
@apply my-0.5 mr-1 !ml-0.5 flex size-[18px] items-center justify-center rounded border-0 text-lg leading-none text-neutral-900/60 hover:text-neutral-900 dark:text-neutral-100/60 dark:hover:bg-neutral-700 dark:hover:text-neutral-100;
}
.ts-dropdown .optgroup-header {
@apply border-t border-neutral-300 bg-white font-semibold text-neutral-900 dark:border-neutral-600 dark:bg-neutral-800 dark:text-neutral-100;
}
.ts-dropdown.plugin-optgroup_columns .optgroup {
height: fit-content;
@apply !mt-0;
}
.optgroup {
@apply mt-1.5 first:mt-0;
}
.dark .ts-dropdown.plugin-optgroup_columns .optgroup {
border-right: 1px solid #525252;
}
.ts-wrapper.multi.has-items .ts-control > input {
@apply !mb-[3px];
}
.tomselect-checkbox {
@apply !mr-0;
}
.input-hidden.focus {
@apply !rounded-lg border border-neutral-300 dark:border-neutral-600;
}
/* Replace the previous attempt with this updated selector */
select[data-select-disable-typing-value="true"] + .ts-wrapper .ts-control,
select[data-select-disable-typing-value="true"] + .ts-wrapper.single .ts-control,
select[data-select-disable-typing-value="true"] + .ts-wrapper.single .ts-control input,
select[data-select-disable-typing-value="true"] + .ts-wrapper.single.input-active .ts-control {
@apply cursor-default;
}
.ts-dropdown-content.is-loading-more .option {
pointer-events: none !important;
}
/* Count display for multi-select */
.ts-count-display {
@apply mr-auto !my-0.5 !bg-transparent !px-0 !text-sm !font-normal;
display: none;
}
/* Hide count display when not active (explicit rule) */
.ts-control:not(.count-active) .ts-count-display {
display: none !important;
}
/* Hide items and input when count is active */
.ts-control.count-active .item {
display: none !important;
visibility: hidden !important;
width: 0 !important;
height: 0 !important;
margin: 0 !important;
padding: 0 !important;
}
/* Keep input technically visible for keyboard navigation but make it invisible */
.ts-control.count-active input {
position: absolute !important;
opacity: 0 !important;
width: 0 !important;
height: 0 !important;
padding: 0 !important;
margin: 0 !important;
}
/* Ensure proper spacing when count is displayed */
.ts-wrapper.multi.has-items .ts-control:has(.ts-count-display) {
@apply !py-[5px];
}
Examples
Basic Select
A simple select with search functionality.
<div class="w-full max-w-xs">
<%= select_tag :framework,
options_for_select([
["Ruby on Rails", "rails"],
["Laravel", "laravel"],
["Django", "django"],
["Express.js", "express"],
["Spring Boot", "spring"],
["ASP.NET Core", "aspnet"],
["Phoenix", "phoenix"],
["FastAPI", "fastapi"]
]),
include_blank: "Select framework...",
class: "w-full [&>*:first-child]:!cursor-pointer",
data: {
controller: "select",
select_disable_dropdown_input_value: true,
select_disable_typing_value: true
} %>
</div>
Select with Icons, Tags, and Descriptions
Enhanced select with icons, side tags, and descriptions.
<div class="w-full max-w-xs">
<%= select_tag :frameworks_with_icons, options_for_select([
[{
icon: "<svg xmlns='http://www.w3.org/2000/svg' class='size-4' width='24' height='24' viewBox='0 0 24 24'><g stroke-linejoin='round' stroke-linecap='round' stroke-width='2' fill='none' stroke='currentColor'><circle cx='12' cy='12' r='4'></circle><path d='M12 2v2'></path><path d='M12 20v2'></path><path d='m4.93 4.93 1.41 1.41'></path><path d='m17.66 17.66 1.41 1.41'></path><path d='M2 12h2'></path><path d='M20 12h2'></path><path d='m6.34 17.66-1.41 1.41'></path><path d='m19.07 4.93-1.41 1.41'></path></g></svg>",
name: "Light",
side: "<span class='ml-auto text-xs border border-neutral-200 bg-white px-1 py-0.5 rounded-md dark:bg-neutral-900/50 dark:border-neutral-700'>Optional</span>",
description: "Light mode is a theme that uses light colors and a light background."
}.to_json, "light"],
[{
icon: "<svg xmlns='http://www.w3.org/2000/svg' class='size-4' width='24' height='24' viewBox='0 0 24 24'><g stroke-linejoin='round' stroke-linecap='round' stroke-width='2' fill='none' stroke='currentColor'><path d='M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z'></path></g></svg>",
name: "Dark",
side: "<span class='ml-auto text-xs border border-neutral-200 bg-white px-1 py-0.5 rounded-md dark:bg-neutral-900/50 dark:border-neutral-700'>Popular</span>",
description: "Dark mode is a theme that uses dark colors and a dark background."
}.to_json, "dark"],
[{
icon: "<svg xmlns='http://www.w3.org/2000/svg' class='size-4' width='24' height='24' viewBox='0 0 24 24'><g stroke-linejoin='round' stroke-linecap='round' stroke-width='2' fill='none' stroke='currentColor'><rect width='20' height='14' x='2' y='3' rx='2'></rect><path d='M8 21L16 21'></path><path d='M12 17L12 21'></path></g></svg>",
name: "System",
side: "<span class='ml-auto text-xs border border-neutral-200 bg-white px-1 py-0.5 rounded-md dark:bg-neutral-900/50 dark:border-neutral-700'>Default</span>",
description: "System mode is a theme that uses the system's default colors and background."
}.to_json, "system"]
]), include_blank: "Select theme...",
class: "w-full [&>*:first-child]:!cursor-pointer",
data: {
controller: "select",
select_disable_typing_value: true,
select_disable_dropdown_input_value: true,
autocomplete: "off"
} %>
</div>
Select with Scroll Arrows
Enhanced select with scroll arrows that appear when the dropdown is too long. Hovering on them will scroll the dropdown.
<div class="w-full max-w-xs">
<%= select_tag :framework,
options_for_select([
["Ruby on Rails", "rails"],
["Laravel", "laravel"],
["Django", "django"],
["Express.js", "express"],
["Spring Boot", "spring"],
["ASP.NET Core", "aspnet"],
["Phoenix", "phoenix"],
["FastAPI", "fastapi"]
]),
include_blank: "Select framework...",
class: "w-full [&>*:first-child]:!cursor-pointer",
data: {
controller: "select",
select_disable_dropdown_input_value: true,
select_disable_typing_value: true,
select_scroll_buttons_value: true
} %>
</div>
Grouped Options
Organize options into groups for better navigation.
<div class="w-full max-w-md space-y-4">
<%= select_tag :cars, grouped_options_for_select({
"European" => [["BMW", "bmw"], ["Mercedes", "mercedes"], ["Audi", "audi"]],
"Japanese" => [["Toyota", "toyota"], ["Honda", "honda"], ["Nissan", "nissan"]],
"American" => [["Ford", "ford"], ["Chevrolet", "chevy"], ["Dodge", "dodge"]]
}),
include_blank: "Select car brand...",
autocomplete: "off",
class: "w-full [&>*:first-child]:!cursor-pointer",
data: {
controller: "select",
select_disable_dropdown_input_value: true,
select_disable_typing_value: true
} %>
<%= select_tag :cars, grouped_options_for_select({
"European" => [["BMW", "bmw"], ["Mercedes", "mercedes"], ["Audi", "audi"], ["Volkswagen", "volkswagen"], ["Seat", "seat"], ["Porsche", "porsche"], ["Alfa Romeo", "alfa-romeo"]],
"Japanese" => [["Toyota", "toyota"], ["Honda", "honda"], ["Nissan", "nissan"], ["Mazda", "mazda"], ["Subaru", "subaru"], ["Lexus", "lexus"], ["Mitsubishi", "mitsubishi"]],
"American" => [["Ford", "ford"], ["Chevrolet", "chevy"], ["Dodge", "dodge"], ["Chrysler", "chrysler"], ["Jeep", "jeep"], ["GMC", "gmc"], ["Tesla", "tesla"]]
}),
include_blank: "Select car brand...",
autocomplete: "off",
class: "w-full [&>*:first-child]:!cursor-pointer",
data: {
controller: "select",
select_disable_dropdown_input_value: true,
select_disable_typing_value: true,
select_optgroup_columns_value: true
} %>
</div>
Disabled States
Examples of disabled selects and disabled options.
This combobox is completely disabled
Some options are disabled and cannot be selected
Disabled options appear in different groups
<div class="space-y-6">
<div class="w-full max-w-md">
<label class="block text-sm font-medium mb-1">Fully Disabled Combobox</label>
<%= select_tag :disabled_select,
options_for_select([
["Option 1", "1"],
["Option 2", "2"],
["Option 3", "3"]
], "2"),
include_blank: "Select option...",
class: "w-full [&>*:first-child]:!cursor-pointer",
disabled: true,
data: {
controller: "select",
select_disable_dropdown_input_value: true,
select_disable_typing_value: true
} %>
<p class="text-xs text-neutral-500 mt-1">This combobox is completely disabled</p>
</div>
<div class="w-full max-w-md">
<label class="block text-sm font-medium mb-1">Combobox with Disabled Options</label>
<%= select_tag :partial_disabled,
options_for_select([
["Available Option 1", "1"],
["Unavailable Option", "2", { disabled: true }],
["Available Option 2", "3"],
["Out of Stock", "4", { disabled: true }],
["Available Option 3", "5"],
["Coming Soon", "6", { disabled: true }]
]),
include_blank: "Select available option...",
class: "w-full [&>*:first-child]:!cursor-pointer",
data: {
controller: "select",
select_disable_dropdown_input_value: true,
select_disable_typing_value: true
} %>
<p class="text-xs text-neutral-500 mt-1">Some options are disabled and cannot be selected</p>
</div>
<div class="w-full max-w-md">
<label class="block text-sm font-medium mb-1">Grouped Options with Disabled Items</label>
<%= select_tag :grouped_disabled,
grouped_options_for_select({
"In Stock" => [
["Product A", "a"],
["Product B", "b"],
["Product C", "c"]
],
"Out of Stock" => [
["Product D", "d", { disabled: true }],
["Product E", "e", { disabled: true }]
],
"Pre-order" => [
["Product F (Available)", "f"],
["Product G (Unavailable)", "g", { disabled: true }],
["Product H (Available)", "h"]
]
}),
include_blank: "Select product...",
class: "w-full [&>*:first-child]:!cursor-pointer",
data: {
controller: "select",
select_disable_dropdown_input_value: true,
select_disable_typing_value: true
} %>
<p class="text-xs text-neutral-500 mt-1">Disabled options appear in different groups</p>
</div>
</div>
Configuration
The select component is powered by TomSelect and a Stimulus controller that provides extensive configuration options.
Configuration Values
Prop | Description | Type | Default |
---|---|---|---|
url
|
URL for loading options asynchronously. When provided, enables remote data loading with pagination |
String
|
null
|
valueField
|
Field to use for the option value when loading from URL |
String
|
"value"
|
labelField
|
Field to use for the option label when loading from URL |
String
|
"label"
|
allowNew
|
Allow users to create new options that don't exist in the list |
Boolean
|
false
|
disableDropdownInput
|
Disable the search input in the dropdown |
Boolean
|
false
|
disableTyping
|
Make the input read-only while still allowing keyboard navigation |
Boolean
|
false
|
submitOnChange
|
Automatically submit the form when a value is selected |
Boolean
|
false
|
updateField
|
Update another field with data from the selected option |
Boolean
|
false
|
updateFieldTarget
|
CSS selector for the field to update when an option is selected |
String
|
null
|
updateFieldSource
|
Property from the selected option to use when updating the target field |
String
|
"name"
|
virtualScroll
|
Enable virtual scrolling for large datasets (requires URL value) |
Boolean
|
false
|
perPage
|
Number of items to load per page when using async loading |
Number
|
60
|
scrollButtons
|
Show scroll buttons at the top and bottom of the dropdown for easier navigation |
Boolean
|
false
|
optgroupColumns
|
Display option groups in columns for better organization |
Boolean
|
false
|
Features
- Search & Filter: Built-in search functionality with customizable search fields
- Multi-select: Support for selecting multiple options with checkboxes and tags
- Custom Rendering: Flexible option and item templates with support for icons and descriptions
- Async Loading: Load options from remote APIs with pagination and infinite scroll
- Keyboard Navigation: Full keyboard support including type-ahead search