From 2b028db271247ec00442bde42af24f552256220d Mon Sep 17 00:00:00 2001 From: Josh Johnson Date: Tue, 27 Sep 2016 13:44:35 +0100 Subject: [PATCH] Refactoring + consistencies --- assets/scripts/src/choices.js | 485 ++++++++++++++++++---------------- index.html | 30 +-- 2 files changed, 268 insertions(+), 247 deletions(-) diff --git a/assets/scripts/src/choices.js b/assets/scripts/src/choices.js index 6ce4018..6e3829d 100644 --- a/assets/scripts/src/choices.js +++ b/assets/scripts/src/choices.js @@ -102,7 +102,7 @@ export default class Choices { callbackOnHighlightItem: (id, value, passedInput) => {}, callbackOnUnhighlightItem: (id, value, passedInput) => {}, callbackOnChange: (value, passedInput) => {}, - callbackOnItemSearch: (value, fn, passedInput) => {}, + callbackOnItemSearch: false, }; // Merge options with user options @@ -181,6 +181,10 @@ export default class Choices { } } + /*======================================== + = Public functions = + ========================================*/ + /** * Initialise Choices * @return @@ -241,6 +245,183 @@ export default class Choices { this.initialised = false; } + /** + * 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; + } + } + /** * Select item (a selected item can be deleted) * @param {Element} item Element to select @@ -607,6 +788,23 @@ export default class Choices { return this; } + /** + * Enable interaction with Choices + * @return {Object} Class instance + */ + enable() { + this.passedElement.disabled = false; + const isDisabled = this.containerOuter.classList.contains(this.config.classNames.disabledState); + if (this.initialised && isDisabled) { + 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; + } + /** * Disable interaction with Choices * @return {Object} Class instance @@ -625,23 +823,6 @@ export default class Choices { return this; } - /** - * Enable interaction with Choices - * @return {Object} Class instance - */ - enable() { - this.passedElement.disabled = false; - const isDisabled = this.containerOuter.classList.contains(this.config.classNames.disabledState); - if (this.initialised && isDisabled) { - 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 @@ -659,6 +840,12 @@ export default class Choices { return this; } + /*===== End of Public functions ======*/ + + /*============================================= + = Private functions = + =============================================*/ + /** * Call change callback * @param {String} value - last added/deleted/selected value @@ -853,7 +1040,7 @@ export default class Choices { * @private */ _handleLoadingState(isLoading = true) { - let placeholderItem = this.itemList.querySelector('.' + this.config.classNames.placeholder); + let placeholderItem = this.itemList.querySelector(`.${this.config.classNames.placeholder}`); if(isLoading) { this.containerOuter.classList.add(this.config.classNames.loadingState); this.containerOuter.setAttribute('aria-busy', 'true'); @@ -905,47 +1092,58 @@ export default class Choices { * @return * @private */ - _searchChoices(value) { + _filterChoices(value) { + const newValue = isType('String', value) ? value.trim() : value; + const currentValue = isType('String', this.currentValue) ? this.currentValue.trim() : this.currentValue; + + // If new value matches the desired length and is not the same as the current value with a space + if (newValue.length >= 1 && newValue !== `${currentValue} `) { + 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)); + } + } + + /** + * Determine the action when a user is searching + * @param {String} value Value entered by user + * @return + * @private + */ + _handleSearch(value) { if (!value) return; // Run callback if it is a function - if (this.config.callbackOnItemSearch) { - const userCallback = this.config.callbackOnItemSearch; - if (isType('Function', userCallback)) { - // Reset choices - this._clearChoices(); - // Reset loading state/text - this._handleLoadingState(); - userCallback(value, this._getAjaxCallback(), this.passedElement); + if (this.input === document.activeElement) { + // If a custom callback has been provided, use it + if (this.config.callbackOnItemSearch) { + const callback = this.config.callbackOnItemSearch; + if (isType('Function', callback)) { + // Reset choices + this._clearChoices(); + // Reset loading state/text + this._handleLoadingState(); + // Run callback + callback(value, this._getAjaxCallback(), this.passedElement); + } else { + console.error('callbackOnOnItemSearch: Callback is not a function'); + } } else { - console.error('callbackOnOnItemSearch: Callback is not a function'); - } - } else { - 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(); + // Filter available choices + this._filterChoices(value); } else if (hasUnactiveChoices) { // Otherwise reset choices to active this.isSearching = false; @@ -1198,13 +1396,11 @@ export default class Choices { this.store.dispatch(activateChoices(true)); } } else if (this.canSearch) { - this._searchChoices(this.input.value); + this._handleSearch(this.input.value); } } } - - /** * Input event * @param {Object} e Event @@ -1977,182 +2173,7 @@ export default class Choices { } } - /** - * 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; - } - } + /*===== End of Private functions ======*/ } window.Choices = module.exports = Choices; diff --git a/index.html b/index.html index 3346ba8..5378ab3 100644 --- a/index.html +++ b/index.html @@ -337,14 +337,14 @@ search: false, removeItemButton: true, choices: [ - {value: 'One', label: 'Label One'}, - {value: 'Two', label: 'Label Two', disabled: true}, - {value: 'Three', label: 'Label Three'}, + {value: 'One', label: 'Label One'}, + {value: 'Two', label: 'Label Two', disabled: true}, + {value: 'Three', label: 'Label Three'}, ], }).setChoices([ - {value: 'Four', label: 'Label Four', disabled: true}, - {value: 'Five', label: 'Label Five'}, - {value: 'Six', label: 'Label Six', selected: true}, + {value: 'Four', label: 'Label Four', disabled: true}, + {value: 'Five', label: 'Label Five'}, + {value: 'Six', label: 'Label Six', selected: true}, ], 'value', 'label'); var example14 = new Choices('#choices-single-preset-options', { @@ -354,9 +354,9 @@ id: 1, disabled: false, choices: [ - {value: 'Child One', label: 'Child One', selected: true}, - {value: 'Child Two', label: 'Child Two', disabled: true}, - {value: 'Child Three', label: 'Child Three'}, + {value: 'Child One', label: 'Child One', selected: true}, + {value: 'Child Two', label: 'Child Two', disabled: true}, + {value: 'Child Three', label: 'Child Three'}, ] }, { @@ -364,17 +364,17 @@ id: 2, disabled: false, choices: [ - {value: 'Child Four', label: 'Child Four', disabled: true}, - {value: 'Child Five', label: 'Child Five'}, - {value: 'Child Six', label: 'Child Six'}, + {value: 'Child Four', label: 'Child Four', disabled: true}, + {value: 'Child Five', label: 'Child Five'}, + {value: 'Child Six', label: 'Child Six'}, ] }], 'value', 'label'); var example15 = new Choices('#choices-single-selected-option', { choices: [ - {value: 'One', label: 'Label One', selected: true}, - {value: 'Two', label: 'Label Two', disabled: true}, - {value: 'Three', label: 'Label Three'}, + {value: 'One', label: 'Label One', selected: true}, + {value: 'Two', label: 'Label Two', disabled: true}, + {value: 'Three', label: 'Label Three'}, ], }).setValueByChoice('Two');