From d40841d8dd3231d23cf54032c8905fe128830064 Mon Sep 17 00:00:00 2001 From: Josh Johnson Date: Mon, 5 Sep 2016 22:04:15 +0100 Subject: [PATCH] Move to 2 space indentation + editorconfig --- .editorconfig | 9 + .eslintrc | 2 +- assets/scripts/src/actions/index.js | 88 +- assets/scripts/src/choices.js | 3953 ++++++++++++------------ assets/scripts/src/lib/polyfills.js | 181 +- assets/scripts/src/lib/utils.js | 489 ++- assets/scripts/src/reducers/choices.js | 176 +- assets/scripts/src/reducers/groups.js | 26 +- assets/scripts/src/reducers/index.js | 24 +- assets/scripts/src/reducers/items.js | 80 +- assets/scripts/src/store/index.js | 276 +- 11 files changed, 2659 insertions(+), 2645 deletions(-) create mode 100644 .editorconfig diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..ef8bdf1 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,9 @@ + +root = true + +[*] +indent_style = space +indent_size = 2 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true \ No newline at end of file diff --git a/.eslintrc b/.eslintrc index fac080d..c90fecf 100644 --- a/.eslintrc +++ b/.eslintrc @@ -10,7 +10,7 @@ "rules": { "quotes": [2, "single"], "strict": [2, "never"], - "indent": ["error", 4, {"SwitchCase": 1}], + "indent": ["error", 2, {"SwitchCase": 1}], "eol-last": "off", "arrow-body-style": "off", "no-underscore-dangle": "off", diff --git a/assets/scripts/src/actions/index.js b/assets/scripts/src/actions/index.js index f275eab..96464d5 100644 --- a/assets/scripts/src/actions/index.js +++ b/assets/scripts/src/actions/index.js @@ -1,67 +1,67 @@ export const addItem = (value, label, id, choiceId, activateOptions) => { - return { - type: 'ADD_ITEM', - value, - label, - id, - choiceId, - activateOptions, - }; + return { + type: 'ADD_ITEM', + value, + label, + id, + choiceId, + activateOptions, + }; }; export const removeItem = (id, choiceId) => { - return { - type: 'REMOVE_ITEM', - id, - choiceId, - }; + return { + type: 'REMOVE_ITEM', + id, + choiceId, + }; }; export const highlightItem = (id, highlighted) => { - return { - type: 'HIGHLIGHT_ITEM', - id, - highlighted, - }; + return { + type: 'HIGHLIGHT_ITEM', + id, + highlighted, + }; }; export const addChoice = (value, label, id, groupId, disabled) => { - return { - type: 'ADD_CHOICE', - value, - label, - id, - groupId, - disabled, - }; + return { + type: 'ADD_CHOICE', + value, + label, + id, + groupId, + disabled, + }; }; export const filterChoices = (results) => { - return { - type: 'FILTER_CHOICES', - results, - }; + return { + type: 'FILTER_CHOICES', + results, + }; }; export const activateChoices = (active = true) => { - return { - type: 'ACTIVATE_CHOICES', - active, - }; + return { + type: 'ACTIVATE_CHOICES', + active, + }; }; export const addGroup = (value, id, active, disabled) => { - return { - type: 'ADD_GROUP', - value, - id, - active, - disabled, - }; + return { + type: 'ADD_GROUP', + value, + id, + active, + disabled, + }; }; export const clearAll = () => { - return { - type: 'CLEAR_ALL', - }; + return { + type: 'CLEAR_ALL', + }; }; \ No newline at end of file diff --git a/assets/scripts/src/choices.js b/assets/scripts/src/choices.js index 9103513..f92d1f9 100644 --- a/assets/scripts/src/choices.js +++ b/assets/scripts/src/choices.js @@ -1,2078 +1,2085 @@ import Fuse from 'fuse.js'; import Store from './store/index.js'; import { - addItem, - removeItem, - highlightItem, - addChoice, - filterChoices, - activateChoices, - addGroup, - clearAll, -} from './actions/index'; + 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'; + 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); - } - } +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) => {}, - }; + 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); + // Merge options with user options + this.config = extend(defaultConfig, userConfig); - // Create data store - this.store = new Store(this.render); + // Create data store + this.store = new Store(this.render); - // State tracking - this.initialised = false; - this.currentState = {}; - this.prevState = {}; - this.currentValue = ''; + // 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; + // 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; - } + if (!this.passedElement) { + console.error('Passed element not found'); + return; + } - this.highlightPosition = 0; - this.canSearch = this.config.search; + this.highlightPosition = 0; + this.canSearch = this.config.search; - // Assing preset choices from passed object - this.presetChoices = this.config.choices; + // Assing preset choices from passed object + this.presetChoices = this.config.choices; - // Assign preset items from passed object first - this.presetItems = this.config.items; + // 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)); - } + // 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 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); + // 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; + // 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'); + // 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); + // 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; + if (canInit) { + // If element has already been initalised with Choices + if (this.passedElement.getAttribute('data-choice') === 'active') return; - // Let's go - this.init(); + // 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('Incompatible input passed'); + 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'); + } } - /** - * Initialise Choices - * @return - * @public - */ - init(callback = this.config.callbackOnInit) { - if (this.initialised === false) { - // Set initialise flag - this.initialised = true; + return this; + } - // Create required elements - this._createTemplates(); + /** + * 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)); - // 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'); - } - } - } + // 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'); + } } - /** - * Destroy Choices and nullify values - * @return - * @public - */ - destroy() { - if (this.initialised === true) { - this._removeEventListeners(); + return this; + } - 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'); + /** + * Highlight items within store + * @return {Object} Class instance + * @public + */ + highlightAll() { + const items = this.store.getItems(); + items.forEach((item) => { + this.highlightItem(item); + }); - this.containerOuter.outerHTML = this.passedElement.outerHTML; + return this; + } - this.passedElement = null; - this.userConfig = null; - this.config = null; - this.store = null; + /** + * Deselect items within store + * @return {Object} Class instance + * @public + */ + unhighlightAll() { + const items = this.store.getItems(); + items.forEach((item) => { + this.unhighlightItem(item); + }); - this.initialised = false; - } + 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; } - /** - * 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)); + const items = this.store.getItemsFilteredByActive(); - // 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'); - } - } + items.forEach((item) => { + if (item.value === value) { + this._removeItem(item); + } + }); - return this; + 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); } - /** - * 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; + // Optionally focus the input if we have a search input + if (focusInput && this.canSearch && document.activeElement !== this.input) { + this.input.focus(); } - /** - * Highlight items within store - * @return {Object} Class instance - * @public - */ - highlightAll() { - const items = this.store.getItems(); - items.forEach((item) => { - this.highlightItem(item); + 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; }); - 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); + if (foundChoice) { + if (!foundChoice.selected) { + this._addItem(foundChoice.value, foundChoice.label, foundChoice.id); + } else { + console.warn('Attempting to select choice already selected'); + } } else { - this.containerOuter.classList.remove(this.config.classNames.flippedState); + console.warn('Attempting to select choice that does not exist'); } - - // 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]')); + 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) { - 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); + this.containerOuter.classList.remove(this.config.classNames.loadingState); + choices.forEach((result, index) => { + if (result.choices) { + this._addGroup(result, index); } 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'); + this._addChoice(result.selected ? result.selected : false, result.disabled ? result.disabled : false, result[value], result[label]); } + }); } + } } + return this; + } - /** - * 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; + /** + * Clear items,choices and groups + * @note Hard delete + * @return {Object} Class instance + * @public + */ + clearStore() { + this.store.dispatch(clearAll()); + return this; + } - // If a prepended value has been passed, prepend it - if (this.config.prependValue) { - passedValue = this.config.prependValue + passedValue.toString(); - } + /** + * 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; + } - // If an appended value has been passed, append it - if (this.config.appendValue) { - passedValue += this.config.appendValue.toString(); - } + /** + * 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; + } - // Generate unique id - const id = items ? items.length + 1 : 1; - - this.store.dispatch(addItem(passedValue, passedLabel, id, passedOptionId)); + /** + * 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') { - 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); - }); + const placeholderItem = this._getTemplate('placeholder', this.config.loadingText); + this.itemList.appendChild(placeholderItem); } else { - this.store.dispatch(addGroup(group.label, group.id, false, group.disabled)); + 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); + } } - /** - * 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); + 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.`; + } } - /** - * 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(` - - `); - }, + 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)); + } }; - this.config.templates = templates; + 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); } - /** - * 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'); + 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; - - 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.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); + } + } + } - if (!this.config.addItems) this.disable(); + /** + * Touch move event + * @param {Object} e Event + * @return + * @private + */ + _onTouchMove() { + if (this.wasTap === true) { + this.wasTap = false; + } + } - containerOuter.appendChild(containerInner); - containerOuter.appendChild(dropdown); - containerInner.appendChild(itemList); + /** + * 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 (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); - } - }); + // 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(); } - /** - * 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; + this.wasTap = true; + } - // If sorting is enabled, filter groups - if (this.config.shouldSort) { - groups.sort(filter); + /** + * 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); - 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; - } + // De-select any highlighted items + if (hasHighlightedItems) { + this.unhighlightAll(); + } - return choice.groupId === group.id && !choice.selected; - }); + // Remove focus state + this.containerOuter.classList.remove(this.config.classNames.focusState); - if (groupChoices.length >= 1) { - const dropdownGroup = this._getTemplate('choiceGroup', group); - groupFragment.appendChild(dropdownGroup); - this.renderChoices(groupChoices, groupFragment); + // 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); }); + } + }; - return groupFragment; + 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(); } - /** - * 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 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 || this.isSearching) { - choices.sort(filter); + if (this.config.shouldSort) { + allChoices.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); - } + // Determine whether there is a selected choice + const hasSelectedChoice = allChoices.some((choice) => { + return choice.selected === true; }); - 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); + // 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); + } }); - - 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; + } + } 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; \ No newline at end of file diff --git a/assets/scripts/src/lib/polyfills.js b/assets/scripts/src/lib/polyfills.js index ab88f03..f52a5f3 100644 --- a/assets/scripts/src/lib/polyfills.js +++ b/assets/scripts/src/lib/polyfills.js @@ -1,113 +1,112 @@ /* eslint-disable */ - // Production steps of ECMA-262, Edition 6, 22.1.2.1 // Reference: https://people.mozilla.org/~jorendorff/es6-draft.html#sec-array.from if (!Array.from) { - Array.from = (function() { - var toStr = Object.prototype.toString; + Array.from = (function() { + var toStr = Object.prototype.toString; - var isCallable = function(fn) { - return typeof fn === 'function' || toStr.call(fn) === '[object Function]'; - }; + var isCallable = function(fn) { + return typeof fn === 'function' || toStr.call(fn) === '[object Function]'; + }; - var toInteger = function(value) { - var number = Number(value); - if (isNaN(number)) { - return 0; - } - if (number === 0 || !isFinite(number)) { - return number; - } - return (number > 0 ? 1 : -1) * Math.floor(Math.abs(number)); - }; + var toInteger = function(value) { + var number = Number(value); + if (isNaN(number)) { + return 0; + } + if (number === 0 || !isFinite(number)) { + return number; + } + return (number > 0 ? 1 : -1) * Math.floor(Math.abs(number)); + }; - var maxSafeInteger = Math.pow(2, 53) - 1; + var maxSafeInteger = Math.pow(2, 53) - 1; - var toLength = function(value) { - var len = toInteger(value); - return Math.min(Math.max(len, 0), maxSafeInteger); - }; + var toLength = function(value) { + var len = toInteger(value); + return Math.min(Math.max(len, 0), maxSafeInteger); + }; - // The length property of the from method is 1. - return function from(arrayLike /*, mapFn, thisArg */ ) { - // 1. Let C be the this value. - var C = this; + // The length property of the from method is 1. + return function from(arrayLike /*, mapFn, thisArg */ ) { + // 1. Let C be the this value. + var C = this; - // 2. Let items be ToObject(arrayLike). - var items = Object(arrayLike); + // 2. Let items be ToObject(arrayLike). + var items = Object(arrayLike); - // 3. ReturnIfAbrupt(items). - if (arrayLike == null) { - throw new TypeError("Array.from requires an array-like object - not null or undefined"); - } + // 3. ReturnIfAbrupt(items). + if (arrayLike == null) { + throw new TypeError("Array.from requires an array-like object - not null or undefined"); + } - // 4. If mapfn is undefined, then let mapping be false. - var mapFn = arguments.length > 1 ? arguments[1] : void undefined; - var T; - if (typeof mapFn !== 'undefined') { - // 5. else - // 5. a If IsCallable(mapfn) is false, throw a TypeError exception. - if (!isCallable(mapFn)) { - throw new TypeError('Array.from: when provided, the second argument must be a function'); - } + // 4. If mapfn is undefined, then let mapping be false. + var mapFn = arguments.length > 1 ? arguments[1] : void undefined; + var T; + if (typeof mapFn !== 'undefined') { + // 5. else + // 5. a If IsCallable(mapfn) is false, throw a TypeError exception. + if (!isCallable(mapFn)) { + throw new TypeError('Array.from: when provided, the second argument must be a function'); + } - // 5. b. If thisArg was supplied, let T be thisArg; else let T be undefined. - if (arguments.length > 2) { - T = arguments[2]; - } - } + // 5. b. If thisArg was supplied, let T be thisArg; else let T be undefined. + if (arguments.length > 2) { + T = arguments[2]; + } + } - // 10. Let lenValue be Get(items, "length"). - // 11. Let len be ToLength(lenValue). - var len = toLength(items.length); + // 10. Let lenValue be Get(items, "length"). + // 11. Let len be ToLength(lenValue). + var len = toLength(items.length); - // 13. If IsConstructor(C) is true, then - // 13. a. Let A be the result of calling the [[Construct]] internal method of C with an argument list containing the single item len. - // 14. a. Else, Let A be ArrayCreate(len). - var A = isCallable(C) ? Object(new C(len)) : new Array(len); + // 13. If IsConstructor(C) is true, then + // 13. a. Let A be the result of calling the [[Construct]] internal method of C with an argument list containing the single item len. + // 14. a. Else, Let A be ArrayCreate(len). + var A = isCallable(C) ? Object(new C(len)) : new Array(len); - // 16. Let k be 0. - var k = 0; - // 17. Repeat, while k < len… (also steps a - h) - var kValue; - while (k < len) { - kValue = items[k]; - if (mapFn) { - A[k] = typeof T === 'undefined' ? mapFn(kValue, k) : mapFn.call(T, kValue, k); - } else { - A[k] = kValue; - } - k += 1; - } - // 18. Let putStatus be Put(A, "length", len, true). - A.length = len; - // 20. Return A. - return A; - }; - }()); + // 16. Let k be 0. + var k = 0; + // 17. Repeat, while k < len… (also steps a - h) + var kValue; + while (k < len) { + kValue = items[k]; + if (mapFn) { + A[k] = typeof T === 'undefined' ? mapFn(kValue, k) : mapFn.call(T, kValue, k); + } else { + A[k] = kValue; + } + k += 1; + } + // 18. Let putStatus be Put(A, "length", len, true). + A.length = len; + // 20. Return A. + return A; + }; + }()); } // Reference: https://developer.mozilla.org/en/docs/Web/JavaScript/Reference/Global_Objects/Array/find if (!Array.prototype.find) { - Array.prototype.find = function(predicate) { - 'use strict'; - if (this == null) { - throw new TypeError('Array.prototype.find called on null or undefined'); - } - if (typeof predicate !== 'function') { - throw new TypeError('predicate must be a function'); - } - var list = Object(this); - var length = list.length >>> 0; - var thisArg = arguments[1]; - var value; + Array.prototype.find = function(predicate) { + 'use strict'; + if (this == null) { + throw new TypeError('Array.prototype.find called on null or undefined'); + } + if (typeof predicate !== 'function') { + throw new TypeError('predicate must be a function'); + } + var list = Object(this); + var length = list.length >>> 0; + var thisArg = arguments[1]; + var value; - for (var i = 0; i < length; i++) { - value = list[i]; - if (predicate.call(thisArg, value, i, list)) { - return value; - } - } - return undefined; - }; + for (var i = 0; i < length; i++) { + value = list[i]; + if (predicate.call(thisArg, value, i, list)) { + return value; + } + } + return undefined; + }; } \ No newline at end of file diff --git a/assets/scripts/src/lib/utils.js b/assets/scripts/src/lib/utils.js index 4468a99..a9d9fa1 100644 --- a/assets/scripts/src/lib/utils.js +++ b/assets/scripts/src/lib/utils.js @@ -1,14 +1,13 @@ /* eslint-disable */ - /** * Capitalises the first letter of each word in a string * @param {String} str String to capitalise * @return {String} Capitalised string */ export const capitalise = function(str) { - return str.replace(/\w\S*/g, function(txt){ - return txt.charAt(0).toUpperCase() + txt.substr(1).toLowerCase(); - }); + return str.replace(/\w\S*/g, function(txt) { + return txt.charAt(0).toUpperCase() + txt.substr(1).toLowerCase(); + }); }; /** @@ -18,8 +17,8 @@ export const capitalise = function(str) { * @return {Boolean} */ export const isType = function(type, obj) { - var clas = Object.prototype.toString.call(obj).slice(8, -1); - return obj !== undefined && obj !== null && clas === type; + var clas = Object.prototype.toString.call(obj).slice(8, -1); + return obj !== undefined && obj !== null && clas === type; }; /** @@ -28,10 +27,10 @@ export const isType = function(type, obj) { * @return {Boolean} */ export const isNode = (o) => { - return ( - typeof Node === "object" ? o instanceof Node : - o && typeof o === "object" && typeof o.nodeType === "number" && typeof o.nodeName==="string" - ); + return ( + typeof Node === "object" ? o instanceof Node : + o && typeof o === "object" && typeof o.nodeType === "number" && typeof o.nodeName === "string" + ); }; /** @@ -40,10 +39,10 @@ export const isNode = (o) => { * @return {Boolean} */ export const isElement = (o) => { - return ( - typeof HTMLElement === "object" ? o instanceof HTMLElement : //DOM2 - o && typeof o === "object" && o !== null && o.nodeType === 1 && typeof o.nodeName==="string" - ); + return ( + typeof HTMLElement === "object" ? o instanceof HTMLElement : //DOM2 + o && typeof o === "object" && o !== null && o.nodeType === 1 && typeof o.nodeName === "string" + ); }; /** @@ -52,63 +51,63 @@ export const isElement = (o) => { * @return {Object} Merged object of arguments */ export const extend = function() { - let extended = {}; - let deep = false; - let length = arguments.length; + let extended = {}; + let deep = false; + let length = arguments.length; - /** - * Merge one object into another - * @param {Object} obj Object to merge into extended object - */ - let merge = function (obj) { - for (let prop in obj) { - if (Object.prototype.hasOwnProperty.call(obj, prop)) { - // If deep merge and property is an object, merge properties - if (deep && isType('Object', obj[prop])) { - extended[prop] = extend( true, extended[prop], obj[prop]); - } else { - extended[prop] = obj[prop]; - } - } - } - }; - - // Loop through each passed argument - for (let i = 0; i < length; i++) { - // store argument at position i - let obj = arguments[i]; - - // If we are in fact dealing with an object, merge it. Otherwise throw error - if (isType('Object', obj)) { - merge(obj); + /** + * Merge one object into another + * @param {Object} obj Object to merge into extended object + */ + let merge = function(obj) { + for (let prop in obj) { + if (Object.prototype.hasOwnProperty.call(obj, prop)) { + // If deep merge and property is an object, merge properties + if (deep && isType('Object', obj[prop])) { + extended[prop] = extend(true, extended[prop], obj[prop]); } else { - console.error('Custom options must be an object'); + extended[prop] = obj[prop]; } + } } + }; - return extended; + // Loop through each passed argument + for (let i = 0; i < length; i++) { + // store argument at position i + let obj = arguments[i]; + + // If we are in fact dealing with an object, merge it. Otherwise throw error + if (isType('Object', obj)) { + merge(obj); + } else { + console.error('Custom options must be an object'); + } + } + + return extended; }; /** * CSS transition end event listener * @return */ -export const whichTransitionEvent = function(){ - var t, +export const whichTransitionEvent = function() { + var t, el = document.createElement("fakeelement"); - var transitions = { - "transition" : "transitionend", - "OTransition" : "oTransitionEnd", - "MozTransition" : "transitionend", - "WebkitTransition": "webkitTransitionEnd" - } + var transitions = { + "transition": "transitionend", + "OTransition": "oTransitionEnd", + "MozTransition": "transitionend", + "WebkitTransition": "webkitTransitionEnd" + } - for (t in transitions){ - if (el.style[t] !== undefined){ - return transitions[t]; - } + for (t in transitions) { + if (el.style[t] !== undefined) { + return transitions[t]; } + } }; /** @@ -116,21 +115,21 @@ export const whichTransitionEvent = function(){ * @return */ export const whichAnimationEvent = function() { - var t, - el = document.createElement('fakeelement'); + var t, + el = document.createElement('fakeelement'); - var animations = { - 'animation': 'animationend', - 'OAnimation': 'oAnimationEnd', - 'MozAnimation': 'animationend', - 'WebkitAnimation': 'webkitAnimationEnd' - }; + var animations = { + 'animation': 'animationend', + 'OAnimation': 'oAnimationEnd', + 'MozAnimation': 'animationend', + 'WebkitAnimation': 'webkitAnimationEnd' + }; - for (t in animations) { - if (el.style[t] !== undefined) { - return animations[t]; - } + for (t in animations) { + if (el.style[t] !== undefined) { + return animations[t]; } + } }; /** @@ -142,103 +141,103 @@ export const whichAnimationEvent = function() { * @return {Array} Array of parent elements */ export const getParentsUntil = function(elem, parent, selector) { - var parents = []; - // Get matches - for (; elem && elem !== document; elem = elem.parentNode) { + var parents = []; + // Get matches + for (; elem && elem !== document; elem = elem.parentNode) { - // Check if parent has been reached - if (parent) { + // Check if parent has been reached + if (parent) { - var parentType = parent.charAt(0); - - // If parent is a class - if (parentType === '.') { - if (elem.classList.contains(parent.substr(1))) { - break; - } - } - - // If parent is an ID - if (parentType === '#') { - if (elem.id === parent.substr(1)) { - break; - } - } - - // If parent is a data attribute - if (parentType === '[') { - if (elem.hasAttribute(parent.substr(1, parent.length - 1))) { - break; - } - } - - // If parent is a tag - if (elem.tagName.toLowerCase() === parent) { - break; - } + var parentType = parent.charAt(0); + // If parent is a class + if (parentType === '.') { + if (elem.classList.contains(parent.substr(1))) { + break; } - if (selector) { - var selectorType = selector.charAt(0); + } - // If selector is a class - if (selectorType === '.') { - if (elem.classList.contains(selector.substr(1))) { - parents.push(elem); - } - } - - // If selector is an ID - if (selectorType === '#') { - if (elem.id === selector.substr(1)) { - parents.push(elem); - } - } - - // If selector is a data attribute - if (selectorType === '[') { - if (elem.hasAttribute(selector.substr(1, selector.length - 1))) { - parents.push(elem); - } - } - - // If selector is a tag - if (elem.tagName.toLowerCase() === selector) { - parents.push(elem); - } - - } else { - parents.push(elem); + // If parent is an ID + if (parentType === '#') { + if (elem.id === parent.substr(1)) { + break; } + } + + // If parent is a data attribute + if (parentType === '[') { + if (elem.hasAttribute(parent.substr(1, parent.length - 1))) { + break; + } + } + + // If parent is a tag + if (elem.tagName.toLowerCase() === parent) { + break; + } + } + if (selector) { + var selectorType = selector.charAt(0); + + // If selector is a class + if (selectorType === '.') { + if (elem.classList.contains(selector.substr(1))) { + parents.push(elem); + } + } + + // If selector is an ID + if (selectorType === '#') { + if (elem.id === selector.substr(1)) { + parents.push(elem); + } + } + + // If selector is a data attribute + if (selectorType === '[') { + if (elem.hasAttribute(selector.substr(1, selector.length - 1))) { + parents.push(elem); + } + } + + // If selector is a tag + if (elem.tagName.toLowerCase() === selector) { + parents.push(elem); + } - // Return parents if any exist - if (parents.length === 0) { - return null; } else { - return parents; + parents.push(elem); } + } + + // Return parents if any exist + if (parents.length === 0) { + return null; + } else { + return parents; + } }; -export const wrap = function (element, wrapper) { - wrapper = wrapper || document.createElement('div'); - if (element.nextSibling) { - element.parentNode.insertBefore(wrapper, element.nextSibling); - } else { - element.parentNode.appendChild(wrapper); - } - return wrapper.appendChild(element); +export const wrap = function(element, wrapper) { + wrapper = wrapper || document.createElement('div'); + if (element.nextSibling) { + element.parentNode.insertBefore(wrapper, element.nextSibling); + } else { + element.parentNode.appendChild(wrapper); + } + return wrapper.appendChild(element); }; -export const getSiblings = function (elem) { - var siblings = []; - var sibling = elem.parentNode.firstChild; - for ( ; sibling; sibling = sibling.nextSibling ) { - if ( sibling.nodeType === 1 && sibling !== elem ) { - siblings.push( sibling ); - } +export const getSiblings = function(elem) { + var siblings = []; + var sibling = elem.parentNode.firstChild; + for (; sibling; sibling = sibling.nextSibling) { + if (sibling.nodeType === 1 && sibling !== elem) { + siblings.push(sibling); } - return siblings; + } + return siblings; }; /** @@ -248,8 +247,8 @@ export const getSiblings = function (elem) { * @return {NodeElement} Found parent element */ export const findAncestor = function(el, cls) { - while ((el = el.parentElement) && !el.classList.contains(cls)); - return el; + while ((el = el.parentElement) && !el.classList.contains(cls)); + return el; }; /** @@ -260,19 +259,19 @@ export const findAncestor = function(el, cls) { * @return {Function} A function will be called after it stops being called for a given delay */ export const debounce = function(func, wait, immediate) { - var timeout; - return function() { - var context = this, - args = arguments; - var later = function() { - timeout = null; - if (!immediate) func.apply(context, args); - }; - var callNow = immediate && !timeout; - clearTimeout(timeout); - timeout = setTimeout(later, wait); - if (callNow) func.apply(context, args); + var timeout; + return function() { + var context = this, + args = arguments; + var later = function() { + timeout = null; + if (!immediate) func.apply(context, args); }; + var callNow = immediate && !timeout; + clearTimeout(timeout); + timeout = setTimeout(later, wait); + if (callNow) func.apply(context, args); + }; }; /** @@ -282,14 +281,14 @@ export const debounce = function(func, wait, immediate) { * @return {Number} Elements Distance from top of page */ export const getElemDistance = function(el) { - var location = 0; - if (el.offsetParent) { - do { - location += el.offsetTop; - el = el.offsetParent; - } while (el); - } - return location >= 0 ? location : 0; + var location = 0; + if (el.offsetParent) { + do { + location += el.offsetTop; + el = el.offsetParent; + } while (el); + } + return location >= 0 ? location : 0; }; /** @@ -299,11 +298,11 @@ export const getElemDistance = function(el) { * @return {Number} Height of element */ export const getElementOffset = function(el, offset) { - var elOffset = offset; - if(elOffset > 1) elOffset = 1; - if(elOffset > 0) elOffset = 0; + var elOffset = offset; + if (elOffset > 1) elOffset = 1; + if (elOffset > 0) elOffset = 0; - return Math.max(el.offsetHeight*elOffset); + return Math.max(el.offsetHeight * elOffset); }; /** @@ -314,15 +313,15 @@ export const getElementOffset = function(el, offset) { * @return {[HTMLElement} Found element */ export const getAdjacentEl = (startEl, className, direction = 1) => { - if(!startEl || !className) return; + if (!startEl || !className) return; - const parent = startEl.parentNode.parentNode; - const children = Array.from(parent.querySelectorAll(className)); + const parent = startEl.parentNode.parentNode; + const children = Array.from(parent.querySelectorAll(className)); - const startPos = children.indexOf(startEl); - const operatorDirection = direction > 0 ? 1 : -1; + const startPos = children.indexOf(startEl); + const operatorDirection = direction > 0 ? 1 : -1; - return children[startPos + operatorDirection]; + return children[startPos + operatorDirection]; }; /** @@ -331,13 +330,13 @@ export const getAdjacentEl = (startEl, className, direction = 1) => { * @return {String} Position of scroll */ export const getScrollPosition = function(position) { - if(position === 'bottom') { - // Scroll position from the bottom of the viewport - return Math.max((window.scrollY || window.pageYOffset) + (window.innerHeight || document.documentElement.clientHeight)); - } else { - // Scroll position from the top of the viewport - return (window.scrollY || window.pageYOffset); - } + if (position === 'bottom') { + // Scroll position from the bottom of the viewport + return Math.max((window.scrollY || window.pageYOffset) + (window.innerHeight || document.documentElement.clientHeight)); + } else { + // Scroll position from the top of the viewport + return (window.scrollY || window.pageYOffset); + } }; /** @@ -347,8 +346,8 @@ export const getScrollPosition = function(position) { * @return {Boolean} */ export const isInView = function(el, position, offset) { - // If the user has scrolled further than the distance from the element to the top of its parent - return this.getScrollPosition(position) > (this.getElemDistance(el) + this.getElementOffset(el, offset)) ? true : false; + // If the user has scrolled further than the distance from the element to the top of its parent + return this.getScrollPosition(position) > (this.getElemDistance(el) + this.getElementOffset(el, offset)) ? true : false; }; /** @@ -359,19 +358,19 @@ export const isInView = function(el, position, offset) { * @return {Boolean} */ export const isScrolledIntoView = (el, parent, direction = 1) => { - if(!el) return; + if (!el) return; - let isVisible; + let isVisible; - if(direction > 0) { - // In view from bottom - isVisible = (parent.scrollTop + parent.offsetHeight) >= (el.offsetTop + el.offsetHeight) ; - } else { - // In view from top - isVisible = el.offsetTop >= parent.scrollTop; - } + if (direction > 0) { + // In view from bottom + isVisible = (parent.scrollTop + parent.offsetHeight) >= (el.offsetTop + el.offsetHeight); + } else { + // In view from top + isVisible = el.offsetTop >= parent.scrollTop; + } - return isVisible; + return isVisible; }; /** @@ -380,9 +379,9 @@ export const isScrolledIntoView = (el, parent, direction = 1) => { * @return {String} Sanitised string */ export const stripHTML = function(html) { - let el = document.createElement("DIV"); - el.innerHTML = html; - return el.textContent || el.innerText || ""; + let el = document.createElement("DIV"); + el.innerHTML = html; + return el.textContent || el.innerText || ""; }; /** @@ -392,15 +391,15 @@ export const stripHTML = function(html) { * @return */ export const addAnimation = (el, animation) => { - let animationEvent = whichAnimationEvent(); + let animationEvent = whichAnimationEvent(); - let removeAnimation = () => { - el.classList.remove(animation); - el.removeEventListener(animationEvent, removeAnimation, false); - }; + let removeAnimation = () => { + el.classList.remove(animation); + el.removeEventListener(animationEvent, removeAnimation, false); + }; - el.classList.add(animation); - el.addEventListener(animationEvent, removeAnimation, false); + el.classList.add(animation); + el.addEventListener(animationEvent, removeAnimation, false); }; @@ -411,7 +410,7 @@ export const addAnimation = (el, animation) => { * @return {Number} Random number */ export const getRandomNumber = function(min, max) { - return Math.floor(Math.random() * (max - min) + min); + return Math.floor(Math.random() * (max - min) + min); }; /** @@ -420,18 +419,18 @@ export const getRandomNumber = function(min, max) { * @return {HTMLElement} Converted node element */ export const strToEl = (function() { - var tmpEl = document.createElement('div'); - return function(str) { - var r; - tmpEl.innerHTML = str; - r = tmpEl.children[0]; + var tmpEl = document.createElement('div'); + return function(str) { + var r; + tmpEl.innerHTML = str; + r = tmpEl.children[0]; - while (tmpEl.firstChild) { - tmpEl.removeChild(tmpEl.firstChild); - } + while (tmpEl.firstChild) { + tmpEl.removeChild(tmpEl.firstChild); + } - return r; - }; + return r; + }; }()); /** @@ -439,39 +438,39 @@ export const strToEl = (function() { * @return {Number} Width of input */ export const getWidthOfInput = (input) => { - const value = input.value || input.placeholder; - let width = input.offsetWidth; + const value = input.value || input.placeholder; + let width = input.offsetWidth; - if(value) { - const testEl = strToEl(`${ value }`); - testEl.style.position = 'absolute'; - testEl.style.padding = '0'; - testEl.style.top = '-9999px'; - testEl.style.left = '-9999px'; - testEl.style.width = 'auto'; - testEl.style.whiteSpace = 'pre'; + if (value) { + const testEl = strToEl(`${ value }`); + testEl.style.position = 'absolute'; + testEl.style.padding = '0'; + testEl.style.top = '-9999px'; + testEl.style.left = '-9999px'; + testEl.style.width = 'auto'; + testEl.style.whiteSpace = 'pre'; - document.body.appendChild(testEl); + document.body.appendChild(testEl); - if(value && testEl.offsetWidth !== input.offsetWidth) { - width = testEl.offsetWidth + 4; - } - - document.body.removeChild(testEl); + if (value && testEl.offsetWidth !== input.offsetWidth) { + width = testEl.offsetWidth + 4; } - return `${width}px`; + document.body.removeChild(testEl); + } + + return `${width}px`; }; export const sortByAlpha = (a, b) => { - const labelA = (a.label || a.value).toLowerCase(); - const labelB = (b.label || b.value).toLowerCase(); + const labelA = (a.label || a.value).toLowerCase(); + const labelB = (b.label || b.value).toLowerCase(); - if (labelA < labelB) return -1; - if (labelA > labelB) return 1; - return 0; + if (labelA < labelB) return -1; + if (labelA > labelB) return 1; + return 0; }; export const sortByScore = (a, b) => { - return a.score - b.score; + return a.score - b.score; }; \ No newline at end of file diff --git a/assets/scripts/src/reducers/choices.js b/assets/scripts/src/reducers/choices.js index 3777048..897e670 100644 --- a/assets/scripts/src/reducers/choices.js +++ b/assets/scripts/src/reducers/choices.js @@ -1,93 +1,93 @@ const choices = (state = [], action) => { - switch (action.type) { - case 'ADD_CHOICE': { - /* - A disabled choice appears in the choice dropdown but cannot be selected - A selected choice has been added to the passed input's value (added as an item) - An active choice appears within the choice dropdown - */ - return [...state, { - id: action.id, - groupId: action.groupId, - value: action.value, - label: action.label, - disabled: action.disabled, - selected: false, - active: true, - score: 9999, - }]; - } - - case 'ADD_ITEM': { - let newState = state; - - // If all choices need to be activated - if (action.activateOptions) { - newState = state.map((choice) => { - choice.active = action.active; - return choice; - }); - } - // When an item is added and it has an associated choice, - // we want to disable it so it can't be chosen again - if (action.choiceId > -1) { - newState = state.map((choice) => { - if (choice.id === parseInt(action.choiceId, 10)) { - choice.selected = true; - } - return choice; - }); - } - - return newState; - } - - case 'REMOVE_ITEM': { - // When an item is removed and it has an associated choice, - // we want to re-enable it so it can be chosen again - if (action.choiceId > -1) { - return state.map((choice) => { - if (choice.id === parseInt(action.choiceId, 10)) { - choice.selected = false; - } - return choice; - }); - } - - return state; - } - - case 'FILTER_CHOICES': { - const filteredResults = action.results; - const filteredState = state.map((choice) => { - // Set active state based on whether choice is - // within filtered results - - choice.active = filteredResults.some((result) => { - if (result.item.id === choice.id) { - choice.score = result.score; - return true; - } - return false; - }); - - return choice; - }); - - return filteredState; - } - - case 'ACTIVATE_CHOICES': { - return state.map((choice) => { - choice.active = action.active; - return choice; - }); - } - - default: { - return state; - } + switch (action.type) { + case 'ADD_CHOICE': { + /* + A disabled choice appears in the choice dropdown but cannot be selected + A selected choice has been added to the passed input's value (added as an item) + An active choice appears within the choice dropdown + */ + return [...state, { + id: action.id, + groupId: action.groupId, + value: action.value, + label: action.label, + disabled: action.disabled, + selected: false, + active: true, + score: 9999, + }]; } + + case 'ADD_ITEM': { + let newState = state; + + // If all choices need to be activated + if (action.activateOptions) { + newState = state.map((choice) => { + choice.active = action.active; + return choice; + }); + } + // When an item is added and it has an associated choice, + // we want to disable it so it can't be chosen again + if (action.choiceId > -1) { + newState = state.map((choice) => { + if (choice.id === parseInt(action.choiceId, 10)) { + choice.selected = true; + } + return choice; + }); + } + + return newState; + } + + case 'REMOVE_ITEM': { + // When an item is removed and it has an associated choice, + // we want to re-enable it so it can be chosen again + if (action.choiceId > -1) { + return state.map((choice) => { + if (choice.id === parseInt(action.choiceId, 10)) { + choice.selected = false; + } + return choice; + }); + } + + return state; + } + + case 'FILTER_CHOICES': { + const filteredResults = action.results; + const filteredState = state.map((choice) => { + // Set active state based on whether choice is + // within filtered results + + choice.active = filteredResults.some((result) => { + if (result.item.id === choice.id) { + choice.score = result.score; + return true; + } + return false; + }); + + return choice; + }); + + return filteredState; + } + + case 'ACTIVATE_CHOICES': { + return state.map((choice) => { + choice.active = action.active; + return choice; + }); + } + + default: { + return state; + } + } }; export default choices; \ No newline at end of file diff --git a/assets/scripts/src/reducers/groups.js b/assets/scripts/src/reducers/groups.js index 29c44df..21b17f4 100644 --- a/assets/scripts/src/reducers/groups.js +++ b/assets/scripts/src/reducers/groups.js @@ -1,18 +1,18 @@ const groups = (state = [], action) => { - switch (action.type) { - case 'ADD_GROUP': { - return [...state, { - id: action.id, - value: action.value, - active: action.active, - disabled: action.disabled, - }]; - } - - default: { - return state; - } + switch (action.type) { + case 'ADD_GROUP': { + return [...state, { + id: action.id, + value: action.value, + active: action.active, + disabled: action.disabled, + }]; } + + default: { + return state; + } + } }; export default groups; \ No newline at end of file diff --git a/assets/scripts/src/reducers/index.js b/assets/scripts/src/reducers/index.js index 66e6c83..3def812 100644 --- a/assets/scripts/src/reducers/index.js +++ b/assets/scripts/src/reducers/index.js @@ -4,22 +4,22 @@ import groups from './groups'; import choices from './choices'; const appReducer = combineReducers({ - items, - groups, - choices, + items, + groups, + choices, }); const rootReducer = (passedState, action) => { - let state = passedState; - // If we are clearing all items, groups and options we reassign - // state and then pass that state to our proper reducer. This isn't - // mutating our actual state - // See: http://stackoverflow.com/a/35641992 - if (action.type === 'CLEAR_ALL') { - state = undefined; - } + let state = passedState; + // If we are clearing all items, groups and options we reassign + // state and then pass that state to our proper reducer. This isn't + // mutating our actual state + // See: http://stackoverflow.com/a/35641992 + if (action.type === 'CLEAR_ALL') { + state = undefined; + } - return appReducer(state, action); + return appReducer(state, action); }; export default rootReducer; \ No newline at end of file diff --git a/assets/scripts/src/reducers/items.js b/assets/scripts/src/reducers/items.js index 3789d69..b1d14bc 100644 --- a/assets/scripts/src/reducers/items.js +++ b/assets/scripts/src/reducers/items.js @@ -1,47 +1,47 @@ const items = (state = [], action) => { - switch (action.type) { - case 'ADD_ITEM': { - // Add object to items array - const newState = [...state, { - id: action.id, - choiceId: action.choiceId, - value: action.value, - label: action.label, - active: true, - highlighted: false, - }]; + switch (action.type) { + case 'ADD_ITEM': { + // Add object to items array + const newState = [...state, { + id: action.id, + choiceId: action.choiceId, + value: action.value, + label: action.label, + active: true, + highlighted: false, + }]; - return newState.map((item) => { - if (item.highlighted) { - item.highlighted = false; - } - return item; - }); - } - - case 'REMOVE_ITEM': { - // Set item to inactive - return state.map((item) => { - if (item.id === action.id) { - item.active = false; - } - return item; - }); - } - - case 'HIGHLIGHT_ITEM': { - return state.map((item) => { - if (item.id === action.id) { - item.highlighted = action.highlighted; - } - return item; - }); - } - - default: { - return state; + return newState.map((item) => { + if (item.highlighted) { + item.highlighted = false; } + return item; + }); } + + case 'REMOVE_ITEM': { + // Set item to inactive + return state.map((item) => { + if (item.id === action.id) { + item.active = false; + } + return item; + }); + } + + case 'HIGHLIGHT_ITEM': { + return state.map((item) => { + if (item.id === action.id) { + item.highlighted = action.highlighted; + } + return item; + }); + } + + default: { + return state; + } + } }; export default items; \ No newline at end of file diff --git a/assets/scripts/src/store/index.js b/assets/scripts/src/store/index.js index 648c066..cbb3cd0 100644 --- a/assets/scripts/src/store/index.js +++ b/assets/scripts/src/store/index.js @@ -2,149 +2,149 @@ import { createStore } from 'redux'; import rootReducer from './../reducers/index.js'; export default class Store { - constructor() { - this.store = createStore( - rootReducer, - window.devToolsExtension ? window.devToolsExtension() : undefined - ); + constructor() { + this.store = createStore( + rootReducer + , window.devToolsExtension ? window.devToolsExtension() : undefined + ); + } + + /** + * Get store object (wrapping Redux method) + * @return {Object} State + */ + getState() { + return this.store.getState(); + } + + /** + * Dispatch event to store (wrapped Redux method) + * @param {Function} action Action function to trigger + * @return + */ + dispatch(action) { + this.store.dispatch(action); + } + + /** + * Subscribe store to function call (wrapped Redux method) + * @param {Function} onChange Function to trigger when state changes + * @return + */ + subscribe(onChange) { + this.store.subscribe(onChange); + } + + /** + * Get items from store + * @return {Array} Item objects + */ + getItems() { + const state = this.store.getState(); + return state.items; + } + + /** + * Get active items from store + * @return {Array} Item objects + */ + getItemsFilteredByActive() { + const items = this.getItems(); + const values = items.filter((item) => { + return item.active === true; + }, []); + + return values; + } + + /** + * Get items from store reduced to just their values + * @return {Array} Item objects + */ + getItemsReducedToValues(items = this.getItems()) { + const values = items.reduce((prev, current) => { + prev.push(current.value); + return prev; + }, []); + + return values; + } + + /** + * Get choices from store + * @return {Array} Option objects + */ + getChoices() { + const state = this.store.getState(); + return state.choices; + } + + /** + * Get active choices from store + * @return {Array} Option objects + */ + getChoicesFilteredByActive() { + const choices = this.getChoices(); + const values = choices.filter((choice) => { + return choice.active === true; + }, []); + + return values; + } + + /** + * Get selectable choices from store + * @return {Array} Option objects + */ + getChoicesFilteredBySelectable() { + const choices = this.getChoices(); + const values = choices.filter((choice) => { + return choice.disabled !== true; + }, []); + + return values; + } + + /** + * Get single choice by it's ID + * @return {Object} Found choice + */ + getChoiceById(id) { + if (id) { + const choices = this.getChoicesFilteredByActive(); + const foundChoice = choices.find((choice) => choice.id === parseInt(id, 10)); + return foundChoice; } + return false; + } - /** - * Get store object (wrapping Redux method) - * @return {Object} State - */ - getState() { - return this.store.getState(); - } + /** + * Get groups from store + * @return {Array} Group objects + */ + getGroups() { + const state = this.store.getState(); + return state.groups; + } - /** - * Dispatch event to store (wrapped Redux method) - * @param {Function} action Action function to trigger - * @return - */ - dispatch(action) { - this.store.dispatch(action); - } + /** + * Get active groups from store + * @return {Array} Group objects + */ + getGroupsFilteredByActive() { + const groups = this.getGroups(); + const choices = this.getChoices(); - /** - * Subscribe store to function call (wrapped Redux method) - * @param {Function} onChange Function to trigger when state changes - * @return - */ - subscribe(onChange) { - this.store.subscribe(onChange); - } + const values = groups.filter((group) => { + const isActive = group.active === true && group.disabled === false; + const hasActiveOptions = choices.some((choice) => { + return choice.active === true && choice.disabled === false; + }); + return isActive && hasActiveOptions; + }, []); - /** - * Get items from store - * @return {Array} Item objects - */ - getItems() { - const state = this.store.getState(); - return state.items; - } - - /** - * Get active items from store - * @return {Array} Item objects - */ - getItemsFilteredByActive() { - const items = this.getItems(); - const values = items.filter((item) => { - return item.active === true; - }, []); - - return values; - } - - /** - * Get items from store reduced to just their values - * @return {Array} Item objects - */ - getItemsReducedToValues(items = this.getItems()) { - const values = items.reduce((prev, current) => { - prev.push(current.value); - return prev; - }, []); - - return values; - } - - /** - * Get choices from store - * @return {Array} Option objects - */ - getChoices() { - const state = this.store.getState(); - return state.choices; - } - - /** - * Get active choices from store - * @return {Array} Option objects - */ - getChoicesFilteredByActive() { - const choices = this.getChoices(); - const values = choices.filter((choice) => { - return choice.active === true; - }, []); - - return values; - } - - /** - * Get selectable choices from store - * @return {Array} Option objects - */ - getChoicesFilteredBySelectable() { - const choices = this.getChoices(); - const values = choices.filter((choice) => { - return choice.disabled !== true; - }, []); - - return values; - } - - /** - * Get single choice by it's ID - * @return {Object} Found choice - */ - getChoiceById(id) { - if (id) { - const choices = this.getChoicesFilteredByActive(); - const foundChoice = choices.find((choice) => choice.id === parseInt(id, 10)); - return foundChoice; - } - return false; - } - - /** - * Get groups from store - * @return {Array} Group objects - */ - getGroups() { - const state = this.store.getState(); - return state.groups; - } - - /** - * Get active groups from store - * @return {Array} Group objects - */ - getGroupsFilteredByActive() { - const groups = this.getGroups(); - const choices = this.getChoices(); - - const values = groups.filter((group) => { - const isActive = group.active === true && group.disabled === false; - const hasActiveOptions = choices.some((choice) => { - return choice.active === true && choice.disabled === false; - }); - return isActive && hasActiveOptions; - }, []); - - return values; - } + return values; + } } module.exports = Store; \ No newline at end of file