Choices/src/scripts/src/choices.js

2462 lines
70 KiB
JavaScript
Raw Normal View History

2016-05-03 15:55:38 +02:00
import Fuse from 'fuse.js';
2017-07-24 16:18:43 +02:00
import Store from './store/store';
import Dropdown from './components/dropdown';
2017-08-16 17:31:47 +02:00
import Container from './components/container';
2017-08-21 09:53:19 +02:00
import Input from './components/input';
2017-08-29 13:56:54 +02:00
import List from './components/list';
2017-10-12 17:27:23 +02:00
import { DEFAULT_CONFIG, DEFAULT_CLASSNAMES, EVENTS, KEY_CODES } from './constants';
2017-10-10 17:59:49 +02:00
import { TEMPLATES } from './templates';
2017-10-10 16:26:38 +02:00
import { addChoice, filterChoices, activateChoices, clearChoices } from './actions/choices';
import { addItem, removeItem, highlightItem } from './actions/items';
import { addGroup } from './actions/groups';
import { clearAll } from './actions/misc';
2016-08-14 23:14:37 +02:00
import {
isScrolledIntoView,
getAdjacentEl,
wrap,
2017-02-17 10:23:52 +01:00
getType,
isType,
isElement,
strToEl,
extend,
sortByAlpha,
sortByScore,
generateId,
2017-01-01 16:32:09 +01:00
triggerEvent,
2017-08-10 12:57:17 +02:00
findAncestorByAttrName,
2017-08-14 14:46:46 +02:00
regexFilter,
} from './lib/utils';
2017-08-14 14:46:46 +02:00
import './lib/polyfills';
2016-08-14 23:14:37 +02:00
/**
* Choices
*/
2016-10-18 15:15:00 +02:00
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) {
2017-08-15 10:29:42 +02:00
for (let i = 1; i < elements.length; i += 1) {
const el = elements[i];
2017-08-15 14:06:59 +02:00
/* eslint-disable no-new */
new Choices(el, userConfig);
}
}
}
const defaultConfig = {
2017-10-10 13:56:36 +02:00
...DEFAULT_CONFIG,
items: [],
choices: [],
2017-10-10 13:56:36 +02:00
classNames: DEFAULT_CLASSNAMES,
sortFilter: sortByAlpha,
};
// Merge options with user options
2017-08-30 21:19:59 +02:00
this.config = extend(defaultConfig, Choices.userDefaults, userConfig);
2017-08-26 16:41:11 +02:00
if (!['auto', 'always'].includes(this.config.renderSelectedChoices)) {
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)
2016-10-22 21:15:28 +02:00
this.element = element;
this.passedElement = isType('String', element) ? document.querySelector(element) : element;
2017-08-03 12:44:06 +02:00
if (!this.passedElement) {
if (!this.config.silent) {
console.error('Passed element not found');
}
2017-08-15 10:29:42 +02:00
return false;
2017-08-03 12:44:06 +02:00
}
2016-10-26 16:43:15 +02:00
this.isTextElement = this.passedElement.type === 'text';
2017-07-13 16:59:33 +02:00
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;
2017-08-01 17:55:38 +02:00
this.isIe11 = !!(navigator.userAgent.match(/Trident/) && navigator.userAgent.match(/rv[ :]11/));
2017-08-02 10:30:00 +02:00
this.isScrollingOnIe = false;
2017-07-13 16:59:33 +02:00
if (this.config.shouldSortItems === true && this.isSelectOneElement) {
if (!this.config.silent) {
2017-08-26 16:41:11 +02:00
console.warn(
'shouldSortElements: Type of passed element is \'select-one\', falling back to false.',
);
}
}
this.highlightPosition = 0;
2017-05-18 18:56:29 +02:00
this.canSearch = this.config.searchEnabled;
2016-04-04 22:44:32 +02:00
2017-08-02 15:05:26 +02:00
this.placeholder = false;
if (!this.isSelectOneElement) {
this.placeholder = this.config.placeholder ?
2017-08-15 10:29:42 +02:00
(this.config.placeholderValue || this.passedElement.getAttribute('placeholder')) :
false;
2017-08-02 15:05:26 +02:00
}
// 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) {
2017-05-18 10:29:18 +02:00
this.presetItems = this.presetItems.concat(
2017-08-14 14:46:46 +02:00
this.passedElement.value.split(this.config.delimiter),
2017-05-18 10:29:18 +02:00
);
}
2016-09-04 14:44:31 +02:00
2017-06-03 13:04:46 +02:00
// Set unique base Id
this.baseId = generateId(this.passedElement, 'choices-');
2017-10-10 16:26:38 +02:00
this.idNames = {
itemChoice: 'item-choice',
};
// 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);
// Monitor touch taps/scrolls
this.wasTap = true;
// Cutting the mustard
2016-10-26 16:43:15 +02:00
const cuttingTheMustard = 'classList' in document.documentElement;
2017-05-18 18:56:29 +02:00
if (!cuttingTheMustard && !this.config.silent) {
console.error('Choices: Your browser doesn\'t support Choices');
}
2017-07-13 16:59:33 +02:00
const canInit = isElement(this.passedElement) && this.isValidElementType;
if (canInit) {
2017-07-24 15:50:40 +02:00
// If element has already been initialised with Choices
2017-05-18 10:36:33 +02:00
if (this.passedElement.getAttribute('data-choice') === 'active') {
2017-08-15 10:29:42 +02:00
return false;
2017-05-18 10:36:33 +02:00
}
// Let's go
this.init();
2017-05-18 18:56:29 +02:00
} else if (!this.config.silent) {
console.error('Incompatible input passed');
}
}
2016-09-04 23:23:20 +02:00
2017-08-14 14:46:46 +02:00
/* ========================================
2016-09-27 14:44:35 +02:00
= Public functions =
2017-08-14 14:46:46 +02:00
======================================== */
2016-09-27 14:44:35 +02:00
/**
* Initialise Choices
* @return
* @public
*/
init() {
2017-08-30 14:04:19 +02:00
if (this.initialised) {
2017-05-18 10:36:33 +02:00
return;
}
2016-09-05 23:31:20 +02:00
// 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();
2017-08-30 14:04:19 +02:00
const callback = this.config.callbackOnInit;
2016-09-05 23:31:20 +02:00
// Run callback if it is a function
2017-08-15 15:31:14 +02:00
if (callback && isType('Function', callback)) {
callback.call(this);
}
}
/**
* Destroy Choices and nullify values
* @return
* @public
*/
destroy() {
2017-08-30 14:04:19 +02:00
if (!this.initialised) {
2017-05-18 10:36:33 +02:00
return;
}
2016-09-05 23:31:20 +02:00
// Remove all event listeners
this._removeEventListeners();
2016-09-05 23:31:20 +02:00
// Reinstate passed element
2017-08-14 14:46:46 +02:00
this.passedElement.classList.remove(
this.config.classNames.input,
this.config.classNames.hiddenState,
);
2016-10-22 21:15:28 +02:00
this.passedElement.removeAttribute('tabindex');
2017-08-15 17:57:29 +02:00
// Recover original styles if any
const origStyle = this.passedElement.getAttribute('data-choice-orig-style');
2017-08-14 14:46:46 +02:00
if (origStyle) {
this.passedElement.removeAttribute('data-choice-orig-style');
this.passedElement.setAttribute('style', origStyle);
} else {
this.passedElement.removeAttribute('style');
}
2016-09-05 23:31:20 +02:00
this.passedElement.removeAttribute('aria-hidden');
2017-07-13 16:59:33 +02:00
this.passedElement.removeAttribute('data-choice');
2016-10-22 21:15:28 +02:00
// Re-assign values - this is weird, I know
this.passedElement.value = this.passedElement.value;
2016-10-22 21:15:28 +02:00
// Move passed element back to original position
2017-08-16 17:31:47 +02:00
this.containerOuter.element.parentNode.insertBefore(
this.passedElement,
this.containerOuter.element,
);
2016-10-22 21:15:28 +02:00
// Remove added elements
2017-08-16 17:31:47 +02:00
this.containerOuter.element.parentNode.removeChild(this.containerOuter.element);
2016-10-22 21:15:28 +02:00
// Clear data store
this.clearStore();
// Nullify instance-specific data
this.config.templates = null;
2016-09-05 23:31:20 +02:00
// Uninitialise
this.initialised = false;
}
2016-09-27 14:44:35 +02:00
/**
* 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;
2017-10-12 17:27:23 +02:00
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);
});
2016-09-27 14:44:35 +02:00
// If sorting is enabled, filter groups
if (this.config.shouldSort) {
groups.sort(filter);
}
groups.forEach((group) => {
2017-10-12 17:27:23 +02:00
const groupChoices = getGroupChoices(group);
2016-09-27 14:44:35 +02:00
if (groupChoices.length >= 1) {
const dropdownGroup = this._getTemplate('choiceGroup', group);
groupFragment.appendChild(dropdownGroup);
this.renderChoices(groupChoices, groupFragment, true);
2016-09-27 14:44:35 +02:00
}
});
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) {
2016-09-27 14:44:35 +02:00
// 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;
2016-09-27 14:44:35 +02:00
const filter = this.isSearching ? sortByScore : this.config.sortFilter;
2017-06-27 17:30:08 +02:00
const appendChoice = (choice) => {
const shouldRender = renderSelectedChoices === 'auto' ?
(this.isSelectOneElement || !choice.selected) :
true;
2017-06-27 17:30:08 +02:00
if (shouldRender) {
const dropdownItem = this._getTemplate('choice', choice);
2017-06-27 17:30:08 +02:00
choicesFragment.appendChild(dropdownItem);
}
};
2016-09-27 14:44:35 +02:00
let rendererableChoices = choices;
if (renderSelectedChoices === 'auto' && !this.isSelectOneElement) {
rendererableChoices = choices.filter(choice => !choice.selected);
}
2017-08-02 15:05:26 +02:00
// 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: [] });
2016-09-27 14:44:35 +02:00
// If sorting is enabled or the user is searching, filter choices
if (this.config.shouldSort || this.isSearching) {
2017-08-02 15:05:26 +02:00
normalChoices.sort(filter);
2016-09-27 14:44:35 +02:00
}
let choiceLimit = rendererableChoices.length;
2017-08-02 15:05:26 +02:00
// Prepend placeholeder
const sortedChoices = [...placeholderChoices, ...normalChoices];
2017-06-27 17:30:08 +02:00
if (this.isSearching) {
choiceLimit = searchResultLimit;
} else if (renderChoiceLimit > 0 && !withinGroup) {
choiceLimit = renderChoiceLimit;
2017-06-27 17:30:08 +02:00
}
2016-09-27 14:44:35 +02:00
// Add each choice to dropdown within range
2017-08-15 10:29:42 +02:00
for (let i = 0; i < choiceLimit; i += 1) {
if (sortedChoices[i]) {
appendChoice(sortedChoices[i]);
}
2017-08-14 14:53:58 +02:00
}
2016-09-27 14:44:35 +02:00
return choicesFragment;
}
/**
* Render items into a DOM fragment and append to items list
* @param {Array} items Items to add to list
2017-07-13 16:59:33 +02:00
* @param {DocumentFragment} [fragment] Fragment to add items to (optional)
2016-09-27 14:44:35 +02:00
* @return
* @private
*/
2017-07-13 16:59:33 +02:00
renderItems(items, fragment = null) {
2016-09-27 14:44:35 +02:00
// Create fragment to add elements to
const itemListFragment = fragment || document.createDocumentFragment();
// If sorting is enabled, filter items
2017-07-13 16:59:33 +02:00
if (this.config.shouldSortItems && !this.isSelectOneElement) {
items.sort(this.config.sortFilter);
}
2016-10-26 16:43:15 +02:00
if (this.isTextElement) {
2017-06-27 16:54:22 +02:00
// Simplify store data to just values
const itemsFiltered = this.store.getItemsReducedToValues(items);
2017-07-19 21:47:42 +02:00
const itemsFilteredString = itemsFiltered.join(this.config.delimiter);
// Update the value of the hidden input
this.passedElement.setAttribute('value', itemsFilteredString);
this.passedElement.value = itemsFilteredString;
2016-09-27 14:44:35 +02:00
} else {
const selectedOptionsFragment = document.createDocumentFragment();
2017-10-12 17:27:23 +02:00
const addOptionToFragment = (item) => {
2016-09-27 14:44:35 +02:00
// Create a standard select option
const option = this._getTemplate('option', item);
// Append it to fragment
selectedOptionsFragment.appendChild(option);
2017-10-12 17:27:23 +02:00
};
// Add each list item to list
items.forEach(item => addOptionToFragment(item));
2016-09-27 14:44:35 +02:00
// Update selected choices
this.passedElement.innerHTML = '';
this.passedElement.appendChild(selectedOptionsFragment);
}
2017-10-12 17:27:23 +02:00
const addItemToFragment = (item) => {
2016-09-27 14:44:35 +02:00
// Create new list element
const listItem = this._getTemplate('item', item);
// Append it to list
itemListFragment.appendChild(listItem);
2017-10-12 17:27:23 +02:00
};
// Add each list item to list
items.forEach(item => addItemToFragment(item));
2016-09-27 14:44:35 +02:00
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 (
2017-08-30 14:04:19 +02:00
(this.currentState.choices !== this.prevState.choices ||
this.currentState.groups !== this.prevState.groups ||
2017-08-30 14:04:19 +02:00
this.currentState.items !== this.prevState.items) &&
this.isSelectElement
) {
2016-09-27 14:44:35 +02:00
// Get active groups/choices
2017-08-30 14:04:19 +02:00
const activeGroups = this.store.getGroupsFilteredByActive();
const activeChoices = this.store.getChoicesFilteredByActive();
2016-09-27 14:44:35 +02:00
2017-08-30 14:04:19 +02:00
let choiceListFragment = document.createDocumentFragment();
2016-09-27 14:44:35 +02:00
// Clear choices
2017-08-30 14:04:19 +02:00
this.choiceList.clear();
2017-01-12 15:13:21 +01:00
2016-09-27 14:44:35 +02:00
// Scroll back to top of choices list
2017-08-30 14:04:19 +02:00
if (this.config.resetScrollPosition) {
this.choiceList.scrollTo(0);
}
2016-09-27 14:44:35 +02:00
// If we have grouped options
2017-08-30 14:04:19 +02:00
if (activeGroups.length >= 1 && this.isSearching !== true) {
2017-09-18 17:46:48 +02:00
// 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.renderChoices(activePlaceholders, choiceListFragment);
}
2017-08-30 14:04:19 +02:00
choiceListFragment = this.renderGroups(activeGroups, activeChoices, choiceListFragment);
} else if (activeChoices.length >= 1) {
choiceListFragment = this.renderChoices(activeChoices, choiceListFragment);
}
2016-09-27 14:44:35 +02:00
2017-08-30 14:04:19 +02:00
const activeItems = this.store.getItemsFilteredByActive();
const canAddItem = this._canAddItem(activeItems, this.input.getValue());
2017-05-11 16:11:26 +02:00
// If we have choices to show
2017-08-30 14:04:19 +02:00
if (choiceListFragment.childNodes && choiceListFragment.childNodes.length > 0) {
2017-05-11 16:11:26 +02:00
// ...and we can select them
2017-08-30 14:04:19 +02:00
if (canAddItem.response) {
2017-05-11 16:11:26 +02:00
// ...append them and highlight the first choice
2017-08-30 14:04:19 +02:00
this.choiceList.append(choiceListFragment);
this._highlightChoice();
2016-09-27 14:44:35 +02:00
} else {
2017-08-30 14:04:19 +02:00
// ...otherwise show a notice
this.choiceList.append(this._getTemplate('notice', canAddItem.notice));
}
} else {
2016-09-27 14:44:35 +02:00
// Otherwise show a notice
2017-08-30 14:04:19 +02:00
let dropdownItem;
let notice;
2017-08-30 14:04:19 +02:00
if (this.isSearching) {
notice = isType('Function', this.config.noResultsText) ?
2017-05-11 16:11:26 +02:00
this.config.noResultsText() :
this.config.noResultsText;
2017-08-30 14:04:19 +02:00
dropdownItem = this._getTemplate('notice', notice, 'no-results');
} else {
notice = isType('Function', this.config.noChoicesText) ?
2017-05-11 16:11:26 +02:00
this.config.noChoicesText() :
this.config.noChoicesText;
2017-08-30 14:04:19 +02:00
dropdownItem = this._getTemplate('notice', notice, 'no-choices');
2016-09-27 14:44:35 +02:00
}
2017-08-30 14:04:19 +02:00
this.choiceList.append(dropdownItem);
2016-09-27 14:44:35 +02:00
}
}
// Items
if (this.currentState.items !== this.prevState.items) {
2017-08-02 15:05:26 +02:00
// Get active items (items that can be selected)
2016-09-27 14:44:35 +02:00
const activeItems = this.store.getItemsFilteredByActive();
2017-08-02 15:05:26 +02:00
// Clear list
2017-08-29 13:56:54 +02:00
this.itemList.clear();
2017-08-02 15:05:26 +02:00
2017-08-30 14:04:19 +02:00
if (activeItems && activeItems.length) {
2016-09-27 14:44:35 +02:00
// 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);
// If we have items to add
if (itemListFragment.childNodes) {
// Update list
2017-08-29 13:56:54 +02:00
this.itemList.append(itemListFragment);
2016-09-27 14:44:35 +02:00
}
}
}
this.prevState = this.currentState;
}
}
/**
* Select item (a selected item can be deleted)
* @param {Element} item Element to select
2017-07-13 16:59:33 +02:00
* @param {Boolean} [runEvent=true] Whether to trigger 'highlightItem' event
* @return {Object} Class instance
* @public
*/
2017-01-01 16:32:09 +01:00
highlightItem(item, runEvent = true) {
2017-05-18 10:36:33 +02:00
if (!item) {
2017-07-13 16:59:33 +02:00
return this;
2017-05-18 10:36:33 +02:00
}
const id = item.id;
const groupId = item.groupId;
2017-01-01 16:32:09 +01:00
const group = groupId >= 0 ? this.store.getGroupById(groupId) : null;
2017-07-19 21:47:42 +02:00
this.store.dispatch(
2017-08-14 14:53:58 +02:00
highlightItem(id, true),
2017-07-19 21:47:42 +02:00
);
2017-01-01 16:32:09 +01:00
if (runEvent) {
2017-08-30 14:04:19 +02:00
const eventResponse = {
id,
value: item.value,
label: item.label,
};
2017-06-27 16:54:22 +02:00
if (group && group.value) {
2017-08-30 14:04:19 +02:00
eventResponse.groupValue = group.value;
}
2017-08-30 14:04:19 +02:00
2017-10-10 14:03:04 +02:00
triggerEvent(this.passedElement, EVENTS.highlightItem, eventResponse);
}
2016-08-14 23:14:37 +02:00
return this;
}
/**
* Deselect item
* @param {Element} item Element to de-select
* @return {Object} Class instance
* @public
*/
unhighlightItem(item) {
2017-05-18 10:36:33 +02:00
if (!item) {
2017-07-13 16:59:33 +02:00
return this;
2017-05-18 10:36:33 +02:00
}
const id = item.id;
const groupId = item.groupId;
2017-01-01 16:32:09 +01:00
const group = groupId >= 0 ? this.store.getGroupById(groupId) : null;
2017-08-30 14:04:19 +02:00
const eventResponse = {
id,
value: item.value,
label: item.label,
};
if (group && group.value) {
eventResponse.groupValue = group.value;
}
2017-07-19 21:47:42 +02:00
this.store.dispatch(
2017-08-14 14:53:58 +02:00
highlightItem(id, false),
2017-07-19 21:47:42 +02:00
);
2017-10-10 14:03:04 +02:00
triggerEvent(this.passedElement, EVENTS.highlightItem, eventResponse);
2016-08-14 23:14:37 +02:00
return this;
}
/**
* Highlight items within store
* @return {Object} Class instance
* @public
*/
highlightAll() {
const items = this.store.getItems();
2017-08-15 17:57:29 +02:00
items.forEach(item => this.highlightItem(item));
return this;
}
/**
* Deselect items within store
* @return {Object} Class instance
* @public
*/
unhighlightAll() {
const items = this.store.getItems();
2017-08-15 17:57:29 +02:00
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)) {
2017-07-13 16:59:33 +02:00
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
*/
2017-01-01 16:32:09 +01:00
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
2017-01-01 16:32:09 +01:00
// 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) {
2017-08-30 14:04:19 +02:00
if (this.dropdown.isActive) {
return this;
}
2017-08-29 13:56:54 +02:00
this.containerOuter.open(this.dropdown.getVerticalPos());
2017-08-16 14:01:17 +02:00
this.dropdown.show();
2017-08-23 14:32:07 +02:00
this.input.activate(focusInput);
2017-10-10 14:03:04 +02:00
triggerEvent(this.passedElement, EVENTS.showDropdown, {});
return this;
}
2016-08-04 14:21:24 +02:00
/**
* Hide dropdown from user
* @return {Object} Class instance
* @public
*/
hideDropdown(blurInput = false) {
2017-08-30 14:04:19 +02:00
if (!this.dropdown.isActive) {
return this;
}
this.containerOuter.close();
2017-08-16 14:01:17 +02:00
this.dropdown.hide();
2017-08-23 14:32:07 +02:00
this.input.deactivate(blurInput);
2017-10-10 14:03:04 +02:00
triggerEvent(this.passedElement, EVENTS.hideDropdown, {});
2017-10-12 17:27:23 +02:00
return this;
}
/**
* Determine whether to hide or show dropdown based on its current state
* @return {Object} Class instance
* @public
*/
toggleDropdown() {
if (this.dropdown.isActive) {
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
2017-08-14 14:53:58 +02:00
* @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) => {
2017-08-17 10:04:45 +02:00
const itemValue = valueOnly ? item.value : item;
2017-08-30 14:04:19 +02:00
if (this.isTextElement || item.active) {
2017-08-17 10:04:45 +02:00
selectedItems.push(itemValue);
}
});
2017-08-30 14:04:19 +02:00
return this.isSelectOneElement ? selectedItems[0] : selectedItems;
}
/**
2017-08-14 14:53:58 +02:00
* Set value of input. If the input is a select box, a choice will
* be created and selected otherwise an item will created directly.
2017-02-17 10:27:01 +01:00
* @param {Array} args Array of value objects or value strings
* @return {Object} Class instance
* @public
*/
setValue(args) {
2017-08-15 17:57:29 +02:00
if (!this.initialised) {
return this;
}
2017-05-18 10:36:33 +02:00
2017-08-15 17:57:29 +02:00
// Convert args to an iterable array
const values = [...args];
const handleValue = (item) => {
2017-08-30 14:04:19 +02:00
const itemType = getType(item).toLowerCase();
const handleType = {
object: () => {
if (!item.value) {
return;
}
2017-02-17 10:23:52 +01:00
2017-08-30 14:04:19 +02:00
// 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,
item.placeholder,
);
} else {
this._addItem(
item.value,
item.label,
item.id,
undefined,
item.customProperties,
item.placeholder,
);
}
},
string: () => {
if (!this.isTextElement) {
this._addChoice(
item,
item,
true,
false, -1,
null,
);
} else {
this._addItem(item);
}
},
};
handleType[itemType]();
2017-08-15 17:57:29 +02:00
};
2017-08-30 14:04:19 +02:00
values.forEach(value => handleValue(value));
2017-08-15 17:57:29 +02:00
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) {
2017-08-15 17:57:29 +02:00
if (this.isTextElement) {
return this;
}
const choices = this.store.getChoices();
// If only one value has been passed, convert to array
const choiceValue = isType('Array', value) ? value : [value];
2017-10-12 17:27:23 +02:00
const findAndSelectChoice = (val) => {
2017-08-15 17:57:29 +02:00
// Check 'value' property exists and the choice isn't already selected
const foundChoice = choices.find(choice => choice.value === val);
if (foundChoice) {
if (!foundChoice.selected) {
this._addItem(
foundChoice.value,
foundChoice.label,
foundChoice.id,
foundChoice.groupId,
foundChoice.customProperties,
foundChoice.placeholder,
foundChoice.keyCode,
);
2017-05-18 18:56:29 +02:00
} else if (!this.config.silent) {
2017-08-15 17:57:29 +02:00
console.warn('Attempting to select choice already selected');
}
2017-08-15 17:57:29 +02:00
} else if (!this.config.silent) {
console.warn('Attempting to select choice that does not exist');
}
2017-10-12 17:27:23 +02:00
};
// Loop through each value and
choiceValue.forEach(val => findAndSelectChoice(val));
2017-08-30 14:04:19 +02:00
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) {
2017-08-15 17:57:29 +02:00
if (
!this.initialised ||
!this.isSelectElement ||
!isType('Array', choices) ||
!value
) {
return this;
}
// Clear choices if needed
if (replaceChoices) {
this._clearChoices();
}
// Add choices if passed
if (choices && choices.length) {
2017-08-27 14:49:35 +02:00
this.containerOuter.removeLoadingState();
2017-08-15 17:57:29 +02:00
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,
result.placeholder,
);
}
2017-08-15 17:57:29 +02:00
});
}
2017-08-15 17:57:29 +02:00
return this;
}
/**
* Clear items,choices and groups
* @note Hard delete
* @return {Object} Class instance
* @public
*/
clearStore() {
2017-08-30 14:04:19 +02:00
this.store.dispatch(clearAll());
return this;
}
/**
* Set value of input to blank
* @return {Object} Class instance
* @public
*/
clearInput() {
2017-08-21 09:53:19 +02:00
const shouldSetInputWidth = !this.isSelectOneElement;
this.input.clear(shouldSetInputWidth);
2017-08-15 17:57:29 +02:00
2017-07-13 16:59:33 +02:00
if (!this.isTextElement && this.config.searchEnabled) {
this.isSearching = false;
2017-08-30 14:04:19 +02:00
this.store.dispatch(activateChoices(true));
}
2017-08-15 17:57:29 +02:00
return this;
}
2016-09-27 14:44:35 +02:00
/**
* Enable interaction with Choices
* @return {Object} Class instance
*/
enable() {
2017-08-15 17:57:29 +02:00
if (!this.initialised) {
return this;
}
this.passedElement.disabled = false;
2017-08-30 14:04:19 +02:00
if (this.containerOuter.isDisabled) {
2017-08-15 17:57:29 +02:00
this._addEventListeners();
this.passedElement.removeAttribute('disabled');
2017-08-23 14:32:07 +02:00
this.input.enable();
2017-08-17 14:50:14 +02:00
this.containerOuter.enable();
2016-09-27 14:44:35 +02:00
}
2017-08-15 17:57:29 +02:00
2016-09-27 14:44:35 +02:00
return this;
}
/**
* Disable interaction with Choices
* @return {Object} Class instance
* @public
*/
disable() {
2017-08-15 17:57:29 +02:00
if (!this.initialised) {
return this;
}
this.passedElement.disabled = true;
2017-08-30 14:04:19 +02:00
if (!this.containerOuter.isDisabled) {
2017-08-15 17:57:29 +02:00
this._removeEventListeners();
this.passedElement.setAttribute('disabled', '');
2017-08-23 14:32:07 +02:00
this.input.disable();
2017-08-17 14:50:14 +02:00
this.containerOuter.disable();
}
2017-08-15 17:57:29 +02:00
return this;
}
/**
* Populate options via ajax callback
* @param {Function} fn Function that actually makes an AJAX request
* @return {Object} Class instance
* @public
*/
ajax(fn) {
2017-08-15 17:57:29 +02:00
if (!this.initialised || !this.isSelectElement) {
return this;
}
2017-08-15 17:57:29 +02:00
// Show loading text
requestAnimationFrame(() => this._handleLoadingState(true));
// Run callback
fn(this._ajaxCallback());
return this;
}
2017-08-15 10:29:42 +02:00
/* ===== End of Public functions ====== */
2016-09-27 14:44:35 +02:00
2017-08-14 14:46:46 +02:00
/* =============================================
2016-09-27 14:44:35 +02:00
= Private functions =
2017-08-14 14:46:46 +02:00
============================================= */
2016-09-27 14:44:35 +02:00
/**
* Call change callback
* @param {String} value - last added/deleted/selected value
* @return
* @private
*/
_triggerChange(value) {
2017-05-18 10:36:33 +02:00
if (!value) {
return;
}
2017-10-10 14:03:04 +02:00
triggerEvent(this.passedElement, EVENTS.change, {
2017-08-15 10:29:42 +02:00
value,
2017-01-01 16:32:09 +01:00
});
}
/**
* 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) {
2017-08-30 14:04:19 +02:00
if (
!activeItems ||
!element ||
!this.config.removeItems ||
!this.config.removeItemButton
) {
2017-05-18 10:36:33 +02:00
return;
}
2016-08-02 22:02:52 +02:00
2017-08-30 14:04:19 +02:00
const itemId = element.parentNode.getAttribute('data-id');
const itemToRemove = activeItems.find(item => item.id === parseInt(itemId, 10));
2017-08-30 14:04:19 +02:00
// Remove item associated with button
this._removeItem(itemToRemove);
this._triggerChange(itemToRemove.value);
2017-08-30 14:04:19 +02:00
if (this.isSelectOneElement) {
this._selectPlaceholderChoice();
}
}
/**
* Select placeholder choice
*/
_selectPlaceholderChoice() {
const placeholderChoice = this.store.getPlaceholderChoice();
if (placeholderChoice) {
this._addItem(
placeholderChoice.value,
placeholderChoice.label,
placeholderChoice.id,
placeholderChoice.groupId,
null,
2017-08-15 10:29:42 +02:00
placeholderChoice.placeholder,
);
this._triggerChange(placeholderChoice.value);
}
}
/**
* 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) {
2017-08-30 14:04:19 +02:00
if (
!activeItems ||
!element ||
!this.config.removeItems ||
this.isSelectOneElement
) {
2017-05-18 10:36:33 +02:00
return;
}
2017-08-30 14:04:19 +02:00
const passedId = element.getAttribute('data-id');
2017-08-30 14:04:19 +02:00
// 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);
}
});
2017-08-30 14:04:19 +02:00
// Focus input as without focus, a user cannot do anything with a
// highlighted item
this.input.focus();
}
/**
* Process click of a choice
* @param {Array} activeItems The currently active items
* @param {Element} element Choice being interacted with
2017-07-13 16:59:33 +02:00
* @return
*/
_handleChoiceAction(activeItems, element) {
2017-05-18 10:36:33 +02:00
if (!activeItems || !element) {
return;
}
// If we are clicking on an option
const id = element.getAttribute('data-id');
const choice = this.store.getChoiceById(id);
2017-08-15 10:29:42 +02:00
const passedKeyCode = activeItems[0] && activeItems[0].keyCode ? activeItems[0].keyCode : null;
2017-08-16 14:01:17 +02:00
const hasActiveDropdown = this.dropdown.isActive;
2017-07-19 19:48:46 +02:00
// Update choice keyCode
2017-07-20 13:05:56 +02:00
choice.keyCode = passedKeyCode;
2017-07-19 19:48:46 +02:00
2017-10-10 14:03:04 +02:00
triggerEvent(this.passedElement, EVENTS.choice, {
2017-03-28 15:41:12 +02:00
choice,
});
if (choice && !choice.selected && !choice.disabled) {
const canAddItem = this._canAddItem(activeItems, choice.value);
if (canAddItem.response) {
2017-06-27 16:54:22 +02:00
this._addItem(
choice.value,
choice.label,
choice.id,
choice.groupId,
2017-07-19 19:48:46 +02:00
choice.customProperties,
2017-08-02 15:05:26 +02:00
choice.placeholder,
2017-08-15 10:29:42 +02:00
choice.keyCode,
2017-06-27 16:54:22 +02:00
);
this._triggerChange(choice.value);
}
}
2017-07-13 16:59:33 +02:00
this.clearInput();
2016-09-04 14:44:31 +02:00
// We wont to close the dropdown if we are dealing with a single select box
2017-07-13 16:59:33 +02:00
if (hasActiveDropdown && this.isSelectOneElement) {
this.hideDropdown();
2017-08-27 14:49:35 +02:00
this.containerOuter.focus();
}
}
/**
* Process back space event
* @param {Array} activeItems items
* @return
* @private
*/
_handleBackspace(activeItems) {
2017-08-30 14:04:19 +02:00
if (!this.config.removeItems || !activeItems) {
return;
}
2017-08-30 14:04:19 +02:00
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.setValue(lastItem.value);
this.input.setWidth();
this._removeItem(lastItem);
this._triggerChange(lastItem.value);
} else {
if (!hasHighlightedItems) {
this.highlightItem(lastItem, false);
}
2017-08-30 14:04:19 +02:00
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;
2017-05-18 13:20:01 +02:00
let notice = isType('Function', this.config.addItemText) ?
this.config.addItemText(value) :
this.config.addItemText;
2017-07-13 16:59:33 +02:00
if (this.isSelectMultipleElement || this.isTextElement) {
2017-05-11 16:11:26 +02:00
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;
2017-05-11 16:11:26 +02:00
notice = isType('Function', this.config.maxItemText) ?
this.config.maxItemText(this.config.maxItemCount) :
this.config.maxItemText;
}
}
2017-08-10 12:57:17 +02:00
if (this.isTextElement && this.config.addItems && canAddItem && this.config.regexFilter) {
// If a user has supplied a regular expression filter
2017-08-10 12:57:17 +02:00
// determine whether we can update based on whether
// our regular expression passes
canAddItem = regexFilter(value, this.config.regexFilter);
}
// If no duplicates are allowed, and the value already exists
// in the array
2017-06-27 14:11:31 +02:00
const isUnique = !activeItems.some((item) => {
if (isType('String', value)) {
return item.value === value.trim();
}
return item.value === value;
});
2017-08-15 10:29:42 +02:00
if (!isUnique &&
!this.config.duplicateItems &&
2017-07-13 16:59:33 +02:00
!this.isSelectOneElement &&
canAddItem
) {
canAddItem = false;
2017-06-27 14:11:31 +02:00
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) {
2017-08-29 13:56:54 +02:00
let placeholderItem = this.itemList.getChild(`.${this.config.classNames.placeholder}`);
2017-06-27 16:54:22 +02:00
if (isLoading) {
2017-08-27 14:49:35 +02:00
this.containerOuter.addLoadingState();
2017-07-13 16:59:33 +02:00
if (this.isSelectOneElement) {
2016-09-27 11:08:29 +02:00
if (!placeholderItem) {
placeholderItem = this._getTemplate('placeholder', this.config.loadingText);
2017-08-29 13:56:54 +02:00
this.itemList.append(placeholderItem);
2016-09-27 11:08:29 +02:00
} else {
placeholderItem.innerHTML = this.config.loadingText;
}
} else {
2017-08-23 14:32:07 +02:00
this.input.setPlaceholder(this.config.loadingText);
}
} else {
2017-08-27 14:49:35 +02:00
this.containerOuter.removeLoadingState();
2017-06-27 16:54:22 +02:00
2017-07-13 16:59:33 +02:00
if (this.isSelectOneElement) {
placeholderItem.innerHTML = (this.placeholder || '');
2016-09-27 11:08:29 +02:00
} else {
2017-08-23 14:32:07 +02:00
this.input.setPlaceholder(this.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) => {
2017-05-18 10:36:33 +02:00
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
2017-07-13 16:59:33 +02:00
parsedResults.forEach((result) => {
if (result.choices) {
2017-06-27 16:54:22 +02:00
const groupId = (result.id || null);
this._addGroup(
result,
groupId,
value,
2017-08-15 10:29:42 +02:00
label,
2017-06-27 16:54:22 +02:00
);
} else {
2017-06-27 16:54:22 +02:00
this._addChoice(
result[value],
result[label],
result.selected,
result.disabled,
undefined,
2017-08-02 15:05:26 +02:00
result.customProperties,
2017-08-15 10:29:42 +02:00
result.placeholder,
2017-06-27 16:54:22 +02:00
);
}
});
2017-08-03 12:22:21 +02:00
if (this.isSelectOneElement) {
this._selectPlaceholderChoice();
}
} else {
// No results, remove loading state
this._handleLoadingState(false);
}
};
}
/**
* Filter choices based on search value
* @param {String} value Value to filter by
* @return
* @private
*/
_searchChoices(value) {
2016-09-27 14:44:35 +02:00
const newValue = isType('String', value) ? value.trim() : value;
2017-08-26 16:41:11 +02:00
const currentValue = isType('String', this.currentValue) ?
this.currentValue.trim() :
this.currentValue;
2016-09-27 14:44:35 +02:00
// 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} `) {
2017-08-02 15:05:26 +02:00
const haystack = this.store.getSearchableChoices();
2016-09-27 14:44:35 +02:00
const needle = newValue;
2017-08-26 16:41:11 +02:00
const keys = isType('Array', this.config.searchFields) ?
this.config.searchFields :
[this.config.searchFields];
2017-01-01 16:59:43 +01:00
const options = Object.assign(this.config.fuseOptions, { keys });
const fuse = new Fuse(haystack, options);
2016-09-27 14:44:35 +02:00
const results = fuse.search(needle);
2017-01-01 16:59:43 +01:00
2016-09-27 14:44:35 +02:00
this.currentValue = newValue;
this.highlightPosition = 0;
this.isSearching = true;
2017-08-30 14:04:19 +02:00
this.store.dispatch(filterChoices(results));
2017-07-31 17:17:03 +02:00
return results.length;
2016-09-27 14:44:35 +02:00
}
2017-07-31 17:17:03 +02:00
return 0;
2016-09-27 14:44:35 +02:00
}
/**
* Determine the action when a user is searching
* @param {String} value Value entered by user
* @return
* @private
*/
_handleSearch(value) {
2017-08-30 14:04:19 +02:00
if (!value || !this.input.isFocussed) {
2017-05-18 10:36:33 +02:00
return;
}
const choices = this.store.getChoices();
2017-06-27 14:11:31 +02:00
const hasUnactiveChoices = choices.some(option => !option.active);
2017-08-30 14:04:19 +02:00
// Check that we have a value to search and the input was an alphanumeric character
if (value && value.length >= this.config.searchFloor) {
const resultCount = this.config.searchChoices ? this._searchChoices(value) : 0;
// Trigger search event
2017-10-10 14:03:04 +02:00
triggerEvent(this.passedElement, EVENTS.search, {
2017-08-30 14:04:19 +02:00
value,
resultCount,
});
} else if (hasUnactiveChoices) {
// Otherwise reset choices to active
this.isSearching = false;
this.store.dispatch(activateChoices(true));
2016-08-02 22:10:53 +02:00
}
}
/**
* 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);
2017-07-13 16:59:33 +02:00
if (this.isSelectOneElement) {
2017-08-16 17:31:47 +02:00
this.containerOuter.element.addEventListener('focus', this._onFocus);
this.containerOuter.element.addEventListener('blur', this._onBlur);
}
2017-08-21 09:53:19 +02:00
this.input.element.addEventListener('focus', this._onFocus);
this.input.element.addEventListener('blur', this._onBlur);
this.input.addEventListeners();
}
/**
2016-10-22 21:15:28 +02:00
* 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);
2017-07-13 16:59:33 +02:00
if (this.isSelectOneElement) {
2017-08-16 17:31:47 +02:00
this.containerOuter.element.removeEventListener('focus', this._onFocus);
this.containerOuter.element.removeEventListener('blur', this._onBlur);
}
2017-08-21 09:53:19 +02:00
this.input.element.removeEventListener('focus', this._onFocus);
this.input.element.removeEventListener('blur', this._onBlur);
this.input.removeEventListeners();
}
/**
* Key down event
* @param {Object} e Event
* @return
*/
_onKeyDown(e) {
2017-08-21 09:53:19 +02:00
if (e.target !== this.input.element && !this.containerOuter.element.contains(e.target)) {
2017-05-18 10:36:33 +02:00
return;
}
const target = e.target;
const activeItems = this.store.getItemsFilteredByActive();
2017-08-27 14:49:35 +02:00
const hasFocusedInput = this.input.isFocussed;
2017-08-16 14:01:17 +02:00
const hasActiveDropdown = this.dropdown.isActive;
2017-08-29 13:56:54 +02:00
const hasItems = this.itemList.hasChildren;
const keyString = String.fromCharCode(e.keyCode);
2017-08-30 14:04:19 +02:00
// TO DO: Move into constants file
2017-10-12 17:27:23 +02:00
const backKey = KEY_CODES.BACK_KEY;
const deleteKey = KEY_CODES.DELETE_KEY;
const enterKey = KEY_CODES.ENTER_KEY;
const aKey = KEY_CODES.A_KEY;
const escapeKey = KEY_CODES.ESC_KEY;
const upKey = KEY_CODES.UP_KEY;
const downKey = KEY_CODES.DOWN_KEY;
const pageUpKey = KEY_CODES.PAGE_UP_KEY;
const pageDownKey = KEY_CODES.PAGE_DOWN_KEY;
2017-08-27 14:49:35 +02:00
const ctrlDownKey = (e.ctrlKey || e.metaKey);
// If a user is typing and the dropdown is not active
2017-08-30 14:04:19 +02:00
if (!this.isTextElement && /[a-zA-Z0-9-_ ]/.test(keyString)) {
this.showDropdown(true);
}
2017-05-18 18:56:29 +02:00
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;
2017-08-21 17:59:56 +02:00
if (
this.config.removeItems &&
!this.input.element.value &&
this.input.element === document.activeElement
) {
// Highlight items
2017-07-13 16:59:33 +02:00
this.highlightAll();
2016-08-14 23:14:37 +02:00
}
}
};
const onEnterKey = () => {
// If enter key is pressed and the input has a value
2017-07-13 16:59:33 +02:00
if (this.isTextElement && target.value) {
2017-08-21 09:53:19 +02:00
const value = this.input.element.value;
const canAddItem = this._canAddItem(activeItems, value);
2017-07-19 21:47:42 +02:00
// All is good, add
if (canAddItem.response) {
2017-08-30 14:04:19 +02:00
this.hideDropdown();
this._addItem(value);
this._triggerChange(value);
2017-07-13 16:59:33 +02:00
this.clearInput();
}
}
if (target.hasAttribute('data-button')) {
this._handleButtonAction(activeItems, target);
e.preventDefault();
}
if (hasActiveDropdown) {
e.preventDefault();
2017-08-29 13:56:54 +02:00
const highlighted = this.dropdown.getChild(`.${this.config.classNames.highlightedState}`);
// If we have a highlighted choice
if (highlighted) {
2017-07-19 19:48:46 +02:00
// add enter keyCode value
2017-07-20 13:05:56 +02:00
if (activeItems[0]) {
activeItems[0].keyCode = enterKey;
}
this._handleChoiceAction(activeItems, highlighted);
}
2017-08-30 14:04:19 +02:00
} else if (this.isSelectOneElement) {
// Open single select dropdown if it's not active
2017-08-15 10:29:42 +02:00
this.showDropdown(true);
e.preventDefault();
}
};
const onEscapeKey = () => {
if (hasActiveDropdown) {
2017-08-27 14:49:35 +02:00
this.hideDropdown();
this.containerOuter.focus();
}
};
const onDirectionKey = () => {
// If up or down key is pressed, traverse through options
2017-07-13 16:59:33 +02:00
if (hasActiveDropdown || this.isSelectOneElement) {
// Show dropdown if focus
2017-08-30 14:04:19 +02:00
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) {
2017-08-26 16:41:11 +02:00
nextEl = Array.from(
this.dropdown.element.querySelectorAll('[data-choice-selectable]'),
).pop();
} else {
nextEl = this.dropdown.element.querySelector('[data-choice-selectable]');
}
} else {
2017-08-26 16:41:11 +02:00
const currentEl = this.dropdown.element.querySelector(
`.${this.config.classNames.highlightedState}`,
);
if (currentEl) {
nextEl = getAdjacentEl(currentEl, '[data-choice-selectable]', directionInt);
} else {
nextEl = this.dropdown.element.querySelector('[data-choice-selectable]');
}
2016-08-14 18:19:49 +02:00
}
if (nextEl) {
// We prevent default to stop the cursor moving
// when pressing the arrow
if (!isScrolledIntoView(nextEl, this.choiceList, directionInt)) {
this._scrollToChoice(nextEl, directionInt);
}
this._highlightChoice(nextEl);
2016-08-14 18:19:49 +02:00
}
2016-08-14 23:14:37 +02:00
// Prevent default to maintain cursor position whilst
// traversing dropdown options
e.preventDefault();
}
};
const onDeleteKey = () => {
// If backspace or delete key is pressed and the input has no value
2017-07-13 16:59:33 +02:00
if (hasFocusedInput && !e.target.value && !this.isSelectOneElement) {
this._handleBackspace(activeItems);
e.preventDefault();
}
};
// Map keys to key actions
const keyDownActions = {
[aKey]: onAKey,
[enterKey]: onEnterKey,
[escapeKey]: onEscapeKey,
[upKey]: onDirectionKey,
[pageUpKey]: onDirectionKey,
[downKey]: onDirectionKey,
[pageDownKey]: onDirectionKey,
[deleteKey]: onDeleteKey,
[backKey]: onDeleteKey,
};
// If keycode has a function, run it
if (keyDownActions[e.keyCode]) {
keyDownActions[e.keyCode]();
2016-08-14 18:19:49 +02:00
}
}
/**
* Key up event
* @param {Object} e Event
* @return
* @private
*/
_onKeyUp(e) {
2017-08-21 09:53:19 +02:00
if (e.target !== this.input.element) {
2017-05-18 10:36:33 +02:00
return;
}
2017-08-21 09:53:19 +02:00
const value = this.input.element.value;
2017-05-11 16:11:26 +02:00
const activeItems = this.store.getItemsFilteredByActive();
const canAddItem = this._canAddItem(activeItems, value);
// We are typing into a text input and have a value, we want to show a dropdown
// notice. Otherwise hide the dropdown
2016-10-26 16:43:15 +02:00
if (this.isTextElement) {
if (value) {
if (canAddItem.notice) {
const dropdownItem = this._getTemplate('notice', canAddItem.notice);
2017-08-21 18:09:55 +02:00
this.dropdown.element.innerHTML = dropdownItem.outerHTML;
}
2016-05-02 16:29:05 +02:00
if (canAddItem.response === true) {
2017-08-30 14:04:19 +02:00
this.showDropdown();
} else if (!canAddItem.notice) {
this.hideDropdown();
2016-03-15 23:42:10 +01:00
}
2017-08-30 14:04:19 +02:00
} else {
this.hideDropdown();
}
} else {
const backKey = 46;
const deleteKey = 8;
// If user has removed value...
if ((e.keyCode === backKey || e.keyCode === deleteKey) && !e.target.value) {
// ...and it is a multiple select input, activate choices (if searching)
2017-07-13 16:59:33 +02:00
if (!this.isTextElement && this.isSearching) {
this.isSearching = false;
2017-07-19 21:47:42 +02:00
this.store.dispatch(
2017-08-15 10:29:42 +02:00
activateChoices(true),
2017-07-19 21:47:42 +02:00
);
}
2017-05-11 16:11:26 +02:00
} else if (this.canSearch && canAddItem.response) {
2017-08-21 09:53:19 +02:00
this._handleSearch(this.input.element.value);
}
}
// Re-establish canSearch value from changes in _onKeyDown
this.canSearch = this.config.searchEnabled;
}
/**
* Touch move event
* @return
* @private
*/
_onTouchMove() {
if (this.wasTap === true) {
this.wasTap = false;
}
}
/**
* Touch end event
* @param {Object} e Event
* @return
* @private
*/
_onTouchEnd(e) {
2017-08-27 14:49:35 +02:00
const target = (e.target || e.touches[0].target);
// If a user tapped within our container...
2017-08-16 17:31:47 +02:00
if (this.wasTap === true && this.containerOuter.element.contains(target)) {
// ...and we aren't dealing with a single select box, show dropdown/focus input
2017-08-15 13:50:37 +02:00
if (
2017-08-16 17:31:47 +02:00
(target === this.containerOuter.element || target === this.containerInner.element) &&
2017-08-15 13:50:37 +02:00
!this.isSelectOneElement
) {
2016-10-26 16:43:15 +02:00
if (this.isTextElement) {
2017-08-27 14:49:35 +02:00
// If text element, we only want to focus the input
this.input.focus();
2017-08-30 14:04:19 +02:00
} else {
// If a select box, we want to show the dropdown
2017-08-15 10:29:42 +02:00
this.showDropdown(true);
2016-05-07 14:30:07 +02:00
}
}
// Prevents focus event firing
e.stopPropagation();
2016-05-04 10:02:22 +02:00
}
this.wasTap = true;
}
/**
* Mouse down event
* @param {Object} e Event
* @return
* @private
*/
_onMouseDown(e) {
2017-03-12 14:19:27 +01:00
const target = e.target;
2017-08-02 10:30:00 +02:00
2017-08-02 10:42:12 +02:00
// If we have our mouse down on the scrollbar and are on IE11...
2017-08-02 10:30:00 +02:00
if (target === this.choiceList && this.isIe11) {
this.isScrollingOnIe = true;
}
2017-08-21 09:53:19 +02:00
if (this.containerOuter.element.contains(target) && target !== this.input.element) {
const activeItems = this.store.getItemsFilteredByActive();
const hasShiftKey = e.shiftKey;
2017-08-14 14:33:20 +02:00
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);
}
e.preventDefault();
}
}
/**
* Click event
* @param {Object} e Event
* @return
* @private
*/
_onClick(e) {
const target = e.target;
2017-08-16 14:01:17 +02:00
const hasActiveDropdown = this.dropdown.isActive;
const activeItems = this.store.getItemsFilteredByActive();
// If target is something that concerns us
2017-08-16 17:31:47 +02:00
if (this.containerOuter.element.contains(target)) {
if (!hasActiveDropdown) {
2016-10-26 16:43:15 +02:00
if (this.isTextElement) {
2017-08-21 09:53:19 +02:00
if (document.activeElement !== this.input.element) {
2017-08-27 14:49:35 +02:00
this.input.focus();
}
2017-08-15 10:29:42 +02:00
} else if (this.canSearch) {
this.showDropdown(true);
} else {
2017-08-15 10:29:42 +02:00
this.showDropdown();
2017-10-13 09:51:28 +02:00
// code smell
2017-08-27 14:49:35 +02:00
this.containerOuter.focus();
}
2017-08-15 13:50:37 +02:00
} else if (
this.isSelectOneElement &&
2017-08-21 09:53:19 +02:00
target !== this.input.element &&
!this.dropdown.element.contains(target)
2017-08-15 13:50:37 +02:00
) {
this.hideDropdown(true);
}
} else {
2017-06-27 14:11:31 +02:00
const hasHighlightedItems = activeItems.some(item => item.highlighted);
// De-select any highlighted items
if (hasHighlightedItems) {
this.unhighlightAll();
}
// Remove focus state
2017-08-27 14:49:35 +02:00
this.containerOuter.removeFocusState();
// Close all other dropdowns
2017-08-30 14:04:19 +02:00
this.hideDropdown();
}
}
/**
* Mouse over (hover) event
* @param {Object} e Event
* @return
* @private
*/
_onMouseOver(e) {
// If the dropdown is either the target or one of its children is the target
2017-10-13 09:51:28 +02:00
const targetWithinDropdown = (
e.target === this.dropdown || this.dropdown.element.contains(e.target)
);
const shouldHighlightChoice = targetWithinDropdown && e.target.hasAttribute('data-choice');
if (shouldHighlightChoice) {
this._highlightChoice(e.target);
}
}
/**
* Focus event
* @param {Object} e Event
* @return
* @private
*/
_onFocus(e) {
const target = e.target;
// If target is something that concerns us
2017-08-16 17:31:47 +02:00
if (this.containerOuter.element.contains(target)) {
const focusActions = {
text: () => {
2017-08-21 09:53:19 +02:00
if (target === this.input.element) {
2017-08-27 14:49:35 +02:00
this.containerOuter.addFocusState();
}
},
'select-one': () => {
2017-08-27 14:49:35 +02:00
this.containerOuter.addFocusState();
2017-08-30 14:04:19 +02:00
if (target === this.input.element) {
// Show dropdown if it isn't already showing
2017-08-27 14:49:35 +02:00
this.showDropdown();
}
},
'select-multiple': () => {
2017-08-21 09:53:19 +02:00
if (target === this.input.element) {
2017-07-13 16:59:33 +02:00
// If element is a select box, the focused element is the container and the dropdown
// isn't already open, focus and show dropdown
2017-08-27 14:49:35 +02:00
this.containerOuter.addFocusState();
2017-08-30 14:04:19 +02:00
this.showDropdown(true);
}
},
};
2016-04-29 19:06:46 +02:00
focusActions[this.passedElement.type]();
}
}
/**
* Blur event
* @param {Object} e Event
* @return
* @private
*/
_onBlur(e) {
const target = e.target;
// If target is something that concerns us
2017-08-16 17:31:47 +02:00
if (this.containerOuter.element.contains(target) && !this.isScrollingOnIe) {
const activeItems = this.store.getItemsFilteredByActive();
2017-06-27 14:11:31 +02:00
const hasHighlightedItems = activeItems.some(item => item.highlighted);
const blurActions = {
text: () => {
2017-08-21 09:53:19 +02:00
if (target === this.input.element) {
// Remove the focus state
2017-08-27 14:49:35 +02:00
this.containerOuter.removeFocusState();
2016-08-01 21:00:24 +02:00
// De-select any highlighted items
2016-08-14 23:14:37 +02:00
if (hasHighlightedItems) {
this.unhighlightAll();
}
2017-08-30 14:04:19 +02:00
this.hideDropdown();
}
},
'select-one': () => {
2017-08-27 14:49:35 +02:00
this.containerOuter.removeFocusState();
2017-08-16 17:31:47 +02:00
if (target === this.containerOuter.element) {
// Hide dropdown if it is showing
2017-08-30 14:04:19 +02:00
if (!this.canSearch) {
this.hideDropdown();
2016-08-01 21:00:24 +02:00
}
}
2017-08-30 14:04:19 +02:00
if (target === this.input.element) {
// Hide dropdown if it is showing
2017-05-29 12:11:50 +02:00
this.hideDropdown();
}
},
'select-multiple': () => {
2017-08-21 09:53:19 +02:00
if (target === this.input.element) {
// Remove the focus state
2017-08-27 14:49:35 +02:00
this.containerOuter.removeFocusState();
2017-08-30 14:04:19 +02:00
this.hideDropdown();
// De-select any highlighted items
if (hasHighlightedItems) {
this.unhighlightAll();
}
}
},
};
blurActions[this.passedElement.type]();
2017-08-02 10:30:00 +02:00
} else {
2017-08-02 10:42:12 +02:00
// 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.
2017-08-02 10:30:00 +02:00
this.isScrollingOnIe = false;
2017-08-21 09:53:19 +02:00
this.input.element.focus();
2016-04-09 12:29:56 +02:00
}
}
/**
* Scroll to an option element
2017-07-13 16:59:33 +02:00
* @param {HTMLElement} choice Option to scroll to
* @param {Number} direction Whether option is above or below
* @return
* @private
*/
_scrollToChoice(choice, direction) {
2017-05-18 10:36:33 +02:00
if (!choice) {
return;
}
2017-08-29 13:56:54 +02:00
const dropdownHeight = this.choiceList.height;
const choiceHeight = choice.offsetHeight;
// Distance from bottom of element to top of parent
const choicePos = choice.offsetTop + choiceHeight;
// Scroll position of dropdown
2017-08-29 13:56:54 +02:00
const containerScrollPos = this.choiceList.scrollPos + dropdownHeight;
// Difference between the choice and scroll position
2017-08-15 10:29:42 +02:00
const endPoint = direction > 0 ? (
2017-08-29 13:56:54 +02:00
(this.choiceList.scrollPos + choicePos) - containerScrollPos) :
2017-08-15 10:29:42 +02:00
choice.offsetTop;
const animateScroll = () => {
const strength = 4;
2017-08-29 13:56:54 +02:00
const choiceListScrollTop = this.choiceList.scrollPos;
let continueAnimation = false;
let easing;
let distance;
if (direction > 0) {
2016-10-26 16:43:15 +02:00
easing = (endPoint - choiceListScrollTop) / strength;
distance = easing > 1 ? easing : 1;
2017-08-29 13:56:54 +02:00
this.choiceList.scrollTo(choiceListScrollTop + distance);
2016-10-26 16:43:15 +02:00
if (choiceListScrollTop < endPoint) {
continueAnimation = true;
}
} else {
2016-10-26 16:43:15 +02:00
easing = (choiceListScrollTop - endPoint) / strength;
distance = easing > 1 ? easing : 1;
2017-08-29 13:56:54 +02:00
this.choiceList.scrollTo(choiceListScrollTop - distance);
2016-10-26 16:43:15 +02:00
if (choiceListScrollTop > endPoint) {
continueAnimation = true;
2016-05-02 22:53:21 +02:00
}
}
if (continueAnimation) {
requestAnimationFrame((time) => {
animateScroll(time, endPoint, direction);
});
}
};
requestAnimationFrame((time) => {
animateScroll(time, endPoint, direction);
});
}
/**
* Highlight choice
2017-07-13 16:59:33 +02:00
* @param {HTMLElement} [el] Element to highlight
* @return
* @private
*/
2017-07-13 16:59:33 +02:00
_highlightChoice(el = null) {
// Highlight first element in dropdown
const choices = Array.from(this.dropdown.element.querySelectorAll('[data-choice-selectable]'));
2017-06-03 13:04:46 +02:00
let passedEl = el;
if (choices && choices.length) {
2017-08-17 14:50:14 +02:00
const highlightedChoices = Array.from(
this.dropdown.element.querySelectorAll(`.${this.config.classNames.highlightedState}`),
);
const hasActiveDropdown = this.dropdown.isActive;
// Remove any highlighted choices
highlightedChoices.forEach((choice) => {
choice.classList.remove(this.config.classNames.highlightedState);
choice.setAttribute('aria-selected', 'false');
});
2017-06-03 13:04:46 +02:00
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
2017-06-03 13:04:46 +02:00
passedEl = choices[this.highlightPosition];
} else {
// Otherwise highlight the option before
2017-06-03 13:04:46 +02:00
passedEl = choices[choices.length - 1];
2016-05-02 22:53:21 +02:00
}
2017-06-03 13:04:46 +02:00
if (!passedEl) {
passedEl = choices[0];
}
}
// Highlight given option, and set accessiblity attributes
2017-06-03 13:04:46 +02:00
passedEl.classList.add(this.config.classNames.highlightedState);
passedEl.setAttribute('aria-selected', 'true');
if (hasActiveDropdown) {
// IE11 ignores aria-label and blocks virtual keyboard
// if aria-activedescendant is set without a dropdown
2017-08-27 14:49:35 +02:00
this.input.setActiveDescendant(passedEl.id);
this.containerOuter.setActiveDescendant(passedEl.id);
}
}
}
/**
* Add item to store with correct value
* @param {String} value Value to add to store
2017-07-13 16:59:33 +02:00
* @param {String} [label] Label to add to store
* @param {Number} [choiceId=-1] ID of the associated choice that was selected
* @param {Number} [groupId=-1] ID of group choice is within. Negative number indicates no group
* @param {Object} [customProperties] Object containing user defined properties
* @return {Object} Class instance
* @public
*/
2017-08-15 13:50:37 +02:00
_addItem(
value,
label = null,
choiceId = -1,
groupId = -1,
customProperties = null,
placeholder = false,
keyCode = null,
) {
let passedValue = isType('String', value) ? value.trim() : value;
2017-08-15 10:29:42 +02:00
const passedKeyCode = keyCode;
const items = this.store.getItems();
const passedLabel = label || passedValue;
const passedOptionId = parseInt(choiceId, 10) || -1;
2017-01-01 16:32:09 +01:00
// Get group if group ID passed
const group = groupId >= 0 ? this.store.getGroupById(groupId) : null;
// Generate unique id
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();
2016-03-21 19:53:26 +01:00
}
2016-04-09 12:29:56 +02:00
2017-07-19 21:47:42 +02:00
this.store.dispatch(
addItem(
passedValue,
passedLabel,
id,
passedOptionId,
groupId,
customProperties,
2017-08-02 15:05:26 +02:00
placeholder,
2017-08-15 10:29:42 +02:00
passedKeyCode,
),
2017-07-19 21:47:42 +02:00
);
2016-06-22 00:06:23 +02:00
2017-07-13 16:59:33 +02:00
if (this.isSelectOneElement) {
this.removeActiveItems(id);
2016-06-22 00:06:23 +02:00
}
2017-01-01 16:32:09 +01:00
// Trigger change event
2017-06-27 16:54:22 +02:00
if (group && group.value) {
2017-10-10 14:03:04 +02:00
triggerEvent(this.passedElement, EVENTS.addItem, {
2017-01-01 16:32:09 +01:00
id,
value: passedValue,
label: passedLabel,
2017-01-01 16:32:09 +01:00
groupValue: group.value,
2017-08-15 10:29:42 +02:00
keyCode: passedKeyCode,
2017-01-01 16:32:09 +01:00
});
} else {
2017-10-10 14:03:04 +02:00
triggerEvent(this.passedElement, EVENTS.addItem, {
2017-01-01 16:32:09 +01:00
id,
value: passedValue,
label: passedLabel,
2017-08-15 10:29:42 +02:00
keyCode: passedKeyCode,
2017-01-01 16:32:09 +01:00
});
}
2016-06-22 00:06:23 +02:00
return this;
}
/**
* Remove item from store
* @param {Object} item Item to remove
* @return {Object} Class instance
* @public
*/
_removeItem(item) {
if (!item || !isType('Object', item)) {
2017-07-13 16:59:33 +02:00
return this;
}
2016-06-22 00:06:23 +02:00
const id = item.id;
const value = item.value;
const label = item.label;
const choiceId = item.choiceId;
const groupId = item.groupId;
2017-01-01 16:32:09 +01:00
const group = groupId >= 0 ? this.store.getGroupById(groupId) : null;
2016-06-22 00:06:23 +02:00
2017-07-19 21:47:42 +02:00
this.store.dispatch(
2017-08-15 10:29:42 +02:00
removeItem(id, choiceId),
2017-07-19 21:47:42 +02:00
);
2016-06-22 00:06:23 +02:00
2017-06-27 16:54:22 +02:00
if (group && group.value) {
2017-10-10 14:03:04 +02:00
triggerEvent(this.passedElement, EVENTS.removeItem, {
2017-01-01 16:32:09 +01:00
id,
value,
label,
2017-01-01 16:32:09 +01:00
groupValue: group.value,
});
} else {
2017-10-10 14:03:04 +02:00
triggerEvent(this.passedElement, EVENTS.removeItem, {
2017-01-01 16:32:09 +01:00
id,
value,
label,
2017-01-01 16:32:09 +01:00
});
2016-06-22 00:06:23 +02:00
}
return this;
}
/**
* Add choice to dropdown
* @param {String} value Value of choice
2017-07-13 16:59:33 +02:00
* @param {String} [label] Label of choice
* @param {Boolean} [isSelected=false] Whether choice is selected
* @param {Boolean} [isDisabled=false] Whether choice is disabled
* @param {Number} [groupId=-1] ID of group choice is within. Negative number indicates no group
* @param {Object} [customProperties] Object containing user defined properties
* @return
* @private
*/
2017-08-15 13:50:37 +02:00
_addChoice(
value,
label = null,
isSelected = false,
isDisabled = false,
groupId = -1,
customProperties = null,
placeholder = false,
keyCode = null,
) {
2017-05-18 10:36:33 +02:00
if (typeof value === 'undefined' || value === null) {
return;
}
// Generate unique id
const choices = this.store.getChoices();
const choiceLabel = label || value;
const choiceId = choices ? choices.length + 1 : 1;
2017-06-03 13:04:46 +02:00
const choiceElementId = `${this.baseId}-${this.idNames.itemChoice}-${choiceId}`;
2017-07-19 21:47:42 +02:00
this.store.dispatch(
addChoice(
value,
choiceLabel,
choiceId,
groupId,
isDisabled,
choiceElementId,
customProperties,
2017-08-02 15:05:26 +02:00
placeholder,
2017-08-15 10:29:42 +02:00
keyCode,
),
2017-07-19 21:47:42 +02:00
);
if (isSelected) {
this._addItem(
value,
choiceLabel,
choiceId,
undefined,
2017-07-19 19:48:46 +02:00
customProperties,
2017-08-02 15:05:26 +02:00
placeholder,
2017-08-15 10:29:42 +02:00
keyCode,
);
2016-05-08 01:02:52 +02:00
}
}
2016-09-26 18:11:32 +02:00
/**
* Clear all choices added to the store.
* @return
* @private
*/
_clearChoices() {
2017-07-19 21:47:42 +02:00
this.store.dispatch(
2017-08-15 10:29:42 +02:00
clearChoices(),
2017-07-19 21:47:42 +02:00
);
2016-09-26 18:11:32 +02:00
}
/**
* Add group to dropdown
* @param {Object} group Group to add
* @param {Number} id Group ID
* @param {String} [valueKey] name of the value property on the object
* @param {String} [labelKey] name of the label property on the object
* @return
* @private
*/
_addGroup(group, id, valueKey = 'value', labelKey = 'label') {
2017-08-26 16:41:11 +02:00
const groupChoices = isType('Object', group) ?
group.choices :
Array.from(group.getElementsByTagName('OPTION'));
2017-08-15 10:29:42 +02:00
const groupId = id || Math.floor(new Date().valueOf() * Math.random());
const isDisabled = group.disabled ? group.disabled : false;
if (groupChoices) {
2017-07-19 21:47:42 +02:00
this.store.dispatch(
addGroup(
group.label,
groupId,
true,
2017-08-15 10:29:42 +02:00
isDisabled,
),
2017-07-19 21:47:42 +02:00
);
groupChoices.forEach((option) => {
2017-08-03 10:55:31 +02:00
const isOptDisabled = option.disabled || (option.parentNode && option.parentNode.disabled);
2017-06-27 16:54:22 +02:00
this._addChoice(
option[valueKey],
2017-08-03 10:55:31 +02:00
(isType('Object', option)) ? option[labelKey] : option.innerHTML,
2017-06-27 16:54:22 +02:00
option.selected,
isOptDisabled,
groupId,
2017-08-02 15:05:26 +02:00
option.customProperties,
2017-08-15 10:29:42 +02:00
option.placeholder,
2017-06-27 16:54:22 +02:00
);
});
} else {
2017-07-19 21:47:42 +02:00
this.store.dispatch(
addGroup(
group.label,
group.id,
false,
2017-08-15 10:29:42 +02:00
group.disabled,
),
2017-07-19 21:47:42 +02:00
);
}
}
/**
* Get template from name
* @param {String} template Name of template to get
* @param {...} args Data to pass to template
* @return {HTMLElement} Template
* @private
*/
_getTemplate(template, ...args) {
2017-05-18 10:36:33 +02:00
if (!template) {
2017-07-13 16:59:33 +02:00
return null;
2017-05-18 10:36:33 +02:00
}
const templates = this.config.templates;
2017-10-10 17:59:49 +02:00
const globalClasses = this.config.classNames;
return templates[template].call(this, globalClasses, ...args);
}
/**
* Create HTML element based on type and arguments
* @return
* @private
*/
_createTemplates() {
// User's custom templates
const callbackTemplate = this.config.callbackOnCreateTemplates;
let userTemplates = {};
2016-09-30 14:50:23 +02:00
if (callbackTemplate && isType('Function', callbackTemplate)) {
userTemplates = callbackTemplate.call(this, strToEl);
}
2017-01-01 16:32:09 +01:00
2017-10-10 17:59:49 +02:00
this.config.templates = extend(TEMPLATES, userTemplates);
}
/**
* Create DOM structure around passed select element
* @return
* @private
*/
_createInput() {
const direction = this.passedElement.getAttribute('dir') || 'ltr';
const containerOuter = this._getTemplate('containerOuter', direction);
const containerInner = this._getTemplate('containerInner');
const itemList = this._getTemplate('itemList');
const choiceList = this._getTemplate('choiceList');
const input = this._getTemplate('input');
const dropdown = this._getTemplate('dropdown');
2017-10-10 13:56:36 +02:00
this.containerOuter = new Container(this, containerOuter, this.config.classNames);
this.containerInner = new Container(this, containerInner, this.config.classNames);
this.input = new Input(this, input, this.config.classNames);
this.choiceList = new List(this, choiceList, this.config.classNames);
this.itemList = new List(this, itemList, this.config.classNames);
2017-08-16 14:01:17 +02:00
this.dropdown = new Dropdown(this, dropdown, this.config.classNames);
// Hide passed input
2017-05-18 10:29:18 +02:00
this.passedElement.classList.add(
this.config.classNames.input,
2017-08-15 10:29:42 +02:00
this.config.classNames.hiddenState,
2017-05-18 10:29:18 +02:00
);
2017-05-29 12:11:50 +02:00
2017-07-19 21:47:42 +02:00
// Remove element from tab index
this.passedElement.tabIndex = '-1';
2017-07-19 21:47:42 +02:00
// Backup original styles if any
const origStyle = this.passedElement.getAttribute('style');
2017-07-19 21:47:42 +02:00
2017-08-15 10:29:42 +02:00
if (origStyle) {
this.passedElement.setAttribute('data-choice-orig-style', origStyle);
}
2017-07-19 21:47:42 +02:00
this.passedElement.setAttribute('style', 'display:none;');
this.passedElement.setAttribute('aria-hidden', 'true');
this.passedElement.setAttribute('data-choice', 'active');
// Wrap input in container preserving DOM ordering
2017-08-16 17:31:47 +02:00
wrap(this.passedElement, this.containerInner.element);
// Wrapper inner container with outer container
2017-08-16 17:31:47 +02:00
wrap(this.containerInner.element, this.containerOuter.element);
2017-08-03 10:55:31 +02:00
if (this.isSelectOneElement) {
2017-08-27 14:49:35 +02:00
this.input.setPlaceholder(this.config.searchPlaceholderValue || '');
} else if (this.placeholder) {
2017-08-27 14:49:35 +02:00
this.input.setPlaceholder(this.placeholder);
this.input.setWidth(true);
}
2016-03-21 19:53:26 +01:00
2017-05-18 10:36:33 +02:00
if (!this.config.addItems) {
this.disable();
}
2016-03-18 13:26:38 +01:00
2017-08-16 17:31:47 +02:00
this.containerOuter.element.appendChild(this.containerInner.element);
this.containerOuter.element.appendChild(this.dropdown.element);
this.containerInner.element.appendChild(itemList);
2016-08-14 23:14:37 +02:00
2017-07-13 16:59:33 +02:00
if (!this.isTextElement) {
dropdown.appendChild(choiceList);
}
2016-04-29 18:11:20 +02:00
2017-07-13 16:59:33 +02:00
if (this.isSelectMultipleElement || this.isTextElement) {
2017-08-21 18:09:55 +02:00
this.containerInner.element.appendChild(this.input.element);
} else if (this.canSearch) {
dropdown.insertBefore(input, dropdown.firstChild);
}
2016-07-02 14:04:38 +02:00
2017-05-18 10:29:18 +02:00
if (this.isSelectElement) {
const passedGroups = Array.from(this.passedElement.getElementsByTagName('OPTGROUP'));
2016-08-14 23:14:37 +02:00
this.highlightPosition = 0;
this.isSearching = false;
if (passedGroups && passedGroups.length) {
2017-09-18 17:46:48 +02:00
// If we have a placeholder option
const placeholderChoice = this.passedElement.querySelector('option[placeholder]');
2017-09-19 13:43:20 +02:00
if (placeholderChoice && placeholderChoice.parentNode.tagName === 'SELECT') {
2017-09-18 17:46:48 +02:00
this._addChoice(
placeholderChoice.value,
placeholderChoice.innerHTML,
placeholderChoice.selected,
placeholderChoice.disabled,
undefined,
undefined,
/* placeholder */ true,
);
}
passedGroups.forEach((group) => {
2017-02-05 10:41:59 +01:00
this._addGroup(group, (group.id || null));
});
} else {
const passedOptions = Array.from(this.passedElement.options);
const filter = this.config.sortFilter;
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,
2017-08-14 14:46:46 +02:00
placeholder: o.hasAttribute('placeholder'),
});
});
2016-05-10 10:02:59 +02:00
// If sorting is enabled or the user is searching, filter choices
if (this.config.shouldSort) {
allChoices.sort(filter);
}
2016-04-29 16:18:53 +02:00
// Determine whether there is a selected choice
2017-06-27 14:11:31 +02:00
const hasSelectedChoice = allChoices.some(choice => choice.selected);
// Add each choice
allChoices.forEach((choice, index) => {
2017-09-18 17:46:48 +02:00
if (this.isSelectElement) {
// If the choice is actually a group
if (choice.choices) {
this._addGroup(choice, 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(
choice.value,
choice.label,
isSelected,
isDisabled,
undefined,
choice.customProperties,
choice.placeholder,
);
}
} else {
2017-06-27 16:54:22 +02:00
this._addChoice(
choice.value,
choice.label,
choice.selected,
choice.disabled,
undefined,
2017-08-02 15:05:26 +02:00
choice.customProperties,
2017-08-14 14:46:46 +02:00
choice.placeholder,
2017-06-27 16:54:22 +02:00
);
}
});
}
2016-10-26 16:43:15 +02:00
} else if (this.isTextElement) {
// Add any preset values seperated by delimiter
this.presetItems.forEach((item) => {
2017-02-17 10:23:52 +01:00
const itemType = getType(item);
if (itemType === 'Object') {
2017-05-18 10:36:33 +02:00
if (!item.value) {
return;
}
this._addItem(
item.value,
item.label,
item.id,
undefined,
2017-08-02 15:05:26 +02:00
item.customProperties,
2017-08-14 14:46:46 +02:00
item.placeholder,
);
2017-02-17 10:23:52 +01:00
} else if (itemType === 'String') {
this._addItem(item);
}
});
}
}
2017-08-15 10:29:42 +02:00
/* ===== End of Private functions ====== */
2016-08-14 23:14:37 +02:00
}
2016-03-15 23:42:10 +01:00
2017-08-30 21:19:59 +02:00
Choices.userDefaults = {};
2016-10-18 15:15:00 +02:00
module.exports = Choices;