import { Controller } from "@hotwired/stimulus"
/**
* **INTERNAL CODE DOCUMENTATION COMPLETE**
*
* Auto Complete Controller
*
* A comprehensive Stimulus controller providing advanced autocomplete functionality with
* AJAX search capabilities, keyboard navigation, and flexible value management. Supports
* remote data loading, local filtering, custom value support, and accessibility features.
*
* Key Features:
* - Remote AJAX data loading with configurable endpoints
* - Local data filtering from pre-loaded datalists
* - Keyboard navigation (arrow keys, enter, escape)
* - Custom value support for "allow other" scenarios
* - Hidden field integration for form submission
* - Debounced search with configurable delay
* - Accessibility support with ARIA attributes
* - Clear button functionality with state management
* - Real-time validation and selection feedback
* - Bootstrap styling integration
*
* @class AutoComplete
* @extends Controller
*
* Targets:
* - input: The visible text input field
* - hidden: Hidden field for storing selected value ID
* - hiddenText: Hidden field for storing selected display text
* - results: Container for autocomplete results dropdown
* - dataList: Pre-loaded data options container
* - clearBtn: Button to clear current selection
*
* Values:
* - ready: Boolean - Controller readiness state
* - submitOnEnter: Boolean - Auto-submit form on Enter key
* - url: String - AJAX endpoint for remote data
* - minLength: Number - Minimum characters before search
* - allowOther: Boolean - Allow custom values not in list
* - required: Boolean - Field is required for form validation
* - initSelection: Object - Initial selection value and text
* - delay: Number - Debounce delay in milliseconds (default: 300)
* - queryParam: String - Query parameter name (default: "q")
*
* Classes:
* - selected: Applied to selected option elements
*
* HTML Structure Example:
* ```html
* <!-- Remote AJAX autocomplete for member search -->
* <div data-controller="auto-complete"
* data-auto-complete-url-value="/members/search"
* data-auto-complete-min-length-value="2"
* data-auto-complete-allow-other-value="false"
* data-auto-complete-delay-value="300">
* <input type="text"
* data-auto-complete-target="input"
* class="form-control"
* placeholder="Start typing member name...">
* <input type="hidden" data-auto-complete-target="hidden" name="member_id">
* <input type="hidden" data-auto-complete-target="hiddenText" name="member_name">
* <button type="button"
* data-auto-complete-target="clearBtn"
* class="btn btn-outline-secondary"
* disabled>Clear</button>
* <div data-auto-complete-target="results" class="autocomplete-results"></div>
* </div>
*
* <!-- Local datalist autocomplete with pre-loaded options -->
* <div data-controller="auto-complete"
* data-auto-complete-allow-other-value="true">
* <input type="text"
* data-auto-complete-target="input"
* class="form-control">
* <div data-auto-complete-target="dataList" style="display: none;">
* <div data-value="1" data-text="Option 1">Option 1</div>
* <div data-value="2" data-text="Option 2">Option 2</div>
* </div>
* </div>
* ```
*/
const optionSelector = "[role='option']:not([aria-disabled='true'])"
const activeSelector = "[aria-selected='true']"
class AutoComplete extends Controller {
static targets = ["input", "hidden", "hiddenText", "results", "dataList", "clearBtn", "status"]
static classes = ["selected"]
static values = {
ready: Boolean,
submitOnEnter: Boolean,
url: String,
minLength: Number,
allowOther: Boolean,
required: Boolean,
showOnFocus: { type: Boolean, default: false },
initSelection: String,
delay: { type: Number, default: 300 },
queryParam: { type: String, default: "q" },
}
static uniqOptionId = 0
static escapeHtml(text) {
const div = document.createElement("div")
div.textContent = text == null ? "" : String(text)
return div.innerHTML
}
static escapeRegExp(text) {
return String(text).replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
}
initialize() {
this._selectOptions = [];
this._datalistLoaded = false;
this._resultsPortal = null;
this._fetchSeq = 0;
}
// Getter for the value property
get value() {
// if there is a hidden value return that
if (this.hasHiddenTarget && this.hiddenTarget.value != "") {
return this.hiddenTarget.value;
} else {
//if we allow other values return the input value
if (this.allowOtherValue) {
return this.inputTarget.value;
}
return "";
}
}
// Setter for the value property
set value(newValue) {
//check if the new value is an object with a "value" property and "text" property
if (typeof newValue === "object" && newValue.hasOwnProperty("value") && newValue.hasOwnProperty("text")) {
this.inputTarget.value = newValue.text;
this.hiddenTarget.value = newValue.value;
this.hiddenTextTarget.value = newValue.text;
this.clearBtnTarget.disabled = false;
this.inputTarget.disabled = !this.allowOtherValue;
return;
}
//if the value matches an option set the input value to the option text
if (newValue != "" && newValue != null) {
let option = this._selectOptions.find(option => option.value == newValue && option.enabled != false);
if (option) {
this.inputTarget.value = option.text;
this.hiddenTextTarget.value = option.text;
this.hiddenTarget.value = option.value;
this.clearBtnTarget.disabled = false;
this.inputTarget.disabled = !this.allowOtherValue;
return;
} else {
if (this.allowOtherValue) {
if (this.hasDataListTarget) {
var newOptions = this.options;
newOptions.push({ value: newValue, text: newValue });
this.options = newOptions;
}
this.inputTarget.value = newValue;
this.hiddenTextTarget.value = newValue;
this.hiddenTarget.value = newValue;
} else {
this.inputTarget.value = "";
this.hiddenTextTarget.value = "";
this.hiddenTarget.value = "";
newValue = "";
}
if (newValue != "") {
this.clearBtnTarget.disabled = false;
this.inputTarget.disabled = !this.allowOtherValue;
} else {
this.clearBtnTarget.disabled = true;
this.inputTarget.disabled = false;
}
return;
}
}
this.inputTarget.value = "";
this.hiddenTarget.value = "";
this.hiddenTextTarget.value = "";
this.clearBtnTarget.disabled = true;
this.inputTarget.disabled = false;
}
get disabled() {
return this.inputTarget.disabled;
}
set disabled(newValue) {
this.hiddenTarget.disabled = newValue;
this.hiddenTextTarget.disabled = newValue;
if (this.inputTarget.value != "") {
this.inputTarget.disabled = newValue || !this.allowOtherValue;
this.clearBtnTarget.disabled = newValue;
} else {
this.clearBtnTarget.disabled = true;
this.inputTarget.disabled = newValue;
}
}
get hidden() {
return this.element.hidden;
}
set hidden(newValue) {
this.element.hidden = newValue;
}
get options() {
return this._selectOptions;
}
set options(newValue) {
this._selectOptions = newValue;
this.makeDataListItems();
}
get selectedOption() {
return this.resultsTarget.querySelector(activeSelector);
}
get renderedOptions() {
return Array.from(this.resultsTarget.querySelectorAll(optionSelector));
}
connect() {
this.close()
if (!this.inputTarget.hasAttribute("autocomplete")) this.inputTarget.setAttribute("autocomplete", "off")
this.inputTarget.setAttribute("spellcheck", "false")
this.ensureAccessibilityAttributes()
this.mouseDown = false
// Guard against double-debounce when the controller reconnects (e.g. Turbo morphing).
// The debounced wrapper exposes a .cancel method; wrap only if not already wrapped.
if (typeof this.onInputChange.cancel !== "function") {
this.onInputChange = debounce(this.onInputChange, this.delayValue)
}
this.inputTarget.addEventListener("keydown", this.onKeydown)
this.inputTarget.addEventListener("blur", this.onInputBlur)
this.inputTarget.addEventListener("input", this.onInputChange)
this.inputTarget.addEventListener("click", this.onInputClick)
this.inputTarget.addEventListener("change", this.onInputChangeTriggered);
this.resultsTarget.addEventListener("mousedown", this.onResultsMouseDown)
this.resultsTarget.addEventListener("click", this.onResultsClick)
this.resultsTarget.addEventListener("touchstart", this.onResultsTouchStart, { passive: true })
this.resultsTarget.addEventListener("touchend", this.onResultsTouchEnd)
this.resultsTarget.addEventListener("touchcancel", this.onResultsTouchEnd)
this.updateFloatingResultsPosition = this.updateFloatingResultsPosition.bind(this)
if (this.inputTarget.hasAttribute("autofocus")) {
this.inputTarget.focus()
}
this.shimElement();
this.readyValue = true
this.element.dispatchEvent(new CustomEvent("ready", { detail: this.element.dataset }));
}
ensureAccessibilityAttributes() {
if (!this.resultsTarget.id) {
this.resultsTarget.id = `ac-results-${AutoComplete.uniqOptionId++}`
}
this.inputTarget.setAttribute("role", "combobox")
this.inputTarget.setAttribute("aria-autocomplete", "list")
this.inputTarget.setAttribute("aria-controls", this.resultsTarget.id)
this.inputTarget.setAttribute("aria-expanded", this.resultsShown ? "true" : "false")
this.resultsTarget.setAttribute("role", "listbox")
}
disconnect() {
if (this.hasResultsTarget) {
this.close()
}
if (this.hasInputTarget) {
this.inputTarget.removeEventListener("keydown", this.onKeydown)
this.inputTarget.removeEventListener("blur", this.onInputBlur)
this.inputTarget.removeEventListener("input", this.onInputChange)
this.inputTarget.removeEventListener("click", this.onInputClick)
this.inputTarget.removeEventListener("change", this.onInputChangeTriggered);
}
if (this.hasResultsTarget) {
this.resultsTarget.removeEventListener("mousedown", this.onResultsMouseDown)
this.resultsTarget.removeEventListener("click", this.onResultsClick)
this.resultsTarget.removeEventListener("touchstart", this.onResultsTouchStart)
this.resultsTarget.removeEventListener("touchend", this.onResultsTouchEnd)
this.resultsTarget.removeEventListener("touchcancel", this.onResultsTouchEnd)
}
}
/**
* @param {string|null|undefined} raw data-ac-init-selection-value JSON
* @return {Record<string, unknown>|null}
*/
parseInitSelection(raw) {
if (raw === null || raw === undefined) {
return null;
}
if (typeof raw === "object" && !Array.isArray(raw)) {
return raw;
}
const trimmed = String(raw).trim();
if (trimmed === "") {
return null;
}
try {
const parsed = JSON.parse(trimmed);
if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
return parsed;
}
} catch (error) {
console.warn("ac: invalid init-selection-value JSON", trimmed, error);
}
return null;
}
initSelectionValueChanged() {
const initSelection = this.parseInitSelection(this.initSelectionValue);
if (this._datalistLoaded) {
if (initSelection === null || !initSelection.hasOwnProperty("value")) {
return;
}
let newOption = initSelection;
if (!newOption.value && (!newOption.text || newOption.text == "")) {
return;
}
if (this.allowOtherValue) {
if (newOption.value == null) {
newOption.value = newOption.text;
}
}
let option = this._selectOptions.find(option => option.value == newOption.value);
if (option) {
this.value = option.value;
this.fireChangeEvent(String(option.value), option.text, option);
} else {
this.addOption(newOption);
this.value = newOption.value;
this.fireChangeEvent(String(newOption.value), newOption.text, newOption);
}
} else if (initSelection !== null) {
if (initSelection.hasOwnProperty("value")) {
this.hiddenTarget.value = initSelection.value;
this.hiddenTextTarget.value = initSelection.text;
this.inputTarget.value = initSelection.text;
if (initSelection.value) {
this.inputTarget.disabled = !this.allowOtherValue;
this.clearBtnTarget.disabled = false;
if (this.hasHiddenTarget) {
this.hiddenTarget.disabled = false;
}
}
this.fireChangeEvent(
String(initSelection.value ?? ""),
String(initSelection.text ?? ""),
null,
);
}
}
}
addOption(option) {
if (option.hasOwnProperty("value") && option.hasOwnProperty("text")) {
this._selectOptions.push(option);
this.makeDataListItems();
}
}
makeDataListItems() {
if (this.hasDataListTarget) {
this.dataListTarget.textContent = "";
var items = JSON.stringify(this._selectOptions);
this.dataListTarget.textContent = items;
}
}
dataListTargetConnected() {
this._selectOptions = JSON.parse(this.dataListTarget.textContent);
this._datalistLoaded = true;
if (this.hasInitSelectionValue) {
this.initSelectionValueChanged();
}
}
shimElement() {
if (this._shimmedElement === this.element) {
return
}
Object.defineProperty(this.element, 'value', {
configurable: true,
get: () => {
return this.value;
},
set: (newValue) => {
this.value = newValue;
}
});
this.element.focus = () => {
this.inputTarget.focus();
}
let proto = Object.getPrototypeOf(this.element);
while (proto && !Object.getOwnPropertyDescriptor(proto, 'hidden')) {
proto = Object.getPrototypeOf(proto);
}
if (proto) {
this.baseHidden = Object.getOwnPropertyDescriptor(proto, 'hidden');
Object.defineProperty(this.element, 'hidden', {
configurable: true,
get: () => {
return this.baseHidden.get.call(this.element);
},
set: (newValue) => {
this.baseHidden.set.call(this.element, newValue);
if (newValue) {
this.hiddenTarget.disabled = true;
this.hiddenTextTarget.disabled = true;
this.inputTarget.disabled = true;
this.close();
} else {
this.hiddenTarget.disabled = false;
this.hiddenTextTarget.disabled = false;
this.inputTarget.disabled = false;
}
}
});
}
Object.defineProperty(this.element, 'disabled', {
configurable: true,
get: () => {
return this.disabled;
},
set: (newValue) => {
this.disabled = newValue;
}
});
Object.defineProperty(this.element, 'options', {
configurable: true,
get: () => {
return this.options;
},
set: (newValue) => {
this.options = newValue;
}
});
this._shimmedElement = this.element
}
sibling(next) {
const options = this.renderedOptions.length > 0 ? this.renderedOptions : this.options
const selected = this.selectedOption
const index = options.indexOf(selected)
const sibling = next ? options[index + 1] : options[index - 1]
const def = next ? options[0] : options[options.length - 1]
return sibling || def
}
resolveSelectionTarget(target) {
if (target instanceof Element) {
return target
}
const targetValue = target?.value != null ? String(target.value) : null
const targetText = target?.text != null ? String(target.text) : null
return this.renderedOptions.find((option) => {
if (targetValue !== null && option.getAttribute("data-ac-value") == targetValue) {
return true
}
return targetText !== null && option.textContent.trim() == targetText
}) || null
}
select(target) {
const option = this.resolveSelectionTarget(target)
if (!option) {
return
}
const previouslySelected = this.selectedOption
if (previouslySelected) {
previouslySelected.removeAttribute("aria-selected")
previouslySelected.classList.remove(...this.selectedClassesOrDefault)
}
option.setAttribute("aria-selected", "true")
option.classList.add(...this.selectedClassesOrDefault)
this.inputTarget.setAttribute("aria-activedescendant", option.id)
option.scrollIntoView({ behavior: "auto", block: "nearest" })
}
cancelPendingInputChange() {
if (typeof this.onInputChange?.cancel === "function") {
this.onInputChange.cancel()
}
}
commitExactTextMatch() {
const newValue = this.inputTarget.value
const option = this._selectOptions.find(option => option.text == newValue && option.enabled != false)
if (!option) {
this.value = ""
return false
}
this.cancelPendingInputChange()
this.value = option.value
this.fireChangeEvent(String(option.value), option.text, this.resolveSelectionTarget(option))
this.hideAndRemoveOptions()
return true
}
onInputChangeTriggered = (event) => {
event.stopPropagation();
this.hiddenTextTarget.value = this.inputTarget.value;
}
onInputClick = (event) => {
this.state = "start";
if (this.hasDataListTarget || (this.hasUrlValue && this.showOnFocusValue)) {
const query = this.inputTarget.value.trim()
this.fetchResults(query);
}
this.hiddenTextTarget.value = this.inputTarget.value;
}
onKeydown = (event) => {
this.hiddenTextTarget.value = this.inputTarget.value;
const handler = this[`on${event.key}Keydown`]
this.hiddenTextTarget.value = this.inputTarget.value;
if (handler) handler(event)
}
onEscapeKeydown = (event) => {
this.hiddenTextTarget.value = this.inputTarget.value;
if (!this.resultsShown) return
this.hideAndRemoveOptions()
event.stopPropagation()
event.preventDefault()
}
onArrowDownKeydown = (event) => {
this.hiddenTextTarget.value = this.inputTarget.value;
const item = this.sibling(true)
if (item) this.select(item)
event.preventDefault()
}
onArrowUpKeydown = (event) => {
this.hiddenTextTarget.value = this.inputTarget.value;
const item = this.sibling(false)
if (item) this.select(item)
event.preventDefault()
}
onTabKeydown = (event) => {
this.hiddenTextTarget.value = this.inputTarget.value;
if (this.allowOtherValue) {
this.fireChangeEvent(this.inputTarget.value, this.inputTarget.value, null);
} else {
if (this.inputTarget.value != "") {
this.commitExactTextMatch();
} else {
this.clear();
}
}
}
onEnterKeydown = (event) => {
this.hiddenTextTarget.value = this.inputTarget.value;
const selected = this.selectedOption
if (selected && this.resultsShown) {
this.commit(selected)
if (!this.hasSubmitOnEnterValue) {
event.preventDefault()
}
}
}
onInputBlur = () => {
this.hiddenTextTarget.value = this.inputTarget.value;
if (this.mouseDown) {
return;
}
// Cancel any pending debounced search so a stale search doesn't fire after blur.
this.cancelPendingInputChange();
this._fetchSeq = (this._fetchSeq ?? 0) + 1;
if (this.state == "open") {
if (this.allowOtherValue) {
this.fireChangeEvent(this.inputTarget.value, this.inputTarget.value, null);
} else {
if (this.inputTarget.value != "") {
this.commitExactTextMatch();
} else {
this.clear();
}
}
}
this.close();
}
commit(selected) {
this.hiddenTextTarget.value = this.inputTarget.value;
if (selected.getAttribute("aria-disabled") === "true") return
this.cancelPendingInputChange()
this._fetchSeq = (this._fetchSeq ?? 0) + 1;
if (selected instanceof HTMLAnchorElement) {
selected.click()
this.close()
return
}
const textValue = selected.getAttribute("data-ac-label") || selected.textContent.trim()
const value = selected.getAttribute("data-ac-value") || textValue
this.inputTarget.value = textValue
if (this.hasHiddenTarget) {
this.hiddenTarget.value = value
this.hiddenTarget.dispatchEvent(new Event("input"))
this.hiddenTarget.dispatchEvent(new Event("change"))
} else {
this.inputTarget.value = value
}
if (this.hasHiddenTextTarget) {
this.hiddenTextTarget.value = textValue;
}
this.inputTarget.focus()
this.state = "finished";
this.fireChangeEvent(value, textValue, selected);
this.hideAndRemoveOptions();
}
fireChangeEvent(value, textValue, selected) {
this.hiddenTextTarget.value = this.inputTarget.value;
this.element.dispatchEvent(
new CustomEvent("autocomplete.change", {
bubbles: true,
detail: { value: value, textValue: textValue, selected: selected }
})
)
if (this.inputTarget.value == "") {
this.clearBtnTarget.disabled = true;
this.inputTarget.disabled = false;
} else {
this.clearBtnTarget.disabled = false;
this.inputTarget.disabled = !this.allowOtherValue;
}
this.element.dispatchEvent(new CustomEvent("change", { bubbles: true }));
this.state = "finished";
}
clear() {
this.cancelPendingInputChange()
this._fetchSeq = (this._fetchSeq ?? 0) + 1;
this.inputTarget.value = "";
if (this.hasHiddenTarget) this.hiddenTarget.value = "";
if (this.hasHiddenTextTarget) this.hiddenTextTarget.value = "";
this.clearBtnTarget.disabled = true;
this.inputTarget.disabled = false;
this.close();
this.fireChangeEvent("", "", null);
}
onResultsClick = (event) => {
this.hiddenTextTarget.value = this.inputTarget.value;
if (!(event.target instanceof Element)) return
const selected = event.target.closest(optionSelector)
if (selected) this.commit(selected)
}
onResultsMouseDown = () => {
this.hiddenTextTarget.value = this.inputTarget.value;
this.mouseDown = true
this.resultsTarget.addEventListener("mouseup", () => {
this.mouseDown = false
}, { once: true })
}
onResultsTouchStart = () => {
this.mouseDown = true
}
onResultsTouchEnd = () => {
this.mouseDown = false
}
onInputChange = () => {
this.hiddenTextTarget.value = this.inputTarget.value;
if (this.hasHiddenTarget) this.hiddenTarget.value = "";
if (this.hasHiddenTextTarget) {
this.hiddenTextTarget.value = this.allowOtherValue ? this.inputTarget.value : "";
}
const query = this.inputTarget.value.trim();
if ((query && query.length >= this.minLengthValue) || this.hasDataListTarget || (this.showOnFocusValue && this.hasUrlValue)) {
this.fetchResults(query);
} else {
this.hideAndRemoveOptions();
}
}
identifyOptions() {
const prefix = this.resultsTarget.id || "stimulus-autocomplete"
const optionsWithoutId = this.resultsTarget.querySelectorAll(`${optionSelector}:not([id])`)
optionsWithoutId.forEach(el => el.id = `${prefix}-option-${AutoComplete.uniqOptionId++}`)
this.resultsTarget.querySelectorAll("[role='option']").forEach(option => {
if (!option.hasAttribute("aria-selected")) {
option.setAttribute("aria-selected", "false")
}
})
}
hideAndRemoveOptions() {
this._fetchSeq = (this._fetchSeq ?? 0) + 1;
this.close()
this.resultsTarget.innerHTML = null
this.updateStatus("")
}
fetchResults = async (query) => {
if (!this.hasUrlValue) {
if (!this.hasDataListTarget) {
throw new Error("You must provide a URL or a DataList target")
} else {
this.resultsTarget.innerHTML = null;
let allItems = this._selectOptions;
for (let item of allItems) {
if (item.text.toLowerCase().includes(query.toLowerCase()) && (item.enabled != false || query == "")) {
let itemHtml = document.createElement("li");
itemHtml.setAttribute("data-ac-value", item.value);
itemHtml.classList.add("list-group-item");
if (item.enabled == false) {
itemHtml.setAttribute("aria-disabled", "true");
itemHtml.classList.add("disabled");
} else {
itemHtml.setAttribute("aria-disabled", "false");
}
itemHtml.setAttribute("role", "option")
itemHtml.setAttribute("aria-selected", "false")
//add a span around matching string to highlight it
if (query != "") {
const escapedText = AutoComplete.escapeHtml(item.text);
const escapedQuery = AutoComplete.escapeRegExp(AutoComplete.escapeHtml(query));
itemHtml.innerHTML = escapedText.replace(
new RegExp(escapedQuery, 'gi'),
match => `<span class="text-primary">${match}</span>`
);
} else {
itemHtml.textContent = item.text;
}
this.resultsTarget.appendChild(itemHtml);
}
}
// Datalist filtering is synchronous – always show results regardless of state
// so that users can search again after a prior selection or blur.
this.identifyOptions();
this.open();
this.updateStatusForResults()
this.state = "open";
return
}
}
// Stamp this fetch so stale responses can be detected and discarded.
this._fetchSeq = (this._fetchSeq ?? 0) + 1;
const fetchId = this._fetchSeq;
// Mark as fetching so async results aren't swallowed after connect/close
if (this.state === "finished") {
this.state = "fetching";
}
const url = this.buildURL(query)
try {
this.element.dispatchEvent(new CustomEvent("loadstart"))
const html = await this.doFetch(url);
// Discard results if a newer fetch has already started or the user
// committed / cleared while this fetch was in flight.
if (fetchId !== this._fetchSeq) {
this.element.dispatchEvent(new CustomEvent("load"))
this.element.dispatchEvent(new CustomEvent("loadend"))
return;
}
if (this.state != "finished") {
this.replaceResults(html)
this.updateStatusForResults()
this.state = "open";
}
this.element.dispatchEvent(new CustomEvent("load"))
this.element.dispatchEvent(new CustomEvent("loadend"))
} catch (error) {
this.element.dispatchEvent(new CustomEvent("error"))
this.element.dispatchEvent(new CustomEvent("loadend"))
// Reset fetching state so the next input attempt can proceed cleanly.
if (this.state === "fetching") {
this.state = "start";
}
throw error
}
}
buildURL(query) {
const url = new URL(this.urlValue, window.location.href)
const params = new URLSearchParams(url.search.slice(1))
params.append(this.queryParamValue, query)
url.search = params.toString()
return url.toString()
}
doFetch = async (url) => {
const response = await fetch(url, this.optionsForFetch())
if (!response.ok) {
throw new Error(`Server responded with status ${response.status}`)
}
const html = await response.text()
return html
}
replaceResults(html) {
this.hiddenTextTarget.value = this.inputTarget.value;
this.resultsTarget.innerHTML = html
this.identifyOptions()
if (!!this.options) {
this.state = "results";
this.open()
} else {
this.state = "empty list";
this.close()
}
}
open() {
if (this.resultsShown) return
this.enableFloatingResults()
this.resultsShown = true
this.updateFloatingResultsPosition()
this.element.setAttribute("aria-expanded", "true")
this.inputTarget.setAttribute("aria-expanded", "true")
this.hiddenTextTarget.value = this.inputTarget.value;
this.element.dispatchEvent(
new CustomEvent("toggle", {
detail: { action: "open", inputTarget: this.inputTarget, resultsTarget: this.resultsTarget }
})
)
}
close() {
if (!this.resultsShown) {
this.disableFloatingResults()
return
}
this.state = "finished";
this.resultsShown = false
this.inputTarget.removeAttribute("aria-activedescendant")
this.element.setAttribute("aria-expanded", "false")
this.inputTarget.setAttribute("aria-expanded", "false")
this.hiddenTextTarget.value = this.inputTarget.value;
this.disableFloatingResults()
this.element.dispatchEvent(
new CustomEvent("toggle", {
detail: { action: "close", inputTarget: this.inputTarget, resultsTarget: this.resultsTarget }
})
)
}
get resultsShown() {
return !this.resultsTarget.hidden
}
set resultsShown(value) {
this.resultsTarget.hidden = !value
}
enableFloatingResults() {
if (!this.element.closest(".modal") || this._resultsPortal) {
return
}
this._resultsPortal = {
cssText: this.resultsTarget.style.cssText,
}
this.resultsTarget.classList.add("kmp-auto-complete-floating-list")
window.addEventListener("resize", this.updateFloatingResultsPosition)
window.addEventListener("scroll", this.updateFloatingResultsPosition, true)
}
disableFloatingResults() {
if (!this._resultsPortal) {
return
}
window.removeEventListener("resize", this.updateFloatingResultsPosition)
window.removeEventListener("scroll", this.updateFloatingResultsPosition, true)
const { cssText } = this._resultsPortal
this.resultsTarget.classList.remove("kmp-auto-complete-floating-list")
this.resultsTarget.style.cssText = cssText
this._resultsPortal = null
}
updateFloatingResultsPosition() {
if (!this._resultsPortal) {
return
}
const rect = this.inputTarget.getBoundingClientRect()
const containerRect = this.element.getBoundingClientRect()
const left = containerRect.width > 0 ? containerRect.left : rect.left
const width = containerRect.width > 0 ? containerRect.width : rect.width
const viewportHeight = window.innerHeight || document.documentElement.clientHeight
const margin = 8
const spaceBelow = viewportHeight - rect.bottom - margin
const spaceAbove = rect.top - margin
const openAbove = spaceBelow < 160 && spaceAbove > spaceBelow
const maxHeight = Math.max(openAbove ? spaceAbove : spaceBelow, 120)
this.resultsTarget.style.setProperty("position", "fixed", "important")
this.resultsTarget.style.left = `${left}px`
this.resultsTarget.style.width = `${width}px`
this.resultsTarget.style.maxHeight = `${maxHeight}px`
this.resultsTarget.style.overflowY = "auto"
this.resultsTarget.style.setProperty("z-index", "1070", "important")
this.resultsTarget.style.top = openAbove ? "auto" : `${rect.bottom}px`
this.resultsTarget.style.bottom = openAbove ? `${viewportHeight - rect.top}px` : "auto"
}
get selectedClassesOrDefault() {
return this.hasSelectedClass ? this.selectedClasses : ["active"]
}
optionsForFetch() {
return {
headers: {
"X-Requested-With": "XMLHttpRequest",
"Accept": "application/json"
}
}
}
updateStatusForResults() {
const count = this.renderedOptions.length
if (count === 0) {
this.updateStatus("No results available.")
} else if (count === 1) {
this.updateStatus("1 result available. Use arrow keys to review options.")
} else {
this.updateStatus(`${count} results available. Use arrow keys to review options.`)
}
}
updateStatus(message) {
if (this.hasStatusTarget) {
this.statusTarget.textContent = message
}
}
}
const debounce = (fn, delay = 10) => {
let timeoutId = null
const debounced = (...args) => {
clearTimeout(timeoutId)
timeoutId = setTimeout(() => {
timeoutId = null
fn(...args)
}, delay)
}
debounced.cancel = () => {
clearTimeout(timeoutId)
timeoutId = null
}
return debounced
}
if (!window.Controllers) {
window.Controllers = {}
}
window.Controllers["ac"] = AutoComplete;