choices.js
Version:
A vanilla JS customisable text input/select box plugin
1,656 lines (1,462 loc) • 82.6 kB
JavaScript
import Fuse from 'fuse.js';
import classNames from 'classnames';
import Store from './store/index.js';
import {
addItem,
removeItem,
highlightItem,
addChoice,
filterChoices,
activateChoices,
addGroup,
clearAll,
clearChoices,
}
from './actions/index';
import {
isScrolledIntoView,
getAdjacentEl,
wrap,
getType,
isType,
isElement,
strToEl,
extend,
getWidthOfInput,
sortByAlpha,
sortByScore,
generateId,
triggerEvent,
findAncestorByAttrName
}
from './lib/utils.js';
import './lib/polyfills.js';
/**
* Choices
*/
class Choices {
constructor(element = '[data-choice]', userConfig = {}) {
// If there are multiple elements, create a new instance
// for each element besides the first one (as that already has an instance)
if (isType('String', element)) {
const elements = document.querySelectorAll(element);
if (elements.length > 1) {
for (let i = 1; i < elements.length; i++) {
const el = elements[i];
new Choices(el, userConfig);
}
}
}
const defaultConfig = {
silent: false,
items: [],
choices: [],
renderChoiceLimit: -1,
maxItemCount: -1,
addItems: true,
removeItems: true,
removeItemButton: false,
editItems: false,
duplicateItems: true,
delimiter: ',',
paste: true,
searchEnabled: true,
searchChoices: true,
searchFloor: 1,
searchResultLimit: 4,
searchFields: ['label', 'value'],
position: 'auto',
resetScrollPosition: true,
regexFilter: null,
shouldSort: true,
shouldSortItems: false,
sortFilter: sortByAlpha,
placeholder: true,
placeholderValue: null,
prependValue: null,
appendValue: null,
renderSelectedChoices: 'auto',
loadingText: 'Loading...',
noResultsText: 'No results found',
noChoicesText: 'No choices to choose from',
itemSelectText: 'Press to select',
addItemText: (value) => {
return `Press Enter to add <b>"${value}"</b>`;
},
maxItemText: (maxItemCount) => {
return `Only ${maxItemCount} values can be added.`;
},
uniqueItemText: 'Only unique values can be added.',
classNames: {
containerOuter: 'choices',
containerInner: 'choices__inner',
input: 'choices__input',
inputCloned: 'choices__input--cloned',
list: 'choices__list',
listItems: 'choices__list--multiple',
listSingle: 'choices__list--single',
listDropdown: 'choices__list--dropdown',
item: 'choices__item',
itemSelectable: 'choices__item--selectable',
itemDisabled: 'choices__item--disabled',
itemChoice: 'choices__item--choice',
placeholder: 'choices__placeholder',
group: 'choices__group',
groupHeading: 'choices__heading',
button: 'choices__button',
activeState: 'is-active',
focusState: 'is-focused',
openState: 'is-open',
disabledState: 'is-disabled',
highlightedState: 'is-highlighted',
hiddenState: 'is-hidden',
flippedState: 'is-flipped',
loadingState: 'is-loading',
},
fuseOptions: {
include: 'score',
},
callbackOnInit: null,
callbackOnCreateTemplates: null,
};
this.idNames = {
itemChoice: 'item-choice'
};
// Merge options with user options
this.config = extend(defaultConfig, userConfig);
if (this.config.renderSelectedChoices !== 'auto' && this.config.renderSelectedChoices !== 'always') {
if (!this.config.silent) {
console.warn(
'renderSelectedChoices: Possible values are \'auto\' and \'always\'. Falling back to \'auto\'.'
);
}
this.config.renderSelectedChoices = 'auto';
}
// Create data store
this.store = new Store(this.render);
// State tracking
this.initialised = false;
this.currentState = {};
this.prevState = {};
this.currentValue = '';
// Retrieve triggering element (i.e. element with 'data-choice' trigger)
this.element = element;
this.passedElement = isType('String', element) ? document.querySelector(element) : element;
this.isTextElement = this.passedElement.type === 'text';
this.isSelectOneElement = this.passedElement.type === 'select-one';
this.isSelectMultipleElement = this.passedElement.type === 'select-multiple';
this.isSelectElement = this.isSelectOneElement || this.isSelectMultipleElement;
this.isValidElementType = this.isTextElement || this.isSelectElement;
if (!this.passedElement) {
if (!this.config.silent) {
console.error('Passed element not found');
}
return;
}
if (this.config.shouldSortItems === true && this.isSelectOneElement) {
if (!this.config.silent) {
console.warn('shouldSortElements: Type of passed element is \'select-one\', falling back to false.');
}
}
this.highlightPosition = 0;
this.canSearch = this.config.searchEnabled;
// Assign preset choices from passed object
this.presetChoices = this.config.choices;
// Assign preset items from passed object first
this.presetItems = this.config.items;
// Then add any values passed from attribute
if (this.passedElement.value) {
this.presetItems = this.presetItems.concat(
this.passedElement.value.split(this.config.delimiter)
);
}
// Set unique base Id
this.baseId = generateId(this.passedElement, 'choices-');
// Bind methods
this.render = this.render.bind(this);
// Bind event handlers
this._onFocus = this._onFocus.bind(this);
this._onBlur = this._onBlur.bind(this);
this._onKeyUp = this._onKeyUp.bind(this);
this._onKeyDown = this._onKeyDown.bind(this);
this._onClick = this._onClick.bind(this);
this._onTouchMove = this._onTouchMove.bind(this);
this._onTouchEnd = this._onTouchEnd.bind(this);
this._onMouseDown = this._onMouseDown.bind(this);
this._onMouseOver = this._onMouseOver.bind(this);
this._onPaste = this._onPaste.bind(this);
this._onInput = this._onInput.bind(this);
// Monitor touch taps/scrolls
this.wasTap = true;
// Cutting the mustard
const cuttingTheMustard = 'classList' in document.documentElement;
if (!cuttingTheMustard && !this.config.silent) {
console.error('Choices: Your browser doesn\'t support Choices');
}
const canInit = isElement(this.passedElement) && this.isValidElementType;
if (canInit) {
// If element has already been initialised with Choices
if (this.passedElement.getAttribute('data-choice') === 'active') {
return;
}
// Let's go
this.init();
} else if (!this.config.silent) {
console.error('Incompatible input passed');
}
}
/*========================================
= Public functions =
========================================*/
/**
* Initialise Choices
* @return
* @public
*/
init() {
if (this.initialised === true) {
return;
}
const callback = this.config.callbackOnInit;
// Set initialise flag
this.initialised = true;
// Create required elements
this._createTemplates();
// Generate input markup
this._createInput();
// Subscribe store to render method
this.store.subscribe(this.render);
// Render any items
this.render();
// Trigger event listeners
this._addEventListeners();
// Run callback if it is a function
if (callback) {
if (isType('Function', callback)) {
callback.call(this);
}
}
}
/**
* Destroy Choices and nullify values
* @return
* @public
*/
destroy() {
if (this.initialised === false) {
return;
}
// Remove all event listeners
this._removeEventListeners();
// Reinstate passed element
this.passedElement.classList.remove(this.config.classNames.input, this.config.classNames.hiddenState);
this.passedElement.removeAttribute('tabindex');
// Recover original styles if any
const origStyle = this.passedElement.getAttribute('data-choice-orig-style');
if (Boolean(origStyle)) {
this.passedElement.removeAttribute('data-choice-orig-style');
this.passedElement.setAttribute('style', origStyle);
} else {
this.passedElement.removeAttribute('style');
}
this.passedElement.removeAttribute('aria-hidden');
this.passedElement.removeAttribute('data-choice');
// Re-assign values - this is weird, I know
this.passedElement.value = this.passedElement.value;
// Move passed element back to original position
this.containerOuter.parentNode.insertBefore(this.passedElement, this.containerOuter);
// Remove added elements
this.containerOuter.parentNode.removeChild(this.containerOuter);
// Clear data store
this.clearStore();
// Nullify instance-specific data
this.config.templates = null;
// Uninitialise
this.initialised = false;
}
/**
* Render group choices into a DOM fragment and append to choice list
* @param {Array} groups Groups to add to list
* @param {Array} choices Choices to add to groups
* @param {DocumentFragment} fragment Fragment to add groups and options to (optional)
* @return {DocumentFragment} Populated options fragment
* @private
*/
renderGroups(groups, choices, fragment) {
const groupFragment = fragment || document.createDocumentFragment();
const filter = this.config.sortFilter;
// If sorting is enabled, filter groups
if (this.config.shouldSort) {
groups.sort(filter);
}
groups.forEach((group) => {
// Grab options that are children of this group
const groupChoices = choices.filter((choice) => {
if (this.isSelectOneElement) {
return choice.groupId === group.id;
}
return choice.groupId === group.id && !choice.selected;
});
if (groupChoices.length >= 1) {
const dropdownGroup = this._getTemplate('choiceGroup', group);
groupFragment.appendChild(dropdownGroup);
this.renderChoices(groupChoices, groupFragment, true);
}
});
return groupFragment;
}
/**
* Render choices into a DOM fragment and append to choice list
* @param {Array} choices Choices to add to list
* @param {DocumentFragment} fragment Fragment to add choices to (optional)
* @return {DocumentFragment} Populated choices fragment
* @private
*/
renderChoices(choices, fragment, withinGroup = false) {
// Create a fragment to store our list items (so we don't have to update the DOM for each item)
const choicesFragment = fragment || document.createDocumentFragment();
const { renderSelectedChoices, searchResultLimit, renderChoiceLimit } = this.config;
const filter = this.isSearching ? sortByScore : this.config.sortFilter;
const appendChoice = (choice) => {
const shouldRender = renderSelectedChoices === 'auto' ?
(this.isSelectOneElement || !choice.selected) :
true;
if (shouldRender) {
const dropdownItem = this._getTemplate('choice', choice);
choicesFragment.appendChild(dropdownItem);
}
};
let rendererableChoices = choices;
if (renderSelectedChoices === 'auto' && !this.isSelectOneElement) {
rendererableChoices = choices.filter(choice => !choice.selected);
}
// If sorting is enabled or the user is searching, filter choices
if (this.config.shouldSort || this.isSearching) {
rendererableChoices.sort(filter);
}
let choiceLimit = rendererableChoices.length;
if (this.isSearching) {
choiceLimit = Math.min(searchResultLimit, rendererableChoices.length - 1);
} else if (renderChoiceLimit > 0 && !withinGroup) {
choiceLimit = Math.min(renderChoiceLimit, rendererableChoices.length - 1);
}
// Add each choice to dropdown within range
for (let i = 0; i < choiceLimit; i++) {
appendChoice(rendererableChoices[i]);
};
return choicesFragment;
}
/**
* Render items into a DOM fragment and append to items list
* @param {Array} items Items to add to list
* @param {DocumentFragment} [fragment] Fragment to add items to (optional)
* @return
* @private
*/
renderItems(items, fragment = null) {
// Create fragment to add elements to
const itemListFragment = fragment || document.createDocumentFragment();
// If sorting is enabled, filter items
if (this.config.shouldSortItems && !this.isSelectOneElement) {
items.sort(this.config.sortFilter);
}
if (this.isTextElement) {
// Simplify store data to just values
const itemsFiltered = this.store.getItemsReducedToValues(items);
const itemsFilteredString = itemsFiltered.join(this.config.delimiter);
// Update the value of the hidden input
this.passedElement.setAttribute('value', itemsFilteredString);
this.passedElement.value = itemsFilteredString;
} else {
const selectedOptionsFragment = document.createDocumentFragment();
// Add each list item to list
items.forEach((item) => {
// Create a standard select option
const option = this._getTemplate('option', item);
// Append it to fragment
selectedOptionsFragment.appendChild(option);
});
// Update selected choices
this.passedElement.innerHTML = '';
this.passedElement.appendChild(selectedOptionsFragment);
}
// Add each list item to list
items.forEach((item) => {
// Create new list element
const listItem = this._getTemplate('item', item);
// Append it to list
itemListFragment.appendChild(listItem);
});
return itemListFragment;
}
/**
* Render DOM with values
* @return
* @private
*/
render() {
this.currentState = this.store.getState();
// Only render if our state has actually changed
if (this.currentState !== this.prevState) {
// Choices
if (
this.currentState.choices !== this.prevState.choices ||
this.currentState.groups !== this.prevState.groups ||
this.currentState.items !== this.prevState.items
) {
if (this.isSelectElement) {
// Get active groups/choices
const activeGroups = this.store.getGroupsFilteredByActive();
const activeChoices = this.store.getChoicesFilteredByActive();
let choiceListFragment = document.createDocumentFragment();
// Clear choices
this.choiceList.innerHTML = '';
// Scroll back to top of choices list
if (this.config.resetScrollPosition) {
this.choiceList.scrollTop = 0;
}
// If we have grouped options
if (activeGroups.length >= 1 && this.isSearching !== true) {
choiceListFragment = this.renderGroups(activeGroups, activeChoices, choiceListFragment);
} else if (activeChoices.length >= 1) {
choiceListFragment = this.renderChoices(activeChoices, choiceListFragment);
}
const activeItems = this.store.getItemsFilteredByActive();
const canAddItem = this._canAddItem(activeItems, this.input.value);
// If we have choices to show
if (choiceListFragment.childNodes && choiceListFragment.childNodes.length > 0) {
// ...and we can select them
if (canAddItem.response) {
// ...append them and highlight the first choice
this.choiceList.appendChild(choiceListFragment);
this._highlightChoice();
} else {
// ...otherwise show a notice
this.choiceList.appendChild(this._getTemplate('notice', canAddItem.notice));
}
} else {
// Otherwise show a notice
let dropdownItem;
let notice;
if (this.isSearching) {
notice = isType('Function', this.config.noResultsText) ?
this.config.noResultsText() :
this.config.noResultsText;
dropdownItem = this._getTemplate('notice', notice);
} else {
notice = isType('Function', this.config.noChoicesText) ?
this.config.noChoicesText() :
this.config.noChoicesText;
dropdownItem = this._getTemplate('notice', notice);
}
this.choiceList.appendChild(dropdownItem);
}
}
}
// Items
if (this.currentState.items !== this.prevState.items) {
const activeItems = this.store.getItemsFilteredByActive();
if (activeItems) {
// Create a fragment to store our list items
// (so we don't have to update the DOM for each item)
const itemListFragment = this.renderItems(activeItems);
// Clear list
this.itemList.innerHTML = '';
// If we have items to add
if (itemListFragment.childNodes) {
// Update list
this.itemList.appendChild(itemListFragment);
}
}
}
this.prevState = this.currentState;
}
}
/**
* Select item (a selected item can be deleted)
* @param {Element} item Element to select
* @param {Boolean} [runEvent=true] Whether to trigger 'highlightItem' event
* @return {Object} Class instance
* @public
*/
highlightItem(item, runEvent = true) {
if (!item) {
return this;
}
const id = item.id;
const groupId = item.groupId;
const group = groupId >= 0 ? this.store.getGroupById(groupId) : null;
this.store.dispatch(
highlightItem(id, true)
);
if (runEvent) {
if (group && group.value) {
triggerEvent(this.passedElement, 'highlightItem', {
id,
value: item.value,
label: item.label,
groupValue: group.value
});
} else {
triggerEvent(this.passedElement, 'highlightItem', {
id,
value: item.value,
label: item.label,
});
}
}
return this;
}
/**
* Deselect item
* @param {Element} item Element to de-select
* @return {Object} Class instance
* @public
*/
unhighlightItem(item) {
if (!item) {
return this;
}
const id = item.id;
const groupId = item.groupId;
const group = groupId >= 0 ? this.store.getGroupById(groupId) : null;
this.store.dispatch(
highlightItem(id, false)
);
if (group && group.value) {
triggerEvent(this.passedElement, 'unhighlightItem', {
id,
value: item.value,
label: item.label,
groupValue: group.value
});
} else {
triggerEvent(this.passedElement, 'unhighlightItem', {
id,
value: item.value,
label: item.label,
});
}
return this;
}
/**
* Highlight items within store
* @return {Object} Class instance
* @public
*/
highlightAll() {
const items = this.store.getItems();
items.forEach((item) => {
this.highlightItem(item);
});
return this;
}
/**
* Deselect items within store
* @return {Object} Class instance
* @public
*/
unhighlightAll() {
const items = this.store.getItems();
items.forEach((item) => {
this.unhighlightItem(item);
});
return this;
}
/**
* Remove an item from the store by its value
* @param {String} value Value to search for
* @return {Object} Class instance
* @public
*/
removeItemsByValue(value) {
if (!value || !isType('String', value)) {
return this;
}
const items = this.store.getItemsFilteredByActive();
items.forEach((item) => {
if (item.value === value) {
this._removeItem(item);
}
});
return this;
}
/**
* Remove all items from store array
* @note Removed items are soft deleted
* @param {Number} excludedId Optionally exclude item by ID
* @return {Object} Class instance
* @public
*/
removeActiveItems(excludedId) {
const items = this.store.getItemsFilteredByActive();
items.forEach((item) => {
if (item.active && excludedId !== item.id) {
this._removeItem(item);
}
});
return this;
}
/**
* Remove all selected items from store
* @note Removed items are soft deleted
* @return {Object} Class instance
* @public
*/
removeHighlightedItems(runEvent = false) {
const items = this.store.getItemsFilteredByActive();
items.forEach((item) => {
if (item.highlighted && item.active) {
this._removeItem(item);
// If this action was performed by the user
// trigger the event
if (runEvent) {
this._triggerChange(item.value);
}
}
});
return this;
}
/**
* Show dropdown to user by adding active state class
* @return {Object} Class instance
* @public
*/
showDropdown(focusInput = false) {
const body = document.body;
const html = document.documentElement;
const winHeight = Math.max(
body.scrollHeight,
body.offsetHeight,
html.clientHeight,
html.scrollHeight,
html.offsetHeight
);
this.containerOuter.classList.add(this.config.classNames.openState);
this.containerOuter.setAttribute('aria-expanded', 'true');
this.dropdown.classList.add(this.config.classNames.activeState);
this.dropdown.setAttribute('aria-expanded', 'true');
const dimensions = this.dropdown.getBoundingClientRect();
const dropdownPos = Math.ceil(dimensions.top + window.scrollY + this.dropdown.offsetHeight);
// If flip is enabled and the dropdown bottom position is greater than the window height flip the dropdown.
let shouldFlip = false;
if (this.config.position === 'auto') {
shouldFlip = dropdownPos >= winHeight;
} else if (this.config.position === 'top') {
shouldFlip = true;
}
if (shouldFlip) {
this.containerOuter.classList.add(this.config.classNames.flippedState);
}
// Optionally focus the input if we have a search input
if (focusInput && this.canSearch && document.activeElement !== this.input) {
this.input.focus();
}
triggerEvent(this.passedElement, 'showDropdown', {});
return this;
}
/**
* Hide dropdown from user
* @return {Object} Class instance
* @public
*/
hideDropdown(blurInput = false) {
// A dropdown flips if it does not have space within the page
const isFlipped = this.containerOuter.classList.contains(this.config.classNames.flippedState);
this.containerOuter.classList.remove(this.config.classNames.openState);
this.containerOuter.setAttribute('aria-expanded', 'false');
this.dropdown.classList.remove(this.config.classNames.activeState);
this.dropdown.setAttribute('aria-expanded', 'false');
if (isFlipped) {
this.containerOuter.classList.remove(this.config.classNames.flippedState);
}
// Optionally blur the input if we have a search input
if (blurInput && this.canSearch && document.activeElement === this.input) {
this.input.blur();
}
triggerEvent(this.passedElement, 'hideDropdown', {});
return this;
}
/**
* Determine whether to hide or show dropdown based on its current state
* @return {Object} Class instance
* @public
*/
toggleDropdown() {
const hasActiveDropdown = this.dropdown.classList.contains(this.config.classNames.activeState);
if (hasActiveDropdown) {
this.hideDropdown();
} else {
this.showDropdown(true);
}
return this;
}
/**
* Get value(s) of input (i.e. inputted items (text) or selected choices (select))
* @param {Boolean} valueOnly Get only values of selected items, otherwise return selected items
* @return {Array/String} selected value (select-one) or array of selected items (inputs & select-multiple)
* @public
*/
getValue(valueOnly = false) {
const items = this.store.getItemsFilteredByActive();
const selectedItems = [];
items.forEach((item) => {
if (this.isTextElement) {
selectedItems.push(valueOnly ? item.value : item);
} else if (item.active) {
selectedItems.push(valueOnly ? item.value : item);
}
});
if (this.isSelectOneElement) {
return selectedItems[0];
}
return selectedItems;
}
/**
* Set value of input. If the input is a select box, a choice will be created and selected otherwise
* an item will created directly.
* @param {Array} args Array of value objects or value strings
* @return {Object} Class instance
* @public
*/
setValue(args) {
if (this.initialised === true) {
// Convert args to an iterable array
const values = [...args],
handleValue = (item) => {
const itemType = getType(item);
if (itemType === 'Object') {
if (!item.value) {
return;
}
// If we are dealing with a select input, we need to create an option first
// that is then selected. For text inputs we can just add items normally.
if (!this.isTextElement) {
this._addChoice(
item.value,
item.label,
true,
false,
-1,
item.customProperties,
null
);
} else {
this._addItem(
item.value,
item.label,
item.id,
undefined,
item.customProperties,
null
);
}
} else if (itemType === 'String') {
if (!this.isTextElement) {
this._addChoice(
item,
item,
true,
false,
-1,
null
);
} else {
this._addItem(item);
}
}
};
if (values.length > 1) {
values.forEach((value) => {
handleValue(value);
});
} else {
handleValue(values[0]);
}
}
return this;
}
/**
* Select value of select box via the value of an existing choice
* @param {Array/String} value An array of strings of a single string
* @return {Object} Class instance
* @public
*/
setValueByChoice(value) {
if (!this.isTextElement) {
const choices = this.store.getChoices();
// If only one value has been passed, convert to array
const choiceValue = isType('Array', value) ? value : [value];
// Loop through each value and
choiceValue.forEach((val) => {
const foundChoice = choices.find((choice) => {
// Check 'value' property exists and the choice isn't already selected
return choice.value === val;
});
if (foundChoice) {
if (!foundChoice.selected) {
this._addItem(
foundChoice.value,
foundChoice.label,
foundChoice.id,
foundChoice.groupId,
foundChoice.customProperties,
foundChoice.keyCode
);
} else if (!this.config.silent) {
console.warn('Attempting to select choice already selected');
}
} else if (!this.config.silent) {
console.warn('Attempting to select choice that does not exist');
}
});
}
return this;
}
/**
* Direct populate choices
* @param {Array} choices - Choices to insert
* @param {String} value - Name of 'value' property
* @param {String} label - Name of 'label' property
* @param {Boolean} replaceChoices Whether existing choices should be removed
* @return {Object} Class instance
* @public
*/
setChoices(choices, value, label, replaceChoices = false) {
if (this.initialised === true) {
if (this.isSelectElement) {
if (!isType('Array', choices) || !value) {
return this;
}
// Clear choices if needed
if (replaceChoices) {
this._clearChoices();
}
// Add choices if passed
if (choices && choices.length) {
this.containerOuter.classList.remove(this.config.classNames.loadingState);
choices.forEach((result) => {
if (result.choices) {
this._addGroup(
result,
(result.id || null),
value,
label
);
} else {
this._addChoice(
result[value],
result[label],
result.selected,
result.disabled,
undefined,
result['customProperties'],
null
);
}
});
}
}
}
return this;
}
/**
* Clear items,choices and groups
* @note Hard delete
* @return {Object} Class instance
* @public
*/
clearStore() {
this.store.dispatch(
clearAll()
);
return this;
}
/**
* Set value of input to blank
* @return {Object} Class instance
* @public
*/
clearInput() {
if (this.input.value){
this.input.value = '';
}
if (!this.isSelectOneElement) {
this._setInputWidth();
}
if (!this.isTextElement && this.config.searchEnabled) {
this.isSearching = false;
this.store.dispatch(
activateChoices(true)
);
}
return this;
}
/**
* Enable interaction with Choices
* @return {Object} Class instance
*/
enable() {
this.passedElement.disabled = false;
const isDisabled = this.containerOuter.classList.contains(this.config.classNames.disabledState);
if (this.initialised && isDisabled) {
this._addEventListeners();
this.passedElement.removeAttribute('disabled');
this.input.removeAttribute('disabled');
this.containerOuter.classList.remove(this.config.classNames.disabledState);
this.containerOuter.removeAttribute('aria-disabled');
if (this.isSelectOneElement) {
this.containerOuter.setAttribute('tabindex', '0');
}
}
return this;
}
/**
* Disable interaction with Choices
* @return {Object} Class instance
* @public
*/
disable() {
this.passedElement.disabled = true;
const isEnabled = !this.containerOuter.classList.contains(this.config.classNames.disabledState);
if (this.initialised && isEnabled) {
this._removeEventListeners();
this.passedElement.setAttribute('disabled', '');
this.input.setAttribute('disabled', '');
this.containerOuter.classList.add(this.config.classNames.disabledState);
this.containerOuter.setAttribute('aria-disabled', 'true');
if (this.isSelectOneElement) {
this.containerOuter.setAttribute('tabindex', '-1');
}
}
return this;
}
/**
* Populate options via ajax callback
* @param {Function} fn Function that actually makes an AJAX request
* @return {Object} Class instance
* @public
*/
ajax(fn) {
if (this.initialised === true) {
if (this.isSelectElement) {
// Show loading text
requestAnimationFrame(() => {
this._handleLoadingState(true)
});
// Run callback
fn(this._ajaxCallback());
}
}
return this;
}
/*===== End of Public functions ======*/
/*=============================================
= Private functions =
=============================================*/
/**
* Call change callback
* @param {String} value - last added/deleted/selected value
* @return
* @private
*/
_triggerChange(value) {
if (!value) {
return;
}
triggerEvent(this.passedElement, 'change', {
value
});
}
/**
* Process enter/click of an item button
* @param {Array} activeItems The currently active items
* @param {Element} element Button being interacted with
* @return
* @private
*/
_handleButtonAction(activeItems, element) {
if (!activeItems || !element) {
return;
}
// If we are clicking on a button
if (this.config.removeItems && this.config.removeItemButton) {
const itemId = element.parentNode.getAttribute('data-id');
const itemToRemove = activeItems.find((item) => item.id === parseInt(itemId, 10));
// Remove item associated with button
this._removeItem(itemToRemove);
this._triggerChange(itemToRemove.value);
if (this.isSelectOneElement) {
const placeholder = this.config.placeholder ? this.config.placeholderValue || this.passedElement.getAttribute('placeholder') :
false;
if (placeholder) {
const placeholderItem = this._getTemplate('placeholder', placeholder);
this.itemList.appendChild(placeholderItem);
}
}
}
}
/**
* Process click of an item
* @param {Array} activeItems The currently active items
* @param {Element} element Item being interacted with
* @param {Boolean} hasShiftKey Whether the user has the shift key active
* @return
* @private
*/
_handleItemAction(activeItems, element, hasShiftKey = false) {
if (!activeItems || !element) {
return;
}
// If we are clicking on an item
if (this.config.removeItems && !this.isSelectOneElement) {
const passedId = element.getAttribute('data-id');
// We only want to select one item with a click
// so we deselect any items that aren't the target
// unless shift is being pressed
activeItems.forEach((item) => {
if (item.id === parseInt(passedId, 10) && !item.highlighted) {
this.highlightItem(item);
} else if (!hasShiftKey) {
if (item.highlighted) {
this.unhighlightItem(item);
}
}
});
// Focus input as without focus, a user cannot do anything with a
// highlighted item
if (document.activeElement !== this.input) {
this.input.focus();
}
}
}
/**
* Process click of a choice
* @param {Array} activeItems The currently active items
* @param {Element} element Choice being interacted with
* @return
*/
_handleChoiceAction(activeItems, element) {
if (!activeItems || !element) {
return;
}
// If we are clicking on an option
const id = element.getAttribute('data-id');
const choice = this.store.getChoiceById(id);
const passedKeyCode = activeItems[0] && activeItems[0].keyCode ? activeItems[0].keyCode : null;
const hasActiveDropdown = this.dropdown.classList.contains(this.config.classNames.activeState);
// Update choice keyCode
choice.keyCode = passedKeyCode;
triggerEvent(this.passedElement, 'choice', {
choice,
});
if (choice && !choice.selected && !choice.disabled) {
const canAddItem = this._canAddItem(activeItems, choice.value);
if (canAddItem.response) {
this._addItem(
choice.value,
choice.label,
choice.id,
choice.groupId,
choice.customProperties,
choice.keyCode
);
this._triggerChange(choice.value);
}
}
this.clearInput();
// We wont to close the dropdown if we are dealing with a single select box
if (hasActiveDropdown && this.isSelectOneElement) {
this.hideDropdown();
this.containerOuter.focus();
}
}
/**
* Process back space event
* @param {Array} activeItems items
* @return
* @private
*/
_handleBackspace(activeItems) {
if (this.config.removeItems && activeItems) {
const lastItem = activeItems[activeItems.length - 1];
const hasHighlightedItems = activeItems.some(item => item.highlighted);
// If editing the last item is allowed and there are not other selected items,
// we can edit the item value. Otherwise if we can remove items, remove all selected items
if (this.config.editItems && !hasHighlightedItems && lastItem) {
this.input.value = lastItem.value;
this._setInputWidth();
this._removeItem(lastItem);
this._triggerChange(lastItem.value);
} else {
if (!hasHighlightedItems) {
this.highlightItem(lastItem, false);
}
this.removeHighlightedItems(true);
}
}
}
/**
* Validates whether an item can be added by a user
* @param {Array} activeItems The currently active items
* @param {String} value Value of item to add
* @return {Object} Response: Whether user can add item
* Notice: Notice show in dropdown
*/
_canAddItem(activeItems, value) {
let canAddItem = true;
let notice = isType('Function', this.config.addItemText) ?
this.config.addItemText(value) :
this.config.addItemText;
if (this.isSelectMultipleElement || this.isTextElement) {
if (this.config.maxItemCount > 0 && this.config.maxItemCount <= activeItems.length) {
// If there is a max entry limit and we have reached that limit
// don't update
canAddItem = false;
notice = isType('Function', this.config.maxItemText) ?
this.config.maxItemText(this.config.maxItemCount) :
this.config.maxItemText;
}
}
if (this.isTextElement && this.config.addItems && canAddItem) {
// If a user has supplied a regular expression filter
if (this.config.regexFilter) {
// Determine whether we can update based on whether
// our regular expression passes
canAddItem = this._regexFilter(value);
}
}
// If no duplicates are allowed, and the value already exists
// in the array
const isUnique = !activeItems.some((item) => {
if (isType('String', value)) {
return item.value === value.trim();
}
return item.value === value;
});
if (
!isUnique &&
!this.config.duplicateItems &&
!this.isSelectOneElement &&
canAddItem
) {
canAddItem = false;
notice = isType('Function', this.config.uniqueItemText) ?
this.config.uniqueItemText(value) :
this.config.uniqueItemText;
}
return {
response: canAddItem,
notice,
};
}
/**
* Apply or remove a loading state to the component.
* @param {Boolean} isLoading default value set to 'true'.
* @return
* @private
*/
_handleLoadingState(isLoading = true) {
let placeholderItem = this.itemList.querySelector(`.${this.config.classNames.placeholder}`);
if (isLoading) {
this.containerOuter.classList.add(this.config.classNames.loadingState);
this.containerOuter.setAttribute('aria-busy', 'true');
if (this.isSelectOneElement) {
if (!placeholderItem) {
placeholderItem = this._getTemplate('placeholder', this.config.loadingText);
this.itemList.appendChild(placeholderItem);
} else {
placeholderItem.innerHTML = this.config.loadingText;
}
} else {
this.input.placeholder = this.config.loadingText;
}
} else {
// Remove loading states/text
this.containerOuter.classList.remove(this.config.classNames.loadingState);
const placeholder = this.config.placeholder ?
this.config.placeholderValue ||
this.passedElement.getAttribute('placeholder') :
false;
if (this.isSelectOneElement) {
placeholderItem.innerHTML = placeholder || '';
} else {
this.input.placeholder = placeholder || '';
}
}
}
/**
* Retrieve the callback used to populate component's choices in an async way.
* @returns {Function} The callback as a function.
* @private
*/
_ajaxCallback() {
return (results, value, label) => {
if (!results || !value) {
return;
}
const parsedResults = isType('Object', results) ? [results] : results;
if (parsedResults && isType('Array', parsedResults) && parsedResults.length) {
// Remove loading states/text
this._handleLoadingState(false);
// Add each result as a choice
parsedResults.forEach((result) => {
if (result.choices) {
const groupId = (result.id || null);
this._addGroup(
result,
groupId,
value,
label
);
} else {
this._addChoice(
result[value],
result[label],
result.selected,
result.disabled,
undefined,
result['customProperties'],
null
);
}
});
} else {
// No results, remove loading state
this._handleLoadingState(false);
}
this.containerOuter.removeAttribute('aria-busy');
};
}
/**
* Filter choices based on search value
* @param {String} value Value to filter by
* @return
* @private
*/
_searchChoices(value) {
const newValue = isType('String', value) ? value.trim() : value;
const currentValue = isType('String', this.currentValue) ? this.currentValue.trim() : this.currentValue;
// If new value matches the desired length and is not the same as the current value with a space
if (newValue.length >= 1 && newValue !== `${currentValue} `) {
const haystack = this.store.getChoicesFilteredBySelectable();
const needle = newValue;
const keys = isType('Array', this.config.searchFields) ? this.config.searchFields : [this.config.searchFields];
const options = Object.assign(this.config.fuseOptions, { keys });
const fuse = new Fuse(haystack, options);
const results = fuse.search(needle);
this.currentValue = newValue;
this.highlightPosition = 0;
this.isSearching = true;
this.store.dispatch(
filterChoices(results)
);
return results.length;
}
return 0;
}
/**
* Determine the action when a user is searching
* @param {String} value Value entered by user
* @return
* @private
*/
_handleSearch(value) {
if (!value) {
return;
}
const choices = this.store.getChoices();
const hasUnactiveChoices = choices.some(option => !option.active);
// Run callback if it is a function
if (this.input === document.activeElement) {
// Check that we have a value to search and the input was an alphanumeric character
if (value && value.length >= this.config.searchFloor) {
let resultCount = 0;
// Check flag to filter search input
if (this.config.searchChoices) {
// Filter available choices
resultCount = this._searchChoices(value);
}
// Trigger search event
triggerEvent(this.passedElement, 'search', {
value,
resultCount
});
} else if (hasUnactiveChoices) {
// Otherwise reset choices to active
this.isSearching = false;
this.store.dispatch(
activateChoices(true)
);
}
}
}
/**
* Trigger event listeners
* @return
* @private
*/
_addEventListeners() {
document.addEventListener('keyup', this._onKeyUp);
document.addEventListener('keydown', this._onKeyDown);
document.addEventListener('click', this._onClick);
document.addEventListener('touchmove', this._onTouchMove);
document.addEventListener('touchend', this._onTouchEnd);
document.addEventListener('mousedown', this._onMouseDown);
document.addEventListener('mouseover', this._onMouseOver);
if (this.isSelectOneElement) {
this.containerOuter.addEventListener('focus', this._onFocus);
this.containerOuter.addEventListener('blur', this._onBlur);
}
this.input.addEventListener('input', this._onInput);
this.input.addEventListener('paste', this._onPaste);
this.input.addEventListener('focus', this._onFocus);
this.input.addEventListener('blur', this._onBlur);
}
/**
* Remove event listeners
* @return
* @private
*/
_removeEventListeners() {
document.removeEventListener('keyup', this._onKeyUp);
document.removeEventListener('keydown', this._onKeyDown);
document.removeEventListener('click', this._onClick);
document.removeEventListener('touchmove', this._onTouchMove);
document.removeEventListener('touchend', this._onTouchEnd);
document.removeEventListener('mousedown', this._onMouseDown);
document.removeEventListener('mouseover', this._onMouseOver);
if (this.isSelectOneElement) {
this.containerOuter.removeEventListener('focus', this._onFocus);
this.containerOuter.removeEventListener('blur', this._onBlur);
}
this.input.removeEventListener('input', this._onInput);
this.input.removeEventListener('paste', this._onPaste);
this.input.removeEventListener('focus', this._onFocus);
this.input.removeEventListener('blur', this._onBlur);
}
/**
* Set the correct input width based on placeholder
* value or input value
* @return
*/
_setInputWidth() {
if ((this.config.placeholderValue || this.passedElement.getAttribute('placeholder') &&
this.config.placeholder)) {
// If there is a placeholder, we only want to set the width of the input when it is a greater
// length than 75% of the placeholder. This stops the input jumping around.
const placeholder = this.config.placeholder ? this.config.placeholderValue ||
this.passedElement.getAttribute('placeholder') : false;
if (this.input.value && this.input.value.length >= (placeholder.length / 1.25)) {
this.input.style.width = getWidthOfInput(this.input);
}
} else {
// If there is no placeholder, resize input to contents
this.input.style.width = getWidthOfInput(this.input);
}
}
/**
* Key down event
* @param {Object} e Event
* @return
*/
_onKeyDown(e) {
if (e.target !== this.input && !this.containerOuter.contains(e.target)) {
return;
}
const target = e.target;
const activeItems = this.store.getItemsFilteredByActive();
const hasFocusedInput = this.input === document.activeElement;
const hasActiveDropdown = this.dropdown.classList.contains(this.config.classNames.activeState);
const hasItems = this.itemList && this.itemList.children;
const keyString = String.fromCharCode(e.keyCode);
const backKey = 46;
const deleteKey = 8;
const enterKey = 13;
const aKey = 65;
const escapeKey = 27;
const upKey = 38;
const downKey = 40;
const pageUpKey = 33;
const pageDownKey = 34;
const ctrlDownKey = e.ctrlKey || e.metaKey;
// If a user is typing and the dropdown is not active
if (!this.isTextElement && /[a-zA-Z0-9-_ ]/.test(keyString) && !hasActiveDropdown) {
this.showDropdown(true);
}
this.canSearch = this.config.searchEnabled;
const onAKey = () => {
// If CTRL + A or CMD + A have been pressed and there are items to select
if (ctrlDownKey && hasItems) {
this.canSearch = false;
if (this.config.removeItems && !this.input.value && this.input === document.activeElement) {
// Highlight items
this.highlightAll();
}
}
};
const onEnterKey = () => {
// If enter key is pressed and the input has a value
if (this.isTextElement && target.value) {
const value = this.input.value;
const canAddItem = this._canAddItem(activeItems, value);
// All is good, add
if (canAddItem.response) {
if (hasActiveDropdown) {
this.hideDropdown();
}
this._addItem(value);
this._triggerChange(value);
this.clearInput();
}
}
if (target.hasAttribute('data-button')) {
this._handleButtonAction(activeItems, target);
e.preventDefault();
}
if (hasActiveDropdown) {
e.preventDefault();
const highlighted = this.dropdown.querySelector(`.${this.config.classNames.highlightedState}`);
// If we have a highlighted choice
if (highlighted) {
// add enter keyCode value
if (activeItems[0]) {
activeItems[0].keyCode = enterKey;
}
this._handleChoiceAction(activeItems, highlighted);
}
} else if (this.isSelectOneElement) {
// Open single select dropdown if it's not active
if (!hasActiveDropdown) {
this.showDropdown(true);
e.preventDefault();
}
}
};
const onEscapeKey = () => {
if (hasActiveDropdown) {
this.toggleDropdown();
this.containerOuter.focus();
}
};
const onDirectionKey = () => {
// If up or down key is pressed, traverse through options
if (hasActiveDropdown || this.isSelectOneElement) {
// Show dropdown if focus
if (!hasActiveDropdown) {
this.showDropdown(true);
}
this.canSearch = false;
const directionInt = e.keyCode === downKey || e.keyCode === pageDownKey ? 1 : -1;
const skipKey = e.metaKey || e.keyCode === pageDownKey || e.keyCode === pageUpKey;
let nextEl;
if (skipKey) {
if (directionInt > 0) {
nextEl = Array.from(this.dropdown.querySelectorAll('[data-choice-selectable]')).pop();
} else {
nextEl = this.dropdown.querySelector('[data-choice-selectable]');
}
} else {
const currentEl = this.dropdown.querySelector(`.${this.config.classNames.highlightedState}`);
if (currentEl) {
nextEl = getAdjacentEl(currentEl, '[data-cho