394 lines
13 KiB
JavaScript
394 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.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: this.newItemPrefix + event.target.value,
|
|
label: event.target.value,
|
|
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) {
|
|
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;
|
|
}
|
|
});
|