396 lines
		
	
	
		
			13 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
			
		
		
	
	
			396 lines
		
	
	
		
			13 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
| /**
 | |
|  * @copyright  (C) 2018 Open Source Matters, Inc. <https://www.joomla.org>
 | |
|  * @license    GNU General Public License version 2 or later; see LICENSE.txt
 | |
|  */
 | |
| 
 | |
| /**
 | |
|  * Fancy select field, which use Choices.js
 | |
|  *
 | |
|  * Example:
 | |
|  * <joomla-field-fancy-select ...attributes>
 | |
|  *   <select>...</select>
 | |
|  * </joomla-field-fancy-select>
 | |
|  *
 | |
|  * Possible attributes:
 | |
|  *
 | |
|  * allow-custom          Whether allow User to dynamically add a new value.
 | |
|  * new-item-prefix=""    Prefix for a dynamically added value.
 | |
|  *
 | |
|  * remote-search         Enable remote search.
 | |
|  * url=""                Url for remote search.
 | |
|  * term-key="term"       Variable key name for searched term, will be appended to Url.
 | |
|  *
 | |
|  * min-term-length="1"   The minimum length a search value should be before choices are searched.
 | |
|  * placeholder=""        The value of the inputs placeholder.
 | |
|  * search-placeholder="" The value of the search inputs placeholder.
 | |
|  *
 | |
|  * data-max-results="30" The maximum amount of search results to be displayed.
 | |
|  * data-max-render="30"  The maximum amount of items to be rendered, critical for large lists.
 | |
|  */
 | |
