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 */ 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, template: data => this.config.templates.option(data), }); } 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; 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; 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; 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; 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; const { activeItems } = this._store; 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; 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; 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; 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; 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; 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; // 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;