import Fuse from 'fuse.js'; import './lib/polyfills'; import Store from './store/store'; import { Dropdown, Container, Input, List, WrappedInput, WrappedSelect, } from './components'; import { DEFAULT_CONFIG, DEFAULT_CLASSNAMES, 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 { isScrolledIntoView, getAdjacentEl, getType, isType, strToEl, extend, sortByAlpha, sortByScore, generateId, findAncestorByAttrName, regexFilter, fetchFromObject, isIE11, existsInArray, cloneObject, } 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 = Choices._generateConfig(userConfig); 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; this._isTextElement = passedElement.type === 'text'; this._isSelectOneElement = passedElement.type === 'select-one'; this._isSelectMultipleElement = passedElement.type === 'select-multiple'; this._isSelectElement = this._isSelectOneElement || this._isSelectMultipleElement; if (this._isTextElement) { this.passedElement = new WrappedInput({ element: passedElement, classNames: this.config.classNames, delimiter: this.config.delimiter, }); } else if (this._isSelectElement) { this.passedElement = new WrappedSelect({ element: passedElement, classNames: this.config.classNames, }); } if (!this.passedElement) { throw new Error('Could not wrap passed element'); } 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._store = new Store(this.render); this.initialised = false; this._initialState = {}; this._currentState = {}; this._prevState = {}; this._currentValue = ''; this._isScrollingOnIe = false; this._highlightPosition = 0; this._wasTap = true; this._placeholderValue = this._generatePlaceholderValue(); this._baseId = generateId(this.passedElement.element, 'choices-'); 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); // If element has already been initialised with Choices, fail silently if (this.passedElement.element.getAttribute('data-choice') === 'active') { return false; } // Let's go this.init(); } /* ======================================== = Public functions = ======================================== */ init() { if (this.initialised) { return; } // Set initialise flag this.initialised = true; // Create required templates this._createTemplates(); // Create required elements this._createElements(); // Generate input markup 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); // Subscribe store to render method this._store.subscribe(this.render); // Render any items this.render(); // Trigger event listeners this._addEventListeners(); 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.initialised) { return this; } this.passedElement.enable(); if (this.containerOuter.isDisabled) { this._addEventListeners(); this.input.enable(); this.containerOuter.enable(); } return this; } disable() { if (!this.initialised) { return this; } this.passedElement.disable(); if (!this.containerOuter.isDisabled) { this._removeEventListeners(); this.input.disable(); this.containerOuter.disable(); } return this; } render() { 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; } 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(focusInput) { if (this.dropdown.isActive) { return this; } requestAnimationFrame(() => { this.dropdown.show(); this.containerOuter.open(this.dropdown.distanceFromTopWindow()); if (focusInput && this.config.searchEnabled) { this.input.focus(); } this.passedElement.triggerEvent(EVENTS.showDropdown, {}); }); return this; } hideDropdown(blurInput) { if (!this.dropdown.isActive) { return this; } requestAnimationFrame(() => { this.dropdown.hide(); this.containerOuter.close(); if (blurInput && this.config.searchEnabled) { this.input.removeActiveDescendant(); this.input.blur(); } this.passedElement.triggerEvent(EVENTS.hideDropdown, {}); }); return this; } toggleDropdown() { if (this.dropdown.isActive) { this.hideDropdown(); } else { this.showDropdown(true); // code smell 🤢 } 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 || !choices.length || !value) { return this; } // Clear choices if needed if (replaceChoices) { this._clearChoices(); } this.containerOuter.removeLoadingState(); const addGroupsAndChoices = groupOrChoice => { if (groupOrChoice.choices) { this._addGroup(groupOrChoice, groupOrChoice.id || null, value, label); } else { this._addChoice( groupOrChoice[value], groupOrChoice[label], groupOrChoice.selected, groupOrChoice.disabled, undefined, groupOrChoice.customProperties, groupOrChoice.placeholder, ); } }; choices.forEach(addGroupsAndChoices); return this; } clearStore() { this._store.dispatch(clearAll()); return this; } clearInput() { const shouldSetInputWidth = !this._isSelectOneElement; this.input.clear(shouldSetInputWidth); if (!this._isTextElement && this.config.searchEnabled) { 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 = ============================================= */ _createGroupsFragment(groups, choices, fragment) { const groupFragment = fragment || document.createDocumentFragment(); const getGroupChoices = group => choices.filter(choice => { if (this._isSelectOneElement) { return choice.groupId === group.id; } return ( choice.groupId === group.id && (this.config.renderSelectedChoices === 'always' || !choice.selected) ); }); // If sorting is enabled, filter groups if (this.config.shouldSort) { groups.sort(this.config.sortFn); } groups.forEach(group => { const groupChoices = getGroupChoices(group); if (groupChoices.length >= 1) { const dropdownGroup = this._getTemplate('choiceGroup', group); groupFragment.appendChild(dropdownGroup); this._createChoicesFragment(groupChoices, groupFragment, true); } }); return groupFragment; } _createChoicesFragment(choices, fragment, withinGroup = false) { // Create a fragment to store our list items (so we don't have to update the DOM for each item) const choicesFragment = fragment || document.createDocumentFragment(); const { renderSelectedChoices, searchResultLimit, renderChoiceLimit, } = this.config; const filter = this._isSearching ? sortByScore : this.config.sortFn; const appendChoice = choice => { const shouldRender = renderSelectedChoices === 'auto' ? this._isSelectOneElement || !choice.selected : true; if (shouldRender) { const dropdownItem = this._getTemplate( 'choice', choice, this.config.itemSelectText, ); choicesFragment.appendChild(dropdownItem); } }; let rendererableChoices = choices; if (renderSelectedChoices === 'auto' && !this._isSelectOneElement) { rendererableChoices = choices.filter(choice => !choice.selected); } // Split array into placeholders and "normal" choices const { placeholderChoices, normalChoices } = rendererableChoices.reduce( (acc, choice) => { if (choice.placeholder) { acc.placeholderChoices.push(choice); } else { acc.normalChoices.push(choice); } return acc; }, { placeholderChoices: [], normalChoices: [] }, ); // If sorting is enabled or the user is searching, filter choices if (this.config.shouldSort || this._isSearching) { normalChoices.sort(filter); } let choiceLimit = rendererableChoices.length; // Prepend placeholeder const sortedChoices = [...placeholderChoices, ...normalChoices]; if (this._isSearching) { choiceLimit = searchResultLimit; } else if (renderChoiceLimit > 0 && !withinGroup) { choiceLimit = renderChoiceLimit; } // Add each choice to dropdown within range for (let i = 0; i < choiceLimit; i += 1) { if (sortedChoices[i]) { appendChoice(sortedChoices[i]); } } return choicesFragment; } _createItemsFragment(items, fragment = null) { // Create fragment to add elements to const { shouldSortItems, sortFn, removeItemButton } = this.config; const itemListFragment = fragment || document.createDocumentFragment(); // If sorting is enabled, filter items if (shouldSortItems && !this._isSelectOneElement) { items.sort(sortFn); } if (this._isTextElement) { // Update the value of the hidden input this.passedElement.value = items; } else { // Update the options of the hidden input this.passedElement.options = items; } const addItemToFragment = item => { // Create new list element const listItem = this._getTemplate('item', item, removeItemButton); // Append it to list itemListFragment.appendChild(listItem); }; // Add each list item to list items.forEach(item => addItemToFragment(item)); return itemListFragment; } _triggerChange(value) { if (value === undefined || value === null) { return; } this.passedElement.triggerEvent(EVENTS.change, { value, }); } _selectPlaceholderChoice() { const placeholderChoice = this._store.placeholderChoice; if (placeholderChoice) { this._addItem( placeholderChoice.value, placeholderChoice.label, placeholderChoice.id, placeholderChoice.groupId, null, 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( choice.value, choice.label, choice.id, choice.groupId, choice.customProperties, choice.placeholder, choice.keyCode, ); this._triggerChange(choice.value); } } this.clearInput(); // We wont to close the dropdown if we are dealing with a single select box if (hasActiveDropdown && this._isSelectOneElement) { this.hideDropdown(); this.containerOuter.focus(); } } _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); } } _handleLoadingState(isLoading = true) { let placeholderItem = this.itemList.getChild( `.${this.config.classNames.placeholder}`, ); if (isLoading) { 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.containerOuter.removeLoadingState(); if (this._isSelectOneElement) { placeholderItem.innerHTML = this._placeholderValue || ''; } else { this.input.placeholder = this._placeholderValue || ''; } } } _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.regexFilter && this._isTextElement && this.config.addItems && canAddItem ) { // If a user has supplied a regular expression filter // determine whether we can update based on whether // our regular expression passes canAddItem = regexFilter(value, this.config.regexFilter); } if ( !this.config.duplicateItemsAllowed && isDuplicateValue && canAddItem ) { canAddItem = false; notice = isType('Function', this.config.uniqueItemText) ? this.config.uniqueItemText(value) : this.config.uniqueItemText; } } 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); // Add each result as a choice parsedResults.forEach(result => { if (result.choices) { const groupId = result.id || null; this._addGroup(result, groupId, value, label); } else { this._addChoice( fetchFromObject(result, value), fetchFromObject(result, label), result.selected, result.disabled, undefined, result.customProperties, result.placeholder, ); } }); 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 = isType('Array', this.config.searchFields) ? this.config.searchFields : [this.config.searchFields]; const options = Object.assign(this.config.fuseOptions, { keys }); const fuse = new Fuse(haystack, options); const results = fuse.search(needle); this._currentValue = newValue; this._highlightPosition = 0; this._isSearching = true; this._store.dispatch(filterChoices(results)); return results.length; } _handleSearch(value) { if (!value || !this.input.isFocussed) { return; } const choices = this._store.choices; const { searchFloor, searchChoices } = this.config; const hasUnactiveChoices = choices.some(option => !option.active); // Check that we have a value to search and the input was an alphanumeric character if (value && value.length >= searchFloor) { const resultCount = searchChoices ? this._searchChoices(value) : 0; // Trigger search event this.passedElement.triggerEvent(EVENTS.search, { value, resultCount, }); } else if (hasUnactiveChoices) { // Otherwise reset choices to active this._isSearching = false; this._store.dispatch(activateChoices(true)); } } _addEventListeners() { document.addEventListener('keyup', this._onKeyUp); document.addEventListener('keydown', this._onKeyDown); document.addEventListener('click', this._onClick); document.addEventListener('touchmove', this._onTouchMove); document.addEventListener('touchend', this._onTouchEnd); document.addEventListener('mousedown', this._onMouseDown); document.addEventListener('mouseover', this._onMouseOver); if (this._isSelectOneElement) { this.containerOuter.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() { document.removeEventListener('keyup', this._onKeyUp); document.removeEventListener('keydown', this._onKeyDown); document.removeEventListener('click', this._onClick); document.removeEventListener('touchmove', this._onTouchMove); document.removeEventListener('touchend', this._onTouchEnd); document.removeEventListener('mousedown', this._onMouseDown); document.removeEventListener('mouseover', this._onMouseOver); if (this._isSelectOneElement) { this.containerOuter.element.removeEventListener('focus', this._onFocus); this.containerOuter.element.removeEventListener('blur', this._onBlur); } this.input.element.removeEventListener('focus', this._onFocus); this.input.element.removeEventListener('blur', this._onBlur); if (this.input.element.form) { this.input.element.form.removeEventListener('reset', this._onFormReset); } this.input.removeEventListeners(); } _onKeyDown(event) { const { target, keyCode, ctrlKey, metaKey } = event; if ( target !== this.input.element && !this.containerOuter.element.contains(target) ) { return; } const activeItems = this._store.activeItems; const hasFocusedInput = this.input.isFocussed; const hasActiveDropdown = this.dropdown.isActive; const hasItems = this.itemList.hasChildren; const keyString = String.fromCharCode(keyCode); const 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; const ctrlDownKey = ctrlKey || metaKey; // If a user is typing and the dropdown is not active if (!this._isTextElement && /[a-zA-Z0-9-_ ]/.test(keyString)) { this.showDropdown(true); } const onAKey = () => { // If CTRL + A or CMD + A have been pressed and there are items to select if (ctrlDownKey && hasItems) { this.config.searchEnabled = false; if ( this.config.removeItems && !this.input.value && this.input.element === document.activeElement ) { // Highlight items this.highlightAll(); } } }; const onEnterKey = () => { // If enter key is pressed and the input has a value if (this._isTextElement && target.value) { const value = this.input.value; const canAddItem = this._canAddItem(activeItems, value); // All is good, add if (canAddItem.response) { this.hideDropdown(); this._addItem(value); this._triggerChange(value); this.clearInput(); } } if (target.hasAttribute('data-button')) { this._handleButtonAction(activeItems, target); event.preventDefault(); } if (hasActiveDropdown) { event.preventDefault(); const highlighted = this.dropdown.getChild( `.${this.config.classNames.highlightedState}`, ); // If we have a highlighted choice if (highlighted) { // add enter keyCode value if (activeItems[0]) { activeItems[0].keyCode = enterKey; } this._handleChoiceAction(activeItems, highlighted); } } else if (this._isSelectOneElement) { // Open single select dropdown if it's not active this.showDropdown(true); event.preventDefault(); } }; const onEscapeKey = () => { if (hasActiveDropdown) { this.hideDropdown(); this.containerOuter.focus(); } }; const onDirectionKey = () => { // If up or down key is pressed, traverse through options if (hasActiveDropdown || this._isSelectOneElement) { // Show dropdown if focus this.showDropdown(true); this.config.searchEnabled = 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(); } }; const onDeleteKey = () => { // If backspace or delete key is pressed and the input has no value if (hasFocusedInput && !target.value && !this._isSelectOneElement) { this._handleBackspace(activeItems); event.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[keyCode]) { keyDownActions[keyCode](); } } _onKeyUp({ target, keyCode }) { if (target !== this.input.element) { return; } const value = this.input.value; const activeItems = this._store.activeItems; const canAddItem = this._canAddItem(activeItems, value); // 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) { if (value) { if (canAddItem.notice) { const dropdownItem = this._getTemplate('notice', canAddItem.notice); this.dropdown.element.innerHTML = dropdownItem.outerHTML; } if (canAddItem.response === true) { this.showDropdown(); } else if (!canAddItem.notice) { this.hideDropdown(); } } else { this.hideDropdown(); } } else { const backKey = KEY_CODES.BACK_KEY; const deleteKey = KEY_CODES.DELETE_KEY; // If user has removed value... if ((keyCode === backKey || keyCode === deleteKey) && !target.value) { if (!this._isTextElement && this._isSearching) { this._isSearching = false; this._store.dispatch(activateChoices(true)); } } else if (this.config.searchEnabled && canAddItem.response) { this._handleSearch(this.input.value); } } // Re-establish canSearch value from changes in _onKeyDown this.config.searchEnabled = this.config.searchEnabled; } _onTouchMove() { if (this._wasTap === true) { this._wasTap = false; } } _onTouchEnd(event) { const target = event.target || event.touches[0].target; // If a user tapped within our container... if (this._wasTap === true && this.containerOuter.element.contains(target)) { // ...and we aren't dealing with a single select box, show dropdown/focus input const containerWasTarget = target === this.containerOuter.element || target === this.containerInner.element; if (containerWasTarget && !this._isSelectOneElement) { if (this._isTextElement) { // If text element, we only want to focus the input this.input.focus(); } else { // If a select box, we want to show the dropdown this.showDropdown(true); } } // 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 (target === this.choiceList && isIE11()) { this._isScrollingOnIe = true; } if ( this.containerOuter.element.contains(target) && target !== this.input.element ) { const activeItems = this._store.activeItems; const hasShiftKey = shiftKey; const buttonTarget = findAncestorByAttrName(target, 'data-button'); const itemTarget = findAncestorByAttrName(target, 'data-item'); const choiceTarget = findAncestorByAttrName(target, 'data-choice'); if (buttonTarget) { this._handleButtonAction(activeItems, buttonTarget); } else if (itemTarget) { this._handleItemAction(activeItems, itemTarget, hasShiftKey); } else if (choiceTarget) { this._handleChoiceAction(activeItems, choiceTarget); } event.preventDefault(); } } _onMouseOver({ target }) { const targetWithinDropdown = target === this.dropdown || this.dropdown.element.contains(target); const shouldHighlightChoice = targetWithinDropdown && target.hasAttribute('data-choice'); if (shouldHighlightChoice) { this._highlightChoice(target); } } _onClick({ target }) { const hasActiveDropdown = this.dropdown.isActive; const activeItems = this._store.activeItems; if (this.containerOuter.element.contains(target)) { if (!hasActiveDropdown) { if (this._isTextElement) { if (document.activeElement !== this.input.element) { this.input.focus(); } } else if (this.config.searchEnabled) { this.showDropdown(true); } else { this.showDropdown(); this.containerOuter.focus(); // code smell 🤢 } } else if ( this._isSelectOneElement && target !== this.input.element && !this.dropdown.element.contains(target) ) { this.hideDropdown(true); } } else { const hasHighlightedItems = activeItems.some(item => item.highlighted); if (hasHighlightedItems) { this.unhighlightAll(); } this.containerOuter.removeFocusState(); this.hideDropdown(); } } _onFocus({ target }) { if (!this.containerOuter.element.contains(target)) { return; } const focusActions = { text: () => { if (target === this.input.element) { this.containerOuter.addFocusState(); } }, 'select-one': () => { this.containerOuter.addFocusState(); if (target === this.input.element) { this.showDropdown(); } }, 'select-multiple': () => { if (target === this.input.element) { // 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(); this.showDropdown(true); } }, }; focusActions[this.passedElement.element.type](); } _onBlur({ target }) { // If target is something that concerns us if ( this.containerOuter.element.contains(target) && !this._isScrollingOnIe ) { const activeItems = this._store.activeItems; const hasHighlightedItems = activeItems.some(item => item.highlighted); const blurActions = { text: () => { if (target === this.input.element) { this.containerOuter.removeFocusState(); if (hasHighlightedItems) { this.unhighlightAll(); } this.hideDropdown(); } }, 'select-one': () => { this.containerOuter.removeFocusState(); if ( target === this.input.element || (target === this.containerOuter.element && !this.config.searchEnabled) ) { this.hideDropdown(); } }, 'select-multiple': () => { if (target === this.input.element) { this.containerOuter.removeFocusState(); this.hideDropdown(); 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}`, ), ); const hasActiveDropdown = this.dropdown.isActive; // 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'); if (hasActiveDropdown) { // IE11 ignores aria-label and blocks virtual keyboard // if aria-activedescendant is set without a dropdown this.input.setActiveDescendant(passedEl.id); this.containerOuter.setActiveDescendant(passedEl.id); } } _addItem( value, label = null, choiceId = -1, groupId = -1, customProperties = null, placeholder = false, keyCode = null, ) { let passedValue = isType('String', value) ? value.trim() : value; const passedKeyCode = keyCode; const passedCustomProperties = customProperties; const items = this._store.items; const passedLabel = label || passedValue; const passedOptionId = parseInt(choiceId, 10) || -1; const group = groupId >= 0 ? this._store.getGroupById(groupId) : null; const id = items ? items.length + 1 : 1; // If a prepended value has been passed, prepend it if (this.config.prependValue) { passedValue = this.config.prependValue + passedValue.toString(); } // If an appended value has been passed, append it if (this.config.appendValue) { passedValue += this.config.appendValue.toString(); } this._store.dispatch( addItem( passedValue, passedLabel, id, passedOptionId, groupId, customProperties, placeholder, passedKeyCode, ), ); if (this._isSelectOneElement) { this.removeActiveItems(id); } // Trigger change event if (group && group.value) { this.passedElement.triggerEvent(EVENTS.addItem, { id, value: passedValue, label: passedLabel, customProperties: passedCustomProperties, groupValue: group.value, keyCode: passedKeyCode, }); } else { this.passedElement.triggerEvent(EVENTS.addItem, { id, value: passedValue, label: passedLabel, customProperties: passedCustomProperties, keyCode: passedKeyCode, }); } return this; } _removeItem(item) { if (!item || !isType('Object', item)) { return this; } const { id, value, label, choiceId, groupId } = item; const group = groupId >= 0 ? this._store.getGroupById(groupId) : null; this._store.dispatch(removeItem(id, choiceId)); if (group && group.value) { this.passedElement.triggerEvent(EVENTS.removeItem, { id, value, label, groupValue: group.value, }); } else { this.passedElement.triggerEvent(EVENTS.removeItem, { id, value, label, }); } return this; } _addChoice( value, label = null, isSelected = false, isDisabled = false, groupId = -1, customProperties = null, placeholder = false, keyCode = null, ) { if (typeof value === 'undefined' || value === null) { return; } // Generate unique id const choices = this._store.choices; const choiceLabel = label || value; const choiceId = choices ? choices.length + 1 : 1; const choiceElementId = `${this._baseId}-${ this._idNames.itemChoice }-${choiceId}`; this._store.dispatch( addChoice( value, choiceLabel, choiceId, groupId, isDisabled, choiceElementId, customProperties, placeholder, keyCode, ), ); if (isSelected) { this._addItem( value, choiceLabel, choiceId, undefined, customProperties, placeholder, keyCode, ); } } _clearChoices() { this._store.dispatch(clearChoices()); } _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( choice[valueKey], isType('Object', choice) ? choice[labelKey] : choice.innerHTML, choice.selected, isOptDisabled, groupId, choice.customProperties, 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 = extend(TEMPLATES, userTemplates); } _createElements() { const direction = this.passedElement.element.getAttribute('dir') || 'ltr'; const containerOuter = this._getTemplate( 'containerOuter', direction, this._isSelectElement, this._isSelectOneElement, this.config.searchEnabled, this.passedElement.element.type, ); const containerInner = this._getTemplate('containerInner'); const itemList = this._getTemplate('itemList', this._isSelectOneElement); const choiceList = this._getTemplate( 'choiceList', this._isSelectOneElement, ); const input = this._getTemplate('input'); const dropdown = this._getTemplate('dropdown'); this.containerOuter = new Container({ element: containerOuter, classNames: this.config.classNames, type: this.passedElement.element.type, position: this.config.position, }); this.containerInner = new Container({ element: containerInner, classNames: this.config.classNames, type: this.passedElement.element.type, position: this.config.position, }); this.input = new Input({ element: input, classNames: this.config.classNames, type: this.passedElement.element.type, }); this.choiceList = new List({ element: choiceList, }); this.itemList = new List({ element: itemList, }); this.dropdown = new Dropdown({ element: 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); } if (!this.config.addItems) { this.disable(); } 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; if (passedGroups && passedGroups.length) { // If we have a placeholder option const placeholderChoice = this.passedElement.placeholderOption; if ( placeholderChoice && placeholderChoice.parentNode.tagName === 'SELECT' ) { this._addChoice( placeholderChoice.value, placeholderChoice.innerHTML, placeholderChoice.selected, placeholderChoice.disabled, undefined, undefined, /* placeholder */ true, ); } passedGroups.forEach(group => { this._addGroup(group, 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'), }); }); // 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) => { 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 { this._addChoice( choice.value, choice.label, choice.selected, choice.disabled, undefined, choice.customProperties, choice.placeholder, ); } }; // Add each choice allChoices.forEach((choice, index) => handleChoice(choice, index)); } } _addPredefinedItems() { const handlePresetItem = item => { const itemType = getType(item); if (itemType === 'Object') { if (!item.value) { return; } this._addItem( item.value, item.label, item.id, undefined, item.customProperties, item.placeholder, ); } else if (itemType === 'String') { this._addItem(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( 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](); } _findAndSelectChoiceByValue(val) { const choices = this._store.choices; // Check 'value' property exists and the choice isn't already selected const foundChoice = choices.find(choice => this.config.itemComparer(choice.value, val), ); if (foundChoice && !foundChoice.selected) { this._addItem( foundChoice.value, foundChoice.label, foundChoice.id, foundChoice.groupId, foundChoice.customProperties, foundChoice.placeholder, foundChoice.keyCode, ); } } _generateInstances(elements, config) { return elements.reduce( (instances, element) => { instances.push(new Choices(element, config)); return instances; }, [this], ); } static _generateConfig(userConfig) { const defaultConfig = { ...DEFAULT_CONFIG, items: [], choices: [], classNames: DEFAULT_CLASSNAMES, sortFn: sortByAlpha, }; return extend(defaultConfig, Choices.userDefaults, userConfig); } _generatePlaceholderValue() { if (this._isSelectOneElement) { return false; } return this.config.placeholder ? this.config.placeholderValue || this.passedElement.element.getAttribute('placeholder') : false; } _renderChoices() { const { activeGroups, activeChoices } = this._store; let choiceListFragment = document.createDocumentFragment(); this.choiceList.clear(); if (this.config.resetScrollPosition) { requestAnimationFrame(() => this.choiceList.scrollToTop()); } // If we have grouped options if (activeGroups.length >= 1 && !this._isSearching) { // If we have a placeholder choice along with groups const activePlaceholders = activeChoices.filter( activeChoice => activeChoice.placeholder === true && activeChoice.groupId === -1, ); if (activePlaceholders.length >= 1) { choiceListFragment = this._createChoicesFragment( activePlaceholders, choiceListFragment, ); } choiceListFragment = this._createGroupsFragment( activeGroups, activeChoices, choiceListFragment, ); } else if (activeChoices.length >= 1) { choiceListFragment = this._createChoicesFragment( activeChoices, choiceListFragment, ); } // If we have choices to show if ( choiceListFragment.childNodes && choiceListFragment.childNodes.length > 0 ) { const activeItems = this._store.activeItems; const canAddItem = this._canAddItem(activeItems, this.input.value); // ...and we can select them if (canAddItem.response) { // ...append them and highlight the first choice this.choiceList.append(choiceListFragment); this._highlightChoice(); } else { // ...otherwise show a notice this.choiceList.append(this._getTemplate('notice', canAddItem.notice)); } } else { // Otherwise show a notice let dropdownItem; let notice; if (this._isSearching) { notice = isType('Function', this.config.noResultsText) ? this.config.noResultsText() : this.config.noResultsText; dropdownItem = this._getTemplate('notice', notice, 'no-results'); } else { notice = isType('Function', this.config.noChoicesText) ? this.config.noChoicesText() : this.config.noChoicesText; dropdownItem = this._getTemplate('notice', notice, 'no-choices'); } this.choiceList.append(dropdownItem); } } _renderItems() { // Get active items (items that can be selected) const activeItems = this._store.activeItems || []; // Clear list this.itemList.clear(); if (activeItems.length) { // 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); } } } /* ===== End of Private functions ====== */ } Choices.userDefaults = {}; // We cannot export default here due to Webpack: https://github.com/webpack/webpack/issues/3929 module.exports = Choices;