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>…</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.