import { Controller } from "@hotwired/stimulus" require("select2/dist/css/select2.min.css"); import select2 from "select2"; import jquery from "jquery"; const select2SelectedPreviewSelector = ".select2-selection--single" const select2SearchInputFieldSelector = ".select2-search__field" export default class extends Controller { static targets = [ "select" ] static values = { acceptsNew: Boolean, enableSearch: Boolean, searchUrl: String, select2Options: String, } // will be reissued as native dom events name prepended with '$' e.g. '$change', '$select2:closing', etc static jQueryEventsToReissue = [ "change", "select2:closing", "select2:close", "select2:opening", "select2:open", "select2:selecting", "select2:select", "select2:unselecting", "select2:unselect", "select2:clearing", "select2:clear" ] initialize() { this.dispatchNativeEvent = this.dispatchNativeEvent.bind(this) if (window.jQuery === undefined) { window.jQuery = jquery } if (!this.isSelect2LoadedOnWindowJquery) { select2() } } get isSelect2LoadedOnWindowJquery() { return window?.jQuery?.fn?.select2 !== undefined } get optionsOverride() { if (!this.hasSelect2OptionsValue) { return {} } return this.select2OptionsValue } connect() { if (this.isSelect2LoadedOnWindowJquery) { this.initPluginInstance() } } disconnect() { if (this.isSelect2LoadedOnWindowJquery) { this.teardownPluginInstance() } } cleanupBeforeInit() { this.element.querySelectorAll('.select2-container--default').forEach(el => el.remove()); } initPluginInstance() { let options = { dropdownParent: jQuery(this.element) }; if (!this.enableSearchValue) { options.minimumResultsForSearch = -1; } options.tags = this.acceptsNewValue if (this.searchUrlValue) { options.ajax = { url: this.searchUrlValue, dataType: 'json', // We enable pagination by default here data: function(params) { var query = { search: params.term, page: params.page || 1 } return query } } } options.templateResult = this.formatState; options.templateSelection = this.formatState; options.width = 'style'; // Merge in custom options. const custom_options = Object.keys(this.optionsOverride).length > 0 ? JSON.parse(this.optionsOverride) : {} options = {...options, ...custom_options} this.cleanupBeforeInit() // in case improperly torn down this.pluginMainEl = this.selectTarget // required because this.selectTarget is unavailable on disconnect() jQuery(this.pluginMainEl).select2(options); this.initReissuePluginEventsAsNativeEvents() } teardownPluginInstance() { if (this.pluginMainEl === undefined) { return } // ensure there are no orphaned event handlers this.teardownPluginEventsAsNativeEvents() // revert to original markup, remove any event listeners jQuery(this.pluginMainEl).select2('destroy'); } open() { jQuery(this.pluginMainEl).select2('open') } focusOnTextField(event) { this.element.querySelector(select2SearchInputFieldSelector)?.focus() } injectKeystrokeIntoTextField(event) { if (!event?.srcElement.matches(select2SelectedPreviewSelector)) { return } if (["Shift", "Alt", "Control", "Meta", "Tab", "Backspace", "Escape"].includes(event.key)) { return } this.open() const searchInputField = this.element.querySelector(select2SearchInputFieldSelector) if (!searchInputField) { return } if (event.type !== "keydown") { // since keydown precedes keyup, and since keyup is what sends the key to an input field, this next line isn't necessary if keydown is the event captured. We'll just focus on the input field, and the keyup will caught by the input field naturally. searchInputField.value = searchInputField.value + event.key } searchInputField.focus() } initReissuePluginEventsAsNativeEvents() { this.constructor.jQueryEventsToReissue.forEach((eventName) => { jQuery(this.pluginMainEl).on(eventName, this.dispatchNativeEvent) }) } teardownPluginEventsAsNativeEvents() { this.constructor.jQueryEventsToReissue.forEach((eventName) => { jQuery(this.pluginMainEl).off(eventName) }) } dispatchNativeEvent(event) { const nativeEventName = '$' + event.type // e.g. '$change.select2' this.element.dispatchEvent(new CustomEvent(nativeEventName, { detail: { event: event }, bubbles: true, cancelable: false })) } // https://stackoverflow.com/questions/29290389/select2-add-image-icon-to-option-dynamically formatState(opt) { var imageUrl = opt.element?.dataset.image var imageHtml = ""; if (imageUrl) { imageHtml = ' '; } return jQuery('' + imageHtml + sanitizeHTML(opt.text) + ''); } } // https://portswigger.net/web-security/cross-site-scripting/preventing function sanitizeHTML(str) { return str.replace(/[^\w. ]/gi, function (c) { return '&#' + c.charCodeAt(0) + ';'; }); };