mirror of
https://github.com/Choices-js/Choices.git
synced 2024-06-16 12:45:10 +02:00
e7d775e2ae
Instead of attaching a new root-level event listener for bubbling events for every choices instance, use a simple event delegation script to handle each event type. Each event callback function already is coded as if it were fully delegated, since the events are attached at the document level, so no changes are needed to detect which element is being called. Note that focus and blur event do not bubble, so they have been left as they are. Also note that the event delegation uses an IIFE purposely instead of ES6 modules, since the event list should be globally cached, and it doesn't make sense to instantiate a new scope for each instance (then we're back where we started!) fix #643
2088 lines
58 KiB
JavaScript
2088 lines
58 KiB
JavaScript
import Fuse from 'fuse.js';
|
|
import merge from 'deepmerge';
|
|
|
|
import './lib/delegate-events';
|
|
import Store from './store/store';
|
|
import {
|
|
Dropdown,
|
|
Container,
|
|
Input,
|
|
List,
|
|
WrappedInput,
|
|
WrappedSelect,
|
|
} from './components';
|
|
import { DEFAULT_CONFIG, EVENTS, KEY_CODES } from './constants';
|
|
import { TEMPLATES } from './templates';
|
|
import {
|
|
addChoice,
|
|
filterChoices,
|
|
activateChoices,
|
|
clearChoices,
|
|
} from './actions/choices';
|
|
import { addItem, removeItem, highlightItem } from './actions/items';
|
|
import { addGroup } from './actions/groups';
|
|
import { clearAll, resetTo } from './actions/misc';
|
|
import { setIsLoading } from './actions/general';
|
|
import {
|
|
isScrolledIntoView,
|
|
getAdjacentEl,
|
|
getType,
|
|
isType,
|
|
strToEl,
|
|
sortByScore,
|
|
generateId,
|
|
findAncestorByAttrName,
|
|
fetchFromObject,
|
|
isIE11,
|
|
existsInArray,
|
|
cloneObject,
|
|
diff,
|
|
} from './lib/utils';
|
|
|
|
/**
|
|
* Choices
|
|
* @author Josh Johnson<josh@joshuajohnson.co.uk>
|
|
*/
|
|
class Choices {
|
|
constructor(element = '[data-choice]', userConfig = {}) {
|
|
if (isType('String', element)) {
|
|
const elements = Array.from(document.querySelectorAll(element));
|
|
|
|
// If there are multiple elements, create a new instance
|
|
// for each element besides the first one (as that already has an instance)
|
|
if (elements.length > 1) {
|
|
return this._generateInstances(elements, userConfig);
|
|
}
|
|
}
|
|
|
|
this.config = merge.all(
|
|
[DEFAULT_CONFIG, Choices.userDefaults, userConfig],
|
|
// When merging array configs, replace with a copy of the userConfig array,
|
|
// instead of concatenating with the default array
|
|
{ arrayMerge: (destinationArray, sourceArray) => [...sourceArray] },
|
|
);
|
|
|
|
const invalidConfigOptions = diff(this.config, DEFAULT_CONFIG);
|
|
if (invalidConfigOptions.length) {
|
|
console.warn(
|
|
'Unknown config option(s) passed',
|
|
invalidConfigOptions.join(', '),
|
|
);
|
|
}
|
|
|
|
if (!['auto', 'always'].includes(this.config.renderSelectedChoices)) {
|
|
this.config.renderSelectedChoices = 'auto';
|
|
}
|
|
|
|
// Retrieve triggering element (i.e. element with 'data-choice' trigger)
|
|
const passedElement = isType('String', element)
|
|
? document.querySelector(element)
|
|
: element;
|
|
|
|
if (!passedElement) {
|
|
return console.error(
|
|
'Could not find passed element or passed element was of an invalid type',
|
|
);
|
|
}
|
|
|
|
this._isTextElement = passedElement.type === 'text';
|
|
this._isSelectOneElement = passedElement.type === 'select-one';
|
|
this._isSelectMultipleElement = passedElement.type === 'select-multiple';
|
|
this._isSelectElement =
|
|
this._isSelectOneElement || this._isSelectMultipleElement;
|
|
|
|
if (this._isTextElement) {
|
|
this.passedElement = new WrappedInput({
|
|
element: passedElement,
|
|
classNames: this.config.classNames,
|
|
delimiter: this.config.delimiter,
|
|
});
|
|
} else if (this._isSelectElement) {
|
|
this.passedElement = new WrappedSelect({
|
|
element: passedElement,
|
|
classNames: this.config.classNames,
|
|
});
|
|
}
|
|
|
|
if (!this.passedElement) {
|
|
return console.error('Passed element was of an invalid type');
|
|
}
|
|
|
|
if (
|
|
this.config.shouldSortItems === true &&
|
|
this._isSelectOneElement &&
|
|
!this.config.silent
|
|
) {
|
|
console.warn(
|
|
"shouldSortElements: Type of passed element is 'select-one', falling back to false.",
|
|
);
|
|
}
|
|
|
|
this.initialised = false;
|
|
|
|
this._store = new Store(this.render);
|
|
this._initialState = {};
|
|
this._currentState = {};
|
|
this._prevState = {};
|
|
this._currentValue = '';
|
|
this._canSearch = this.config.searchEnabled;
|
|
this._isScrollingOnIe = false;
|
|
this._highlightPosition = 0;
|
|
this._wasTap = true;
|
|
this._placeholderValue = this._generatePlaceholderValue();
|
|
this._baseId = generateId(this.passedElement.element, 'choices-');
|
|
this._direction = this.passedElement.element.getAttribute('dir') || 'ltr';
|
|
this._idNames = {
|
|
itemChoice: 'item-choice',
|
|
};
|
|
// 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),
|
|
);
|
|
}
|
|
|
|
this._render = this._render.bind(this);
|
|
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._onFormReset = this._onFormReset.bind(this);
|
|
this._onAKey = this._onAKey.bind(this);
|
|
this._onEnterKey = this._onEnterKey.bind(this);
|
|
this._onEscapeKey = this._onEscapeKey.bind(this);
|
|
this._onDirectionKey = this._onDirectionKey.bind(this);
|
|
this._onDeleteKey = this._onDeleteKey.bind(this);
|
|
|
|
// If element has already been initialised with Choices, fail silently
|
|
if (this.passedElement.element.getAttribute('data-choice') === 'active') {
|
|
console.warn(
|
|
'Trying to initialise Choices on element already initialised',
|
|
);
|
|
}
|
|
|
|
// Let's go
|
|
this.init();
|
|
}
|
|
|
|
/* ========================================
|
|
= Public functions =
|
|
======================================== */
|
|
|
|
init() {
|
|
if (this.initialised) {
|
|
return;
|
|
}
|
|
|
|
this._createTemplates();
|
|
this._createElements();
|
|
this._createStructure();
|
|
|
|
// Set initial state (We need to clone the state because some reducers
|
|
// modify the inner objects properties in the state) 🤢
|
|
this._initialState = cloneObject(this._store.state);
|
|
this._store.subscribe(this._render);
|
|
this._render();
|
|
this._addEventListeners();
|
|
|
|
const shouldDisable =
|
|
!this.config.addItems ||
|
|
this.passedElement.element.hasAttribute('disabled');
|
|
|
|
if (shouldDisable) {
|
|
this.disable();
|
|
}
|
|
|
|
this.initialised = true;
|
|
|
|
const { callbackOnInit } = this.config;
|
|
// Run callback if it is a function
|
|
if (callbackOnInit && isType('Function', callbackOnInit)) {
|
|
callbackOnInit.call(this);
|
|
}
|
|
}
|
|
|
|
destroy() {
|
|
if (!this.initialised) {
|
|
return;
|
|
}
|
|
|
|
this._removeEventListeners();
|
|
this.passedElement.reveal();
|
|
this.containerOuter.unwrap(this.passedElement.element);
|
|
|
|
if (this._isSelectElement) {
|
|
this.passedElement.options = this._presetChoices;
|
|
}
|
|
|
|
this.clearStore();
|
|
|
|
this.config.templates = null;
|
|
this.initialised = false;
|
|
}
|
|
|
|
enable() {
|
|
if (this.passedElement.isDisabled) {
|
|
this.passedElement.enable();
|
|
}
|
|
|
|
if (this.containerOuter.isDisabled) {
|
|
this._addEventListeners();
|
|
this.input.enable();
|
|
this.containerOuter.enable();
|
|
}
|
|
|
|
return this;
|
|
}
|
|
|
|
disable() {
|
|
if (!this.passedElement.isDisabled) {
|
|
this.passedElement.disable();
|
|
}
|
|
|
|
if (!this.containerOuter.isDisabled) {
|
|
this._removeEventListeners();
|
|
this.input.disable();
|
|
this.containerOuter.disable();
|
|
}
|
|
|
|
return this;
|
|
}
|
|
|
|
highlightItem(item, runEvent = true) {
|
|
if (!item) {
|
|
return this;
|
|
}
|
|
|
|
const { id, groupId = -1, value = '', label = '' } = item;
|
|
const group = groupId >= 0 ? this._store.getGroupById(groupId) : null;
|
|
|
|
this._store.dispatch(highlightItem(id, true));
|
|
|
|
if (runEvent) {
|
|
this.passedElement.triggerEvent(EVENTS.highlightItem, {
|
|
id,
|
|
value,
|
|
label,
|
|
groupValue: group && group.value ? group.value : null,
|
|
});
|
|
}
|
|
|
|
return this;
|
|
}
|
|
|
|
unhighlightItem(item) {
|
|
if (!item) {
|
|
return this;
|
|
}
|
|
|
|
const { id, groupId = -1, value = '', label = '' } = item;
|
|
const group = groupId >= 0 ? this._store.getGroupById(groupId) : null;
|
|
|
|
this._store.dispatch(highlightItem(id, false));
|
|
this.passedElement.triggerEvent(EVENTS.highlightItem, {
|
|
id,
|
|
value,
|
|
label,
|
|
groupValue: group && group.value ? group.value : null,
|
|
});
|
|
|
|
return this;
|
|
}
|
|
|
|
highlightAll() {
|
|
this._store.items.forEach(item => this.highlightItem(item));
|
|
return this;
|
|
}
|
|
|
|
unhighlightAll() {
|
|
this._store.items.forEach(item => this.unhighlightItem(item));
|
|
return this;
|
|
}
|
|
|
|
removeActiveItemsByValue(value) {
|
|
this._store.activeItems
|
|
.filter(item => item.value === value)
|
|
.forEach(item => this._removeItem(item));
|
|
|
|
return this;
|
|
}
|
|
|
|
removeActiveItems(excludedId) {
|
|
this._store.activeItems
|
|
.filter(({ id }) => id !== excludedId)
|
|
.forEach(item => this._removeItem(item));
|
|
|
|
return this;
|
|
}
|
|
|
|
removeHighlightedItems(runEvent = false) {
|
|
this._store.highlightedActiveItems.forEach(item => {
|
|
this._removeItem(item);
|
|
// If this action was performed by the user
|
|
// trigger the event
|
|
if (runEvent) {
|
|
this._triggerChange(item.value);
|
|
}
|
|
});
|
|
|
|
return this;
|
|
}
|
|
|
|
showDropdown(preventInputFocus) {
|
|
if (this.dropdown.isActive) {
|
|
return this;
|
|
}
|
|
|
|
requestAnimationFrame(() => {
|
|
this.dropdown.show();
|
|
this.containerOuter.open(this.dropdown.distanceFromTopWindow());
|
|
|
|
if (!preventInputFocus && this._canSearch) {
|
|
this.input.focus();
|
|
}
|
|
|
|
this.passedElement.triggerEvent(EVENTS.showDropdown, {});
|
|
});
|
|
|
|
return this;
|
|
}
|
|
|
|
hideDropdown(preventInputBlur) {
|
|
if (!this.dropdown.isActive) {
|
|
return this;
|
|
}
|
|
|
|
requestAnimationFrame(() => {
|
|
this.dropdown.hide();
|
|
this.containerOuter.close();
|
|
|
|
if (!preventInputBlur && this._canSearch) {
|
|
this.input.removeActiveDescendant();
|
|
this.input.blur();
|
|
}
|
|
|
|
this.passedElement.triggerEvent(EVENTS.hideDropdown, {});
|
|
});
|
|
|
|
return this;
|
|
}
|
|
|
|
getValue(valueOnly = false) {
|
|
const values = this._store.activeItems.reduce((selectedItems, item) => {
|
|
const itemValue = valueOnly ? item.value : item;
|
|
selectedItems.push(itemValue);
|
|
return selectedItems;
|
|
}, []);
|
|
|
|
return this._isSelectOneElement ? values[0] : values;
|
|
}
|
|
|
|
setValue(args) {
|
|
if (!this.initialised) {
|
|
return this;
|
|
}
|
|
|
|
[...args].forEach(value => this._setChoiceOrItem(value));
|
|
return this;
|
|
}
|
|
|
|
setChoiceByValue(value) {
|
|
if (!this.initialised || this._isTextElement) {
|
|
return this;
|
|
}
|
|
|
|
// 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 => this._findAndSelectChoiceByValue(val));
|
|
|
|
return this;
|
|
}
|
|
|
|
setChoices(choices = [], value = '', label = '', replaceChoices = false) {
|
|
if (!this._isSelectElement || !value) {
|
|
return this;
|
|
}
|
|
|
|
// Clear choices if needed
|
|
if (replaceChoices) {
|
|
this.clearChoices();
|
|
}
|
|
|
|
this.containerOuter.removeLoadingState();
|
|
const addGroupsAndChoices = groupOrChoice => {
|
|
if (groupOrChoice.choices) {
|
|
this._addGroup({
|
|
group: groupOrChoice,
|
|
id: groupOrChoice.id || null,
|
|
valueKey: value,
|
|
labelKey: label,
|
|
});
|
|
} else {
|
|
this._addChoice({
|
|
value: groupOrChoice[value],
|
|
label: groupOrChoice[label],
|
|
isSelected: groupOrChoice.selected,
|
|
isDisabled: groupOrChoice.disabled,
|
|
customProperties: groupOrChoice.customProperties,
|
|
placeholder: groupOrChoice.placeholder,
|
|
});
|
|
}
|
|
};
|
|
|
|
this._setLoading(true);
|
|
choices.forEach(addGroupsAndChoices);
|
|
this._setLoading(false);
|
|
|
|
return this;
|
|
}
|
|
|
|
clearChoices() {
|
|
this._store.dispatch(clearChoices());
|
|
}
|
|
|
|
clearStore() {
|
|
this._store.dispatch(clearAll());
|
|
return this;
|
|
}
|
|
|
|
clearInput() {
|
|
const shouldSetInputWidth = !this._isSelectOneElement;
|
|
this.input.clear(shouldSetInputWidth);
|
|
|
|
if (!this._isTextElement && this._canSearch) {
|
|
this._isSearching = false;
|
|
this._store.dispatch(activateChoices(true));
|
|
}
|
|
|
|
return this;
|
|
}
|
|
|
|
ajax(fn) {
|
|
if (!this.initialised || !this._isSelectElement || !fn) {
|
|
return this;
|
|
}
|
|
|
|
requestAnimationFrame(() => this._handleLoadingState(true));
|
|
fn(this._ajaxCallback());
|
|
|
|
return this;
|
|
}
|
|
|
|
/* ===== End of Public functions ====== */
|
|
|
|
/* =============================================
|
|
= Private functions =
|
|
============================================= */
|
|
|
|
_render() {
|
|
if (this._store.isLoading()) {
|
|
return;
|
|
}
|
|
|
|
this._currentState = this._store.state;
|
|
|
|
const stateChanged =
|
|
this._currentState.choices !== this._prevState.choices ||
|
|
this._currentState.groups !== this._prevState.groups ||
|
|
this._currentState.items !== this._prevState.items;
|
|
const shouldRenderChoices = this._isSelectElement;
|
|
const shouldRenderItems =
|
|
this._currentState.items !== this._prevState.items;
|
|
|
|
if (!stateChanged) {
|
|
return;
|
|
}
|
|
|
|
if (shouldRenderChoices) {
|
|
this._renderChoices();
|
|
}
|
|
|
|
if (shouldRenderItems) {
|
|
this._renderItems();
|
|
}
|
|
|
|
this._prevState = this._currentState;
|
|
}
|
|
|
|
_renderChoices() {
|
|
const { activeGroups, activeChoices } = this._store;
|
|
let choiceListFragment = document.createDocumentFragment();
|
|
|
|
this.choiceList.clear();
|
|
|
|
if (this.config.resetScrollPosition) {
|
|
requestAnimationFrame(() => this.choiceList.scrollToTop());
|
|
}
|
|
|
|
// If we have grouped options
|
|
if (activeGroups.length >= 1 && !this._isSearching) {
|
|
// If we have a placeholder choice along with groups
|
|
const activePlaceholders = activeChoices.filter(
|
|
activeChoice =>
|
|
activeChoice.placeholder === true && activeChoice.groupId === -1,
|
|
);
|
|
if (activePlaceholders.length >= 1) {
|
|
choiceListFragment = this._createChoicesFragment(
|
|
activePlaceholders,
|
|
choiceListFragment,
|
|
);
|
|
}
|
|
choiceListFragment = this._createGroupsFragment(
|
|
activeGroups,
|
|
activeChoices,
|
|
choiceListFragment,
|
|
);
|
|
} else if (activeChoices.length >= 1) {
|
|
choiceListFragment = this._createChoicesFragment(
|
|
activeChoices,
|
|
choiceListFragment,
|
|
);
|
|
}
|
|
|
|
// If we have choices to show
|
|
if (
|
|
choiceListFragment.childNodes &&
|
|
choiceListFragment.childNodes.length > 0
|
|
) {
|
|
const activeItems = this._store.activeItems;
|
|
const canAddItem = this._canAddItem(activeItems, this.input.value);
|
|
|
|
// ...and we can select them
|
|
if (canAddItem.response) {
|
|
// ...append them and highlight the first choice
|
|
this.choiceList.append(choiceListFragment);
|
|
this._highlightChoice();
|
|
} else {
|
|
// ...otherwise show a notice
|
|
this.choiceList.append(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, 'no-results');
|
|
} else {
|
|
notice = isType('Function', this.config.noChoicesText)
|
|
? this.config.noChoicesText()
|
|
: this.config.noChoicesText;
|
|
|
|
dropdownItem = this._getTemplate('notice', notice, 'no-choices');
|
|
}
|
|
|
|
this.choiceList.append(dropdownItem);
|
|
}
|
|
}
|
|
|
|
_renderItems() {
|
|
const activeItems = this._store.activeItems || [];
|
|
this.itemList.clear();
|
|
|
|
// Create a fragment to store our list items
|
|
// (so we don't have to update the DOM for each item)
|
|
const itemListFragment = this._createItemsFragment(activeItems);
|
|
|
|
// If we have items to add, append them
|
|
if (itemListFragment.childNodes) {
|
|
this.itemList.append(itemListFragment);
|
|
}
|
|
}
|
|
|
|
_createGroupsFragment(groups, choices, fragment) {
|
|
const groupFragment = fragment || document.createDocumentFragment();
|
|
const getGroupChoices = group =>
|
|
choices.filter(choice => {
|
|
if (this._isSelectOneElement) {
|
|
return choice.groupId === group.id;
|
|
}
|
|
return (
|
|
choice.groupId === group.id &&
|
|
(this.config.renderSelectedChoices === 'always' || !choice.selected)
|
|
);
|
|
});
|
|
|
|
// If sorting is enabled, filter groups
|
|
if (this.config.shouldSort) {
|
|
groups.sort(this.config.sortFn);
|
|
}
|
|
|
|
groups.forEach(group => {
|
|
const groupChoices = getGroupChoices(group);
|
|
if (groupChoices.length >= 1) {
|
|
const dropdownGroup = this._getTemplate('choiceGroup', group);
|
|
groupFragment.appendChild(dropdownGroup);
|
|
this._createChoicesFragment(groupChoices, groupFragment, true);
|
|
}
|
|
});
|
|
|
|
return groupFragment;
|
|
}
|
|
|
|
_createChoicesFragment(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.sortFn;
|
|
const appendChoice = choice => {
|
|
const shouldRender =
|
|
renderSelectedChoices === 'auto'
|
|
? this._isSelectOneElement || !choice.selected
|
|
: true;
|
|
if (shouldRender) {
|
|
const dropdownItem = this._getTemplate(
|
|
'choice',
|
|
choice,
|
|
this.config.itemSelectText,
|
|
);
|
|
choicesFragment.appendChild(dropdownItem);
|
|
}
|
|
};
|
|
|
|
let rendererableChoices = choices;
|
|
|
|
if (renderSelectedChoices === 'auto' && !this._isSelectOneElement) {
|
|
rendererableChoices = choices.filter(choice => !choice.selected);
|
|
}
|
|
|
|
// Split array into placeholders and "normal" choices
|
|
const { placeholderChoices, normalChoices } = rendererableChoices.reduce(
|
|
(acc, choice) => {
|
|
if (choice.placeholder) {
|
|
acc.placeholderChoices.push(choice);
|
|
} else {
|
|
acc.normalChoices.push(choice);
|
|
}
|
|
return acc;
|
|
},
|
|
{ placeholderChoices: [], normalChoices: [] },
|
|
);
|
|
|
|
// If sorting is enabled or the user is searching, filter choices
|
|
if (this.config.shouldSort || this._isSearching) {
|
|
normalChoices.sort(filter);
|
|
}
|
|
|
|
let choiceLimit = rendererableChoices.length;
|
|
|
|
// Prepend placeholeder
|
|
const sortedChoices = [...placeholderChoices, ...normalChoices];
|
|
|
|
if (this._isSearching) {
|
|
choiceLimit = searchResultLimit;
|
|
} else if (renderChoiceLimit > 0 && !withinGroup) {
|
|
choiceLimit = renderChoiceLimit;
|
|
}
|
|
|
|
// Add each choice to dropdown within range
|
|
for (let i = 0; i < choiceLimit; i += 1) {
|
|
if (sortedChoices[i]) {
|
|
appendChoice(sortedChoices[i]);
|
|
}
|
|
}
|
|
|
|
return choicesFragment;
|
|
}
|
|
|
|
_createItemsFragment(items, fragment = null) {
|
|
// Create fragment to add elements to
|
|
const { shouldSortItems, sortFn, removeItemButton } = this.config;
|
|
const itemListFragment = fragment || document.createDocumentFragment();
|
|
|
|
// If sorting is enabled, filter items
|
|
if (shouldSortItems && !this._isSelectOneElement) {
|
|
items.sort(sortFn);
|
|
}
|
|
|
|
if (this._isTextElement) {
|
|
// Update the value of the hidden input
|
|
this.passedElement.value = items;
|
|
} else {
|
|
// Update the options of the hidden input
|
|
this.passedElement.options = items;
|
|
}
|
|
|
|
const addItemToFragment = item => {
|
|
// Create new list element
|
|
const listItem = this._getTemplate('item', item, removeItemButton);
|
|
// Append it to list
|
|
itemListFragment.appendChild(listItem);
|
|
};
|
|
|
|
// Add each list item to list
|
|
items.forEach(item => addItemToFragment(item));
|
|
|
|
return itemListFragment;
|
|
}
|
|
|
|
_triggerChange(value) {
|
|
if (value === undefined || value === null) {
|
|
return;
|
|
}
|
|
|
|
this.passedElement.triggerEvent(EVENTS.change, {
|
|
value,
|
|
});
|
|
}
|
|
|
|
_selectPlaceholderChoice() {
|
|
const placeholderChoice = this._store.placeholderChoice;
|
|
|
|
if (placeholderChoice) {
|
|
this._addItem({
|
|
value: placeholderChoice.value,
|
|
label: placeholderChoice.label,
|
|
choiceId: placeholderChoice.id,
|
|
groupId: placeholderChoice.groupId,
|
|
placeholder: placeholderChoice.placeholder,
|
|
});
|
|
|
|
this._triggerChange(placeholderChoice.value);
|
|
}
|
|
}
|
|
|
|
_handleButtonAction(activeItems, element) {
|
|
if (
|
|
!activeItems ||
|
|
!element ||
|
|
!this.config.removeItems ||
|
|
!this.config.removeItemButton
|
|
) {
|
|
return;
|
|
}
|
|
|
|
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) {
|
|
this._selectPlaceholderChoice();
|
|
}
|
|
}
|
|
|
|
_handleItemAction(activeItems, element, hasShiftKey = false) {
|
|
if (
|
|
!activeItems ||
|
|
!element ||
|
|
!this.config.removeItems ||
|
|
this._isSelectOneElement
|
|
) {
|
|
return;
|
|
}
|
|
|
|
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 && item.highlighted) {
|
|
this.unhighlightItem(item);
|
|
}
|
|
});
|
|
|
|
// Focus input as without focus, a user cannot do anything with a
|
|
// highlighted item
|
|
this.input.focus();
|
|
}
|
|
|
|
_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.isActive;
|
|
|
|
// Update choice keyCode
|
|
choice.keyCode = passedKeyCode;
|
|
|
|
this.passedElement.triggerEvent(EVENTS.choice, {
|
|
choice,
|
|
});
|
|
|
|
if (choice && !choice.selected && !choice.disabled) {
|
|
const canAddItem = this._canAddItem(activeItems, choice.value);
|
|
|
|
if (canAddItem.response) {
|
|
this._addItem({
|
|
value: choice.value,
|
|
label: choice.label,
|
|
choiceId: choice.id,
|
|
groupId: choice.groupId,
|
|
customProperties: choice.customProperties,
|
|
placeholder: choice.placeholder,
|
|
keyCode: 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(true);
|
|
this.containerOuter.focus();
|
|
}
|
|
}
|
|
|
|
_handleBackspace(activeItems) {
|
|
if (!this.config.removeItems || !activeItems) {
|
|
return;
|
|
}
|
|
|
|
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.input.setWidth();
|
|
this._removeItem(lastItem);
|
|
this._triggerChange(lastItem.value);
|
|
} else {
|
|
if (!hasHighlightedItems) {
|
|
// Highlight last item if none already highlighted
|
|
this.highlightItem(lastItem, false);
|
|
}
|
|
this.removeHighlightedItems(true);
|
|
}
|
|
}
|
|
|
|
_setLoading(isLoading) {
|
|
this._store.dispatch(setIsLoading(isLoading));
|
|
}
|
|
|
|
_handleLoadingState(setLoading = true) {
|
|
let placeholderItem = this.itemList.getChild(
|
|
`.${this.config.classNames.placeholder}`,
|
|
);
|
|
|
|
if (setLoading) {
|
|
this.disable();
|
|
this.containerOuter.addLoadingState();
|
|
|
|
if (this._isSelectOneElement) {
|
|
if (!placeholderItem) {
|
|
placeholderItem = this._getTemplate(
|
|
'placeholder',
|
|
this.config.loadingText,
|
|
);
|
|
this.itemList.append(placeholderItem);
|
|
} else {
|
|
placeholderItem.innerHTML = this.config.loadingText;
|
|
}
|
|
} else {
|
|
this.input.placeholder = this.config.loadingText;
|
|
}
|
|
} else {
|
|
this.enable();
|
|
this.containerOuter.removeLoadingState();
|
|
|
|
if (this._isSelectOneElement) {
|
|
placeholderItem.innerHTML = this._placeholderValue || '';
|
|
} else {
|
|
this.input.placeholder = this._placeholderValue || '';
|
|
}
|
|
}
|
|
}
|
|
|
|
_handleSearch(value) {
|
|
if (!value || !this.input.isFocussed) {
|
|
return;
|
|
}
|
|
|
|
const choices = this._store.choices;
|
|
const { searchFloor, searchChoices } = this.config;
|
|
const hasUnactiveChoices = choices.some(option => !option.active);
|
|
|
|
// Check that we have a value to search and the input was an alphanumeric character
|
|
if (value && value.length >= searchFloor) {
|
|
const resultCount = searchChoices ? this._searchChoices(value) : 0;
|
|
// Trigger search event
|
|
this.passedElement.triggerEvent(EVENTS.search, {
|
|
value,
|
|
resultCount,
|
|
});
|
|
} else if (hasUnactiveChoices) {
|
|
// Otherwise reset choices to active
|
|
this._isSearching = false;
|
|
this._store.dispatch(activateChoices(true));
|
|
}
|
|
}
|
|
|
|
_canAddItem(activeItems, value) {
|
|
let canAddItem = true;
|
|
let notice = isType('Function', this.config.addItemText)
|
|
? this.config.addItemText(value)
|
|
: this.config.addItemText;
|
|
|
|
if (!this._isSelectOneElement) {
|
|
const isDuplicateValue = existsInArray(activeItems, value);
|
|
|
|
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.config.duplicateItemsAllowed &&
|
|
isDuplicateValue &&
|
|
canAddItem
|
|
) {
|
|
canAddItem = false;
|
|
notice = isType('Function', this.config.uniqueItemText)
|
|
? this.config.uniqueItemText(value)
|
|
: this.config.uniqueItemText;
|
|
}
|
|
|
|
if (
|
|
this._isTextElement &&
|
|
this.config.addItems &&
|
|
canAddItem &&
|
|
isType('Function', this.config.addItemFilterFn) &&
|
|
!this.config.addItemFilterFn(value)
|
|
) {
|
|
canAddItem = false;
|
|
notice = isType('Function', this.config.customAddItemText)
|
|
? this.config.customAddItemText(value)
|
|
: this.config.customAddItemText;
|
|
}
|
|
}
|
|
|
|
return {
|
|
response: canAddItem,
|
|
notice,
|
|
};
|
|
}
|
|
|
|
_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);
|
|
this._setLoading(true);
|
|
// Add each result as a choice
|
|
parsedResults.forEach(result => {
|
|
if (result.choices) {
|
|
this._addGroup({
|
|
group: result,
|
|
id: result.id || null,
|
|
valueKey: value,
|
|
labelKey: label,
|
|
});
|
|
} else {
|
|
this._addChoice({
|
|
value: fetchFromObject(result, value),
|
|
label: fetchFromObject(result, label),
|
|
isSelected: result.selected,
|
|
isDisabled: result.disabled,
|
|
customProperties: result.customProperties,
|
|
placeholder: result.placeholder,
|
|
});
|
|
}
|
|
});
|
|
|
|
this._setLoading(false);
|
|
|
|
if (this._isSelectOneElement) {
|
|
this._selectPlaceholderChoice();
|
|
}
|
|
} else {
|
|
// No results, remove loading state
|
|
this._handleLoadingState(false);
|
|
}
|
|
};
|
|
}
|
|
|
|
_searchChoices(value) {
|
|
const newValue = isType('String', value) ? value.trim() : value;
|
|
const currentValue = isType('String', this._currentValue)
|
|
? this._currentValue.trim()
|
|
: this._currentValue;
|
|
|
|
if (newValue.length < 1 && newValue === `${currentValue} `) {
|
|
return 0;
|
|
}
|
|
|
|
// If new value matches the desired length and is not the same as the current value with a space
|
|
const haystack = this._store.searchableChoices;
|
|
const needle = newValue;
|
|
const keys = [...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;
|
|
}
|
|
|
|
_addEventListeners() {
|
|
window.delegateEvent.add('keyup', this._onKeyUp);
|
|
window.delegateEvent.add('keydown', this._onKeyDown);
|
|
window.delegateEvent.add('click', this._onClick);
|
|
window.delegateEvent.add('touchmove', this._onTouchMove);
|
|
window.delegateEvent.add('touchend', this._onTouchEnd);
|
|
window.delegateEvent.add('mousedown', this._onMouseDown);
|
|
window.delegateEvent.add('mouseover', this._onMouseOver);
|
|
|
|
if (this._isSelectOneElement) {
|
|
this.containerOuter.element.addEventListener('focus', this._onFocus);
|
|
this.containerOuter.element.addEventListener('blur', this._onBlur);
|
|
}
|
|
|
|
this.input.element.addEventListener('focus', this._onFocus);
|
|
this.input.element.addEventListener('blur', this._onBlur);
|
|
|
|
if (this.input.element.form) {
|
|
this.input.element.form.addEventListener('reset', this._onFormReset);
|
|
}
|
|
|
|
this.input.addEventListeners();
|
|
}
|
|
|
|
_removeEventListeners() {
|
|
window.delegateEvent.remove('keyup', this._onKeyUp);
|
|
window.delegateEvent.remove('keydown', this._onKeyDown);
|
|
window.delegateEvent.remove('click', this._onClick);
|
|
window.delegateEvent.remove('touchmove', this._onTouchMove);
|
|
window.delegateEvent.remove('touchend', this._onTouchEnd);
|
|
window.delegateEvent.remove('mousedown', this._onMouseDown);
|
|
window.delegateEvent.remove('mouseover', this._onMouseOver);
|
|
|
|
if (this._isSelectOneElement) {
|
|
this.containerOuter.element.removeEventListener('focus', this._onFocus);
|
|
this.containerOuter.element.removeEventListener('blur', this._onBlur);
|
|
}
|
|
|
|
this.input.element.removeEventListener('focus', this._onFocus);
|
|
this.input.element.removeEventListener('blur', this._onBlur);
|
|
|
|
if (this.input.element.form) {
|
|
this.input.element.form.removeEventListener('reset', this._onFormReset);
|
|
}
|
|
|
|
this.input.removeEventListeners();
|
|
}
|
|
|
|
_onKeyDown(event) {
|
|
const { target, keyCode, ctrlKey, metaKey } = event;
|
|
|
|
if (
|
|
target !== this.input.element &&
|
|
!this.containerOuter.element.contains(target)
|
|
) {
|
|
return;
|
|
}
|
|
|
|
const activeItems = this._store.activeItems;
|
|
const hasFocusedInput = this.input.isFocussed;
|
|
const hasActiveDropdown = this.dropdown.isActive;
|
|
const hasItems = this.itemList.hasChildren;
|
|
const keyString = String.fromCharCode(keyCode);
|
|
|
|
const {
|
|
BACK_KEY,
|
|
DELETE_KEY,
|
|
ENTER_KEY,
|
|
A_KEY,
|
|
ESC_KEY,
|
|
UP_KEY,
|
|
DOWN_KEY,
|
|
PAGE_UP_KEY,
|
|
PAGE_DOWN_KEY,
|
|
} = KEY_CODES;
|
|
const hasCtrlDownKeyPressed = ctrlKey || metaKey;
|
|
|
|
// If a user is typing and the dropdown is not active
|
|
if (!this._isTextElement && /[a-zA-Z0-9-_ ]/.test(keyString)) {
|
|
this.showDropdown();
|
|
}
|
|
|
|
// Map keys to key actions
|
|
const keyDownActions = {
|
|
[A_KEY]: this._onAKey,
|
|
[ENTER_KEY]: this._onEnterKey,
|
|
[ESC_KEY]: this._onEscapeKey,
|
|
[UP_KEY]: this._onDirectionKey,
|
|
[PAGE_UP_KEY]: this._onDirectionKey,
|
|
[DOWN_KEY]: this._onDirectionKey,
|
|
[PAGE_DOWN_KEY]: this._onDirectionKey,
|
|
[DELETE_KEY]: this._onDeleteKey,
|
|
[BACK_KEY]: this._onDeleteKey,
|
|
};
|
|
|
|
// If keycode has a function, run it
|
|
if (keyDownActions[keyCode]) {
|
|
keyDownActions[keyCode]({
|
|
event,
|
|
target,
|
|
keyCode,
|
|
metaKey,
|
|
activeItems,
|
|
hasFocusedInput,
|
|
hasActiveDropdown,
|
|
hasItems,
|
|
hasCtrlDownKeyPressed,
|
|
});
|
|
}
|
|
}
|
|
|
|
_onKeyUp({ target, keyCode }) {
|
|
if (target !== this.input.element) {
|
|
return;
|
|
}
|
|
|
|
const value = this.input.value;
|
|
const activeItems = this._store.activeItems;
|
|
const canAddItem = this._canAddItem(activeItems, value);
|
|
const { BACK_KEY: backKey, DELETE_KEY: deleteKey } = KEY_CODES;
|
|
|
|
// We are typing into a text input and have a value, we want to show a dropdown
|
|
// notice. Otherwise hide the dropdown
|
|
if (this._isTextElement) {
|
|
const canShowDropdownNotice = canAddItem.notice && value;
|
|
if (canShowDropdownNotice) {
|
|
const dropdownItem = this._getTemplate('notice', canAddItem.notice);
|
|
this.dropdown.element.innerHTML = dropdownItem.outerHTML;
|
|
this.showDropdown(true);
|
|
} else {
|
|
this.hideDropdown(true);
|
|
}
|
|
} else {
|
|
const userHasRemovedValue =
|
|
(keyCode === backKey || keyCode === deleteKey) && !target.value;
|
|
const canReactivateChoices = !this._isTextElement && this._isSearching;
|
|
const canSearch = this._canSearch && canAddItem.response;
|
|
|
|
if (userHasRemovedValue && canReactivateChoices) {
|
|
this._isSearching = false;
|
|
this._store.dispatch(activateChoices(true));
|
|
} else if (canSearch) {
|
|
this._handleSearch(this.input.value);
|
|
}
|
|
}
|
|
|
|
this._canSearch = this.config.searchEnabled;
|
|
}
|
|
|
|
_onAKey({ hasItems, hasCtrlDownKeyPressed }) {
|
|
// If CTRL + A or CMD + A have been pressed and there are items to select
|
|
if (hasCtrlDownKeyPressed && hasItems) {
|
|
this._canSearch = false;
|
|
|
|
const shouldHightlightAll =
|
|
this.config.removeItems &&
|
|
!this.input.value &&
|
|
this.input.element === document.activeElement;
|
|
|
|
if (shouldHightlightAll) {
|
|
this.highlightAll();
|
|
}
|
|
}
|
|
}
|
|
|
|
_onEnterKey({ event, target, activeItems, hasActiveDropdown }) {
|
|
const { ENTER_KEY: enterKey } = KEY_CODES;
|
|
const targetWasButton = target.hasAttribute('data-button');
|
|
|
|
if (this._isTextElement && target.value) {
|
|
const value = this.input.value;
|
|
const canAddItem = this._canAddItem(activeItems, value);
|
|
|
|
if (canAddItem.response) {
|
|
this.hideDropdown(true);
|
|
this._addItem({ value });
|
|
this._triggerChange(value);
|
|
this.clearInput();
|
|
}
|
|
}
|
|
|
|
if (targetWasButton) {
|
|
this._handleButtonAction(activeItems, target);
|
|
event.preventDefault();
|
|
}
|
|
|
|
if (hasActiveDropdown) {
|
|
const highlightedChoice = this.dropdown.getChild(
|
|
`.${this.config.classNames.highlightedState}`,
|
|
);
|
|
|
|
if (highlightedChoice) {
|
|
// add enter keyCode value
|
|
if (activeItems[0]) {
|
|
activeItems[0].keyCode = enterKey; // eslint-disable-line no-param-reassign
|
|
}
|
|
this._handleChoiceAction(activeItems, highlightedChoice);
|
|
}
|
|
|
|
event.preventDefault();
|
|
} else if (this._isSelectOneElement) {
|
|
this.showDropdown();
|
|
event.preventDefault();
|
|
}
|
|
}
|
|
|
|
_onEscapeKey({ hasActiveDropdown }) {
|
|
if (hasActiveDropdown) {
|
|
this.hideDropdown(true);
|
|
this.containerOuter.focus();
|
|
}
|
|
}
|
|
|
|
_onDirectionKey({ event, hasActiveDropdown, keyCode, metaKey }) {
|
|
const {
|
|
DOWN_KEY: downKey,
|
|
PAGE_UP_KEY: pageUpKey,
|
|
PAGE_DOWN_KEY: pageDownKey,
|
|
} = KEY_CODES;
|
|
|
|
// If up or down key is pressed, traverse through options
|
|
if (hasActiveDropdown || this._isSelectOneElement) {
|
|
this.showDropdown();
|
|
this._canSearch = false;
|
|
|
|
const directionInt =
|
|
keyCode === downKey || keyCode === pageDownKey ? 1 : -1;
|
|
const skipKey =
|
|
metaKey || keyCode === pageDownKey || keyCode === pageUpKey;
|
|
const selectableChoiceIdentifier = '[data-choice-selectable]';
|
|
|
|
let nextEl;
|
|
if (skipKey) {
|
|
if (directionInt > 0) {
|
|
nextEl = Array.from(
|
|
this.dropdown.element.querySelectorAll(selectableChoiceIdentifier),
|
|
).pop();
|
|
} else {
|
|
nextEl = this.dropdown.element.querySelector(
|
|
selectableChoiceIdentifier,
|
|
);
|
|
}
|
|
} else {
|
|
const currentEl = this.dropdown.element.querySelector(
|
|
`.${this.config.classNames.highlightedState}`,
|
|
);
|
|
if (currentEl) {
|
|
nextEl = getAdjacentEl(
|
|
currentEl,
|
|
selectableChoiceIdentifier,
|
|
directionInt,
|
|
);
|
|
} else {
|
|
nextEl = this.dropdown.element.querySelector(
|
|
selectableChoiceIdentifier,
|
|
);
|
|
}
|
|
}
|
|
|
|
if (nextEl) {
|
|
// We prevent default to stop the cursor moving
|
|
// when pressing the arrow
|
|
if (
|
|
!isScrolledIntoView(nextEl, this.choiceList.element, directionInt)
|
|
) {
|
|
this.choiceList.scrollToChoice(nextEl, directionInt);
|
|
}
|
|
this._highlightChoice(nextEl);
|
|
}
|
|
|
|
// Prevent default to maintain cursor position whilst
|
|
// traversing dropdown options
|
|
event.preventDefault();
|
|
}
|
|
}
|
|
|
|
_onDeleteKey({ event, target, hasFocusedInput, activeItems }) {
|
|
// If backspace or delete key is pressed and the input has no value
|
|
if (hasFocusedInput && !target.value && !this._isSelectOneElement) {
|
|
this._handleBackspace(activeItems);
|
|
event.preventDefault();
|
|
}
|
|
}
|
|
|
|
_onTouchMove() {
|
|
if (this._wasTap) {
|
|
this._wasTap = false;
|
|
}
|
|
}
|
|
|
|
_onTouchEnd(event) {
|
|
const { target } = event || event.touches[0];
|
|
const touchWasWithinContainer =
|
|
this._wasTap && this.containerOuter.element.contains(target);
|
|
|
|
if (touchWasWithinContainer) {
|
|
const containerWasExactTarget =
|
|
target === this.containerOuter.element ||
|
|
target === this.containerInner.element;
|
|
|
|
if (containerWasExactTarget) {
|
|
if (this._isTextElement) {
|
|
this.input.focus();
|
|
} else if (this._isSelectMultipleElement) {
|
|
this.showDropdown();
|
|
}
|
|
}
|
|
|
|
// Prevents focus event firing
|
|
event.stopPropagation();
|
|
}
|
|
|
|
this._wasTap = true;
|
|
}
|
|
|
|
_onMouseDown(event) {
|
|
const { target, shiftKey } = event;
|
|
// If we have our mouse down on the scrollbar and are on IE11...
|
|
if (this.choiceList.element.contains(target) && isIE11()) {
|
|
this._isScrollingOnIe = true;
|
|
}
|
|
|
|
if (
|
|
!this.containerOuter.element.contains(target) ||
|
|
target === this.input.element
|
|
) {
|
|
return;
|
|
}
|
|
|
|
const activeItems = this._store.activeItems;
|
|
const hasShiftKey = shiftKey;
|
|
const buttonTarget = findAncestorByAttrName(target, 'data-button');
|
|
const itemTarget = findAncestorByAttrName(target, 'data-item');
|
|
const choiceTarget = findAncestorByAttrName(target, 'data-choice');
|
|
|
|
if (buttonTarget) {
|
|
this._handleButtonAction(activeItems, buttonTarget);
|
|
} else if (itemTarget) {
|
|
this._handleItemAction(activeItems, itemTarget, hasShiftKey);
|
|
} else if (choiceTarget) {
|
|
this._handleChoiceAction(activeItems, choiceTarget);
|
|
}
|
|
|
|
event.preventDefault();
|
|
}
|
|
|
|
_onMouseOver({ target }) {
|
|
const targetWithinDropdown =
|
|
target === this.dropdown || this.dropdown.element.contains(target);
|
|
const shouldHighlightChoice =
|
|
targetWithinDropdown && target.hasAttribute('data-choice');
|
|
|
|
if (shouldHighlightChoice) {
|
|
this._highlightChoice(target);
|
|
}
|
|
}
|
|
|
|
_onClick({ target }) {
|
|
const clickWasWithinContainer = this.containerOuter.element.contains(
|
|
target,
|
|
);
|
|
|
|
if (clickWasWithinContainer) {
|
|
if (!this.dropdown.isActive && !this.containerOuter.isDisabled) {
|
|
if (this._isTextElement) {
|
|
if (document.activeElement !== this.input.element) {
|
|
this.input.focus();
|
|
}
|
|
} else {
|
|
this.showDropdown();
|
|
this.containerOuter.focus();
|
|
}
|
|
} else if (
|
|
this._isSelectOneElement &&
|
|
target !== this.input.element &&
|
|
!this.dropdown.element.contains(target)
|
|
) {
|
|
this.hideDropdown();
|
|
}
|
|
} else {
|
|
const hasHighlightedItems = this._store.highlightedActiveItems.length > 0;
|
|
|
|
if (hasHighlightedItems) {
|
|
this.unhighlightAll();
|
|
}
|
|
|
|
this.containerOuter.removeFocusState();
|
|
this.hideDropdown(true);
|
|
}
|
|
}
|
|
|
|
_onFocus({ target }) {
|
|
const focusWasWithinContainer = this.containerOuter.element.contains(
|
|
target,
|
|
);
|
|
|
|
if (!focusWasWithinContainer) {
|
|
return;
|
|
}
|
|
|
|
const focusActions = {
|
|
text: () => {
|
|
if (target === this.input.element) {
|
|
this.containerOuter.addFocusState();
|
|
}
|
|
},
|
|
'select-one': () => {
|
|
this.containerOuter.addFocusState();
|
|
if (target === this.input.element) {
|
|
this.showDropdown(true);
|
|
}
|
|
},
|
|
'select-multiple': () => {
|
|
if (target === this.input.element) {
|
|
this.showDropdown(true);
|
|
// If element is a select box, the focused element is the container and the dropdown
|
|
// isn't already open, focus and show dropdown
|
|
this.containerOuter.addFocusState();
|
|
}
|
|
},
|
|
};
|
|
|
|
focusActions[this.passedElement.element.type]();
|
|
}
|
|
|
|
_onBlur({ target }) {
|
|
const blurWasWithinContainer = this.containerOuter.element.contains(target);
|
|
|
|
if (blurWasWithinContainer && !this._isScrollingOnIe) {
|
|
const activeItems = this._store.activeItems;
|
|
const hasHighlightedItems = activeItems.some(item => item.highlighted);
|
|
const blurActions = {
|
|
text: () => {
|
|
if (target === this.input.element) {
|
|
this.containerOuter.removeFocusState();
|
|
if (hasHighlightedItems) {
|
|
this.unhighlightAll();
|
|
}
|
|
this.hideDropdown(true);
|
|
}
|
|
},
|
|
'select-one': () => {
|
|
this.containerOuter.removeFocusState();
|
|
if (
|
|
target === this.input.element ||
|
|
(target === this.containerOuter.element && !this._canSearch)
|
|
) {
|
|
this.hideDropdown(true);
|
|
}
|
|
},
|
|
'select-multiple': () => {
|
|
if (target === this.input.element) {
|
|
this.containerOuter.removeFocusState();
|
|
this.hideDropdown(true);
|
|
if (hasHighlightedItems) {
|
|
this.unhighlightAll();
|
|
}
|
|
}
|
|
},
|
|
};
|
|
|
|
blurActions[this.passedElement.element.type]();
|
|
} else {
|
|
// On IE11, clicking the scollbar blurs our input and thus
|
|
// closes the dropdown. To stop this, we refocus our input
|
|
// if we know we are on IE *and* are scrolling.
|
|
this._isScrollingOnIe = false;
|
|
this.input.element.focus();
|
|
}
|
|
}
|
|
|
|
_onFormReset() {
|
|
this._store.dispatch(resetTo(this._initialState));
|
|
}
|
|
|
|
_highlightChoice(el = null) {
|
|
const choices = Array.from(
|
|
this.dropdown.element.querySelectorAll('[data-choice-selectable]'),
|
|
);
|
|
|
|
if (!choices.length) {
|
|
return;
|
|
}
|
|
|
|
let passedEl = el;
|
|
const highlightedChoices = Array.from(
|
|
this.dropdown.element.querySelectorAll(
|
|
`.${this.config.classNames.highlightedState}`,
|
|
),
|
|
);
|
|
|
|
// Remove any highlighted choices
|
|
highlightedChoices.forEach(choice => {
|
|
choice.classList.remove(this.config.classNames.highlightedState);
|
|
choice.setAttribute('aria-selected', 'false');
|
|
});
|
|
|
|
if (passedEl) {
|
|
this._highlightPosition = choices.indexOf(passedEl);
|
|
} else {
|
|
// Highlight choice based on last known highlight location
|
|
if (choices.length > this._highlightPosition) {
|
|
// If we have an option to highlight
|
|
passedEl = choices[this._highlightPosition];
|
|
} else {
|
|
// Otherwise highlight the option before
|
|
passedEl = choices[choices.length - 1];
|
|
}
|
|
|
|
if (!passedEl) {
|
|
passedEl = choices[0];
|
|
}
|
|
}
|
|
|
|
passedEl.classList.add(this.config.classNames.highlightedState);
|
|
passedEl.setAttribute('aria-selected', 'true');
|
|
this.passedElement.triggerEvent(EVENTS.highlightChoice, { el: passedEl });
|
|
|
|
if (this.dropdown.isActive) {
|
|
// IE11 ignores aria-label and blocks virtual keyboard
|
|
// if aria-activedescendant is set without a dropdown
|
|
this.input.setActiveDescendant(passedEl.id);
|
|
this.containerOuter.setActiveDescendant(passedEl.id);
|
|
}
|
|
}
|
|
|
|
_addItem({
|
|
value,
|
|
label = null,
|
|
choiceId = -1,
|
|
groupId = -1,
|
|
customProperties = null,
|
|
placeholder = false,
|
|
keyCode = null,
|
|
}) {
|
|
let passedValue = isType('String', value) ? value.trim() : value;
|
|
|
|
const passedKeyCode = keyCode;
|
|
const passedCustomProperties = customProperties;
|
|
const items = this._store.items;
|
|
const passedLabel = label || passedValue;
|
|
const passedOptionId = parseInt(choiceId, 10) || -1;
|
|
const group = groupId >= 0 ? this._store.getGroupById(groupId) : null;
|
|
const id = items ? items.length + 1 : 1;
|
|
|
|
// If a prepended value has been passed, prepend it
|
|
if (this.config.prependValue) {
|
|
passedValue = this.config.prependValue + passedValue.toString();
|
|
}
|
|
|
|
// If an appended value has been passed, append it
|
|
if (this.config.appendValue) {
|
|
passedValue += this.config.appendValue.toString();
|
|
}
|
|
|
|
this._store.dispatch(
|
|
addItem({
|
|
value: passedValue,
|
|
label: passedLabel,
|
|
id,
|
|
choiceId: passedOptionId,
|
|
groupId,
|
|
customProperties,
|
|
placeholder,
|
|
keyCode: passedKeyCode,
|
|
}),
|
|
);
|
|
|
|
if (this._isSelectOneElement) {
|
|
this.removeActiveItems(id);
|
|
}
|
|
|
|
// Trigger change event
|
|
this.passedElement.triggerEvent(EVENTS.addItem, {
|
|
id,
|
|
value: passedValue,
|
|
label: passedLabel,
|
|
customProperties: passedCustomProperties,
|
|
groupValue: group && group.value ? group.value : undefined,
|
|
keyCode: passedKeyCode,
|
|
});
|
|
|
|
return this;
|
|
}
|
|
|
|
_removeItem(item) {
|
|
if (!item || !isType('Object', item)) {
|
|
return this;
|
|
}
|
|
|
|
const { id, value, label, choiceId, groupId } = item;
|
|
const group = groupId >= 0 ? this._store.getGroupById(groupId) : null;
|
|
|
|
this._store.dispatch(removeItem(id, choiceId));
|
|
|
|
if (group && group.value) {
|
|
this.passedElement.triggerEvent(EVENTS.removeItem, {
|
|
id,
|
|
value,
|
|
label,
|
|
groupValue: group.value,
|
|
});
|
|
} else {
|
|
this.passedElement.triggerEvent(EVENTS.removeItem, {
|
|
id,
|
|
value,
|
|
label,
|
|
});
|
|
}
|
|
|
|
return this;
|
|
}
|
|
|
|
_addChoice({
|
|
value,
|
|
label = null,
|
|
isSelected = false,
|
|
isDisabled = false,
|
|
groupId = -1,
|
|
customProperties = null,
|
|
placeholder = false,
|
|
keyCode = null,
|
|
}) {
|
|
if (typeof value === 'undefined' || value === null) {
|
|
return;
|
|
}
|
|
|
|
// Generate unique id
|
|
const choices = this._store.choices;
|
|
const choiceLabel = label || value;
|
|
const choiceId = choices ? choices.length + 1 : 1;
|
|
const choiceElementId = `${this._baseId}-${
|
|
this._idNames.itemChoice
|
|
}-${choiceId}`;
|
|
|
|
this._store.dispatch(
|
|
addChoice({
|
|
value,
|
|
label: choiceLabel,
|
|
id: choiceId,
|
|
groupId,
|
|
disabled: isDisabled,
|
|
elementId: choiceElementId,
|
|
customProperties,
|
|
placeholder,
|
|
keyCode,
|
|
}),
|
|
);
|
|
|
|
if (isSelected) {
|
|
this._addItem({
|
|
value,
|
|
label: choiceLabel,
|
|
choiceId,
|
|
customProperties,
|
|
placeholder,
|
|
keyCode,
|
|
});
|
|
}
|
|
}
|
|
|
|
_addGroup({ group, id, valueKey = 'value', labelKey = 'label' }) {
|
|
const groupChoices = isType('Object', group)
|
|
? group.choices
|
|
: Array.from(group.getElementsByTagName('OPTION'));
|
|
const groupId = id || Math.floor(new Date().valueOf() * Math.random());
|
|
const isDisabled = group.disabled ? group.disabled : false;
|
|
|
|
if (groupChoices) {
|
|
this._store.dispatch(addGroup(group.label, groupId, true, isDisabled));
|
|
|
|
const addGroupChoices = choice => {
|
|
const isOptDisabled =
|
|
choice.disabled || (choice.parentNode && choice.parentNode.disabled);
|
|
|
|
this._addChoice({
|
|
value: choice[valueKey],
|
|
label: isType('Object', choice) ? choice[labelKey] : choice.innerHTML,
|
|
isSelected: choice.selected,
|
|
isDisabled: isOptDisabled,
|
|
groupId,
|
|
customProperties: choice.customProperties,
|
|
placeholder: choice.placeholder,
|
|
});
|
|
};
|
|
|
|
groupChoices.forEach(addGroupChoices);
|
|
} else {
|
|
this._store.dispatch(
|
|
addGroup(group.label, group.id, false, group.disabled),
|
|
);
|
|
}
|
|
}
|
|
|
|
_getTemplate(template, ...args) {
|
|
if (!template) {
|
|
return null;
|
|
}
|
|
|
|
const { templates, classNames } = this.config;
|
|
return templates[template].call(this, classNames, ...args);
|
|
}
|
|
|
|
_createTemplates() {
|
|
const { callbackOnCreateTemplates } = this.config;
|
|
let userTemplates = {};
|
|
|
|
if (
|
|
callbackOnCreateTemplates &&
|
|
isType('Function', callbackOnCreateTemplates)
|
|
) {
|
|
userTemplates = callbackOnCreateTemplates.call(this, strToEl);
|
|
}
|
|
|
|
this.config.templates = merge(TEMPLATES, userTemplates);
|
|
}
|
|
|
|
_createElements() {
|
|
this.containerOuter = new Container({
|
|
element: this._getTemplate(
|
|
'containerOuter',
|
|
this._direction,
|
|
this._isSelectElement,
|
|
this._isSelectOneElement,
|
|
this.config.searchEnabled,
|
|
this.passedElement.element.type,
|
|
),
|
|
classNames: this.config.classNames,
|
|
type: this.passedElement.element.type,
|
|
position: this.config.position,
|
|
});
|
|
|
|
this.containerInner = new Container({
|
|
element: this._getTemplate('containerInner'),
|
|
classNames: this.config.classNames,
|
|
type: this.passedElement.element.type,
|
|
position: this.config.position,
|
|
});
|
|
|
|
this.input = new Input({
|
|
element: this._getTemplate('input', this._placeholderValue),
|
|
classNames: this.config.classNames,
|
|
type: this.passedElement.element.type,
|
|
});
|
|
|
|
this.choiceList = new List({
|
|
element: this._getTemplate('choiceList', this._isSelectOneElement),
|
|
});
|
|
|
|
this.itemList = new List({
|
|
element: this._getTemplate('itemList', this._isSelectOneElement),
|
|
});
|
|
|
|
this.dropdown = new Dropdown({
|
|
element: this._getTemplate('dropdown'),
|
|
classNames: this.config.classNames,
|
|
type: this.passedElement.element.type,
|
|
});
|
|
}
|
|
|
|
_createStructure() {
|
|
// Hide original element
|
|
this.passedElement.conceal();
|
|
// Wrap input in container preserving DOM ordering
|
|
this.containerInner.wrap(this.passedElement.element);
|
|
// Wrapper inner container with outer container
|
|
this.containerOuter.wrap(this.containerInner.element);
|
|
|
|
if (this._isSelectOneElement) {
|
|
this.input.placeholder = this.config.searchPlaceholderValue || '';
|
|
} else if (this._placeholderValue) {
|
|
this.input.placeholder = this._placeholderValue;
|
|
this.input.setWidth(true);
|
|
}
|
|
|
|
this.containerOuter.element.appendChild(this.containerInner.element);
|
|
this.containerOuter.element.appendChild(this.dropdown.element);
|
|
this.containerInner.element.appendChild(this.itemList.element);
|
|
|
|
if (!this._isTextElement) {
|
|
this.dropdown.element.appendChild(this.choiceList.element);
|
|
}
|
|
|
|
if (!this._isSelectOneElement) {
|
|
this.containerInner.element.appendChild(this.input.element);
|
|
} else if (this.config.searchEnabled) {
|
|
this.dropdown.element.insertBefore(
|
|
this.input.element,
|
|
this.dropdown.element.firstChild,
|
|
);
|
|
}
|
|
|
|
if (this._isSelectElement) {
|
|
this._addPredefinedChoices();
|
|
} else if (this._isTextElement) {
|
|
this._addPredefinedItems();
|
|
}
|
|
}
|
|
|
|
_addPredefinedChoices() {
|
|
const passedGroups = this.passedElement.optionGroups;
|
|
|
|
this._highlightPosition = 0;
|
|
this._isSearching = false;
|
|
this._setLoading(true);
|
|
|
|
if (passedGroups && passedGroups.length) {
|
|
// If we have a placeholder option
|
|
const placeholderChoice = this.passedElement.placeholderOption;
|
|
if (
|
|
placeholderChoice &&
|
|
placeholderChoice.parentNode.tagName === 'SELECT'
|
|
) {
|
|
this._addChoice({
|
|
value: placeholderChoice.value,
|
|
label: placeholderChoice.innerHTML,
|
|
isSelected: placeholderChoice.selected,
|
|
isDisabled: placeholderChoice.disabled,
|
|
placeholder: true,
|
|
});
|
|
}
|
|
|
|
passedGroups.forEach(group =>
|
|
this._addGroup({
|
|
group,
|
|
id: group.id || null,
|
|
}),
|
|
);
|
|
} else {
|
|
const passedOptions = this.passedElement.options;
|
|
const filter = this.config.sortFn;
|
|
const allChoices = this._presetChoices;
|
|
|
|
// Create array of options from option elements
|
|
passedOptions.forEach(o => {
|
|
allChoices.push({
|
|
value: o.value,
|
|
label: o.innerHTML,
|
|
selected: o.selected,
|
|
disabled: o.disabled || o.parentNode.disabled,
|
|
placeholder: o.hasAttribute('placeholder'),
|
|
customProperties: o.getAttribute('data-custom-properties'),
|
|
});
|
|
});
|
|
|
|
// If sorting is enabled or the user is searching, filter choices
|
|
if (this.config.shouldSort) allChoices.sort(filter);
|
|
|
|
// Determine whether there is a selected choice
|
|
const hasSelectedChoice = allChoices.some(choice => choice.selected);
|
|
const handleChoice = (choice, index) => {
|
|
const { value, label, customProperties, placeholder } = choice;
|
|
|
|
if (this._isSelectElement) {
|
|
// If the choice is actually a group
|
|
if (choice.choices) {
|
|
this._addGroup({
|
|
group: choice,
|
|
id: choice.id || null,
|
|
});
|
|
} else {
|
|
// If there is a selected choice already or the choice is not
|
|
// the first in the array, add each choice normally
|
|
// Otherwise pre-select the first choice in the array if it's a single select
|
|
const shouldPreselect =
|
|
this._isSelectOneElement && !hasSelectedChoice && index === 0;
|
|
const isSelected = shouldPreselect ? true : choice.selected;
|
|
const isDisabled = shouldPreselect ? false : choice.disabled;
|
|
|
|
this._addChoice({
|
|
value,
|
|
label,
|
|
isSelected,
|
|
isDisabled,
|
|
customProperties,
|
|
placeholder,
|
|
});
|
|
}
|
|
} else {
|
|
this._addChoice({
|
|
value,
|
|
label,
|
|
isSelected: choice.selected,
|
|
isDisabled: choice.disabled,
|
|
customProperties,
|
|
placeholder,
|
|
});
|
|
}
|
|
};
|
|
|
|
// Add each choice
|
|
allChoices.forEach((choice, index) => handleChoice(choice, index));
|
|
}
|
|
|
|
this._setLoading(false);
|
|
}
|
|
|
|
_addPredefinedItems() {
|
|
const handlePresetItem = item => {
|
|
const itemType = getType(item);
|
|
if (itemType === 'Object' && item.value) {
|
|
this._addItem({
|
|
value: item.value,
|
|
label: item.label,
|
|
choiceId: item.id,
|
|
customProperties: item.customProperties,
|
|
placeholder: item.placeholder,
|
|
});
|
|
} else if (itemType === 'String') {
|
|
this._addItem({
|
|
value: item,
|
|
});
|
|
}
|
|
};
|
|
|
|
this._presetItems.forEach(item => handlePresetItem(item));
|
|
}
|
|
|
|
_setChoiceOrItem(item) {
|
|
const itemType = getType(item).toLowerCase();
|
|
const handleType = {
|
|
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({
|
|
value: item.value,
|
|
label: item.label,
|
|
isSelected: true,
|
|
isDisabled: false,
|
|
customProperties: item.customProperties,
|
|
placeholder: item.placeholder,
|
|
});
|
|
} else {
|
|
this._addItem({
|
|
value: item.value,
|
|
label: item.label,
|
|
choiceId: item.id,
|
|
customProperties: item.customProperties,
|
|
placeholder: item.placeholder,
|
|
});
|
|
}
|
|
},
|
|
string: () => {
|
|
if (!this._isTextElement) {
|
|
this._addChoice({
|
|
value: item,
|
|
label: item,
|
|
isSelected: true,
|
|
isDisabled: false,
|
|
});
|
|
} else {
|
|
this._addItem({
|
|
value: item,
|
|
});
|
|
}
|
|
},
|
|
};
|
|
|
|
handleType[itemType]();
|
|
}
|
|
|
|
_findAndSelectChoiceByValue(val) {
|
|
const choices = this._store.choices;
|
|
// Check 'value' property exists and the choice isn't already selected
|
|
const foundChoice = choices.find(choice =>
|
|
this.config.itemComparer(choice.value, val),
|
|
);
|
|
|
|
if (foundChoice && !foundChoice.selected) {
|
|
this._addItem({
|
|
value: foundChoice.value,
|
|
label: foundChoice.label,
|
|
choiceId: foundChoice.id,
|
|
groupId: foundChoice.groupId,
|
|
customProperties: foundChoice.customProperties,
|
|
placeholder: foundChoice.placeholder,
|
|
keyCode: foundChoice.keyCode,
|
|
});
|
|
}
|
|
}
|
|
|
|
_generateInstances(elements, config) {
|
|
return elements.reduce(
|
|
(instances, element) => {
|
|
instances.push(new Choices(element, config));
|
|
return instances;
|
|
},
|
|
[this],
|
|
);
|
|
}
|
|
|
|
_generatePlaceholderValue() {
|
|
if (this._isSelectOneElement) {
|
|
return false;
|
|
}
|
|
|
|
return this.config.placeholder
|
|
? this.config.placeholderValue ||
|
|
this.passedElement.element.getAttribute('placeholder')
|
|
: false;
|
|
}
|
|
|
|
/* ===== End of Private functions ====== */
|
|
}
|
|
|
|
Choices.userDefaults = {};
|
|
// We cannot export default here due to Webpack: https://github.com/webpack/webpack/issues/3929
|
|
module.exports = Choices;
|