PageSourceSearch

https://railsblocks.com/assets/controllers/select_controller-74d1f…e592c0cec58d74fdca287b0d8c9c6a5bdfb70410.js

js railsblocks.com collected 2026-09-25 20:24:06 UTC 79,582 bytes, 2,254 lines download raw bytes

1import { Controller } from "@hotwired/stimulus";
2import TomSelect from "tom-select";
3import { computePosition, flip, shift, offset } from "@floating-ui/dom";
4
5export default class extends Controller {
6  static values = {
7    url: String, // URL to fetch options from
8    valueField: { type: String, default: "value" }, // Field to use for value
9    labelField: { type: String, default: "label" }, // Field to use for label
10    submitOnChange: { type: Boolean, default: false }, // Submit form on change
11    dropdownInput: { type: Boolean, default: true }, // Enable dropdown input plugin
12    dropdownInputPlaceholder: { type: String, default: "Search..." }, // Custom placeholder for dropdown input (if "", it will use the default placeholder)
13    clearButton: { type: Boolean, default: true }, // Show clear button when typing or option selected (onle for single select, this is never shown for multiple select)
14    openOnMouseDown: { type: Boolean, default: true }, // Open dropdown on mousedown for a more immediate interaction
15    lockScroll: { type: Boolean, default: false }, // Lock page scrolling while dropdown is open
16    disableTyping: { type: Boolean, default: false }, // Disable typing
17    allowNew: { type: Boolean, default: false }, // Allow new options
18    scrollButtons: { type: Boolean, default: false }, // Show scroll buttons
19    updateField: { type: Boolean, default: false }, // Update field with selected value
20    updateFieldTarget: String, // Target to update
21    updateFieldSource: { type: String, default: "name" }, // Source to update
22    perPage: { type: Number, default: 60 }, // Number of options per page
23    virtualScroll: { type: Boolean, default: false }, // Use virtual scroll
24    optgroupColumns: { type: Boolean, default: false }, // Use optgroup columns
25    responseDataField: { type: String, default: "data" }, // Field in response containing array of items (auto-detects common patterns)
26    searchParam: { type: String, default: "query" }, // Search parameter name for the API
27    // New flexible rendering options
28    imageField: String, // Field containing image URL
29    subtitleField: String, // Field to show as subtitle
30    metaFields: String, // Comma-separated fields to show as metadata (e.g., "status,species")
31    badgeField: String, // Field to show as a badge/tag
32    renderTemplate: String, // Custom template for option rendering
33    // New count display options
34    showCount: { type: Boolean, default: false }, // Show count instead of individual items
35    countText: { type: String, default: "selected" }, // Text to show after count
36    countTextSingular: { type: String, default: "" }, // Text to show when only one item is selected (optional)
37    // Tags position options
38    tagsPosition: { type: String, default: "inline" }, // Position of tags: "inline" (inside input), "above", "below", or a custom container ID
39    // Flag toggle options
40    enableFlagToggle: { type: Boolean, default: false }, // Enable flag toggle buttons on tags
41    // Internationalization options
42    noMoreResultsText: { type: String, default: "No more results" }, // Text to show when there are no more results
43    noSearchResultsText: { type: String, default: "No results found for" }, // Text to show when no results match the search (will be followed by the search term)
44    loadingText: { type: String, default: "Loading..." }, // Text to show when loading more results
45    createText: { type: String, default: "Add" }, // Text to show for the create option
46  };
47
48  connect() {
49    if (this.element.tomselect) return;
50
51    const options = this.#buildOptions();
52    this.select = new TomSelect(this.element, options);
53
54    this.#setupEventHandlers();
55    this.#setupPositioning();
56    this.#handleInitialValue();
57    this.#preventNativeFocus();
58    this.#setupMouseDownOpen();
59    this.#setupTouchPrelock();
60
61    this.element.style.visibility = "visible";
62  }
63
64  disconnect() {
65    this.#cleanup();
66  }
67
68  // Private methods
69
70  #buildOptions() {
71    const plugins = this.#getPlugins();
72    const baseOptions = {
73      plugins,
74      maxOptions: null,
75      openOnFocus: !this.#shouldSearchBeforeOpen(),
76      closeAfterSelect: !this.element.multiple,
77      create: this.allowNewValue,
78      render: this.#getRenderConfig(),
79      onChange: this.#handleChange.bind(this),
80      onDropdownOpen: () => this.#updatePosition(),
81    };
82
83    if (!this.hasUrlValue) return baseOptions;
84
85    return {
86      ...baseOptions,
87      preload: true,
88      ...(this.virtualScrollValue ? this.#getVirtualScrollConfig() : this.#getCustomScrollConfig()),
89    };
90  }
91
92  #getPlugins() {
93    const plugins = [];
94    const isMultiple = this.element.multiple;
95    const useVirtualScroll = this.virtualScrollValue && this.hasUrlValue;
96
97    if (useVirtualScroll) {
98      plugins.push("virtual_scroll");
99      if (isMultiple) plugins.push("remove_button", "checkbox_options", "no_active_items");
100    } else if (isMultiple) {
101      plugins.push("remove_button", "checkbox_options", "no_active_items");
102    }
103
104    if (this.optgroupColumnsValue) plugins.push("optgroup_columns");
105    if (this.dropdownInputValue) plugins.push("dropdown_input");
106
107    return plugins;
108  }
109
110  #getRenderConfig() {
111    const renderOption = (data, escape) => {
112      if (this.renderTemplateValue) return this.#renderWithTemplate(this.renderTemplateValue, data, escape);
113      if (this.hasUrlValue && this.#hasCustomFields()) return this.#renderApiOption(data, escape);
114      return this.#renderStandardOption(data, escape);
115    };
116
117    const renderItem = (data, escape) => {
118      if (this.hasUrlValue && this.imageFieldValue && data[this.imageFieldValue]) {
119        return this.#renderImageItem(data, escape);
120      }
121      return this.#renderStandardItem(data, escape);
122    };
123
124    return {
125      option: renderOption,
126      item: renderItem,
127      option_create: (data, escape) =>
128        `<div class="create">${escape(this.createTextValue)} <strong>${escape(data.input)}</strong>&hellip;</div>`,
129      loading_more: () => this.#renderLoadingMore(),
130      no_more_results: () =>
131        `<div class="no-more-results py-2 text-center text-sm text-neutral-500 dark:text-neutral-400">${this.noMoreResultsTextValue}</div>`,
132      no_results: (data, escape) =>
133        `<div class="no-results">${escape(this.noSearchResultsTextValue)} "${escape(data.input)}"</div>`,
134    };
135  }
136
137  #getVirtualScrollConfig() {
138    return {
139      valueField: this.valueFieldValue,
140      labelField: this.labelFieldValue,
141      searchField: this.labelFieldValue,
142      firstUrl: (query) => this.#buildApiUrl(this.urlValue, query, 1),
143      load: this.#virtualScrollLoad.bind(this),
144      shouldLoadMore: this.#shouldLoadMore.bind(this),
145    };
146  }
147
148  #getCustomScrollConfig() {
149    return {
150      valueField: this.valueFieldValue,
151      labelField: this.labelFieldValue,
152      searchField: this.labelFieldValue,
153      load: this.#customScrollLoad.bind(this),
154    };
155  }
156
157  async #virtualScrollLoad(query, callback) {
158    // Early return if select is destroyed
159    if (!this.select) {
160      callback();
161      return;
162    }
163
164    const url = this.select.getUrl(query);
165    const scrollState = this.#captureScrollState(url);
166
167    try {
168      const response = await fetch(url);
169      if (!response.ok) throw new Error(response.statusText);
170
171      const rawJson = await response.json();
172      const json = this.#transformApiResponse(rawJson);
173
174      // Check if select still exists before updating state
175      if (this.select) {
176        this.#updateVirtualScrollState(url, query, json);
177        callback(json.data);
178
179        // For pages after the first, just maintain scroll position
180        if (scrollState.currentPage > 1) {
181          requestAnimationFrame(() => {
182            if (this.select?.dropdown_content && typeof scrollState.scrollTop === "number") {
183              this.select.dropdown_content.scrollTop = scrollState.scrollTop;
184            }
185          });
186        } else {
187          requestAnimationFrame(() => {
188            this.#restoreScrollState(scrollState);
189            this.#handlePostLoadFocus(query, scrollState);
190          });
191        }
192      } else {
193        callback();
194      }
195    } catch (error) {
196      console.error("Virtual scroll load error:", error);
197      this.select?.setNextUrl(query, null);
198      callback();
199    } finally {
200      this.#cleanupScrollState();
201    }
202  }
203
204  async #customScrollLoad(query, callback) {
205    this.#resetPagination();
206
207    try {
208      const response = await this.#fetchPage(query, 1);
209      const json = this.#transformApiResponse(response);
210
211      callback(json.data);
212      this.hasMore = json.has_more;
213
214      if (this.select?.dropdown_content) {
215        this.#setupInfiniteScroll();
216        setTimeout(() => this.#focusFirstOption(query), 10);
217      }
218    } catch (error) {
219      console.error("Custom scroll load error:", error);
220      callback();
221      this.hasMore = false;
222    }
223  }
224
225  #setupEventHandlers() {
226    // Override setActiveOption for single active state and custom scrolling
227    const original = this.select.setActiveOption.bind(this.select);
228    this.select.setActiveOption = (option, scroll) => {
229      this.#clearAllActiveStates();
230
231      // Prevent TomSelect's default scrolling, use our custom scroll instead
232      const result = original(option, false);
233
234      // Apply our custom scroll behavior with padding
235      if (option && scroll !== false) {
236        this.#scrollToOption(option);
237      }
238
239      return result;
240    };
241
242    // Clear options if URL-based
243    if (this.hasUrlValue) this.select.clearOptions();
244
245    // Dropdown open handler
246    this.select.on("dropdown_open", () => this.#handleDropdownOpen());
247    this.select.on("dropdown_close", () => this.#handleDropdownClose());
248
249    // Setup additional features
250    if (this.scrollButtonsValue && this.select.dropdown_content) this.#addScrollButtons();
251    if (this.element.multiple) this.#setupScrollTracking();
252    if (this.disableTypingValue) this.#setupReadonlyInput();
253
254    // Setup count display for multi-select
255    if (this.element.multiple && this.showCountValue) {
256      this.#setupCountDisplay();
257    }
258
259    if (this.#shouldSearchBeforeOpen()) {
260      this.#setupSearchFirstOpenBehavior();
261    }
262
263    // Setup external tags display for multi-select
264    if (this.element.multiple && this.tagsPositionValue !== "inline") {
265      this.#setupExternalTags();
266    }
267
268    // Setup custom dropdown input placeholder
269    this.#setupDropdownInputPlaceholder();
270
271    // Setup clear button visibility (delay to ensure TomSelect is fully initialized)
272    if (this.clearButtonValue) {
273      setTimeout(() => this.#setupClearButton(), 50);
274    }
275
276    // Add data-flag attribute to items when they're added
277    this.select.on("item_add", (value) => {
278      this.#markFlaggedItem(value);
279      if (this.enableFlagToggleValue) {
280        this.#addFlagButtonToItem(value);
281      }
282    });
283
284    // Mark pre-selected items as flagged if needed
285    this.#markExistingFlaggedItems();
286
287    // Add flag buttons to existing items if flag toggle is enabled
288    if (this.enableFlagToggleValue) {
289      setTimeout(() => this.#addFlagButtonsToExistingItems(), 50);
290    }
291
292    // Setup form submission handler to include flag data
293    if (this.enableFlagToggleValue) {
294      this.#setupFormSubmissionHandler();
295    }
296  }
297
298  #setupFormSubmissionHandler() {
299    const form = this.element.closest("form");
300    if (!form) return;
301
302    // Store reference to the handler so we can remove it later
303    this.formSubmitHandler = (e) => {
304      this.#addFlagDataToForm(form);
305    };
306
307    form.addEventListener("submit", this.formSubmitHandler);
308  }
309
310  #addFlagDataToForm(form) {
311    // Remove any existing flag inputs for this select
312    const existingInputs = form.querySelectorAll(`input[name="${this.element.name}_flags[]"]`);
313    existingInputs.forEach((input) => input.remove());
314
315    // Get all selected values
316    const selectedValues = this.select.getValue();
317    const values = Array.isArray(selectedValues) ? selectedValues : [selectedValues].filter(Boolean);
318
319    // Add hidden inputs for flagged items
320    values.forEach((value) => {
321      const option = this.select.options[value];
322      if (option?.$option?.dataset?.flag === "true") {
323        const hiddenInput = document.createElement("input");
324        hiddenInput.type = "hidden";
325        hiddenInput.name = `${this.element.name}_flags[]`;
326        hiddenInput.value = value;
327        form.appendChild(hiddenInput);
328      }
329    });
330  }
331
332  #markFlaggedItem(value) {
333    const option = this.select.options[value];
334    if (option?.$option?.dataset?.flag === "true") {
335      // Find the item element and add the data-flag attribute
336      const itemElement = this.select.control.querySelector(`[data-value="${value}"]`);
337      if (itemElement) {
338        itemElement.setAttribute("data-flag", "true");
339      }
340    }
341  }
342
343  #markExistingFlaggedItems() {
344    // Check all currently selected items and mark them as flagged if needed
345    const selectedValues = this.select.getValue();
346    const values = Array.isArray(selectedValues) ? selectedValues : [selectedValues].filter(
346Boolean);
347
348    values.forEach((value) => {
349      // Small delay to ensure TomSelect has rendered the items
350      setTimeout(() => this.#markFlaggedItem(value), 0);
351    });
352  }
353
354  #addFlagButtonsToExistingItems() {
355    const selectedValues = this.select.getValue();
356    const values = Array.isArray(selectedValues) ? selectedValues : [selectedValues].filter(Boolean);
357
358    values.forEach((value) => {
359      this.#addFlagButtonToItem(value);
360    });
361  }
362
363  #addFlagButtonToItem(value) {
364    if (!this.element.multiple) return; // Only for multi-select
365
366    const itemElement = this.select.control.querySelector(`[data-value="${value}"]`);
367    if (!itemElement) return;
368
369    // Check if flag button already exists
370    if (itemElement.querySelector(".flag-toggle")) return;
371
372    // Check if item is currently flagged
373    const isFlagged = itemElement.dataset.flag === "true";
374
375    // Create flag toggle button
376    const flagButton = document.createElement("button");
377    flagButton.type = "button";
378    flagButton.className =
379      "flag-toggle flex size-[18px] items-center justify-center rounded hover:bg-neutral-200 dark:hover:bg-neutral-700 text-neutral-600 dar
379k:text-neutral-400 -mr-0.5";
380    flagButton.innerHTML = isFlagged ? this.#getFlaggedIcon() : this.#getUnflaggedIcon();
381
382    // Add click handler
383    flagButton.addEventListener("click", (e) => {
384      e.preventDefault();
385      e.stopPropagation();
386      this.#toggleItemFlag(value, itemElement, flagButton);
387    });
388
389    // Insert before the remove button
390    const removeButton = itemElement.querySelector(".remove");
391    if (removeButton) {
392      removeButton.parentNode.insertBefore(flagButton, removeButton);
393    }
394  }
395
396  #getUnflaggedIcon() {
397    return `
398      <svg xmlns="http://www.w3.org/2000/svg" class="size-3" width="12" height="12" viewBox="0 0 12 12"><g fill="currentColor"><rect x=".751" y="5.25" width="10.499" height="1.5" transform="translate(-2.485 6) rotate(-45)" stroke-width="0"></rect><path d="m6,12c-3.309,0-6-2.691-6-6S2.691,0,6,0s6,2.691,6,6-2.691,6-6,6Zm0-10.5C3.519,1.5,1.5,3.519,1.5,6s2.019,4.5,4.5,4.5,4.5-2.019,4.5-4.5S8.481,1.5,6,1.5Z" stroke-width="0"></path></g></svg>
399    `;
400  }
401
402  #getFlaggedIcon() {
403    return `
404      <svg xmlns="http://www.w3.org/2000/svg" class="size-3" width="12" height="12" viewBox="0 0 12 12"><g fill="currentColor"><rect x=".751" y="5.25" width="10.499" height="1.5" transform="translate(-2.485 6) rotate(-45)" stroke-width="0"></rect><path d="m6,12c-3.309,0-6-2.691-6-6S2.691,0,6,0s6,2.691,6,6-2.691,6-6,6Zm0-10.5C3.519,1.5,1.5,3.519,1.5,6s2.019,4.5,4.5,4.5,4.5-2.019,4.5-4.5S8.481,1.5,6,1.5Z" stroke-width="0"></path></g></svg>
405    `;
406  }
407
408  #updateDropdownOptionStyling(value, isFlagged) {
409    // Find the dropdown option element
410    if (!this.select?.dropdown_content) return;
411
412    const dropdownOption = this.select.dropdown_content.querySelector(`[data-value="${value}"]`);
413    if (!dropdownOption) return;
414
415    // Find the span element that contains the text (not the checkbox)
416    const textSpan = dropdownOption.querySelector("span:not(.tomselect-checkbox)");
417    if (!textSpan) return;
418
419    // Update the text color classes
420    if (isFlagged) {
421      // Add red text classes
422      textSpan.classList.add("text-red-600", "dark:text-red-400");
423    } else {
424      // Remove red text classes
425      textSpan.classList.remove("text-red-600", "dark:text-red-400");
426    }
427  }
428
429  #toggleItemFlag(value, itemElement, flagButton) {
430    const option = this.select.options[value];
431    const currentFlag = itemElement.dataset.flag === "true";
432    const newFlag = !currentFlag;
433
434    // Update item element
435    if (newFlag) {
436      itemElement.setAttribute("data-flag", "true");
437      itemElement.classList.add("!bg-red-100", "!text-red-900");
438      itemElement.classList.add("dark:!bg-[#281212]", "dark:!text-red-200");
439    } else {
440      itemElement.removeAttribute("data-flag");
441      itemElement.classList.remove("!bg-red-100", "!text-red-900");
442      itemElement.classList.remove("dark:!bg-[#281212]", "dark:!text-red-200");
443    }
444
445    // Update flag button icon
446    if (flagButton) {
447      flagButton.innerHTML = newFlag ? this.#getFlaggedIcon() : this.#getUnflaggedIcon();
448    }
449
450    // Update original option element's data-flag attribute
451    // This persists the flag state back to the <option> element in the DOM
452    if (option?.$option) {
453      if (newFlag) {
454        option.$option.setAttribute("data-flag", "true");
455        option.$option.dataset.flag = "true";
456      } else {
457        option.$option.removeAttribute("data-flag");
458        delete option.$option.dataset.flag;
459      }
460    }
461
462    // Update dropdown option styling if dropdown is open
463    this.#updateDropdownOptionStyling(value, newFlag);
464
465    // Update external tags if they exist
466    if (this.externalTagsContainer) {
467      this.#updateExternalTags();
468    }
469
470    // Dispatch custom event for external listeners
471    this.element.dispatchEvent(
472      new CustomEvent("flag-toggled", {
473        detail: { value, flagged: newFlag },
474        bubbles: true,
475      }),
476    );
477  }
478
479  #toggleExternalTagFlag(value, tagElement) {
480    const option = this.select.options[value];
481    const currentFlag = tagElement.dataset.flag === "true";
482    const newFlag = !currentFlag;
483
484    // Update original option element's data-flag attribute
485    // This persists the flag state back to the <option> element in the DOM
486    if (option?.$option) {
487      if (newFlag) {
488        option.$option.setAttribute("data-flag", "true");
489        option.$option.dataset.flag = "true";
490      } else {
491        option.$option.removeAttribute("data-flag");
492        delete option.$option.dataset.flag;
493      }
494    }
495
496    // Update dropdown option styling if dropdown is open
497    this.#updateDropdownOptionStyling(value, newFlag);
498
499    // Find the corresponding internal item and update it
500    const itemElement = this.select.control.querySelector(`[data-value="${value}"]`);
501    if (itemElement) {
502      if (newFlag) {
503        itemElement.setAttribute("data-flag", "true");
504        itemElement.classList.add("!bg-red-100", "!text-red-900");
505        itemElement.classList.add("dark:!bg-[#281212]", "dark:!text-red-200");
506      } else {
507        itemElement.removeAttribute("data-flag");
508        itemElement.classList.remove("!bg-red-100", "!text-red-900");
509        itemElement.classList.remove("dark:!bg-[#281212]", "dark:!text-red-200");
510      }
511    }
512
513    // Re-render external tags to reflect the change
514    this.#updateExternalTags();
515
516    // Dispatch custom event for external listeners
517    this.element.dispatchEvent(
518      new CustomEvent("flag-toggled", {
519        detail: { value, flagged: newFlag },
520        bubbles: true,
521      }),
522    );
523  }
524
525  #setupPositioning() {
526    this.scrollHandler = () => this.#updatePosition();
527    window.addEventListener("scroll", this.scrollHandler, true);
528
529    this.resizeObserver = new ResizeObserver(() => this.#updatePosition());
530    this.resizeObserver.observe(document.documentElement);
531
532    this.mutationObserver = new MutationObserver(() => {
533      if (this.select?.dropdown?.classList.contains("ts-dropdown")) {
534        this.#updatePosition();
535      }
536    });
537    this.mutationObserver.observe(document.body, { childList: true, subtree: true });
538  }
539
540  #handleDropdownOpen() {
541    // Only clear active states if no item was just selected
542    if (!this.justSelectedItem) {
543      this.#clearAllActiveStates();
544      this.select.setActiveOption(null);
545    }
546    this.justSelectedItem = false; // Reset the flag
547
548    // Update position multiple times to ensure proper placement
549    [0, 10, 50, 100].forEach((delay) => {
550      setTimeout(() => this.#updatePosition(), delay);
551    });
552
553    if (this.hasUrlValue && !this.virtualScrollValue) {
554      this.#setupInfiniteScroll();
555      this.#resetPagination();
556    }
557
558    if (this.lockScrollValue) this.#lockPageScroll();
559    this.prelockedFromTouch = false;
560  }
561
562  #handleDropdownClose() {
563    this.#stopPointerSelection();
564    this.#clearSuppressedControlClick();
565    if (this.lockScrollValue) this.#unlockPageScroll();
566  }
567
568  #setupInfiniteScroll() {
569    const content = this.select.dropdown_content;
570    if (!content) return;
571
572    const handler = this.#handleScroll.bind(this);
573    content.removeEventListener("scroll", handler);
574    content.addEventListener("scroll", handler);
575  }
576
577  #handleScroll() {
578    if (this.virtualScrollValue || !this.select?.dropdown_content) return;
579
580    const { scrollTop, scrollHeight, clientHeight } = this.select.dropdown_content;
581    if (scrollTop + clientHeight >= scrollHeight - 50) {
582      const query = this.select.control_input?.value || "";
583      this.#loadMore(query);
584    }
585  }
586
587  async #loadMore(query) {
588    if (this.virtualScrollValue || this.loadingMore || !this.hasMore) return;
589
590    this.loadingMore = true;
591    this.currentPage += 1;
592
593    const lastActiveValue = this.#getActiveValue();
594
595    try {
596      const response = await this.#fetchPage(query, this.currentPage);
597      const newOptions = this.#transformApiResponse(response);
598
599      if (newOptions?.data?.length > 0) {
600        this.select.addOptions(newOptions.data);
601        this.hasMore = newOptions.has_more;
602
603        setTimeout(() => this.#restoreSelectionAfterLoading(lastActiveValue), 300);
604      } else {
605        this.hasMore = false;
606      }
607
608      this.select.control_input?.focus();
609    } catch (error) {
610      console.error("Load more error:", error);
611      this.hasMore = false;
612    } finally {
613      this.loadingMore = false;
614      this.#updatePosition();
615    }
616  }
617
618  #setupScrollTracking() {
619    const content = this.select.dropdown_content;
620    if (!content) return;
621
622    content.addEventListener("scroll", () => {
623      this.lastScrollPosition = content.scrollTop;
624    });
625
626    ["item_add", "item_remove"].forEach((event) => {
627      this.select.on(event, () => {
628        if (this.lastScrollPosition) {
629          setTimeout(() => {
630            content.scrollTop = this.lastScrollPosition;
631          }, 0);
632        }
633      });
634    });
635  }
636
637  #setupReadonlyInput() {
638    // Only apply readonly to the main control input, not the dropdown input
639    const mainInput = this.select.control.querySelector("input:not(.dropdown-input)");
640    if (!mainInput) return;
641
642    mainInput.readOnly = true;
643    mainInput.setAttribute("readonly", "readonly");
644
645    let buffer = "";
646    let timeout;
647
648    mainInput.addEventListener("keydown", (e) => {
649      if (!this.#isNavigationKey(e.key)) return;
650
651      if (e.key.length === 1) {
652        document.body.requestPointerLock();
653        this.#handleTypeAhead(e.key, buffer, timeout);
654      }
655    });
656
657    document.addEventListener("mousemove", () => {
658      if (document.pointerLockElement) document.exitPointerLock();
659    });
660  }
661
662  #setupCountDisplay() {
663    // Create count element
664    this.countElement = document.createElement("div");
665    this.countElement.className = "ts-count-display";
666
667    // Insert count element into the control
668    this.select.control.appendChild(this.countElement);
669
670    // Update count on initial load
671    this.#updateCountDisplay();
672
673    // Listen for changes and prevent dropdown from closing
674    this.select.on("item_add", () => {
675      this.#updateCountDisplay();
676      // Force dropdown to stay open after selection
677      setTimeout(() => {
678        if (!this.select.isOpen) {
679          this.select.open();
680        }
681      }, 0);
682    });
683
684    this.select.on("item_remove", () => this.#updateCountDisplay());
685  }
686
687  #setupDropdownInputPlaceholder() {
688    // Set the dropdown input placeholder after TomSelect is initialized
689    const setPlaceholder = () => {
690      const dropdownInput = this.select.dropdown?.querySelector(".dropdown-input");
691      if (dropdownInput && this.dropdownInputPlaceholderValue) {
692        dropdownInput.placeholder = this.dropdownInputPlaceholderValue;
693      }
694    };
695
696    // Set immediately if dropdown already exists
697    setPlaceholder();
698
699    // Also set when dropdown opens (in case it's created dynamically)
700    this.select.on("dropdown_open", setPlaceholder);
701
702    // Ensure search icon is present alongside the dropdown input
703    const setIcon = () => {
704      const dropdownInput = this.select.dropdown?.querySelector(".dropdown-input");
705      if (dropdownInput) {
706        this.#addSearchIconToDropdownInput(dropdownInput);
707        // Setup observer to re-add icon if it gets removed (e.g., by macOS emoji picker)
708        this.#setupSearchIconObserver(dropdownInput);
709      }
710    };
711
712    // Add immediately if dropdown already exists
713    setIcon();
714
715    // Also add when dropdown opens
716    this.select.on("dropdown_open", setIcon);
717  }
718
719  #setupSearchIconObserver(dropdownInput) {
720    // Clean up existing observer if any
721    if (this.searchIconObserver) {
722      this.searchIconObserver.disconnect();
723    }
724
725    const wrap = dropdownInput.closest(".dropdown-input-wrap") || dropdownInput.parentElement;
726    if (!wrap) return;
727
728    // Create observer to watch for the icon being removed
729    this.searchIconObserver = new MutationObserver((mutations) => {
730      // Check if the icon still exists
731      const iconExists = wrap.querySelector(".dropdown-input-search-icon");
732      if (!iconExists && this.select?.isOpen) {
733        // Icon was removed while dropdown is open, re-add it
734        this.#addSearchIconToDropdownInput(dropdownInput);
735      }
736    });
737
738    // Observe child list changes in the wrap element
739    this.searchIconObserver.observe(wrap, {
740      childList: true,
741      subtree: false,
742    });
743  }
744
745  #preventNativeFocus() {
746    // Prevent native select from being focused (e.g., after form validation)
747    // and immediately transfer focus to TomSelect control
748    this.element.addEventListener(
749      "focus",
750      (e) => {
751        e.preventDefault();
752        e.stopPropagation();
753
754        // Transfer focus to the TomSelect control
755        if (this.select?.control_input) {
756          this.select.control_input.focus();
757        } else if (this.select?.control) {
758          this.select.control.focus();
759        }
760      },
761      true,
762    );
763
764    // Also prevent arrow keys on the native select from opening native dropdown
765    this.element.addEventListener(
766      "keydown",
767      (e) => {
768        if (["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", " ", "Enter"].includes(e.ke
768y)) {
769          e.preventDefault();
770          e.stopPropagation();
771
772          // Open TomSelect dropdown instead
773          if (this.select && !this.select.isOpen) {
774            this.select.open();
775          }
776        }
777      },
778      true,
779    );
780  }
781
782  #setupMouseDownOpen() {
783    if (!this.#shouldUseMouseDownOpen() || !this.select?.control) return;
784
785    this.controlElement = this.select.control;
786    this.controlMouseDownHandler = (event) => {
787      if (event.button !== 0 || this.element.disabled || !this.select) return;
788
789      const target = event.target;
790      if (!(target instanceof Element)) return;
791
792      // Let actionable controls inside the input handle their own click behavior.
793      if (target.closest(".remove, .flag-toggle, .dropdown-input, button, a")) return;
794
795      if (!this.select.isOpen) {
796        event.preventDefault();
797        this.#suppressNextControlClick();
798        this.#primeDropdownOptions();
799        this.select.open();
800        requestAnimationFrame(() => this.#primeDropdownOptions());
801        this.#updatePosition();
802      }
803
804      if (this.select.control_input) {
805        try {
806          this.select.control_input.focus({ preventScroll: true });
807        } catch {
808          this.select.control_input.focus();
809        }
810      }
811      this.#startPointerSelection();
812    };
813
814    this.controlElement.addEventListener("mousedown", this.controlMouseDownHandler);
815  }
816
817  #setupTouchPrelock() {
818    if (!this.lockScrollValue || this.#shouldSearchBeforeOpen() || !this.select?.control) return;
819
820    this.controlTouchElement = this.select.control;
821    this.controlTouchStartHandler = (event) => {
822      if (this.element.disabled || !this.select || this.select.isOpen || this.pageScrollLocked) return;
823
824      const target = event.target;
825      if (!(target instanceof Element)) return;
826
827      // Keep native behavior for actionable controls inside the input.
828      if (target.closest(".remove, .flag-toggle, .dropdown-input, button, a")) return;
829
830      this.#lockPageScroll();
831      this.prelockedFromTouch = true;
832    };
833
834    this.controlTouchEndHandler = () => {
835      if (!this.prelockedFromTouch) return;
836
837      // If touch interaction didn't open the dropdown, revert the pre-lock.
838      setTimeout(() => {
839        if (!this.prelockedFromTouch || this.select?.isOpen) return;
840        if (this.pageScrollLocked) {
841          this.#unlockPageScroll();
842        }
843        this.prelockedFromTouch = false;
844      }, 0);
845    };
846
847    this.controlTouchElement.addEventListener("touchstart", this.controlTouchStartHandler, { passive: true });
848    this.controlTouchElement.addEventListener("touchend", this.controlTouchEndHandler);
849    this.controlTouchElement.addEventListener("touchcancel", this.controlTouchEndHandler);
850  }
851
852  #suppressNextControlClick() {
853    if (!this.controlElement) return;
854
855    if (this.suppressControlClickHandler) return;
856
857    this.suppressControlClickHandler = (event) => {
858      event.preventDefault();
859      event.stopPropagation();
860      this.#clearSuppressedControlClick();
861    };
862
863    this.controlElement.addEventListener("click", this.suppressControlClickHandler, true);
864  }
865
866  #startPointerSelection() {
867    if (this.pointerSelectionActive) return;
868
869    this.pointerSelectionActive = true;
870    this.pointerMoveHandler = this.pointerMoveHandler || ((event) => this.#handlePointerMove(event));
871    this.pointerUpHandler = this.pointerUpHandler || ((event) => this.#handlePointerUp(event));
872
873    document.addEventListener("mousemove", this.pointerMoveHandler);
874    document.addEventListener("mouseup", this.pointerUpHandler);
875  }
876
877  #handlePointerMove(event) {
878    if (!this.pointerSelectionActive || !this.select?.isOpen || !this.select?.dropdown_content) return;
879    if ((event.buttons & 1) !== 1) return;
880
881    const hoveredElement = document.elementFromPoint(event.clientX, event.clientY);
882    const option = hoveredElement?.closest?.(".option[data-selectable]");
883    if (!option) return;
884
885    this.select.setActiveOption(option);
886  }
887
888  #handlePointerUp(event) {
889    if (!this.pointerSelectionActive || !this.select?.isOpen) {
890      this.#stopPointerSelection();
891      setTimeout(() => this.#clearSuppressedControlClick(), 0);
892      return;
893    }
894
895    const hoveredElement = document.elementFromPoint(event.clientX, event.clientY);
896    const option = hoveredElement?.closest?.(".option[data-selectable]");
897    if (option) {
898      option.dispatchEvent(
899        new MouseEvent("click", {
900          bubbles: true,
901          cancelable: true,
902          view: window,
903        }),
904      );
905    }
906
907    this.#stopPointerSelection();
908
909    // Let the native click event fire first, then clear suppression.
910    setTimeout(() => this.#clearSuppressedControlClick(), 0);
911  }
912
913  #stopPointerSelection() {
914    if (this.pointerMoveHandler) {
915      document.removeEventListener("mousemove", this.pointerMoveHandler);
916    }
917
918    if (this.pointerUpHandler) {
919      document.removeEventListener("mouseup", this.pointerUpHandler);
920    }
921
922    this.pointerSelectionActive = false;
923  }
924
925  #lockPageScroll() {
926    if (this.pageScrollLocked) return;
927
928    this.scrollLockTarget = this.#getScrollLockTarget();
929
930    if (this.scrollLockTarget !== document.body) {
931      this.previousTargetOverflow = this.scrollLockTarget.style.overflow;
932      this.previousTargetPaddingRight = this.scrollLockTarget.style.paddingRight;
933      this.previousTargetOverscrollBehavior = this.scrollLockTarget.style.overscrollBehavior;
934
935      // Preserve layout width when locking an element scrollbar (e.g., dialog content).
936      // Subtract borders so we only compensate for the actual scrollbar gutter.
937      const targetStyles = window.getComputedStyle(this.scrollLockTarget);
938      const borderLeft = parseFloat(targetStyles.borderLeftWidth) || 0;
939      const borderRight = parseFloat(targetStyles.borderRightWidth) || 0;
940      const rawDelta = this.scrollLockTarget.offsetWidth - this.scrollLockTarget.clientWidth;
941      const scrollbarWidth = Math.max(0, rawDelta - borderLeft - borderRight);
942      if (scrollbarWidth > 0) {
943        const computedPaddingRight = parseFloat(targetStyles.paddingRight) || 0;
944        this.scrollLockTarget.style.paddingRight = `${computedPaddingRight + scrollbarWidth}px`;
945      }
946
947      this.scrollLockTarget.style.overflow = "hidden";
948      this.scrollLockTarget.style.overscrollBehavior = "contain";
949      this.pageScrollLocked = true;
950      return;
951    }
952
953    this.#lockBodyScrollWithCompensation();
954    this.pageScrollLocked = true;
955  }
956
957  #unlockPageScroll() {
958    if (!this.pageScrollLocked) return;
959
960    if (this.scrollLockTarget && this.scrollLockTarget !== document.body) {
961      this.scrollLockTarget.style.overflow = this.previousTargetOverflow || "";
962      this.scrollLockTarget.style.paddingRight = this.previousTargetPaddingRight || "";
963      this.scrollLockTarget.style.overscrollBehavior = this.previousTargetOverscrollBehavior || "";
964      this.previousTargetOverflow = null;
965      this.previousTargetPaddingRight = null;
966      this.previousTargetOverscrollBehavior = null;
967      this.scrollLockTarget = null;
968      this.pageScrollLocked = false;
969      return;
970    }
971
972    this.#unlockBodyScrollWithCompensation();
973    this.scrollLockTarget = null;
974    this.pageScrollLocked = false;
975  }
976
977  #lockBodyScrollWithCompensation() {
978    if (this.hasRegisteredBodyLock) return;
979
980    const state = this.#getBodyLockState();
981    if (state.count === 0) {
982      const scrollbarWidth = this.#measureScrollbarWidth();
983      state.previousBodyOverflow = document.body.style.overflow;
984      state.previousBodyPaddingRight = document.body.style.paddingRight;
985
986      if (scrollbarWidth > 0) {
987        const bodyStyles = window.getComputedStyle(document.body);
988        const computedBodyPaddingRight = parseFloat(bodyStyles.paddingRight) || 0;
989        document.body.style.paddingRight = `${computedBodyPaddingRight + scrollbarWidth}px`;
990      }
991
992      document.body.style.overflow = "hidden";
993
994      state.compensatedFixedElements = [];
995      if (scrollbarWidth > 0) {
996        document.querySelectorAll(".fixed").forEach((element) => {
997          if (!(element instanceof HTMLElement) || this.#shouldSkipFixedCompensation(element)) return;
998
999          state.compensatedFixedElements.push({
1000            element,
1001            previousPaddingRight: element.style.paddingRight,
1002          });
1003
1004          const fixedStyles = window.getComputedStyle(element);
1005          const computedFixedPaddingRight = parseFloat(fixedStyles.paddingRight) || 0;
1006          element.style.paddingRight = `${computedFixedPaddingRight + scrollbarWidth}px`;
1007        });
1008      }
1009    }
1010
1011    state.count += 1;
1012    this.hasRegisteredBodyLock = true;
1013  }
1014
1015  #unlockBodyScrollWithCompensation() {
1016    if (!this.hasRegisteredBodyLock) return;
1017
1018    const state = this.#getBodyLockState();
1019    state.count = Math.max(0, state.count - 1);
1020
1021    if (state.count === 0) {
1022      document.body.style.overflow = state.previousBodyOverflow || "";
1023      document.body.style.paddingRight = state.previousBodyPaddingRight || "";
1024      state.previousBodyOverflow = "";
1025      state.previousBodyPaddingRight = "";
1026
1027      state.compensatedFixedElements.forEach(({ element, previousPaddingRight }) => {
1028        if (!(element instanceof HTMLElement)) return;
1029        element.style.paddingRight = previousPaddingRight || "";
1030      });
1031      state.compensatedFixedElements = [];
1032    }
1033
1034    this.hasRegisteredBodyLock = false;
1035  }
1036
1037  #measureScrollbarWidth() {
1038    const outer = document.createElement("div");
1039    outer.style.visibility = "hidden";
1040    outer.style.overflow = "scroll";
1041    outer.style.msOverflowStyle = "scrollbar";
1042    document.body.appendChild(outer);
1043
1044    const inner = document.createElement("div");
1045    outer.appendChild(inner);
1046
1047    const scrollbarWidth = Math.max(0, outer.offsetWidth - inner.offsetWidth);
1048    outer.parentNode?.removeChild(outer);
1049
1050    return scrollbarWidth;
1051  }
1052
1053  #getBodyLockState() {
1054    if (!window.__floatingBodyScrollLockState) {
1055      window.__floatingBodyScrollLockState = {
1056        count: 0,
1057        previousBodyOverflow: "",
1058        previousBodyPaddingRight: "",
1059        compensatedFixedElements: [],
1060      };
1061    }
1062
1063    return window.__floatingBodyScrollLockState;
1064  }
1065
1066  #shouldSkipFixedCompensation(element) {
1067    return Boolean(
1068      element.closest(
1069        'dialog[data-context-menu-target="menu"], [data-slideover-target="dialog"], [data-drawer-target="dialog"]',
1070      ),
1071    );
1072  }
1073
1074  #clearSuppressedControlClick() {
1075    if (!this.controlElement || !this.suppressControlClickHandler) return;
1076
1077    this.controlElement.removeEventListener("click", this.suppressControlClickHandler, true);
1078    this.suppressControlClickHandler = null;
1079  }
1080
1081  #setupSearchFirstOpenBehavior() {
1082    const syncDropdownToQuery = (queryValue = null) => {
1083      if (!this.select) return;
1084
1085      const query = (queryValue ?? this.select.control_input?.value ?? "").trim();
1086      if (query.length > 0) {
1087        if (!this.select.isOpen) {
1088          this.select.open();
1089          this.#updatePosition();
1090        }
1091      } else if (this.select.isOpen) {
1092        this.select.close();
1093      }
1094    };
1095
1096    this.searchFirstTypeHandler = (query) => syncDropdownToQuery(query);
1097    this.select.on("type", this.searchFirstTypeHandler);
1098    this.searchFirstClearHandler = () => syncDropdownToQuery("");
1099    this.select.on("clear", this.searchFirstClearHandler);
1100
1101    if (this.select.control_input) {
1102      this.searchFirstInputElement = this.select.control_input;
1103      this.searchFirstInputHandler = () => syncDropdownToQuery();
1104      this.searchFirstInputElement.addEventListener("input", this.searchFirstInputHandler);
1105    }
1106
1107    if (this.select.control) {
1108      this.searchFirstControlElement = this.select.control;
1109      this.searchFirstControlMouseDownHandler = (event) => {
1110        if (event.button !== 0 || this.element.disabled || !this.select?.control_input) return;
1111
1112        const target = event.target;
1113        if (!(target instanceof Element)) return;
1114        if (target.closest(".remove, .flag-toggle, button, a")) return;
1115
1116        // Keep search-first behavior: focus on mousedown, but don't open until typing.
1117        event.preventDefault();
1118        try {
1119          this.select.control_input.focus({ preventScroll: true });
1120        } catch {
1121          this.select.control_input.focus();
1122        }
1123      };
1124
1125      this.searchFirstControlElement.addEventListener("mousedown", this.searchFirstControlMouseDownHandler);
1126    }
1127  }
1128
1129  #primeDropdownOptions() {
1130    // Local options can briefly appear empty when opening on mousedown before TomSelect
1131    // finishes its normal click/focus cycle; refresh once to pre-render immediately.
1132    if (!this.select || this.hasUrlValue || typeof this.select.refreshOptions !== "function") return;
1133    this.select.refreshOptions(false);
1134  }
1135
1136  #getScrollLockTarget() {
1137    const parentOverlay = this.element.closest(
1138      'dialog[open][data-modal-target="dialog"], dialog[open][data-slideover-target="dialog"], dialog[open][data-drawer-target="dialog"], dialog[open], .modal-div.modal-open',
1139    );
1140
1141    if (parentOverlay instanceof HTMLElement) {
1142      const nearestScrollableWithinOverlay = this.#findNearestScrollableAncestor(
1143        this.select?.control || this.element,
1144        parentOverlay,
1145      );
1146      return nearestScrollableWithinOverlay || parentOverlay;
1147    }
1148
1149    return document.body;
1150  }
1151
1152  #findNearestScrollableAncestor(fromElement, boundaryElement) {
1153    let current = fromElement?.parentElement;
1154
1155    while (current && current !== boundaryElement) {
1156      const style = window.getComputedStyle(current);
1157      const overflowY = style.overflowY;
1158      const isScrollable =
1159        (overflowY === "auto" || overflowY === "scroll") && current.scrollHeight > current.clientHeight;
1160
1161      if (isScrollable) return current;
1162      current = current.parentElement;
1163    }
1164
1165    if (boundaryElement instanceof HTMLElement) {
1166      const style = window.getComputedStyle(boundaryElement);
1167      const overflowY = style.overflowY;
1168      const isScrollable =
1169        (overflowY === "auto" || overflowY === "scroll") && boundaryElement.scrollHeight > boundaryElement.clientHeight;
1170      if (isScrollable) return boundaryElement;
1171    }
1172
1173    return null;
1174  }
1175
1176  #hasExplicitOpenOnMouseDownSetting() {
1177    return this.element.hasAttribute("data-select-open-on-mouse-down-value");
1178  }
1179
1180  #shouldSearchBeforeOpen() {
1181    // For async controls using the main input (no dropdown_input plugin), default to
1182    // a "type first, then open results" UX unless explicitly overridden.
1183    return this.hasUrlValue && !this.dropdownInputValue && !this.#hasExplicitOpenOnMouseDownSetting();
1184  }
1185
1186  #shouldUseMouseDownOpen() {
1187    if (!this.openOnMouseDownValue) return false;
1188    if (this.#shouldSearchBeforeOpen()) return false;
1189    return true;
1190  }
1191
1192  #setupClearButton() {
1193    // Don't show clear button for multiple selects
1194    if (this.element.multiple) return;
1195
1196    // Don't show clear button if the select is disabled
1197    if (this.element.disabled) return;
1198
1199    // Create the clear button dynamically
1200    this.#createClearButton();
1201
1202    // Initial visibility check
1203    this.#updateClearButtonVisibility();
1204
1205    // Listen for input changes (typing)
1206    this.select.on("input", () => {
1207      this.#updateClearButtonVisibility();
1208    });
1209
1210    // Listen for value changes (selection)
1211    this.select.on("change", () => {
1212      this.#updateClearButtonVisibility();
1213    });
1214
1215    // Listen for item add/remove (for single selects that might have items)
1216    this.select.on("item_add", () => {
1217      this.#updateClearButtonVisibility();
1218    });
1219
1220    this.select.on("item_remove", () => {
1221      this.#updateClearButtonVisibility();
1222    });
1223
1224    // Listen for dropdown open/close to update visibility
1225    this.select.on("dropdown_open", () => {
1226      // Immediate check when dropdown opens
1227      this.#updateClearButtonVisibility();
1228      // Also check after a small delay to catch any delayed state changes
1229      setTimeout(() => {
1230        this.#updateClearButtonVisibility();
1231      }, 5);
1232    });
1233
1234    this.select.on("dropdown_close", () => {
1235      // Small delay to ensure TomSelect has finished processing
1236      setTimeout(() => {
1237        this.#updateClearButtonVisibility();
1238      }, 10);
1239    });
1240
1241    // Listen for type-ahead and search events
1242    this.select.on("type", () => {
1243      this.#updateClearButtonVisibility();
1244    });
1245
1246    // Also listen directly to the control input for typing
1247    if (this.select.control_input) {
1248      this.select.control_input.addEventListener("input", () => {
1249        this.#updateClearButtonVisibility();
1250      });
1251
1252      // Listen for keydown events to catch ESC key and immediate typing
1253      this.select.control_input.addEventListener("keydown", (e) => {
1254        if (e.key === "Escape") {
1255          // Small delay to let TomSelect process the ESC key first
1256          setTimeout(() => {
1257            this.#updateClearButtonVisibility();
1258          }, 10);
1259        } else if (e.key.length === 1 && this.select.isOpen) {
1260          // Only show button immediately if dropdown is open (actual typing)
1261          this.clearButton.classList.remove("hidden");
1262          this.clearButton.classList.add("flex");
1263        }
1264      });
1265    }
1266
1267    // Also listen to the main control for immediate keydown detection
1268    const mainControl = this.select.control;
1269    if (mainControl) {
1270      mainControl.addEventListener("keydown", (e) => {
1271        if (e.key.length === 1 && this.select.isOpen) {
1272          // Only show button immediately if dropdown is open (actual typing)
1273          this.clearButton.classList.remove("hidden");
1274          this.clearButton.classList.add("flex");
1275        }
1276      });
1277    }
1278  }
1279
1280  #createClearButton() {
1281    // Use the wrapper created for this specific TomSelect instance.
1282    const tsWrapper =
1283      this.select?.wrapper ||
1284      (this.element.nextElementSibling?.classList?.contains("ts-wrapper") ? this.element.nextElementSibling : null);
1285    if (!tsWrapper) {
1286      // Retry after a short delay
1287      setTimeout(() => this.#createClearButton(), 100);
1288      return;
1289    }
1290
1291    // Create the clear button
1292    this.clearButton = document.createElement("button");
1293    this.clearButton.type = "button";
1294    this.clearButton.className =
1295      "hidden absolute items-center justify-center size-5 right-2 top-2.5 rounded-full text-neutral-500 hover:text-neutral-400 focus-visible:outline-2 focus-visible:outline-offset-0 focus-visible:outline-neutral-600 dar
1295k:focus-visible:outline-neutral-200 z-10 bg-white dark:bg-neutral-700 dark:text-neutral-400 dark:hover:text-neutral-500";
1296    this.clearButton.setAttribute("data-select-target", "clearButton");
1297
1298    // Add the SVG icon
1299    this.clearButton.innerHTML = `
1300      <svg xmlns="http://www.w3.org/2000/svg" class="size-3" width="12" height="12" viewBox="0 0 12 12">
1301        <g fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" stroke="currentColor">
1302          <line x1="2.25" y1="9.75" x2="9.75" y2="2.25"></line>
1303          <line x1="9.75" y1="9.75" x2="2.25" y2="2.25"></line>
1304        </g>
1305      </svg>
1306    `;
1307
1308    // Add click event listener
1309    this.clearButton.addEventListener("click", (e) => {
1310      e.preventDefault();
1311      e.stopPropagation();
1312      this.clearInput();
1313    });
1314
1315    // Append to ts-wrapper
1316    tsWrapper.appendChild(this.clearButton);
1317  }
1318
1319  #addSearchIconToDropdownInput(dropdownInput) {
1320    const wrap = dropdownInput.closest(".dropdown-input-wrap") || dropdownInput.parentElement;
1321    if (!wrap) return;
1322
1323    // Ensure relative positioning for absolute icon placement
1324    wrap.classList.add("relative");
1325
1326    // Avoid duplicating the icon
1327    if (wrap.querySelector(".dropdown-input-search-icon")) return;
1328
1329    // Create the icon container
1330    const icon = document.createElement("span");
1331    icon.className =
1332      "dropdown-input-search-icon pointer-events-none absolute left-2.5 inset-y-0 flex items-center text-neutral-400 dark:text-neutral-300";
1333    icon.setAttribute("aria-hidden", "true");
1334    icon.innerHTML = `
1335      <svg xmlns="http://www.w3.org/2000/svg" class="size-3.5 sm:size-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
1336        <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
1337      </svg>
1338    `;
1339
1340    // Insert icon before the input
1341    wrap.insertBefore(icon, dropdownInput);
1342
1343    // Add left padding to the input so text doesn't overlap the icon
1344    dropdownInput.classList.add("!pl-8");
1345  }
1346
1347  #updateClearButtonVisibility() {
1348    if (!this.clearButtonValue || this.element.multiple || this.element.disabled || !this.clearButton) return;
1349
1350    const hasValue = this.select.getValue() && this.select.getValue() !== "";
1351    const hasInput = this.select.control_input?.value && this.select.control_input.value.trim() !== "";
1352    const isDropdownOpen = this.select.isOpen;
1353    const hasActiveSearch = this.select.lastQuery && this.select.lastQuery.trim() !== "";
1354
1355    // Additional check: if dropdown is closed and no value is selected, hide the button
1356    const shouldShow = hasValue || (isDropdownOpen && (hasInput || hasActiveSearch));
1357
1358    if (shouldShow) {
1359      this.clearButton.classList.remove("hidden");
1360      this.clearButton.classList.add("flex");
1361    } else {
1362      this.clearButton.classList.add("hidden");
1363      this.clearButton.classList.remove("flex");
1364    }
1365  }
1366
1367  #updateCountDisplay() {
1368    const count = Object.keys(this.select.getValue()).length;
1369
1370    if (count > 0) {
1371      // Use singular text if provided and count is 1, otherwise use regular countText
1372      const textToUse = count === 1 && this.countTextSingularValue ? this.countTextSingularValue : this.countTextValue;
1373
1374      this.countElement.textContent = `${count} ${textToUse}`;
1375      this.select.control.classList.add("count-active");
1376    } else {
1377      this.select.control.classList.remove("count-active");
1378    }
1379  }
1380
1381  #setupExternalTags() {
1382    // Create external tags container
1383    this.externalTagsContainer = document.createElement("div");
1384
1385    const position = this.tagsPositionValue;
1386    const isAbove = position === "above";
1387    const isBelow = position === "below";
1388    const isCustomContainer = !isAbove && !isBelow;
1389
1390    // Set up classes based on position type
1391    if (isCustomContainer) {
1392      // Custom container doesn't need margin classes
1393      this.externalTagsContainer.className = "external-tags-container flex flex-wrap gap-2";
1394    } else {
1395      const positionClass = isAbove ? "mb-2" : "mt-2";
1396      this.externalTagsContainer.className = `external-tags-container ${positionClass} flex flex-wrap gap-2`;
1397    }
1398
1399    // Insert into custom container or relative to ts-wrapper
1400    if (isCustomContainer) {
1401      const customContainer = document.getElementById(position);
1402      if (!customContainer) {
1403        console.warn(`Custom container with id "${position}" not found. Falling back to "below" position.`);
1404
1405        // Display error message to user
1406        const tsWrapper = this.element.parentElement?.querySelector(".ts-wrapper");
1407        if (!tsWrapper) {
1408          setTimeout(() => this.#setupExternalTags(), 100);
1409          return;
1410        }
1411
1412        // Create error message
1413        const errorMessage = document.createElement("div");
1414        errorMessage.className = "mt-1 text-sm text-red-600 dark:text-red-400";
1415        errorMessage.textContent = `Error: Container with id "${position}" not found. Using default position.`;
1416        tsWrapper.parentElement.insertBefore(errorMessage, tsWrapper.nextSibling);
1417
1418        // Store reference for cleanup
1419        this.customContainerError = errorMessage;
1420
1421        // Fall back to below position
1422        tsWrapper.parentElement.insertBefore(this.externalTagsContainer, errorMessage.nextSibling);
1423      } else {
1424        // Append to custom container
1425        customContainer.appendChild(this.externalTagsContainer);
1426      }
1427    } else {
1428      // Standard above/below positioning
1429      const tsWrapper = this.element.parentElement?.querySelector(".ts-wrapper");
1430      if (!tsWrapper) {
1431        setTimeout(() => this.#setupExternalTags(), 100);
1432        return;
1433      }
1434
1435      if (isAbove) {
1436        tsWrapper.parentElement.insertBefore(this.externalTagsContainer, tsWrapper);
1437      } else {
1438        tsWrapper.parentElement.insertBefore(this.externalTagsContainer, tsWrapper.nextSibling);
1439      }
1440    }
1441
1442    // Hide tags inside the control
1443    this.select.control.classList.add("external-tags-active");
1444
1445    // Initial render
1446    this.#updateExternalTags();
1447
1448    // Listen for changes
1449    this.select.on("item_add", () => this.#updateExternalTags());
1450    this.select.on("item_remove", () => this.#updateExternalTags());
1451  }
1452
1453  #updateExternalTags() {
1454    if (!this.externalTagsContainer) return;
1455
1456    // Clear existing tags
1457    this.externalTagsContainer.innerHTML = "";
1458
1459    // Get selected values
1460    const values = this.select.getValue();
1461    if (!values || (Array.isArray(values) && values.length === 0)) {
1462      // Hide container when empty
1463      this.externalTagsContainer.style.display = "none";
1464      return;
1465    }
1466
1467    // Show container when there are tags
1468    this.externalTagsContainer.style.display = "flex";
1469
1470    // Convert to array if needed
1471    const valueArray = Array.isArray(values) ? values : [values];
1472
1473    // Render each tag
1474    valueArray.forEach((value) => {
1475      const option = this.select.options[value];
1476      if (!option) return;
1477
1478      const tag = this.#createExternalTag(value, option);
1479      this.externalTagsContainer.appendChild(tag);
1480    });
1481  }
1482
1483  #createExternalTag(value, option) {
1484    const tag = document.createElement("div");
1485    const isFlagged = option.$option?.dataset?.flag === "true";
1486    const tagClass = isFlagged
1487      ? "inline-flex items-center gap-1 rounded-md bg-red-100 pl-2 pr-1 py-1 text-xs font-medium text-red-900 dark:bg-[#281212] dark:text-red-200 *:text-red-900 dark:*:text-red-200"
1488      : "inline-flex items-center gap-1 rounded-md bg-neutral-100 pl-2 pr-1 py-1 text-xs font-medium text-neutral-900 dark:bg-neutral-900 dark:text-neutral-100";
1489    tag.className = tagClass;
1490    tag.setAttribute("data-value", value);
1491    if (isFlagged) {
1492      tag.setAttribute("data-flag", "true");
1493    }
1494
1495    // Get label from option
1496    const optionData = this.#parseOptionData(option);
1497    const label = optionData?.name || option[this.labelFieldValue] || option.text || value;
1498
1499    // Add icon if available
1500    if (optionData?.icon) {
1501      const iconSpan = document.createElement("span");
1502      iconSpan.innerHTML = optionData.icon;
1503      tag.appendChild(iconSpan);
1504    }
1505
1506    // Add label
1507    const labelSpan = document.createElement("span");
1508    labelSpan.textContent = label;
1509    tag.appendChild(labelSpan);
1510
1511    // Add flag toggle button if enabled
1512    if (this.enableFlagToggleValue) {
1513      const flagBtn = document.createElement("button");
1514      flagBtn.type = "button";
1515      const flagBtnClass = isFlagged
1516        ? "flex size-[18px] items-center justify-center rounded hover:bg-red-200 dark:hover:bg-red-100/10 text-red-700 dark:text-red-300"
1517        : "flex size-[18px] items-center justify-center rounded hover:bg-neutral-200 dark:hover:bg-neutral-800 text-neutral-600 dar
1517k:text-neutral-400";
1518      flagBtn.className = flagBtnClass;
1519      flagBtn.innerHTML = isFlagged ? this.#getFlaggedIcon() : this.#getUnflaggedIcon();
1520
1521      flagBtn.addEventListener("click", (e) => {
1522        e.preventDefault();
1523        e.stopPropagation();
1524        this.#toggleExternalTagFlag(value, tag);
1525      });
1526
1527      tag.appendChild(flagBtn);
1528    }
1529
1530    // Add remove button
1531    const removeBtn = document.createElement("button");
1532    removeBtn.type = "button";
1533    const removeBtnClass = isFlagged
1534      ? "flex size-[18px] items-center justify-center rounded hover:bg-red-200 dark:hover:bg-red-100/10 text-red-700 dark:text-red-300"
1535      : "flex size-[18px] items-center justify-center rounded hover:bg-neutral-200 dark:hover:bg-neutral-800 text-neutral-600 dark:text-neutral-400";
1536    removeBtn.className = removeBtnClass;
1537    removeBtn.innerHTML = `
1538      <svg xmlns="http://www.w3.org/2000/svg" class="size-3" viewBox="0 0 12 12" fill="currentColor">
1539        <path d="m2.25,10.5c-.192,0-.384-.073-.53-.22-.293-.293-.293-.768,0-1.061L9.22,1.72c.293-.293.768-.293,1.061,0s.293.768,0,1.061l-7.5,7.5c-.146.146-.338.22-.53.22Z" stroke-width="0"></path>
1540        <path d="m9.75,10.5c-.192,0-.384-.073-.53-.22L1.72,2.78c-.293-.293-.293-.768,0-1.061s.768-.293,1.061,0l7.5,7.5c.293.293.293.768,0,1.061-.146.146-.338.22-.53.22Z" stroke-width="0"></path>
1541      </svg>
1542    `;
1543
1544    removeBtn.addEventListener("click", (e) => {
1545      e.preventDefault();
1546      e.stopPropagation();
1547      this.select.removeItem(value);
1548    });
1549
1550    tag.appendChild(removeBtn);
1551
1552    return tag;
1553  }
1554
1555  #handleTypeAhead(key, buffer, timeout) {
1556    clearTimeout(timeout);
1557    timeout = setTimeout(() => {
1558      buffer = "";
1559    }, 1000);
1560
1561    buffer += key.toLowerCase();
1562    const match = this.#findMatchingOption(buffer);
1563
1564    if (match) {
1565      const optionEl = this.select.dropdown_content.querySelector(`[data-value="${match[this.valueFieldValue]}"]`);
1566
1567      if (optionEl) {
1568        this.select.setActiveOption(optionEl);
1569        this.select.open();
1570        this.#scrollToOption(optionEl);
1571      }
1572    }
1573  }
1574
1575  #addScrollButtons() {
1576    const createButton = (direction, position) => {
1577      const btn = document.createElement("div");
1578      btn.className = `absolute left-0 right-0 ${position} h-5 bg-gradient-to-${
1579        direction === "up" ? "b" : "t"
1580      } from-white to-transparent dark:from-neutral-800 z-10 cursor-default flex items-center justify-center transition-opacity duration-150`;
1581      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${
1582        direction === "up" ? "5 15l7-7 7 7" : "19 9l-7 7-7-7"
1583      }"></path></svg>`;
1584      return btn;
1585    };
1586
1587    const scrollUpBtn = createButton("up", "top-0");
1588    const scrollDownBtn = createButton("down", "bottom-0");
1589
1590    const scrollSpeed = 4; // Pixels per frame for smooth scrolling
1591
1592    const setupScrollButton = (btn, direction) => {
1593      let isScrolling = false;
1594      let animationId = null;
1595
1596      const smoothScroll = () => {
1597        if (!isScrolling || !this.select?.dropdown_content) return;
1598
1599        const scrollAmount = direction === "up" ? -scrollSpeed : scrollSpeed;
1600        this.select.dropdown_content.scrollTop += scrollAmount;
1601
1602        animationId = requestAnimationFrame(smoothScroll);
1603      };
1604
1605      const startScroll = () => {
1606        isScrolling = true;
1607        btn.style.opacity = "0.7";
1608        smoothScroll();
1609      };
1610
1611      const stopScroll = () => {
1612        isScrolling = false;
1613        if (animationId) {
1614          cancelAnimationFrame(animationId);
1615          animationId = null;
1616        }
1617        btn.style.opacity = "1";
1618      };
1619
1620      // Mouse events
1621      btn.addEventListener("mouseenter", startScroll);
1622      btn.addEventListener("mouseleave", stopScroll);
1623
1624      // Touch events
1625      ["touchstart", "touchend", "touchcancel"].forEach((event) => {
1626        btn.addEventListener(
1627          event,
1628          (e) => {
1629            e.preventDefault();
1630            event === "touchstart" ? startScroll() : stopScroll();
1631          },
1632          { passive: false },
1633        );
1634      });
1635    };
1636
1637    setupScrollButton(scrollUpBtn, "up");
1638    setupScrollButton(scrollDownBtn, "down");
1639
1640    this.select.dropdown.insertBefore(scrollUpBtn, this.select.dropdown.firstChild);
1641    this.select.dropdown.appendChild(scrollDownBtn);
1642
1643    // Show/hide based on scroll
1644    this.select.dropdown_content.addEventListener("scroll", () => {
1645      const { scrollTop, scrollHeight, clientHeight } = this.select.dropdown_content;
1646      scrollUpBtn.style.display = scrollTop > 0 ? "flex" : "none";
1647      scrollDownBtn.style.display = scrollTop + clientHeight < scrollHeight ? "flex" : "none";
1648    });
1649
1650    scrollUpBtn.style.display = "none";
1651  }
1652
1653  async #updatePosition() {
1654    if (!this.select?.dropdown) return;
1655
1656    // Don't reposition during infinite scroll loading
1657    if (this.select.dropdown_content?.classList.contains("is-loading-more")) return;
1658
1659    const reference = this.select.control;
1660    const floating = this.select.dropdown;
1661
1662    if (!reference.getBoundingClientRect().height || !floating.getBoundingClientRect().height) {
1663      if (floating.offsetParent !== null) {
1664        setTimeout(() => this.#updatePosition(), 50);
1665      }
1666      return;
1667    }
1668
1669    try {
1670      // Determine placement based on external tags position
1671      let placement = "bottom-start";
1672      if (this.tagsPositionValue !== "inline") {
1673        const position = this.tagsPositionValue;
1674        // Only adjust placement for standard above/below positions, not custom containers
1675        if (position === "above") {
1676          placement = "bottom-start"; // Tags are above, dropdown below
1677        } else if (position === "below") {
1678          placement = "top-start"; // Tags are below, dropdown above
1679        }
1680        // For custom container IDs, keep default "bottom-start"
1681      }
1682
1683      const { x, y } = await computePosition(reference, floating, {
1684        placement,
1685        middleware: [offset(6), flip(), shift({ padding: 8 })],
1686      });
1687
1688      Object.assign(floating.style, {
1689        position: "absolute",
1690        left: `${x}px`,
1691        top: `${y}px`,
1692        width: `${Math.max(reference.offsetWidth, 160)}px`,
1693      });
1694    } catch (error) {
1695      console.warn("Position update error:", error);
1696    }
1697  }
1698
1699  #handleChange(value) {
1700    // Set flag to indicate an item was just selected
1701    this.justSelectedItem = true;
1702
1703    if (value === "none") {
1704      this.element.value = "";
1705      if (this.submitOnChangeValue) {
1706        const url = new URL(window.location.href);
1707        url.searchParams.delete(this.element.name);
1708        window.location.href = url.toString();
1709      }
1710    } else {
1711      if (this.submitOnChangeValue) {
1712        this.element.form.requestSubmit();
1713        this.element.value = value;
1714        this.#addSpinner();
1715      }
1716      if (this.updateFieldValue) {
1717        this.#updateTargetField(value);
1718      }
1719    }
1720  }
1721
1722  #updateTargetField(value) {
1723    const form = this.element.closest("form");
1724    if (!form) return;
1725
1726    const targetField = this.updateFieldTargetValue
1727      ? form.querySelector(this.updateFieldTargetValue)
1728      : form.querySelector('input[name="list_contact[name]"]');
1729
1730    if (!targetField) return;
1731
1732    const selectedOption = this.select.options[value];
1733    if (!selectedOption) return;
1734
1735    const data = this.#parseOptionData(selectedOption);
1736    if (data?.[this.updateFieldSourceValue]) {
1737      targetField.value = data[this.updateFieldSourceValue];
1738      targetField.dispatchEvent(new Event("input", { bubbles: true }));
1739    }
1740  }
1741
1742  #parseOptionData(option) {
1743    if (typeof option.text === "string" && option.text.startsWith("{")) {
1744      try {
1745        return JSON.parse(option.text);
1746      } catch (e) {
1747        console.warn("Parse error:", e);
1748      }
1749    }
1750    return null;
1751  }
1752
1753  #addSpinner() {
1754    const container = this.element.closest(".relative")?.querySelector(".absolute.z-10");
1755    if (container) {
1756      container.innerHTML = `
1757        <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">
1758          <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
1759          <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>
1760        </svg>
1761      `;
1762    }
1763  }
1764
1765  #transformApiResponse(response) {
1766    const data = this.#extractDataArray(response);
1767    const transformedData = (data || []).map((item) => ({
1768      ...item,
1769      text: item.text || item[this.labelFieldValue] || item.name || "",
1770      value: item.value || item[this.valueFieldValue],
1771    }));
1772
1773    if (!this.virtualScrollValue) {
1774      const hasMore = this.#detectHasMore(response, data);
1775      return { data: transformedData, has_more: hasMore };
1776    }
1777
1778    return { data: transformedData };
1779  }
1780
1781  #extractDataArray(response) {
1782    if (this.responseDataFieldValue !== "data") {
1783      return this.#getNestedValue(response, this.responseDataFieldValue);
1784    }
1785
1786    if (Array.isArray(response)) return response;
1787
1788    const fields = ["data", "results", "items"];
1789    for (const field of fields) {
1790      if (response[field] && Array.isArray(response[field])) {
1791        return response[field];
1792      }
1793    }
1794
1795    return null;
1796  }
1797
1798  #detectHasMore(response, data) {
1799    return (
1800      response.has_more ||
1801      response.hasMore ||
1802      !!response.next ||
1803      !!response.next_page_url ||
1804      (response.info && !!response.info.next) ||
1805      (data && data.length === this.perPageValue) ||
1806      false
1807    );
1808  }
1809
1810  #buildApiUrl(baseUrl, query, page) {
1811    const url = new URL(baseUrl, window.location.origin);
1812
1813    if (query) url.searchParams.set(this.searchParamValue, query);
1814    url.searchParams.set("page", page);
1815
1816    const isExternalApi = !baseUrl.startsWith("/") && !baseUrl.startsWith(window.location.origin);
1817    if (!isExternalApi) {
1818      url.searchParams.set("per_page", this.perPageValue);
1819    }
1820
1821    return url.toString();
1822  }
1823
1824  async #fetchPage(query, page) {
1825    const url = this.#buildApiUrl(this.urlValue, query, page);
1826    const response = await fetch(url);
1827    if (!response.ok) throw new Error(response.statusText);
1828    return response.json();
1829  }
1830
1831  #renderStandardOption(data, escape) {
1832    const optionData = this.#parseOptionData(data);
1833    const isFlagged = data.$option?.dataset?.flag === "true";
1834    const textClass = isFlagged ? "text-red-600 dark:text-red-400" : "";
1835
1836    if (optionData) {
1837      return `<div class='flex items-center gap-y-[3px] gap-x-1.5 flex-wrap'>
1838        ${optionData.icon || ""}
1839        <span class='${textClass}'>${escape(optionData.name)}</span>
1840        ${optionData.side || ""}
1841        ${
1842          optionData.description
1843            ? `<p class='text-neutral-500 dark:text-neutral-300 text-xs my-0 w-full'>${escape(
1844                optionData.description,
1845              )}</p>`
1846            : ""
1847        }
1848      </div>`;
1849    }
1850
1851    return `<div class='flex items-center gap-1.5'><span class='${textClass}'>${escape(data.text)}</span></div>`;
1852  }
1853
1854  #renderStandardItem(data, escape) {
1855    const optionData = this.#parseOptionData(data);
1856    const isFlagged = data.$option?.dataset?.flag === "true";
1857    const itemClass = isFlagged
1858      ? "!flex items-center gap-1.5 !bg-red-100 !text-red-900 dark:!bg-[#281212] dark:!text-red-200"
1859      : "!flex items-center gap-1.5";
1860
1861    if (optionData) {
1862      return `<div class='${itemClass}'>
1863        ${optionData.icon || ""}
1864        <span>${escape(optionData.name)}</span>
1865      </div>`;
1866    }
1867
1868    return `<div class='${itemClass}'><span class='line-clamp-1'>${escape(data.text)}</span></div>`;
1869  }
1870
1871  #renderImageItem(data, escape) {
1872    const label = data[this.labelFieldValue] || data.name || data.text;
1873    const isFlagged = data.$option?.dataset?.flag === "true";
1874    const itemClass = isFlagged
1875      ? "!flex items-center gap-2 !bg-red-100 !text-red-900 dark:!bg-[#281212] dark:!text-red-200 *:text-red-900 dark:*:text-red-200"
1876      : "!flex items-center gap-2";
1877    return `<div class='${itemClass}'>
1878      <img class='size-5 rounded-full' src='${escape(data[this.imageFieldValue])}' alt='${escape(label)}'>
1879      <span class='line-clamp-1'>${escape(label)}</span>
1880    </div>`;
1881  }
1882
1883  #renderApiOption(data, escape) {
1884    const hasImage = this.imageFieldValue && data[this.imageFieldValue];
1885    const label = data[this.labelFieldValue] || data.name || data.text;
1886    const isFlagged = data.$option?.dataset?.flag === "true";
1887    const labelClass = isFlagged ? "font-medium text-red-600 dark:text-red-400" : "font-medium";
1888
1889    let html = `<div class='${hasImage ? "flex items-start gap-3" : ""} py-1'>`;
1890
1891    if (hasImage) {
1892      html += `<img class='size-10 rounded-full flex-shrink-0' src='${escape(
1893        data[this.imageFieldValue],
1894      )}' alt='${escape(label)}'>`;
1895      html += `<div class='flex-1 min-w-0'>`;
1896    }
1897
1898    html += `<div class='${labelClass}'>${escape(label)}</div>`;
1899
1900    if (this.subtitleFieldValue && data[this.subtitleFieldValue]) {
1901      html += `<div class='text-xs text-neutral-500 dark:text-neutral-400'>${escape(data[this.subtitleFieldValue])}`;
1902      if (this.badgeFieldValue && data[this.badgeFieldValue]) {
1903        html += ` • ${escape(data[this.badgeFieldValue])}`;
1904      }
1905      html += `</div>`;
1906    }
1907
1908    if (this.metaFieldsValue) {
1909      const metaValues = this.metaFieldsValue
1910        .split(",")
1911        .map((f) => f.trim())
1912        .filter((field) => data[field])
1913        .map((field) => escape(data[field]));
1914
1915      if (metaValues.length > 0) {
1916        html += `<div class='text-xs text-neutral-500 dark:text-neutral-400'>${metaValues.join(" • ")}</div>`;
1917      }
1918    }
1919
1920    if (hasImage) html += `</div>`;
1921    html += `</div>`;
1922
1923    return html;
1924  }
1925
1926  #renderWithTemplate(template, data, escape) {
1927    return template.replace(/\{\{(\w+)\}\}/g, (match, field) => (data[field] ? escape(data[field]) : ""));
1928  }
1929
1930  #renderLoadingMore() {
1931    return `<div class="loading-more-results py-2 flex items-center justify-center text-sm text-neutral-500 dark:text-neutral-400">
1932      <svg class="animate-spin -ml-1 mr-3 size-4 text-neutral-500 dark:text-neutral-400" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
1933        <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
1934        <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>
1935      </svg>${this.loadingTextValue}
1936    </div>`;
1937  }
1938
1939  // Helper methods
1940
1941  #hasCustomFields() {
1942    return this.imageFieldValue || this.subtitleFieldValue || this.metaFieldsValue;
1943  }
1944
1945  #isNavigationKey(key) {
1946    return (
1947      (key.length === 1 && key.match(/[a-z0-9]/i)) || ["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(key)
1948    );
1949  }
1950
1951  #getNestedValue(obj, path) {
1952    return path.split(".").reduce((current, key) => current?.[key], obj);
1953  }
1954
1955  #clearAllActiveStates() {
1956    if (!this.select?.dropdown_content) return;
1957
1958    // Clear both regular options and create option
1959    this.select.dropdown_content.querySelectorAll(".option.active, .create.active").forEach((opt) => {
1960      opt.classList.remove("active");
1961      opt.setAttribute("aria-selected", "false");
1962    });
1963
1964    if (this.select.activeOption) {
1965      this.select.activeOption = null;
1966    }
1967  }
1968
1969  #captureScrollState(url) {
1970    const currentUrl = new URL(url, window.location.origin);
1971    const currentPage = parseInt(currentUrl.searchParams.get("page") || "1");
1972    let state = { currentPage };
1973
1974    // Early return if select is destroyed
1975    if (!this.select?.dropdown_content) return state;
1976
1977    state.scrollTop = this.select.dropdown_content.scrollTop;
1978    state.scrollHandler = this.select.dropdown_content.onscroll;
1979
1980    if (currentPage > 1) {
1981      const activeItem = this.select.dropdown_content.querySelector(".option.active");
1982      if (activeItem) {
1983        state.lastActiveValue = activeItem.getAttribute("data-value");
1984      }
1985    }
1986
1987    this.select.dropdown_content.onscroll = null;
1988    this.select.dropdown_content.classList.add("is-loading-more");
1989
1990    return state;
1991  }
1992
1993  #restoreScrollState(state) {
1994    if (!this.select?.dropdown_content || !state) return;
1995
1996    if (typeof state.scrollTop === "number") {
1997      this.select.dropdown_content.scrollTop = state.scrollTop;
1998    }
1999
2000    this.select.dropdown_content.onscroll = state.scrollHandler;
2001  }
2002
2003  #cleanupScrollState() {
2004    if (this.select?.dropdown_content) {
2005      this.select.dropdown_content.classList.remove("is-loading-more");
2006    }
2007  }
2008
2009  #updateVirtualScrollState(url, query, json) {
2010    // Early return if select is destroyed
2011    if (!this.select) return;
2012
2013    const currentUrl = new URL(url, window.location.origin);
2014    const currentPage = parseInt(currentUrl.searchParams.get("page") || "1");
2015
2016    const hasMore = json.data.length === this.perPageValue || json.info?.next || json.next || json.has_more;
2017
2018    if (hasMore) {
2019      const nextUrl = this.#buildApiUrl(this.urlValue, query, currentPage + 1);
2020      this.select.setNextUrl(query, nextUrl);
2021    } else {
2022      this.select.setNextUrl(query, null);
2023    }
2024  }
2025
2026  #handlePostLoadFocus(query, scrollState) {
2027    if (!this.select?.dropdown_content) return;
2028
2029    // Don't mess with focus/selection during infinite scroll
2030    if (scrollState.currentPage > 1) {
2031      // Just maintain the current scroll position
2032      return;
2033    }
2034
2035    // Only clear active states if no item was just selected
2036    if (!this.justSelectedItem) {
2037      this.#clearAllActiveStates();
2038      this.select.setActiveOption(null);
2039
2040      if (scrollState.currentPage === 1) {
2041        this.#focusFirstOption(query);
2042      }
2043    }
2044  }
2045
2046  #focusFirstOption(query) {
2047    if (!query?.trim() || !this.select?.dropdown_content) return;
2048
2049    const currentActive = this.select.dropdown_content.querySelector(".option.active");
2050    if (currentActive || this.select.activeOption) return;
2051
2052    const firstOption = this.select.dropdown_content.querySelector(".option:not(.create):not(.no-results)");
2053    if (firstOption) {
2054      this.select.setActiveOption(firstOption);
2055    }
2056  }
2057
2058  #restoreSelectionAfterLoading(lastActiveValue) {
2059    if (!this.select?.dropdown_content || !lastActiveValue) return;
2060
2061    const currentActive = this.select.dropdown_content.querySelector(".option.active");
2062    if (currentActive) return;
2063
2064    const itemToRestore = this.select.dropdown_content.querySelector(`[data-value="${lastActiveValue}"]`);
2065    if (itemToRestore) {
2066      this.select.setActiveOption(itemToRestore);
2067    }
2068  }
2069
2070  #getActiveValue() {
2071    const activeItem = this.select?.dropdown_content?.querySelector(".option.active");
2072    return activeItem?.getAttribute("data-value");
2073  }
2074
2075  #findMatchingOption(buffer) {
2076    return Object.values(this.select.options).find((option) => {
2077      const label = this.hasUrlValue
2078        ? option[this.labelFieldValue]
2079        : this.#parseOptionData(option)?.name || option.text;
2080      return label.toLowerCase().startsWith(buffer);
2081    });
2082  }
2083
2084  #scrollToOption(optionEl) {
2085    const content = this.select.dropdown_content;
2086    if (!content) return;
2087
2088    const contentRect = content.getBoundingClientRect();
2089    const optionRect = optionEl.getBoundingClientRect();
2090
2091    // Calculate the option's position relative to the scrollable content
2092    // Use getBoundingClientRect to get accurate positions relative to viewport
2093    const optionRelativeTop = optionRect.top - contentRect.top;
2094    const optionRelativeBottom = optionRelativeTop + optionRect.height;
2095    const contentScrollTop = content.scrollTop;
2096    const contentHeight = contentRect.height;
2097
2098    // Define padding: scroll when the option is within this distance from the edge
2099    const scrollPadding = optionRect.height * 1 + 6;
2100
2101    // Check if we need to scroll down
2102    if (optionRelativeBottom > contentHeight - scrollPadding) {
2103      // Scroll so the option is visible with padding below
2104      content.scrollTop = contentScrollTop + (optionRelativeBottom - contentHeight + scrollPadding);
2105    }
2106    // Check if we need to scroll up
2107    else if (optionRelativeTop < scrollPadding) {
2108      // Scroll so the option is visible with padding above
2109      content.scrollTop = contentScrollTop + (optionRelativeTop - scrollPadding);
2110    }
2111  }
2112
2113  #resetPagination() {
2114    this.currentPage = 1;
2115    this.hasMore = true;
2116    this.loadingMore = false;
2117  }
2118
2119  #shouldLoadMore() {
2120    if (!this.select?.dropdown_content) return false;
2121    const { scrollTop, scrollHeight, clientHeight } = this.select.dropdown_content;
2122    return scrollTop + clientHeight + 150 >= scrollHeight;
2123  }
2124
2125  #handleInitialValue() {
2126    if (!this.updateFieldValue || !this.hasUrlValue) return;
2127
2128    try {
2129      const currentValue = this.getValue(this.urlValue);
2130      if (currentValue) {
2131        this.select.setValue(currentValue);
2132      }
2133    } catch (error) {
2134      console.warn("Initial value setting skipped");
2135    }
2136  }
2137
2138  // Public methods
2139
2140  clearInput() {
2141    if (!this.select) return;
2142
2143    // Clear all selected values using TomSelect's API
2144    this.select.clear();
2145
2146    // Clear the search/filter input text
2147    if (this.select.control_input) {
2148      this.select.control_input.value = "";
2149    }
2150
2151    // Clear the search cache and last query
2152    this.select.clearCache();
2153    this.select.lastQuery = "";
2154
2155    // Close the dropdown if open
2156    if (this.select.isOpen) {
2157      this.select.close();
2158    }
2159
2160    // Update clear button visibility
2161    this.#updateClearButtonVisibility();
2162
2163    // Focus the control for continued interaction
2164    setTimeout(() => {
2165      if (this.dropdownInputValue && this.select.control) {
2166        this.select.control.focus();
2167      } else if (this.select.control_input) {
2168        this.select.control_input.focus();
2169      }
2170    }, 10);
2171  }
2172
2173  // Private methods
2174
2175  #cleanup() {
2176    this.#stopPointerSelection();
2177    this.#unlockPageScroll();
2178
2179    if (this.controlElement && this.controlMouseDownHandler) {
2180      this.controlElement.removeEventListener("mousedown", this.controlMouseDownHandler);
2181      this.#clearSuppressedControlClick();
2182      this.controlMouseDownHandler = null;
2183      this.controlElement = null;
2184    }
2185
2186    if (this.controlTouchElement && this.controlTouchStartHandler) {
2187      this.controlTouchElement.removeEventListener("touchstart", this.controlTouchStartHandler);
2188      this.controlTouchStartHandler = null;
2189    }
2190
2191    if (this.controlTouchElement && this.controlTouchEndHandler) {
2192      this.controlTouchElement.removeEventListener("touchend", this.controlTouchEndHandler);
2193      this.controlTouchElement.removeEventListener("touchcancel", this.controlTouchEndHandler);
2194      this.controlTouchEndHandler = null;
2195      this.controlTouchElement = null;
2196    }
2197
2198    this.prelockedFromTouch = false;
2199
2200    if (this.searchFirstTypeHandler && this.select) {
2201      this.select.off("type", this.searchFirstTypeHandler);
2202      this.searchFirstTypeHandler = null;
2203    }
2204
2205    if (this.searchFirstClearHandler && this.select) {
2206      this.select.off("clear", this.searchFirstClearHandler);
2207      this.searchFirstClearHandler = null;
2208    }
2209
2210    if (this.searchFirstInputElement && this.searchFirstInputHandler) {
2211      this.searchFirstInputElement.removeEventListener("input", this.searchFirstInputHandler);
2212      this.searchFirstInputElement = null;
2213      this.searchFirstInputHandler = null;
2214    }
2215
2216    if (this.searchFirstControlElement && this.searchFirstControlMouseDownHandler) {
2217      this.searchFirstControlElement.removeEventListener("mousedown", this.searchFirstControlMouseDownHandler);
2218      this.searchFirstControlElement = null;
2219      this.searchFirstControlMouseDownHandler = null;
2220    }
2221
2222    if (this.checkboxObserver) this.checkboxObserver.disconnect();
2223    if (this.searchIconObserver) this.searchIconObserver.disconnect();
2224    if (this.select) {
2225      this.select.destroy();
2226      this.select = null;
2227    }
2228
2229    // Clean up external tags container
2230    if (this.externalTagsContainer) {
2231      this.externalTagsContainer.remove();
2232      this.externalTagsContainer = null;
2233    }
2234
2235    // Clean up custom container error message
2236    if (this.customContainerError) {
2237      this.customContainerError.remove();
2238      this.customContainerError = null;
2239    }
2240
2241    // Clean up form submit handler
2242    if (this.formSubmitHandler) {
2243      const form = this.element.closest("form");
2244      if (form) {
2245        form.removeEventListener("submit", this.formSubmitHandler);
2246      }
2247      this.formSubmitHandler = null;
2248    }
2249
2250    window.removeEventListener("scroll", this.scrollHandler, true);
2251    if (this.resizeObserver) this.resizeObserver.disconnect();
2252    if (this.mutationObserver) this.mutationObserver.disconnect();
2253  }
2254};

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.