| window.customElements.define('joomla-field-fancy-select', class extends HTMLElement {
 | |
|   // Attributes to monitor
 | |
|   get allowCustom() {
 | |
|     return this.hasAttribute('allow-custom');
 | |
|   }
 | |
|   get remoteSearch() {
 | |
|     return this.hasAttribute('remote-search');
 | |
|   }
 | |
|   get url() {
 | |
|     return this.getAttribute('url');
 | |
|   }
 | |
|   get termKey() {
 | |
|     return this.getAttribute('term-key') || 'term';
 | |
|   }
 | |
|   get minTermLength() {
 | |
|     return parseInt(this.getAttribute('min-term-length'), 10) || 1;
 | |
|   }
 | |
|   get newItemPrefix() {
 | |
|     return this.getAttribute('new-item-prefix') || '';
 | |
|   }
 | |
|   get placeholder() {
 | |
|     return this.getAttribute('placeholder');
 | |
|   }
 | |
|   get searchPlaceholder() {
 | |
|     return this.getAttribute('search-placeholder');
 | |
|   }
 | |
|   get value() {
 | |
|     return this.choicesInstance.getValue(true);
 | |
|   }
 | |
|   set value($val) {
 | |
|     this.choicesInstance.setChoiceByValue($val);
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * Lifecycle
 | |
|    */
 | |
|   constructor() {
 | |
|     super();
 | |
| 
 | |
|     // Keycodes
 | |
|     this.keyCode = {
 | |
|       ENTER: 13
 | |
|     };
 | |
|     if (!Joomla) {
 | |
|       throw new Error('Joomla API is not properly initiated');
 | |
|     }
 | |
|     if (!window.Choices) {
 | |
|       throw new Error('JoomlaFieldFancySelect requires Choices.js to work');
 | |
|     }
 | |
|     this.choicesCache = {};
 | |
|     this.activeXHR = null;
 | |
|     this.choicesInstance = null;
 | |
|     this.isDisconnected = false;
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * Lifecycle
 | |
|    */
 | |
|   connectedCallback() {
 | |
|     // Make sure Choices are loaded
 | |
|     if (window.Choices || document.readyState === 'complete') {
 | |
|       this.doConnect();
 | |
|     } else {
 | |
|       const callback = () => {
 | |
|         this.doConnect();
 | |
|         window.removeEventListener('load', callback);
 | |
|       };
 | |
|       window.addEventListener('load', callback);
 | |
|     }
 | |
|   }
 | |
|   doConnect() {
 | |
|     // Get a <select> element
 | |
|     this.select = this.querySelector('select');
 | |
|     if (!this.select) {
 | |
|       throw new Error('JoomlaFieldFancySelect requires <select> element to work');
 | |
|     }
 | |
| 
 | |
|     // The element was already initialised previously and perhaps was detached from DOM
 | |
|     if (this.choicesInstance) {
 | |
|       if (this.isDisconnected) {
 | |
|         // Re init previous instance
 | |
|         this.choicesInstance.init();
 | |
|         this.choicesInstance.setChoiceByValue(this.disconnectValues);
 | |
|         this.isDisconnected = false;
 | |
|       }
 | |
|       return;
 | |
|     }
 | |
|     this.isDisconnected = false;
 | |
| 
 | |
|     // Add placeholder option for multiple mode,
 | |
|     // Because it not supported as parameter by Choices for <select> https://github.com/jshjohnson/Choices#placeholder
 | |
|     if (this.select.multiple && this.placeholder) {
 | |
|       const option = document.createElement('option');
 | |
|       option.setAttribute('placeholder', '');
 | |
|       option.textContent = this.placeholder;
 | |
|       this.select.appendChild(option);
 | |
|     }
 | |
| 
 | |
|     // Init Choices
 | |
|     // eslint-disable-next-line no-undef
 | |
|     this.choicesInstance = new Choices(this.select, {
 | |
|       placeholderValue: this.placeholder,
 | |
|       searchPlaceholderValue: this.searchPlaceholder,
 | |
|       removeItemButton: true,
 | |
|       searchFloor: this.minTermLength,
 | |
|       searchResultLimit: parseInt(this.select.dataset.maxResults, 10) || 10,
 | |
|       renderChoiceLimit: parseInt(this.select.dataset.maxRender, 10) || -1,
 | |
|       shouldSort: false,
 | |
|       fuseOptions: {
 | |
|         threshold: 0.3 // Strict search
 | |
|       },
 | |
|       noResultsText: Joomla.Text._('JGLOBAL_SELECT_NO_RESULTS_MATCH', 'No results found'),
 | |
|       noChoicesText: Joomla.Text._('JGLOBAL_SELECT_NO_RESULTS_MATCH', 'No results found'),
 | |
|       itemSelectText: Joomla.Text._('JGLOBAL_SELECT_PRESS_TO_SELECT', 'Press to select'),
 | |
|       // Redefine some classes
 | |
|       classNames: {
 | |
|         button: 'choices__button_joomla' // It is need because an original styling use unavailable Icon.svg file
 | |
|       }
 | |
|     });
 | |
| 
 | |
|     // Handle typing of custom Term
 | |
|     if (this.allowCustom) {
 | |
|       // START Work around for issue https://github.com/joomla/joomla-cms/issues/29459
 | |
|       // The choices.js always auto-highlights the first element
 | |
|       // in the dropdown that not allow to add a custom Term.
 | |
|       //
 | |
|       // This workaround can be removed when choices.js
 | |
|       // will have an option that allow to disable it.
 | |
| 
 | |
|       // eslint-disable-next-line no-underscore-dangle, prefer-destructuring
 | |
|       const _highlightChoice = this.choicesInstance._highlightChoice;
 | |
|       // eslint-disable-next-line no-underscore-dangle
 | |
|       this.choicesInstance._highlightChoice = el => {
 | |
|         // Prevent auto-highlight of first element, if nothing actually highlighted
 | |
|         if (!el) return;
 | |
| 
 | |
|         // Call original highlighter
 | |
|         _highlightChoice.call(this.choicesInstance, el);
 | |
|       };
 | |
| 
 | |
|       // Unhighlight any highlighted items, when mouse leave the dropdown
 | |
|       this.addEventListener('mouseleave', () => {
 | |
|         if (!this.choicesInstance.dropdown.isActive) {
 | |
|           return;
 | |
|         }
 | |
|         const highlighted = Array.from(this.choicesInstance.dropdown.element.querySelectorAll(`.${this.choicesInstance.config.classNames.highlightedState}`));
 | |
|         highlighted.forEach(choice => {
 | |
|           choice.classList.remove(this.choicesInstance.config.classNames.highlightedState);
 | |
|           choice.setAttribute('aria-selected', 'false');
 | |
|         });
 | |
| 
 | |
|         // eslint-disable-next-line no-underscore-dangle
 | |
|         this.choicesInstance._highlightPosition = 0;
 | |
|       });
 | |
|       // END workaround for issue #29459
 | |
| 
 | |
|       // Add custom term on ENTER keydown
 | |
|       this.addEventListener('keydown', event => {
 | |
|         if (event.keyCode !== this.keyCode.ENTER || event.target !== this.choicesInstance.input.element) {
 | |
|           return;
 | |
|         }
 | |
|         event.preventDefault();
 | |
| 
 | |
|         // eslint-disable-next-line no-underscore-dangle
 | |
|         if (this.choicesInstance._highlightPosition || !event.target.value) {
 | |
|           return;
 | |
|         }
 | |
| 
 | |
|         // Make sure nothing is highlighted
 | |
|         const highlighted = this.choicesInstance.dropdown.element.querySelector(`.${this.choicesInstance.config.classNames.highlightedState}`);
 | |
|         if (highlighted) {
 | |
|           return;
 | |
|         }
 | |
| 
 | |
|         // Check if value already exist
 | |
|         const lowerValue = event.target.value.toLowerCase();
 | |
|         let valueInCache = false;
 | |
| 
 | |
|         // Check if value in existing choices
 | |
|         this.choicesInstance.config.choices.some(choiceItem => {
 | |
|           if (choiceItem.value.toLowerCase() === lowerValue || choiceItem.label.toLowerCase() === lowerValue) {
 | |
|             valueInCache = choiceItem.value;
 | |
|             return true;
 | |
|           }
 | |
|           return false;
 | |
|         });
 | |
|         if (valueInCache === false) {
 | |
|           // Check if value in cache
 | |
|           Object.keys(this.choicesCache).some(key => {
 | |
|             if (key.toLowerCase() === lowerValue || this.choicesCache[key].toLowerCase() === lowerValue) {
 | |
|               valueInCache = key;
 | |
|               return true;
 | |
|             }
 | |
|             return false;
 | |
|           });
 | |
|         }
 | |
| 
 | |
|         // Make choice based on existing value
 | |
|         if (valueInCache !== false) {
 | |
|           this.choicesInstance.setChoiceByValue(valueInCache);
 | |
|           event.target.value = null;
 | |
|           this.choicesInstance.hideDropdown();
 | |
|           return;
 | |
|         }
 | |
| 
 | |
|         // Create and add new
 | |
|         this.choicesInstance.setChoices([{
 | |
|           value: new DOMParser().parseFromString(this.newItemPrefix + event.target.value, 'text/html').body.textContent,
 | |
|           label: new DOMParser().parseFromString(event.target.value, 'text/html').body.textContent,
 | |
|           selected: true,
 | |
|           customProperties: {
 | |
|             value: event.target.value // Store real value, just in case
 | |
|           }
 | |
|         }], 'value', 'label', false);
 | |
|         this.choicesCache[event.target.value] = event.target.value;
 | |
|         event.target.value = null;
 | |
|         this.choicesInstance.hideDropdown();
 | |
|       });
 | |
|     }
 | |
| 
 | |
|     // Handle remote search
 | |
|     if (this.remoteSearch && this.url) {
 | |
|       // Cache existing
 | |
|       this.choicesInstance.config.choices.forEach(choiceItem => {
 | |
|         this.choicesCache[choiceItem.value] = choiceItem.label;
 | |
|       });
 | |
|       const lookupDelay = 300;
 | |
|       let lookupTimeout = null;
 | |
|       this.select.addEventListener('search', () => {
 | |
|         clearTimeout(lookupTimeout);
 | |
|         lookupTimeout = setTimeout(this.requestLookup.bind(this), lookupDelay);
 | |
|       });
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * Lifecycle
 | |
|    */
 | |
|   disconnectedCallback() {
 | |
|     // Destroy Choices instance, to unbind event listeners
 | |
|     if (this.choicesInstance) {
 | |
|       // Keep selected values, because choices will reset them on re-init
 | |
|       this.disconnectValues = this.choicesInstance.getValue(true);
 | |
|       this.choicesInstance.destroy();
 | |
|       this.isDisconnected = true;
 | |
|     }
 | |
|     if (this.activeXHR) {
 | |
|       this.activeXHR.abort();
 | |
|       this.activeXHR = null;
 | |
|     }
 | |
|   }
 | |
|   requestLookup() {
 | |
|     let {
 | |
|       url
 | |
|     } = this;
 | |
|     url += url.indexOf('?') === -1 ? '?' : '&';
 | |
|     url += `${encodeURIComponent(this.termKey)}=${encodeURIComponent(this.choicesInstance.input.value)}`;
 | |
| 
 | |
|     // Stop previous request if any
 | |
|     if (this.activeXHR) {
 | |
|       this.activeXHR.abort();
 | |
|     }
 | |
|     this.activeXHR = Joomla.request({
 | |
|       url,
 | |
|       onSuccess: response => {
 | |
|         this.activeXHR = null;
 | |
|         const items = response ? JSON.parse(response) : [];
 | |
|         if (!items.length) {
 | |
|           return;
 | |
|         }
 | |
| 
 | |
|         // Remove duplications
 | |
|         let item;
 | |
|         // eslint-disable-next-line no-plusplus
 | |
|         for (let i = items.length - 1; i >= 0; i--) {
 | |
|           // The loop must be form the end !!!
 | |
|           item = items[i];
 | |
|           // eslint-disable-next-line prefer-template
 | |
|           item.value = '' + item.value; // Make sure the value is a string, choices.js expect a string.
 | |
| 
 | |
|           if (this.choicesCache[item.value]) {
 | |
|             items.splice(i, 1);
 | |
|           } else {
 | |
|             this.choicesCache[item.value] = item.text;
 | |
|           }
 | |
|         }
 | |
| 
 | |
|         // Add new options to field, assume that each item is object, eg {value: "foo", text: "bar"}
 | |
|         if (items.length) {
 | |
|           this.choicesInstance.setChoices(items, 'value', 'text', false);
 | |
|         }
 | |
|       },
 | |
|       onError: () => {
 | |
|         this.activeXHR = null;
 | |
|       }
 | |
|     });
 | |
|   }
 | |
|   disableAllOptions() {
 | |
|     // Choices.js does not offer a public API for accessing the choices
 | |
|     // So we have to access the private store => don't eslint
 | |
|     // eslint-disable-next-line no-underscore-dangle
 | |
|     const {
 | |
|       choices
 | |
|     } = this.choicesInstance._store;
 | |
|     choices.forEach((elem, index) => {
 | |
|       choices[index].disabled = true;
 | |
|       choices[index].selected = false;
 | |
|     });
 | |
|     this.choicesInstance.clearStore();
 | |
|     this.choicesInstance.setChoices(choices, 'value', 'label', true);
 | |
|   }
 | |
|   enableAllOptions() {
 | |
|     // Choices.js does not offer a public API for accessing the choices
 | |
|     // So we have to access the private store => don't eslint
 | |
|     // eslint-disable-next-line no-underscore-dangle
 | |
|     const {
 | |
|       choices
 | |
|     } = this.choicesInstance._store;
 | |
|     const values = this.choicesInstance.getValue(true);
 | |
|     choices.forEach((elem, index) => {
 | |
|       choices[index].disabled = false;
 | |
|     });
 | |
|     this.choicesInstance.clearStore();
 | |
|     this.choicesInstance.setChoices(choices, 'value', 'label', true);
 | |
|     this.value = values;
 | |
|   }
 | |
|   disableByValue($val) {
 | |
|     // Choices.js does not offer a public API for accessing the choices
 | |
|     // So we have to access the private store => don't eslint
 | |
|     // eslint-disable-next-line no-underscore-dangle
 | |
|     const {
 | |
|       choices
 | |
|     } = this.choicesInstance._store;
 | |
|     const values = this.choicesInstance.getValue(true);
 | |
|     choices.forEach((elem, index) => {
 | |
|       if (elem.value === $val) {
 | |
|         choices[index].disabled = true;
 | |
|         choices[index].selected = false;
 | |
|       }
 | |
|     });
 | |
|     const index = values.indexOf($val);
 | |
|     if (index > -1) {
 | |
|       values.slice(index, 1);
 | |
|     }
 | |
|     this.choicesInstance.clearStore();
 | |
|     this.choicesInstance.setChoices(choices, 'value', 'label', true);
 | |
|     this.value = values;
 | |
|   }
 | |
|   enableByValue($val) {
 | |
|     // Choices.js does not offer a public API for accessing the choices
 | |
|     // So we have to access the private store => don't eslint
 | |
|     // eslint-disable-next-line no-underscore-dangle
 | |
|     const {
 | |
|       choices
 | |
|     } = this.choicesInstance._store;
 | |
|     const values = this.choicesInstance.getValue(true);
 | |
|     choices.forEach((elem, index) => {
 | |
|       if (elem.value === $val) {
 | |
|         choices[index].disabled = false;
 | |
|       }
 | |
|     });
 | |
|     this.choicesInstance.clearStore();
 | |
|     this.choicesInstance.setChoices(choices, 'value', 'label', true);
 | |
|     this.value = values;
 | |
|   }
 | |
| });
 |