import Fuse from 'fuse.js'; import Store from './store/index.js'; import { addItem, removeItem, highlightItem, addChoice, filterChoices, activateChoices, addGroup, clearAll, } from './actions/index'; import { isScrolledIntoView, getAdjacentEl, wrap, isType, isElement, strToEl, extend, getWidthOfInput, sortByAlpha, sortByScore, } from './lib/utils.js'; import './lib/polyfills.js'; /** * Choices */ export default class Choices { constructor(element = '[data-choice]', userConfig = {}) { // If there are multiple elements, create a new instance // for each element besides the first one (as that already has an instance) if (isType('String', element)) { const elements = document.querySelectorAll(element); if (elements.length > 1) { for (let i = 1; i < elements.length; i++) { const el = elements[i]; new Choices(el, userConfig); } } } const defaultConfig = { items: [], choices: [], maxItemCount: -1, addItems: true, removeItems: true, removeItemButton: false, editItems: false, duplicateItems: true, delimiter: ',', paste: true, search: true, flip: true, regexFilter: null, shouldSort: true, sortFilter: sortByAlpha, sortFields: ['label', 'value'], placeholder: true, placeholderValue: null, prependValue: null, appendValue: null, loadingText: 'Loading...', noResultsText: 'No results round', noChoicesText: 'No choices to choose from', classNames: { containerOuter: 'choices', containerInner: 'choices__inner', input: 'choices__input', inputCloned: 'choices__input--cloned', list: 'choices__list', listItems: 'choices__list--multiple', listSingle: 'choices__list--single', listDropdown: 'choices__list--dropdown', item: 'choices__item', itemSelectable: 'choices__item--selectable', itemDisabled: 'choices__item--disabled', itemChoice: 'choices__item--choice', placeholder: 'choices__placeholder', group: 'choices__group', groupHeading: 'choices__heading', button: 'choices__button', activeState: 'is-active', focusState: 'is-focused', openState: 'is-open', disabledState: 'is-disabled', highlightedState: 'is-highlighted', hiddenState: 'is-hidden', flippedState: 'is-flipped', loadingState: 'is-loading', }, callbackOnInit: () => {}, callbackOnAddItem: (id, value, passedInput) => {}, callbackOnRemoveItem: (id, value, passedInput) => {}, callbackOnHighlightItem: (id, value, passedInput) => {}, callbackOnUnhighlightItem: (id, value, passedInput) => {}, callbackOnChange: (value, passedInput) => {}, }; // Merge options with user options this.config = extend(defaultConfig, userConfig); // Create data store this.store = new Store(this.render); // State tracking this.initialised = false; this.currentState = {}; this.prevState = {}; this.currentValue = ''; // Retrieve triggering element (i.e. element with 'data-choice' trigger) this.passedElement = isType('String', element) ? document.querySelector(element) : element; if (!this.passedElement) { console.error('Passed element not found'); return; } this.highlightPosition = 0; this.canSearch = this.config.search; // Assing 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)); } // Bind methods this.init = this.init.bind(this); this.render = this.render.bind(this); this.destroy = this.destroy.bind(this); this.disable = this.disable.bind(this); // Bind event handlers this._onFocus = this._onFocus.bind(this); this._onBlur = this._onBlur.bind(this); this._onKeyUp = this._onKeyUp.bind(this); this._onKeyDown = this._onKeyDown.bind(this); this._onClick = this._onClick.bind(this); this._onTouchMove = this._onTouchMove.bind(this); this._onTouchEnd = this._onTouchEnd.bind(this); this._onMouseDown = this._onMouseDown.bind(this); this._onMouseOver = this._onMouseOver.bind(this); this._onPaste = this._onPaste.bind(this); this._onInput = this._onInput.bind(this); // Monitor touch taps/scrolls this.wasTap = true; // Cutting the mustard const cuttingTheMustard = 'querySelector' in document && 'addEventListener' in document && 'classList' in document.createElement('div'); if (!cuttingTheMustard) console.error('Choices: Your browser doesn\'t support Choices'); // Input type check const canInit = this.passedElement && isElement(this.passedElement) && ['select-one', 'select-multiple', 'text'].some(type => type === this.passedElement.type); if (canInit) { // If element has already been initalised with Choices if (this.passedElement.getAttribute('data-choice') === 'active') return; // Let's go this.init(); } else { console.error('Incompatible input passed'); } } /** * Initialise Choices * @return * @public */ init(callback = this.config.callbackOnInit) { if (this.initialised === false) { // Set initialise flag this.initialised = true; // Create required elements this._createTemplates(); // Generate input markup this._createInput(); this.store.subscribe(this.render); // Render any items this.render(); // Trigger event listeners this._addEventListeners(); // Run callback if it is a function if (callback) { if (isType('Function', callback)) { callback(); } else { console.error('callbackOnInit: Callback is not a function'); } } } } /** * Destroy Choices and nullify values * @return * @public */ destroy() { if (this.initialised === true) { this._removeEventListeners(); this.passedElement.classList.remove(this.config.classNames.input, this.config.classNames.hiddenState); this.passedElement.tabIndex = ''; this.passedElement.removeAttribute('style', 'display:none;'); this.passedElement.removeAttribute('aria-hidden'); this.containerOuter.outerHTML = this.passedElement.outerHTML; this.passedElement = null; this.userConfig = null; this.config = null; this.store = null; this.initialised = false; } } /** * Select item (a selected item can be deleted) * @param {Element} item Element to select * @return {Object} Class instance * @public */ highlightItem(item) { if (!item) return; const id = item.id; this.store.dispatch(highlightItem(id, true)); // Run callback if it is a function if (this.config.callbackOnHighlightItem) { const callback = this.config.callbackOnHighlightItem; if (isType('Function', callback)) { callback(id, item.value, this.passedElement); } else { console.error('callbackOnHighlightItem: Callback is not a function'); } } return this; } /** * Deselect item * @param {Element} item Element to de-select * @return {Object} Class instance * @public */ unhighlightItem(item) { if (!item) return; const id = item.id; this.store.dispatch(highlightItem(id, false)); // Run callback if it is a function if (this.config.callbackOnUnhighlightItem) { const callback = this.config.callbackOnUnhighlightItem; if (isType('Function', callback)) { callback(id, item.value, this.passedElement); } else { console.error('callbackOnUnhighlightItem: Callback is not a function'); } } return this; } /** * Highlight items within store * @return {Object} Class instance * @public */ highlightAll() { const items = this.store.getItems(); items.forEach((item) => { this.highlightItem(item); }); return this; } /** * Deselect items within store * @return {Object} Class instance * @public */ unhighlightAll() { const items = this.store.getItems(); items.forEach((item) => { this.unhighlightItem(item); }); return this; } /** * Remove an item from the store by its value * @param {String} value Value to search for * @return {Object} Class instance * @public */ removeItemsByValue(value) { if (!value || !isType('String', value)) { console.error('removeItemsByValue: No value was passed to be removed'); return; } const items = this.store.getItemsFilteredByActive(); items.forEach((item) => { if (item.value === value) { this._removeItem(item); } }); return this; } /** * Remove all items from store array * @note Removed items are soft deleted * @param {Number} excludedId Optionally exclude item by ID * @return {Object} Class instance * @public */ removeActiveItems(excludedId) { const items = this.store.getItemsFilteredByActive(); items.forEach((item) => { if (item.active && excludedId !== item.id) { this._removeItem(item); } }); return this; } /** * Remove all selected items from store * @note Removed items are soft deleted * @return {Object} Class instance * @public */ removeHighlightedItems() { const items = this.store.getItemsFilteredByActive(); items.forEach((item) => { if (item.highlighted && item.active) { this._removeItem(item); } }); return this; } /** * Show dropdown to user by adding active state class * @return {Object} Class instance * @public */ showDropdown(focusInput = false) { const body = document.body; const html = document.documentElement; const winHeight = Math.max(body.scrollHeight, body.offsetHeight, html.clientHeight, html.scrollHeight, html.offsetHeight); this.containerOuter.classList.add(this.config.classNames.openState); this.containerOuter.setAttribute('aria-expanded', 'true'); this.dropdown.classList.add(this.config.classNames.activeState); const dimensions = this.dropdown.getBoundingClientRect(); const dropdownPos = Math.ceil(dimensions.top + window.scrollY + dimensions.height); // If flip is enabled and the dropdown bottom position is greater than the window height flip the dropdown. const shouldFlip = this.config.flip ? dropdownPos >= winHeight : false; if (shouldFlip) { this.containerOuter.classList.add(this.config.classNames.flippedState); } else { this.containerOuter.classList.remove(this.config.classNames.flippedState); } // Optionally focus the input if we have a search input if (focusInput && this.canSearch && document.activeElement !== this.input) { this.input.focus(); } return this; } /** * Hide dropdown from user * @return {Object} Class instance * @public */ hideDropdown(blurInput = false) { // A dropdown flips if it does not have space within the page const isFlipped = this.containerOuter.classList.contains(this.config.classNames.flippedState); this.containerOuter.classList.remove(this.config.classNames.openState); this.containerOuter.setAttribute('aria-expanded', 'false'); this.dropdown.classList.remove(this.config.classNames.activeState); if (isFlipped) { this.containerOuter.classList.remove(this.config.classNames.flippedState); } // Optionally blur the input if we have a search input if (blurInput && this.canSearch && document.activeElement === this.input) { this.input.blur(); } return this; } /** * Determine whether to hide or show dropdown based on its current state * @return {Object} Class instance * @public */ toggleDropdown() { const hasActiveDropdown = this.dropdown.classList.contains(this.config.classNames.activeState); if (hasActiveDropdown) { this.hideDropdown(); } else { this.showDropdown(true); } return this; } /** * Get value(s) of input (i.e. inputted items (text) or selected choices (select)) * @param {Boolean} valueOnly Get only values of selected items, otherwise return selected items * @return {Array/String} selected value (select-one) or array of selected items (inputs & select-multiple) * @public */ getValue(valueOnly = false) { const items = this.store.getItemsFilteredByActive(); const selectedItems = []; items.forEach((item) => { if (this.passedElement.type === 'text') { selectedItems.push(valueOnly ? item.value : item); } else if (item.active) { selectedItems.push(valueOnly ? item.value : item); } }); if (this.passedElement.type === 'select-one') { return selectedItems[0]; } return selectedItems; } /** * Set value of input. If the input is a select box, a choice will be created and selected otherwise * an item will created directly. * @param {Array} args Array of value objects or value strings * @return {Object} Class instance * @public */ setValue(args) { if (this.initialised === true) { // Convert args to an itterable array const values = [...args]; values.forEach((item) => { if (isType('Object', item)) { 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.passedElement.type !== 'text') { this._addChoice(true, false, item.value, item.label, -1); } else { this._addItem(item.value, item.label, item.id); } } else if (isType('String', item)) { if (this.passedElement.type !== 'text') { this._addChoice(true, false, item, item, -1); } else { this._addItem(item); } } }); } return this; } /** * Select value of select box via the value of an existing choice * @param {Array/String} value An array of strings of a single string * @return {Object} Class instance * @public */ setValueByChoice(value) { if (this.passedElement.type !== 'text') { const choices = this.store.getChoices(); // If only one value has been passed, convert to array const choiceValue = isType('Array', value) ? value : [value]; // Loop through each value and choiceValue.forEach((val) => { const foundChoice = choices.find((choice) => { // Check 'value' property exists and the choice isn't already selected return choice.value === val; }); if (foundChoice) { if (!foundChoice.selected) { this._addItem(foundChoice.value, foundChoice.label, foundChoice.id); } else { console.warn('Attempting to select choice already selected'); } } else { console.warn('Attempting to select choice that does not exist'); } }); } return this; } /** * Direct populate choices * @param {Array} choices - Choices to insert * @param {String} value - Name of 'value' property * @param {String} label - Name of 'label' property * @return {Object} Class instance * @public */ setChoices(choices, value, label) { if (this.initialised === true) { if (this.passedElement.type === 'select-one' || this.passedElement.type === 'select-multiple') { if (!isType('Array', choices) || !value) return; if (choices && choices.length) { this.containerOuter.classList.remove(this.config.classNames.loadingState); choices.forEach((result, index) => { if (result.choices) { this._addGroup(result, index); } else { this._addChoice(result.selected ? result.selected : false, result.disabled ? result.disabled : false, result[value], result[label]); } }); } } } return this; } /** * Clear items,choices and groups * @note Hard delete * @return {Object} Class instance * @public */ clearStore() { this.store.dispatch(clearAll()); return this; } /** * Set value of input to blank * @return {Object} Class instance * @public */ clearInput() { if (this.input.value) this.input.value = ''; if (this.passedElement.type !== 'select-one') { this.input.style.width = getWidthOfInput(this.input); } if (this.passedElement.type !== 'text' && this.config.search) { this.isSearching = false; this.store.dispatch(activateChoices(true)); } return this; } /** * Disable interaction with Choices * @return {Object} Class instance * @public */ disable() { this.passedElement.disabled = true; if (this.initialised) { if (!this.containerOuter.classList.contains(this.config.classNames.disabledState)) { this._removeEventListeners(); this.passedElement.setAttribute('disabled', ''); this.input.setAttribute('disabled', ''); this.containerOuter.classList.add(this.config.classNames.disabledState); this.containerOuter.setAttribute('aria-disabled', 'true'); } } return this; } /** * Enable interaction with Choices * @return {Object} Class instance */ enable() { this.passedElement.disabled = false; if (this.initialised) { if (this.containerOuter.classList.contains(this.config.classNames.disabledState)) { this._addEventListeners(); this.passedElement.removeAttribute('disabled'); this.input.removeAttribute('disabled'); this.containerOuter.classList.remove(this.config.classNames.disabledState); this.containerOuter.removeAttribute('aria-disabled'); } } return this; } /** * Populate options via ajax callback * @param {Function} fn Passed * @return {Object} Class instance * @public */ ajax(fn) { if (this.initialised === true) { if (this.passedElement.type === 'select-one' || this.passedElement.type === 'select-multiple') { this.containerOuter.classList.add(this.config.classNames.loadingState); this.containerOuter.setAttribute('aria-busy', 'true'); if (this.passedElement.type === 'select-one') { const placeholderItem = this._getTemplate('placeholder', this.config.loadingText); this.itemList.appendChild(placeholderItem); } else { this.input.placeholder = this.config.loadingText; } const callback = (results, value, label) => { if (!isType('Array', results) || !value) return; if (results && results.length) { // Remove loading states/text this.containerOuter.classList.remove(this.config.classNames.loadingState); if (this.passedElement.type === 'select-multiple') { const placeholder = this.config.placeholder ? this.config.placeholderValue || this.passedElement.getAttribute('placeholder') : false; if (placeholder) { this.input.placeholder = placeholder; } } // Add each result as a choice results.forEach((result, index) => { this._addChoice(false, false, result[value], result[label]); }); } this.containerOuter.removeAttribute('aria-busy'); }; fn(callback); } } return this; } /** * Call change callback * @param {String} value - last added/deleted/selected value * @return * @private */ _triggerChange(value) { if (!value) return; // Run callback if it is a function if (this.config.callbackOnChange) { const callback = this.config.callbackOnChange; if (isType('Function', callback)) { callback(value, this.passedElement); } else { console.error('callbackOnChange: Callback is not a function'); } } } /** * Process enter/click of an item button * @param {Array} activeItems The currently active items * @param {Element} element Button being interacted with * @return * @private */ _handleButtonAction(activeItems, element) { if (!activeItems || !element) return; // If we are clicking on a button if (this.config.removeItems && this.config.removeItemButton) { const itemId = element.parentNode.getAttribute('data-id'); const itemToRemove = activeItems.find((item) => item.id === parseInt(itemId, 10)); // Remove item associated with button this._removeItem(itemToRemove); this._triggerChange(itemToRemove.value); if (this.passedElement.type === 'select-one') { const placeholder = this.config.placeholder ? this.config.placeholderValue || this.passedElement.getAttribute('placeholder') : false; if (placeholder) { const placeholderItem = this._getTemplate('placeholder', placeholder); this.itemList.appendChild(placeholderItem); } } } } /** * Process click of an item * @param {Array} activeItems The currently active items * @param {Element} element Item being interacted with * @param {Boolean} hasShiftKey Whether the user has the shift key active * @return * @private */ _handleItemAction(activeItems, element, hasShiftKey = false) { if (!activeItems || !element) return; // If we are clicking on an item if (this.config.removeItems && this.passedElement.type !== 'select-one') { const passedId = element.getAttribute('data-id'); // We only want to select one item with a click // so we deselect any items that aren't the target // unless shift is being pressed activeItems.forEach((item) => { if (item.id === parseInt(passedId, 10) && !item.highlighted) { this.highlightItem(item); } else if (!hasShiftKey) { if (item.highlighted) { this.unhighlightItem(item); } } }); // Focus input as without focus, a user cannot do anything with a // highlighted item if (document.activeElement !== this.input) this.input.focus(); } } /** * Process click of a choice * @param {Array} activeItems The currently active items * @param {Element} element Choice being interacted with * @return {[type]} [description] */ _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 hasActiveDropdown = this.dropdown.classList.contains(this.config.classNames.activeState); if (choice && !choice.selected && !choice.disabled) { const canAddItem = this._canAddItem(activeItems, choice.value); if (canAddItem.response) { this._addItem(choice.value, choice.label, choice.id); this._triggerChange(choice.value); } } this.clearInput(this.passedElement); // We wont to close the dropdown if we are dealing with a single select box if (hasActiveDropdown && this.passedElement.type === 'select-one') { this.hideDropdown(); this.containerOuter.focus(); } } /** * Process back space event * @param {Array} Active items * @return * @private */ _handleBackspace(activeItems) { if (this.config.removeItems && activeItems) { const lastItem = activeItems[activeItems.length - 1]; const hasHighlightedItems = activeItems.some((item) => item.highlighted === true); // 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._removeItem(lastItem); this._triggerChange(lastItem.value); } else { if (!hasHighlightedItems) { this.highlightItem(lastItem); } this.removeHighlightedItems(); } } } /** * Validates whether an item can be added by a user * @param {Array} activeItems The currently active items * @param {String} value Value of item to add * @return {Object} Response: Whether user can add item * Notice: Notice show in dropdown */ _canAddItem(activeItems, value) { let canAddItem = true; let notice = `Press Enter to add "${value}"`; if (this.passedElement.type === 'select-multiple' || this.passedElement.type === 'text') { if (this.config.maxItemCount > 0 && this.config.maxItemCount <= this.itemList.children.length) { // If there is a max entry limit and we have reached that limit // don't update canAddItem = false; notice = `Only ${this.config.maxItemCount} values can be added.`; } } if (this.passedElement.type === 'text' && this.config.addItems) { const isUnique = !activeItems.some((item) => item.value === value.trim()); // If a user has supplied a regular expression filter if (this.config.regexFilter) { // Determine whether we can update based on whether // our regular expression passes canAddItem = this._regexFilter(value); } // If no duplicates are allowed, and the value already exists // in the array if (this.config.duplicateItems === false && !isUnique) { canAddItem = false; notice = 'Only unique values can be added.'; } } return { response: canAddItem, notice, }; } /** * Filter choices based on search value * @param {String} value Value to filter by * @return * @private */ _searchChoices(value) { if (!value) return; if (this.input === document.activeElement) { const choices = this.store.getChoices(); const hasUnactiveChoices = choices.some((option) => option.active !== true); // Check that we have a value to search and the input was an alphanumeric character if (value && value.length > 1) { const handleFilter = () => { 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} `) { const haystack = this.store.getChoicesFilteredBySelectable(); const needle = newValue; const keys = isType('Array', this.config.sortFields) ? this.config.sortFields : [this.config.sortFields]; const fuse = new Fuse(haystack, { keys, shouldSort: true, include: 'score', }); const results = fuse.search(needle); this.currentValue = newValue; this.highlightPosition = 0; this.isSearching = true; this.store.dispatch(filterChoices(results)); } }; handleFilter(); } else if (hasUnactiveChoices) { // Otherwise reset choices to active this.isSearching = false; this.store.dispatch(activateChoices(true)); } } } /** * Trigger event listeners * @return * @private */ _addEventListeners() { document.addEventListener('keyup', this._onKeyUp); document.addEventListener('keydown', this._onKeyDown); document.addEventListener('click', this._onClick); document.addEventListener('touchmove', this._onTouchMove); document.addEventListener('touchend', this._onTouchEnd); document.addEventListener('mousedown', this._onMouseDown); document.addEventListener('mouseover', this._onMouseOver); if (this.passedElement.type && this.passedElement.type === 'select-one') { this.containerOuter.addEventListener('focus', this._onFocus); this.containerOuter.addEventListener('blur', this._onBlur); } this.input.addEventListener('input', this._onInput); this.input.addEventListener('paste', this._onPaste); this.input.addEventListener('focus', this._onFocus); this.input.addEventListener('blur', this._onBlur); } /** * Destroy event listeners * @return * @private */ _removeEventListeners() { document.removeEventListener('keyup', this._onKeyUp); document.removeEventListener('keydown', this._onKeyDown); document.removeEventListener('click', this._onClick); document.removeEventListener('touchmove', this._onTouchMove); document.removeEventListener('touchend', this._onTouchEnd); document.removeEventListener('mousedown', this._onMouseDown); document.removeEventListener('mouseover', this._onMouseOver); if (this.passedElement.type && this.passedElement.type === 'select-one') { this.containerOuter.removeEventListener('focus', this._onFocus); this.containerOuter.removeEventListener('blur', this._onBlur); } this.input.removeEventListener('input', this._onInput); this.input.removeEventListener('paste', this._onPaste); this.input.removeEventListener('focus', this._onFocus); this.input.removeEventListener('blur', this._onBlur); } /** * Key down event * @param {Object} e Event * @return */ _onKeyDown(e) { if (e.target !== this.input && !this.containerOuter.contains(e.target)) return; const target = e.target; const activeItems = this.store.getItemsFilteredByActive(); const hasFocusedInput = this.input === document.activeElement; const hasActiveDropdown = this.dropdown.classList.contains(this.config.classNames.activeState); const hasItems = this.itemList && this.itemList.children; const keyString = String.fromCharCode(e.keyCode); const backKey = 46; const deleteKey = 8; const enterKey = 13; const aKey = 65; const escapeKey = 27; const upKey = 38; const downKey = 40; const ctrlDownKey = e.ctrlKey || e.metaKey; // If a user is typing and the dropdown is not active if (this.passedElement.type !== 'text' && /[a-zA-Z0-9-_ ]/.test(keyString) && !hasActiveDropdown) { this.showDropdown(true); } this.canSearch = this.config.search; const onAKey = () => { // If CTRL + A or CMD + A have been pressed and there are items to select if (ctrlDownKey && hasItems) { this.canSearch = false; if (this.config.removeItems && !this.input.value && this.input === document.activeElement) { // Highlight items this.highlightAll(this.itemList.children); } } }; const onEnterKey = () => { // If enter key is pressed and the input has a value if (this.passedElement.type === 'text' && target.value) { const value = this.input.value; const canAddItem = this._canAddItem(activeItems, value); // All is good, add if (canAddItem.response) { if (hasActiveDropdown) { this.hideDropdown(); } this._addItem(value); this._triggerChange(value); this.clearInput(this.passedElement); } } if (target.hasAttribute('data-button')) { this._handleButtonAction(activeItems, target); e.preventDefault(); } if (hasActiveDropdown) { const highlighted = this.dropdown.querySelector(`.${this.config.classNames.highlightedState}`); // If we have a highlighted choice if (highlighted) { this._handleChoiceAction(activeItems, highlighted); } } else if (this.passedElement.type === 'select-one') { // Open single select dropdown if it's not active if (!hasActiveDropdown) { this.showDropdown(true); e.preventDefault(); } } }; const onEscapeKey = () => { if (hasActiveDropdown) { this.toggleDropdown(); } }; const onDirectionKey = () => { // If up or down key is pressed, traverse through options if (hasActiveDropdown || this.passedElement.type === 'select-one') { // Show dropdown if focus if (!hasActiveDropdown) { this.showDropdown(true); } const currentEl = this.dropdown.querySelector(`.${this.config.classNames.highlightedState}`); const directionInt = e.keyCode === downKey ? 1 : -1; let nextEl; this.canSearch = false; if (currentEl) { nextEl = getAdjacentEl(currentEl, '[data-choice-selectable]', directionInt); } else { nextEl = this.dropdown.querySelector('[data-choice-selectable]'); } if (nextEl) { // We prevent default to stop the cursor moving // when pressing the arrow if (!isScrolledIntoView(nextEl, this.choiceList, directionInt)) { this._scrollToChoice(nextEl, directionInt); } this._highlightChoice(nextEl); } // Prevent default to maintain cursor position whilst // traversing dropdown options e.preventDefault(); } }; const onDeleteKey = () => { // If backspace or delete key is pressed and the input has no value if (hasFocusedInput && !e.target.value && this.passedElement.type !== 'select-one') { this._handleBackspace(activeItems); e.preventDefault(); } }; // Map keys to key actions const keyDownActions = { [aKey]: onAKey, [enterKey]: onEnterKey, [escapeKey]: onEscapeKey, [upKey]: onDirectionKey, [downKey]: onDirectionKey, [deleteKey]: onDeleteKey, [backKey]: onDeleteKey, }; // If keycode has a function, run it if (keyDownActions[e.keyCode]) { keyDownActions[e.keyCode](); } } /** * Key up event * @param {Object} e Event * @return * @private */ _onKeyUp(e) { if (e.target !== this.input) return; // We are typing into a text input and have a value, we want to show a dropdown // notice. Otherwise hide the dropdown if (this.passedElement.type === 'text') { const hasActiveDropdown = this.dropdown.classList.contains(this.config.classNames.activeState); const value = this.input.value; if (value) { const activeItems = this.store.getItemsFilteredByActive(); const canAddItem = this._canAddItem(activeItems, value); if (canAddItem.notice) { const dropdownItem = this._getTemplate('notice', canAddItem.notice); this.dropdown.innerHTML = dropdownItem.outerHTML; } if (canAddItem.response === true) { if (!hasActiveDropdown) { this.showDropdown(); } } else if (!canAddItem.notice && hasActiveDropdown) { this.hideDropdown(); } } else if (hasActiveDropdown) { this.hideDropdown(); } } else { const backKey = 46; const deleteKey = 8; // If user has removed value... if ((e.keyCode === backKey || e.keyCode === deleteKey) && !e.target.value) { // ...and it is a multiple select input, activate choices (if searching) if (this.passedElement.type !== 'text' && this.isSearching) { this.isSearching = false; this.store.dispatch(activateChoices(true)); } } else if (this.canSearch) { this._searchChoices(this.input.value); } } } /** * Input event * @param {Object} e Event * @return * @private */ _onInput() { if (this.passedElement.type !== 'select-one') { if (this.config.placeholder && (this.config.placeholderValue || this.passedElement.getAttribute('placeholder'))) { // If there is a placeholder, we only want to set the width of the input when it is a greater // length than 75% of the placeholder. This stops the input jumping around. const placeholder = this.config.placeholder ? this.config.placeholderValue || this.passedElement.getAttribute('placeholder') : false; if (this.input.value && this.input.value.length >= (placeholder.length / 1.25)) { this.input.style.width = getWidthOfInput(this.input); } } else { // If there is no placeholder, resize input to contents this.input.style.width = getWidthOfInput(this.input); } } } /** * Touch move event * @param {Object} e Event * @return * @private */ _onTouchMove() { if (this.wasTap === true) { this.wasTap = false; } } /** * Touch end event * @param {Object} e Event * @return * @private */ _onTouchEnd(e) { const target = e.target || e.touches[0].target; const hasActiveDropdown = this.dropdown.classList.contains(this.config.classNames.activeState); // If a user tapped within our container... if (this.wasTap === true && this.containerOuter.contains(target)) { // ...and we aren't dealing with a single select box, show dropdown/focus input if ((target === this.containerOuter || target === this.containerInner) && this.passedElement.type !== 'select-one') { if (this.passedElement.type === 'text') { // If text element, we only want to focus the input (if it isn't already) if (document.activeElement !== this.input) { this.input.focus(); } } else { if (!hasActiveDropdown) { // If a select box, we want to show the dropdown this.showDropdown(true); } } } // Prevents focus event firing e.stopPropagation(); } this.wasTap = true; } /** * Mouse down event * @param {Object} e Event * @return * @private */ _onMouseDown(e) { const target = e.target; if (this.containerOuter.contains(target) && target !== this.input) { const activeItems = this.store.getItemsFilteredByActive(); const hasShiftKey = e.shiftKey; if (target.hasAttribute('data-item')) { this._handleItemAction(activeItems, target, hasShiftKey); } else if (target.hasAttribute('data-choice')) { this._handleChoiceAction(activeItems, target); } e.preventDefault(); } } /** * Click event * @param {Object} e Event * @return * @private */ _onClick(e) { const target = e.target; const hasActiveDropdown = this.dropdown.classList.contains(this.config.classNames.activeState); const activeItems = this.store.getItemsFilteredByActive(); // If target is something that concerns us if (this.containerOuter.contains(target)) { // Handle button delete if (target.hasAttribute('data-button')) { this._handleButtonAction(activeItems, target); } if (!hasActiveDropdown) { if (this.passedElement.type === 'text') { if (document.activeElement !== this.input) { this.input.focus(); } } else { if (this.canSearch) { this.showDropdown(true); } else { this.showDropdown(); this.containerOuter.focus(); } } } else if (this.passedElement.type === 'select-one' && target !== this.input && !this.dropdown.contains(target)) { this.hideDropdown(true); } } else { const hasHighlightedItems = activeItems.some((item) => item.highlighted === true); // De-select any highlighted items if (hasHighlightedItems) { this.unhighlightAll(); } // Remove focus state this.containerOuter.classList.remove(this.config.classNames.focusState); // Close all other dropdowns if (hasActiveDropdown) { this.hideDropdown(); } } } /** * Mouse over (hover) event * @param {Object} e Event * @return * @private */ _onMouseOver(e) { // If the dropdown is either the target or one of its children is the target if (e.target === this.dropdown || this.dropdown.contains(e.target)) { if (e.target.hasAttribute('data-choice')) this._highlightChoice(e.target); } } /** * Paste event * @param {Object} e Event * @return * @private */ _onPaste(e) { // Disable pasting into the input if option has been set if (e.target === this.input && !this.config.paste) { e.preventDefault(); } } /** * Focus event * @param {Object} e Event * @return * @private */ _onFocus(e) { const target = e.target; // If target is something that concerns us if (this.containerOuter.contains(target)) { const hasActiveDropdown = this.dropdown.classList.contains(this.config.classNames.activeState); const focusActions = { text: () => { if (target === this.input) { this.containerOuter.classList.add(this.config.classNames.focusState); } }, 'select-one': () => { this.containerOuter.classList.add(this.config.classNames.focusState); if (target === this.input) { // Show dropdown if it isn't already showing if (!hasActiveDropdown) { this.showDropdown(); } } }, 'select-multiple': () => { if (target === this.input) { // If element is a select box, the focussed element is the container and the dropdown // isn't already open, focus and show dropdown this.containerOuter.classList.add(this.config.classNames.focusState); if (!hasActiveDropdown) { this.showDropdown(true); } } }, }; focusActions[this.passedElement.type](); } } /** * Blur event * @param {Object} e Event * @return * @private */ _onBlur(e) { const target = e.target; // If target is something that concerns us if (this.containerOuter.contains(target)) { const activeItems = this.store.getItemsFilteredByActive(); const hasActiveDropdown = this.dropdown.classList.contains(this.config.classNames.activeState); const hasHighlightedItems = activeItems.some((item) => item.highlighted === true); const blurActions = { text: () => { if (target === this.input) { // Remove the focus state this.containerOuter.classList.remove(this.config.classNames.focusState); // De-select any highlighted items if (hasHighlightedItems) { this.unhighlightAll(); } // Hide dropdown if it is showing if (hasActiveDropdown) { this.hideDropdown(); } } }, 'select-one': () => { this.containerOuter.classList.remove(this.config.classNames.focusState); if (target === this.containerOuter) { // Hide dropdown if it is showing if (hasActiveDropdown && !this.canSearch) { this.hideDropdown(); } } if (target === this.input) { // Hide dropdown if it is showing if (hasActiveDropdown) { this.hideDropdown(); } } }, 'select-multiple': () => { if (target === this.input) { // Remove the focus state this.containerOuter.classList.remove(this.config.classNames.focusState); if (hasActiveDropdown) { this.hideDropdown(); } // De-select any highlighted items if (hasHighlightedItems) { this.unhighlightAll(); } } }, }; blurActions[this.passedElement.type](); } } /** * Tests value against a regular expression * @param {string} value Value to test * @return {Boolean} Whether test passed/failed * @private */ _regexFilter(value) { if (!value) return; const regex = this.config.regexFilter; const expression = new RegExp(regex.source, 'i'); return expression.test(value); } /** * Scroll to an option element * @param {HTMLElement} option Option to scroll to * @param {Number} direction Whether option is above or below * @return * @private */ _scrollToChoice(choice, direction) { if (!choice) return; const dropdownHeight = this.choiceList.offsetHeight; const choiceHeight = choice.offsetHeight; // Distance from bottom of element to top of parent const choicePos = choice.offsetTop + choiceHeight; // Scroll position of dropdown const containerScrollPos = this.choiceList.scrollTop + dropdownHeight; // Difference between the choice and scroll position const endPoint = direction > 0 ? ((this.choiceList.scrollTop + choicePos) - containerScrollPos) : choice.offsetTop; const animateScroll = () => { const strength = 4; let continueAnimation = false; let easing; let distance; if (direction > 0) { easing = (endPoint - this.choiceList.scrollTop) / strength; distance = easing > 1 ? easing : 1; this.choiceList.scrollTop = this.choiceList.scrollTop + distance; if (this.choiceList.scrollTop < endPoint) { continueAnimation = true; } } else { easing = (this.choiceList.scrollTop - endPoint) / strength; distance = easing > 1 ? easing : 1; this.choiceList.scrollTop = this.choiceList.scrollTop - distance; if (this.choiceList.scrollTop > endPoint) { continueAnimation = true; } } if (continueAnimation) { requestAnimationFrame((time) => { animateScroll(time, endPoint, direction); }); } }; requestAnimationFrame((time) => { animateScroll(time, endPoint, direction); }); } /** * Highlight choice * @param {HTMLElement} el Element to highlight * @return * @private */ _highlightChoice(el) { // Highlight first element in dropdown const choices = Array.from(this.dropdown.querySelectorAll('[data-choice-selectable]')); if (choices && choices.length) { const highlightedChoices = Array.from(this.dropdown.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 (el) { // Highlight given option el.classList.add(this.config.classNames.highlightedState); this.highlightPosition = choices.indexOf(el); } else { // Highlight choice based on last known highlight location let choice; if (choices.length > this.highlightPosition) { // If we have an option to highlight choice = choices[this.highlightPosition]; } else { // Otherwise highlight the option before choice = choices[choices.length - 1]; } if (!choice) choice = choices[0]; choice.classList.add(this.config.classNames.highlightedState); choice.setAttribute('aria-selected', 'true'); } } } /** * Add item to store with correct value * @param {String} value Value to add to store * @param {String} label Label to add to store * @return {Object} Class instance * @public */ _addItem(value, label, choiceId = -1) { let passedValue = isType('String', value) ? value.trim() : value; const items = this.store.getItems(); const passedLabel = label || passedValue; const passedOptionId = parseInt(choiceId, 10) || -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(); } // Generate unique id const id = items ? items.length + 1 : 1; this.store.dispatch(addItem(passedValue, passedLabel, id, passedOptionId)); if (this.passedElement.type === 'select-one') { this.removeActiveItems(id); } // Run callback if it is a function if (this.config.callbackOnAddItem) { const callback = this.config.callbackOnAddItem; if (isType('Function', callback)) { callback(id, passedValue, this.passedElement); } else { console.error('callbackOnAddItem: Callback is not a function'); } } return this; } /** * Remove item from store * @param {Object} item Item to remove * @param {Function} callback Callback to trigger * @return {Object} Class instance * @public */ _removeItem(item, callback = this.config.callbackOnRemoveItem) { if (!item || !isType('Object', item)) { console.error('removeItem: No item object was passed to be removed'); return; } const id = item.id; const value = item.value; const choiceId = item.choiceId; this.store.dispatch(removeItem(id, choiceId)); // Run callback if (callback) { if (!isType('Function', callback)) { console.error('callbackOnRemoveItem: Callback is not a function'); return; } callback(id, value, this.passedElement); } return this; } /** * Add choice to dropdown * @param {Boolean} isSelected Whether choice is selected * @param {Boolean} isDisabled Whether choice is disabled * @param {String} value Value of choice * @param {String} Label Label of choice * @param {Number} groupId ID of group choice is within. Negative number indicates no group * @return * @private */ _addChoice(isSelected, isDisabled, value, label, groupId = -1) { if (!value) return; // Generate unique id const choices = this.store.getChoices(); const choiceLabel = label || value; const choiceId = choices ? choices.length + 1 : 1; this.store.dispatch(addChoice(value, choiceLabel, choiceId, groupId, isDisabled)); if (isSelected && !isDisabled) { this._addItem(value, choiceLabel, choiceId); } } /** * Add group to dropdown * @param {Object} group Group to add * @param {Number} id Group ID * @return * @private */ _addGroup(group, id) { const groupChoices = isType('Object', group) ? group.choices : Array.from(group.getElementsByTagName('OPTION')); const groupId = id; const isDisabled = group.disabled ? group.disabled : false; if (groupChoices) { this.store.dispatch(addGroup(group.label, groupId, true, isDisabled)); groupChoices.forEach((option) => { const isOptDisabled = (option.disabled || (option.parentNode && option.parentNode.disabled)) || false; const isOptSelected = option.selected ? option.selected : false; let label; if (isType('Object', option)) { label = option.label || option.value; } else { label = option.innerHTML; } this._addChoice(isOptSelected, isOptDisabled, option.value, label, groupId); }); } else { this.store.dispatch(addGroup(group.label, group.id, false, group.disabled)); } } /** * Get template from name * @param {String} template Name of template to get * @param {...} args Data to pass to template * @return {HTMLElement} Template * @private */ _getTemplate(template, ...args) { if (!template) return; const templates = this.config.templates; return templates[template](...args); } /** * Create HTML element based on type and arguments * @return * @private */ _createTemplates() { const classNames = this.config.classNames; const templates = { containerOuter: (direction) => { return strToEl(` `); }, containerInner: () => { return strToEl(`
`); }, itemList: () => { return strToEl(`
`); }, placeholder: (value) => { return strToEl(`
${value}
`); }, item: (data) => { if (this.config.removeItemButton) { return strToEl(`
${data.label}
`); } return strToEl(`
${data.label}
`); }, choiceList: () => { return strToEl(`
`); }, choiceGroup: (data) => { return strToEl(`
${data.value}
`); }, choice: (data) => { return strToEl(`
0 ? 'role="treeitem"' : 'role="option"'}> ${data.label}
`); }, input: () => { return strToEl(` `); }, dropdown: () => { return strToEl(` `); }, notice: (label) => { return strToEl(`
${label}
`); }, option: (data) => { return strToEl(` `); }, }; this.config.templates = templates; } /** * Create DOM structure around passed select element * @return * @private */ _createInput() { const direction = this.passedElement.getAttribute('dir') || 'ltr'; const containerOuter = this._getTemplate('containerOuter', direction); const containerInner = this._getTemplate('containerInner'); const itemList = this._getTemplate('itemList'); const choiceList = this._getTemplate('choiceList'); const input = this._getTemplate('input'); const dropdown = this._getTemplate('dropdown'); const placeholder = this.config.placeholder ? this.config.placeholderValue || this.passedElement.getAttribute('placeholder') : false; this.containerOuter = containerOuter; this.containerInner = containerInner; this.input = input; this.choiceList = choiceList; this.itemList = itemList; this.dropdown = dropdown; // Hide passed input this.passedElement.classList.add(this.config.classNames.input, this.config.classNames.hiddenState); this.passedElement.tabIndex = '-1'; this.passedElement.setAttribute('style', 'display:none;'); this.passedElement.setAttribute('aria-hidden', 'true'); this.passedElement.setAttribute('data-choice', 'active'); // Wrap input in container preserving DOM ordering wrap(this.passedElement, containerInner); // Wrapper inner container with outer container wrap(containerInner, containerOuter); // If placeholder has been enabled and we have a value if (placeholder) { input.placeholder = placeholder; if (this.passedElement.type !== 'select-one') { input.style.width = getWidthOfInput(input); } } if (!this.config.addItems) this.disable(); containerOuter.appendChild(containerInner); containerOuter.appendChild(dropdown); containerInner.appendChild(itemList); if (this.passedElement.type !== 'text') { dropdown.appendChild(choiceList); } if (this.passedElement.type === 'select-multiple' || this.passedElement.type === 'text') { containerInner.appendChild(input); } else if (this.canSearch) { dropdown.insertBefore(input, dropdown.firstChild); } if (this.passedElement.type === 'select-multiple' || this.passedElement.type === 'select-one') { const passedGroups = Array.from(this.passedElement.getElementsByTagName('OPTGROUP')); this.highlightPosition = 0; this.isSearching = false; if (passedGroups && passedGroups.length) { passedGroups.forEach((group, index) => { this._addGroup(group, index); }); } else { const passedOptions = Array.from(this.passedElement.options); const filter = this.config.sortFilter; const allChoices = this.presetChoices; // Create array of options from option elements passedOptions.forEach((o) => { allChoices.push({ value: o.value, label: o.innerHTML, selected: o.selected, disabled: o.disabled || o.parentNode.disabled, }); }); // 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) => { return choice.selected === true; }); // Add each choice allChoices.forEach((choice, index) => { const isDisabled = choice.disabled ? choice.disabled : false; const isSelected = choice.selected ? choice.selected : false; // Pre-select first choice if it's a single select if (this.passedElement.type === 'select-one') { if (hasSelectedChoice || (!hasSelectedChoice && index > 0)) { // If there is a selected choice already or the choice is not // the first in the array, add each choice normally this._addChoice(isSelected, isDisabled, choice.value, choice.label); } else { // Otherwise pre-select the first choice in the array this._addChoice(true, false, choice.value, choice.label); } } else { this._addChoice(isSelected, isDisabled, choice.value, choice.label); } }); } } else if (this.passedElement.type === 'text') { // Add any preset values seperated by delimiter this.presetItems.forEach((item) => { if (isType('Object', item)) { if (!item.value) return; this._addItem(item.value, item.label, item.id); } else if (isType('String', item)) { this._addItem(item); } }); } } /** * Render group choices into a DOM fragment and append to choice list * @param {Array} groups Groups to add to list * @param {Array} choices Choices to add to groups * @param {DocumentFragment} fragment Fragment to add groups and options to (optional) * @return {DocumentFragment} Populated options fragment * @private */ renderGroups(groups, choices, fragment) { const groupFragment = fragment || document.createDocumentFragment(); const filter = this.config.sortFilter; // If sorting is enabled, filter groups if (this.config.shouldSort) { groups.sort(filter); } groups.forEach((group) => { // Grab options that are children of this group const groupChoices = choices.filter((choice) => { if (this.passedElement.type === 'select-one') { return choice.groupId === group.id; } return choice.groupId === group.id && !choice.selected; }); if (groupChoices.length >= 1) { const dropdownGroup = this._getTemplate('choiceGroup', group); groupFragment.appendChild(dropdownGroup); this.renderChoices(groupChoices, groupFragment); } }); return groupFragment; } /** * Render choices into a DOM fragment and append to choice list * @param {Array} choices Choices to add to list * @param {DocumentFragment} fragment Fragment to add choices to (optional) * @return {DocumentFragment} Populated choices fragment * @private */ renderChoices(choices, fragment) { // 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 filter = this.isSearching ? sortByScore : this.config.sortFilter; // If sorting is enabled or the user is searching, filter choices if (this.config.shouldSort || this.isSearching) { choices.sort(filter); } choices.forEach((choice) => { const dropdownItem = this._getTemplate('choice', choice); const shouldRender = this.passedElement.type === 'select-one' || !choice.selected; if (shouldRender) { choicesFragment.appendChild(dropdownItem); } }); return choicesFragment; } /** * Render items into a DOM fragment and append to items list * @param {Array} items Items to add to list * @param {DocumentFragment} fragment Fragrment to add items to (optional) * @return * @private */ renderItems(items, fragment) { // Create fragment to add elements to const itemListFragment = fragment || document.createDocumentFragment(); // Simplify store data to just values const itemsFiltered = this.store.getItemsReducedToValues(items); if (this.passedElement.type === 'text') { // Assign hidden input array of values this.passedElement.setAttribute('value', itemsFiltered.join(this.config.delimiter)); } else { const selectedOptionsFragment = document.createDocumentFragment(); // Add each list item to list items.forEach((item) => { // Create a standard select option const option = this._getTemplate('option', item); // Append it to fragment selectedOptionsFragment.appendChild(option); }); // Update selected choices this.passedElement.innerHTML = ''; this.passedElement.appendChild(selectedOptionsFragment); } // Add each list item to list items.forEach((item) => { // Create new list element const listItem = this._getTemplate('item', item); // Append it to list itemListFragment.appendChild(listItem); }); return itemListFragment; } /** * Render DOM with values * @return * @private */ render() { this.currentState = this.store.getState(); // Only render if our state has actually changed if (this.currentState !== this.prevState) { // Choices if (this.currentState.choices !== this.prevState.choices || this.currentState.groups !== this.prevState.groups) { if (this.passedElement.type === 'select-multiple' || this.passedElement.type === 'select-one') { // Get active groups/choices const activeGroups = this.store.getGroupsFilteredByActive(); const activeChoices = this.store.getChoicesFilteredByActive(); let choiceListFragment = document.createDocumentFragment(); // Clear choices this.choiceList.innerHTML = ''; // Scroll back to top of choices list this.choiceList.scrollTop = 0; // If we have grouped options if (activeGroups.length >= 1 && this.isSearching !== true) { choiceListFragment = this.renderGroups(activeGroups, activeChoices, choiceListFragment); } else if (activeChoices.length >= 1) { choiceListFragment = this.renderChoices(activeChoices, choiceListFragment); } if (choiceListFragment.childNodes && choiceListFragment.childNodes.length > 0) { // If we actually have anything to add to our dropdown // append it and highlight the first choice this.choiceList.appendChild(choiceListFragment); this._highlightChoice(); } else { // Otherwise show a notice const dropdownItem = this.isSearching ? this._getTemplate('notice', this.config.noResultsText) : this._getTemplate('notice', this.config.noChoicesText); this.choiceList.appendChild(dropdownItem); } } } // Items if (this.currentState.items !== this.prevState.items) { const activeItems = this.store.getItemsFilteredByActive(); if (activeItems) { // Create a fragment to store our list items // (so we don't have to update the DOM for each item) const itemListFragment = this.renderItems(activeItems); // Clear list this.itemList.innerHTML = ''; // If we have items to add if (itemListFragment.childNodes) { // Update list this.itemList.appendChild(itemListFragment); } } } this.prevState = this.currentState; } } } window.Choices = module.exports = Choices;